Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49d3e113ee | |||
| f1e879ca6c | |||
| 9c31c641d6 | |||
| d9dc85d825 | |||
| 0b78ab4a94 | |||
| 440218fc4c | |||
| a56a5846a5 | |||
| 053dac5308 | |||
| c71c03f0f1 | |||
| 1e98b53113 | |||
| 30380abdef | |||
| 237588253f | |||
| fc034ceade | |||
| f09a4f382a | |||
| f61b5a4977 |
@@ -31,6 +31,39 @@ WORKSPACE_PATH=~/projects
|
|||||||
# Path to SSH keys on host
|
# Path to SSH keys on host
|
||||||
SSH_KEY_PATH=~/.ssh
|
SSH_KEY_PATH=~/.ssh
|
||||||
|
|
||||||
|
# ── LAN access from the container (host-OS-agnostic) ─────────────────
|
||||||
|
# On VM-backed hosts (macOS OrbStack / Docker Desktop, also Docker Desktop
|
||||||
|
# on Windows) the container runs in a Linux VM and CANNOT reach the host's
|
||||||
|
# directly-attached LAN peers by default. On native Linux Docker the LAN is
|
||||||
|
# reachable directly and nothing is needed. The entrypoint detects this and,
|
||||||
|
# on VM-backed hosts, generates ~/.ssh-local/config so the host can be used
|
||||||
|
# as an SSH jump (use the `dssh` alias). Reach the host itself with
|
||||||
|
# `dssh host`. To reach named LAN peers, put `ProxyJump host` overrides in a
|
||||||
|
# host-owned ~/.config/devbox-shell/ssh-lan.conf (bind-mounted in) rather than
|
||||||
|
# editing your ~/.ssh/config — see ssh-lan.conf.example. Public-IP hosts (and
|
||||||
|
# anything reached via a public jump host) connect directly, no jump needed.
|
||||||
|
#
|
||||||
|
# DEVBOX_LAN_ACCESS: auto (default) | jump | off
|
||||||
|
# auto = set up the jump only on VM-backed hosts; no-op on native Linux.
|
||||||
|
# jump = always set up (e.g. native Linux with extra_hosts host-gateway).
|
||||||
|
# off = disable entirely.
|
||||||
|
# DEVBOX_LAN_ACCESS=auto
|
||||||
|
#
|
||||||
|
# HOST_SSH_USER: your username on the host. REQUIRED for the jump to
|
||||||
|
# authenticate. On first start the entrypoint prints the public key to
|
||||||
|
# authorize on the host (append to the host's ~/.ssh/authorized_keys) and
|
||||||
|
# reminds you to enable the host's SSH server (e.g. macOS Remote Login).
|
||||||
|
# HOST_SSH_USER=
|
||||||
|
#
|
||||||
|
# DEVBOX_HOST_ALIAS: host hostname to reach (default host.docker.internal).
|
||||||
|
# DEVBOX_HOST_ALIAS=host.docker.internal
|
||||||
|
#
|
||||||
|
# DEVBOX_LAN_AUTOJUMP_PRIVATE: 1 = ProxyJump ANY RFC1918 (private) IP through
|
||||||
|
# the host, so bare `dssh user@<ip>` works on whatever LAN the (roaming) host
|
||||||
|
# is currently joined to, without naming peers. Matches the typed address, not
|
||||||
|
# the resolved HostName, so named hosts with their own ProxyJump are unaffected.
|
||||||
|
# DEVBOX_LAN_AUTOJUMP_PRIVATE=0
|
||||||
|
|
||||||
# ── Skillset (agent skills and instructions) ─────────────────────────
|
# ── Skillset (agent skills and instructions) ─────────────────────────
|
||||||
# If you have a skillset repo, the entrypoint auto-deploys skills and
|
# If you have a skillset repo, the entrypoint auto-deploys skills and
|
||||||
# instructions on container start using relative symlinks (portable
|
# instructions on container start using relative symlinks (portable
|
||||||
|
|||||||
+6
-6
@@ -8,14 +8,14 @@ the build pipeline is shaped the way it is, you're in the right place.
|
|||||||
|
|
||||||
| File | Trigger | Role |
|
| File | Trigger | Role |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [`workflows/docker-publish-split.yml`](workflows/docker-publish-split.yml) | `push: tags: v*` | **Production release pipeline.** Two-phase split-base build: shared `base-<hash>` published once (skipped on cache hit), then four parallel variant deltas. ~40–80 min wall clock depending on runner count and whether base needs rebuilding. |
|
| [`workflows/docker-publish-split.yml`](workflows/docker-publish-split.yml) | `push: tags: v*` | **Production release pipeline.** Two-phase split-base build: shared `base-<hash>` published once (skipped on cache hit), then five parallel variant deltas. ~40–80 min wall clock depending on runner count and whether base needs rebuilding. |
|
||||||
| [`workflows/validate.yml`](workflows/validate.yml) | `push: branches: main` + PR | **Lightweight gate.** amd64-only smoke test of all four variants + `DOCKER_HUB.md` sync check. ~30 min. Fires on every push to `main`. |
|
| [`workflows/validate.yml`](workflows/validate.yml) | `push: branches: main` + PR | **Lightweight gate.** amd64-only smoke test of all five variants + `DOCKER_HUB.md` sync check. ~30 min. Fires on every push to `main`. |
|
||||||
|
|
||||||
## Why the split-base pipeline exists
|
## Why the split-base pipeline exists
|
||||||
|
|
||||||
opencode-devbox publishes **four image variants** (`base`, `omos`, `with-pi`, `omos-with-pi`) × **two architectures** (amd64, arm64) = **eight image tags per release**. Today's runners are 2 self-hosted gitea Actions runners. arm64 builds are emulated under QEMU, which is the dominant cost (~3–5x slower than native).
|
opencode-devbox builds **five image variants** (`base`, `omos`, `with-pi`, `omos-with-pi`, `pi-only`) × **two architectures** (amd64, arm64). Four opencode-bearing variants publish under this repo (**eight tags per release** + the floating `base-latest`); the `pi-only` build is pushed into the separate `joakimp/pi-devbox` repo as `base-pi-only` (so no opencode-less tag appears here). Today's runners are 2 self-hosted gitea Actions runners. arm64 builds are emulated under QEMU, which is the dominant cost (~3–5x slower than native).
|
||||||
|
|
||||||
The four variants share ~95% of their layers (Debian + apt + Node + AWS CLI + mempalace + dev tools + entrypoints). The original `Dockerfile` was a single multi-stage build with `INSTALL_*` build-args gating variant-specific RUNs. BuildKit's per-layer cache key is content-addressed, but as soon as a build-arg-gated `RUN` produces a different layer hash for variant A vs variant B, every subsequent layer also has a different parent → identical commands re-execute per variant. Result: minimal cross-variant cache reuse on a fresh build.
|
The five variants share ~95% of their layers (Debian + apt + Node + AWS CLI + mempalace + dev tools + entrypoints). The original `Dockerfile` was a single multi-stage build with `INSTALL_*` build-args gating variant-specific RUNs. BuildKit's per-layer cache key is content-addressed, but as soon as a build-arg-gated `RUN` produces a different layer hash for variant A vs variant B, every subsequent layer also has a different parent → identical commands re-execute per variant. Result: minimal cross-variant cache reuse on a fresh build.
|
||||||
|
|
||||||
Two improvements were considered:
|
Two improvements were considered:
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ production aliases pointing at the previous good release.
|
|||||||
|
|
||||||
### Step 5: `promote-base-latest`
|
### Step 5: `promote-base-latest`
|
||||||
|
|
||||||
Once all four variants successfully publish, re-tag `base-<hash>` as
|
Once all five variants successfully publish, re-tag `base-<hash>` as
|
||||||
`base-latest` using `crane copy`. This is a **manifest-level re-tag, not
|
`base-latest` using `crane copy`. This is a **manifest-level re-tag, not
|
||||||
a rebuild** — it touches only Docker Hub's image index, takes seconds,
|
a rebuild** — it touches only Docker Hub's image index, takes seconds,
|
||||||
and is atomic.
|
and is atomic.
|
||||||
@@ -252,7 +252,7 @@ on every push to `main` and on PRs. It:
|
|||||||
|
|
||||||
1. Runs `scripts/generate-dockerhub-md.py --check` to enforce
|
1. Runs `scripts/generate-dockerhub-md.py --check` to enforce
|
||||||
`DOCKER_HUB.md` is in sync with `HUB_TEMPLATE`.
|
`DOCKER_HUB.md` is in sync with `HUB_TEMPLATE`.
|
||||||
2. Builds each of the four variants amd64-only (no multi-arch, no push)
|
2. Builds each of the five variants amd64-only (no multi-arch, no push)
|
||||||
and runs `scripts/smoke-test.sh`.
|
and runs `scripts/smoke-test.sh`.
|
||||||
|
|
||||||
This catches regressions before they reach a tag push. Wall clock ~30 min.
|
This catches regressions before they reach a tag push. Wall clock ~30 min.
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ concurrency:
|
|||||||
env:
|
env:
|
||||||
BUILDKIT_PROGRESS: plain
|
BUILDKIT_PROGRESS: plain
|
||||||
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox
|
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox
|
||||||
|
# The pi-only variant is built here (single source of truth for the pi stack)
|
||||||
|
# but published into the pi-devbox repo as an internal building-block tag,
|
||||||
|
# NOT under opencode-devbox — so opencode-devbox never shows a tag with no
|
||||||
|
# opencode in it. pi-devbox's own CI FROMs PI_IMAGE:base-pi-only.
|
||||||
|
PI_IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/pi-devbox
|
||||||
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag }}
|
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag }}
|
||||||
PROMOTE_LATEST: ${{ github.ref_type == 'tag' && 'true' || inputs.promote_latest }}
|
PROMOTE_LATEST: ${{ github.ref_type == 'tag' && 'true' || inputs.promote_latest }}
|
||||||
|
|
||||||
@@ -122,6 +127,8 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
pi_version: ${{ steps.resolve.outputs.pi_version }}
|
pi_version: ${{ steps.resolve.outputs.pi_version }}
|
||||||
omos_version: ${{ steps.resolve.outputs.omos_version }}
|
omos_version: ${{ steps.resolve.outputs.omos_version }}
|
||||||
|
fork_ref: ${{ steps.resolve.outputs.fork_ref }}
|
||||||
|
obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }}
|
||||||
steps:
|
steps:
|
||||||
- name: Resolve pi + omos versions from npm registry
|
- name: Resolve pi + omos versions from npm registry
|
||||||
id: resolve
|
id: resolve
|
||||||
@@ -136,7 +143,23 @@ jobs:
|
|||||||
OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.version')
|
OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.version')
|
||||||
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
|
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "omos_version=${OMOS_VERSION}" >> "$GITHUB_OUTPUT"
|
echo "omos_version=${OMOS_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
# Resolve the pi-fork / pi-observational-memory git refs (default
|
||||||
|
# branch master) to concrete commit SHAs so the build-arg string
|
||||||
|
# changes whenever upstream moves — defeating the same registry-
|
||||||
|
# buildcache cache-hit footgun that PI_VERSION/OMOS_VERSION guard
|
||||||
|
# against. The Accept: application/vnd.github.sha media type returns
|
||||||
|
# the bare SHA. Falls back to the branch name if the API is
|
||||||
|
# unreachable/rate-limited (still functional, just cache-stale-prone).
|
||||||
|
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}, OMOS_VERSION=${OMOS_VERSION}"
|
echo "Resolved PI_VERSION=${PI_VERSION}, OMOS_VERSION=${OMOS_VERSION}"
|
||||||
|
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
|
||||||
|
|
||||||
# ── Phase 2: build & push base (multi-arch), only when needed ──────
|
# ── Phase 2: build & push base (multi-arch), only when needed ──────
|
||||||
build-base:
|
build-base:
|
||||||
@@ -359,8 +382,11 @@ jobs:
|
|||||||
INSTALL_OMOS=false
|
INSTALL_OMOS=false
|
||||||
INSTALL_PI=true
|
INSTALL_PI=true
|
||||||
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
|
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 }}
|
||||||
- env:
|
- env:
|
||||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||||
|
STRICT_REGISTRATION: "1"
|
||||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi
|
run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi
|
||||||
|
|
||||||
smoke-omos-with-pi:
|
smoke-omos-with-pi:
|
||||||
@@ -405,11 +431,62 @@ jobs:
|
|||||||
INSTALL_PI=true
|
INSTALL_PI=true
|
||||||
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
|
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
|
||||||
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
|
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
|
||||||
|
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
|
||||||
|
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||||
- env:
|
- env:
|
||||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||||
EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
|
EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
|
||||||
|
STRICT_REGISTRATION: "1"
|
||||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi
|
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi
|
||||||
|
|
||||||
|
smoke-pi-only:
|
||||||
|
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: |
|
||||||
|
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||||
|
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||||
|
/usr/local/lib/android /usr/local/share/powershell \
|
||||||
|
/usr/local/share/chromium /usr/local/share/boost \
|
||||||
|
/usr/lib/jvm 2>/dev/null || true
|
||||||
|
docker system prune -af --volumes || true
|
||||||
|
docker builder prune -af || true
|
||||||
|
- uses: docker/setup-buildx-action@v4
|
||||||
|
with: {driver-opts: network=host}
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
- uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile.variant
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: opencode-devbox:smoke-pi-only
|
||||||
|
build-args: |
|
||||||
|
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||||
|
INSTALL_OPENCODE=false
|
||||||
|
INSTALL_OMOS=false
|
||||||
|
INSTALL_PI=true
|
||||||
|
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 }}
|
||||||
|
- env:
|
||||||
|
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||||
|
STRICT_REGISTRATION: "1"
|
||||||
|
run: bash scripts/smoke-test.sh opencode-devbox:smoke-pi-only --variant pi-only
|
||||||
|
|
||||||
# ── Phase 4: multi-arch publish per variant ────────────────────────
|
# ── Phase 4: multi-arch publish per variant ────────────────────────
|
||||||
|
|
||||||
build-variant-base:
|
build-variant-base:
|
||||||
@@ -594,6 +671,8 @@ jobs:
|
|||||||
TAGS: ${{ steps.tags.outputs.tags }}
|
TAGS: ${{ steps.tags.outputs.tags }}
|
||||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG_FLAGS=()
|
TAG_FLAGS=()
|
||||||
@@ -610,6 +689,8 @@ jobs:
|
|||||||
--build-arg "INSTALL_OMOS=false" \
|
--build-arg "INSTALL_OMOS=false" \
|
||||||
--build-arg "INSTALL_PI=true" \
|
--build-arg "INSTALL_PI=true" \
|
||||||
--build-arg "PI_VERSION=${PI_VERSION}" \
|
--build-arg "PI_VERSION=${PI_VERSION}" \
|
||||||
|
--build-arg "PI_FORK_REF=${FORK_REF}" \
|
||||||
|
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||||
"${TAG_FLAGS[@]}" \
|
"${TAG_FLAGS[@]}" \
|
||||||
.; then
|
.; then
|
||||||
echo "==> Attempt ${attempt} succeeded"
|
echo "==> Attempt ${attempt} succeeded"
|
||||||
@@ -666,6 +747,8 @@ jobs:
|
|||||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||||
OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
|
OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
|
||||||
|
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
|
||||||
|
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG_FLAGS=()
|
TAG_FLAGS=()
|
||||||
@@ -683,6 +766,86 @@ jobs:
|
|||||||
--build-arg "INSTALL_PI=true" \
|
--build-arg "INSTALL_PI=true" \
|
||||||
--build-arg "PI_VERSION=${PI_VERSION}" \
|
--build-arg "PI_VERSION=${PI_VERSION}" \
|
||||||
--build-arg "OMOS_VERSION=${OMOS_VERSION}" \
|
--build-arg "OMOS_VERSION=${OMOS_VERSION}" \
|
||||||
|
--build-arg "PI_FORK_REF=${FORK_REF}" \
|
||||||
|
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||||
|
"${TAG_FLAGS[@]}" \
|
||||||
|
.; then
|
||||||
|
echo "==> Attempt ${attempt} succeeded"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "${attempt}" -lt 3 ]]; then
|
||||||
|
backoff=$(( attempt * 15 ))
|
||||||
|
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||||
|
sleep "${backoff}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "==> All 3 build+push attempts failed"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
build-variant-pi-only:
|
||||||
|
needs: [base-decide, smoke-pi-only, resolve-versions]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||||
|
- run: |
|
||||||
|
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||||
|
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||||
|
/usr/local/lib/android /usr/local/share/powershell \
|
||||||
|
/usr/local/share/chromium /usr/local/share/boost \
|
||||||
|
/usr/lib/jvm 2>/dev/null || true
|
||||||
|
docker system prune -af --volumes || true
|
||||||
|
docker builder prune -af || true
|
||||||
|
- uses: docker/setup-qemu-action@v3
|
||||||
|
with: {platforms: arm64}
|
||||||
|
- uses: docker/setup-buildx-action@v4
|
||||||
|
with: {driver-opts: network=host}
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
- name: Compute version-specific tags
|
||||||
|
id: tags
|
||||||
|
run: |
|
||||||
|
# Option B: push the pi-only build into the pi-devbox repo as an
|
||||||
|
# internal building-block tag (base-pi-only[-<version>]), NOT under
|
||||||
|
# opencode-devbox. pi-devbox's CI FROMs ${PI_IMAGE}:base-pi-only.
|
||||||
|
VERSION="${{ env.RELEASE_TAG }}"
|
||||||
|
{ echo "tags<<EOF"
|
||||||
|
echo "${PI_IMAGE}:base-pi-only-${VERSION}"
|
||||||
|
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||||
|
echo "${PI_IMAGE}:base-pi-only"
|
||||||
|
fi
|
||||||
|
echo "EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Build and push variant (with retry)
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TAGS: ${{ steps.tags.outputs.tags }}
|
||||||
|
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||||
|
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||||
|
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
|
||||||
|
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG_FLAGS=()
|
||||||
|
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||||
|
# 3-attempt retry (see build-base step for rationale). Variant: pi-only.
|
||||||
|
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 "INSTALL_OPENCODE=false" \
|
||||||
|
--build-arg "INSTALL_OMOS=false" \
|
||||||
|
--build-arg "INSTALL_PI=true" \
|
||||||
|
--build-arg "PI_VERSION=${PI_VERSION}" \
|
||||||
|
--build-arg "PI_FORK_REF=${FORK_REF}" \
|
||||||
|
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||||
"${TAG_FLAGS[@]}" \
|
"${TAG_FLAGS[@]}" \
|
||||||
.; then
|
.; then
|
||||||
echo "==> Attempt ${attempt} succeeded"
|
echo "==> Attempt ${attempt} succeeded"
|
||||||
@@ -705,6 +868,7 @@ jobs:
|
|||||||
- build-variant-omos
|
- build-variant-omos
|
||||||
- build-variant-with-pi
|
- build-variant-with-pi
|
||||||
- build-variant-omos-with-pi
|
- build-variant-omos-with-pi
|
||||||
|
- build-variant-pi-only
|
||||||
# Skip on cache-hit base builds: when need_build=false, base-latest
|
# 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
|
# already points at the same digest as base-<hash>, so the retag is
|
||||||
# a tautology and any transient failure of it is purely cosmetic.
|
# a tautology and any transient failure of it is purely cosmetic.
|
||||||
@@ -757,6 +921,7 @@ jobs:
|
|||||||
- build-variant-omos
|
- build-variant-omos
|
||||||
- build-variant-with-pi
|
- build-variant-with-pi
|
||||||
- build-variant-omos-with-pi
|
- build-variant-omos-with-pi
|
||||||
|
- build-variant-pi-only
|
||||||
# Run when at least the base variant published — don't let a single
|
# Run when at least the base variant published — don't let a single
|
||||||
# variant failure (e.g., omos-with-pi smoke threshold) prevent Hub
|
# variant failure (e.g., omos-with-pi smoke threshold) prevent Hub
|
||||||
# description refresh for the other variants that did publish.
|
# description refresh for the other variants that did publish.
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ name: Validate
|
|||||||
# release tags are the gate that fully validates base-image changes.
|
# release tags are the gate that fully validates base-image changes.
|
||||||
# The base-change-warning job below surfaces a runtime warning when this
|
# The base-change-warning job below surfaces a runtime warning when this
|
||||||
# blind-spot applies.
|
# blind-spot applies.
|
||||||
|
#
|
||||||
|
# Because of this, the fork/recall *registration* smoke checks (which depend on
|
||||||
|
# the base entrypoint running `pi install /opt/<pkg>`) are warn-only here:
|
||||||
|
# smoke-test.sh leaves STRICT_REGISTRATION unset on this path, so a base-latest
|
||||||
|
# that lags the entrypoint in the current commit can't red the run with a false
|
||||||
|
# negative. The release smoke jobs build the base fresh and set
|
||||||
|
# STRICT_REGISTRATION=1 to enforce those checks. The build-time /opt +
|
||||||
|
# node_modules checks stay hard in both paths.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -312,3 +320,62 @@ jobs:
|
|||||||
- name: Smoke test
|
- name: Smoke test
|
||||||
run: |
|
run: |
|
||||||
bash scripts/smoke-test.sh opencode-devbox:ci-omos-with-pi --variant omos-with-pi
|
bash scripts/smoke-test.sh opencode-devbox:ci-omos-with-pi --variant omos-with-pi
|
||||||
|
|
||||||
|
validate-pi-only:
|
||||||
|
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 df || true
|
||||||
|
docker system prune -af --volumes || true
|
||||||
|
docker builder prune -af || true
|
||||||
|
df -h / || true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
with:
|
||||||
|
driver-opts: network=host
|
||||||
|
|
||||||
|
- name: Build pi-only image (amd64, load to local daemon)
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile.variant
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
build-args: |
|
||||||
|
BASE_IMAGE=joakimp/opencode-devbox:base-latest
|
||||||
|
INSTALL_OPENCODE=false
|
||||||
|
INSTALL_PI=true
|
||||||
|
tags: opencode-devbox:ci-pi-only
|
||||||
|
|
||||||
|
- name: Smoke test
|
||||||
|
run: |
|
||||||
|
bash scripts/smoke-test.sh opencode-devbox:ci-pi-only --variant pi-only
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d
|
|||||||
## File roles
|
## File roles
|
||||||
|
|
||||||
- `Dockerfile.base` — variant-independent layers (apt, locales, AWS CLI, Node.js, mempalace, gitea-mcp, user setup, chromadb prewarm, ENVs, entrypoints). Published as `joakimp/opencode-devbox:base-<sha12>`. Rebuilt only when its content hash changes.
|
- `Dockerfile.base` — variant-independent layers (apt, locales, AWS CLI, Node.js, mempalace, gitea-mcp, user setup, chromadb prewarm, ENVs, entrypoints). Published as `joakimp/opencode-devbox:base-<sha12>`. Rebuilt only when its content hash changes.
|
||||||
- `Dockerfile.variant` — `FROM`s the base and adds only opencode/omos/pi installs gated by build args: `INSTALL_OPENCODE` (default true), `INSTALL_OMOS`, `INSTALL_PI`, and `INSTALL_MEMPALACE`. All GitHub-sourced binaries are pinned with version ARGs.
|
- `Dockerfile.variant` — `FROM`s the base and adds only opencode/omos/pi installs gated by build args: `INSTALL_OPENCODE` (default true), `INSTALL_OMOS`, `INSTALL_PI`, and `INSTALL_MEMPALACE`. All GitHub-sourced binaries are pinned with version ARGs. When `INSTALL_PI=true` it also clones `pi-fork` + `pi-observational-memory` (from `github.com/elpapi42`, refs `PI_FORK_REF`/`PI_OBSMEM_REF`) to `/opt` and runs `npm install` there at build time so the `fork`/`recall` extensions can load (a local-path `pi install` does not npm-install). The `pi-only` variant sets `INSTALL_OPENCODE=false`, `INSTALL_PI=true` — pi without opencode, the single source of truth for the separate `pi-devbox` image. It is built and smoke-tested here, but **published into the `joakimp/pi-devbox` repo** as the internal building-block tag `base-pi-only[-vX.Y.Z]` (NOT under `opencode-devbox`), so an opencode-devbox tag never ships without opencode.
|
||||||
- `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu. Volume ownership loop covers `~/.pi/` when `INSTALL_PI=true`.
|
- `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu. Volume ownership loop covers `~/.pi/` when `INSTALL_PI=true`.
|
||||||
- `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), pi-toolkit + pi-extensions deploy (when pi installed), pi settings.json bootstrap, mempalace pi-bridge symlink, skillset auto-deploy from mounted skillset repo, OMOS setup.
|
- `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), LAN-access setup (delegated to `setup-lan-access.sh`), pi-toolkit + pi-extensions deploy (when pi installed), pi settings.json bootstrap, mempalace pi-bridge symlink, runtime `pi install /opt/{pi-fork,pi-observational-memory}` registration (idempotent), skillset auto-deploy from mounted skillset repo, OMOS setup.
|
||||||
|
- `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh` — host-OS-agnostic LAN reachability helper. Detects VM-backed hosts (macOS OrbStack / Docker Desktop, via `host.docker.internal` resolution) and generates a writable `~/.ssh-local/config` using the host as an SSH jump; no-op on native Linux. Controlled by `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` / `DEVBOX_HOST_ALIAS` / `DEVBOX_LAN_AUTOJUMP_PRIVATE`. Ships the mechanism only (generic `host` jump alias); user targets stay host-side — named-peer `ProxyJump host` overrides go in a bind-mounted `~/.config/devbox-shell/ssh-lan.conf` (Included before `~/.ssh/config`), never baked into the image. **Scoping invariant:** every `Include` in the generated config MUST be preceded by a bare `Host *` reset — an `Include` is scoped to the enclosing `Host`/`Match` block, so without the reset the included config only applies when targeting `host`/`mac` and named peers fall back to SSH defaults. The top `Host *` block also overrides `UserKnownHostsFile` and `ControlPath` into the writable `~/.ssh-local` sidecar (first-value-wins), because the bind-mounted `~/.ssh` is read-only — otherwise multiplexed hosts (`ControlPath ~/.ssh/cm/...`) fail to create their master socket. Non-fatal. Counted in the base hash, so editing it advances `base-latest`.
|
||||||
- `rootfs/usr/local/lib/opencode-devbox/generate-config.py` — generates `~/.config/opencode/opencode.jsonc` from env vars. Never overwrites an existing config (checks both `.json` and `.jsonc`). Auto-registers MCP servers for detected tools (mempalace via `mempalace-mcp`, gitea-mcp, context7 remote endpoint).
|
- `rootfs/usr/local/lib/opencode-devbox/generate-config.py` — generates `~/.config/opencode/opencode.jsonc` from env vars. Never overwrites an existing config (checks both `.json` and `.jsonc`). Auto-registers MCP servers for detected tools (mempalace via `mempalace-mcp`, gitea-mcp, context7 remote endpoint).
|
||||||
- `scripts/smoke-test.sh` — post-build image verification. Asserts binary presence, opencode startup, entrypoint correctness, config generation idempotency, and image size thresholds. Used by both CI workflows.
|
- `scripts/smoke-test.sh` — post-build image verification. Asserts binary presence, opencode startup, entrypoint correctness, config generation idempotency, and image size thresholds. Used by both CI workflows.
|
||||||
- `scripts/generate-dockerhub-md.py` — generates `DOCKER_HUB.md` from a hand-maintained `HUB_TEMPLATE` constant. `--check` fails if the committed file is out of sync (enforced by the `validate` workflow).
|
- `scripts/generate-dockerhub-md.py` — generates `DOCKER_HUB.md` from a hand-maintained `HUB_TEMPLATE` constant. `--check` fails if the committed file is out of sync (enforced by the `validate` workflow).
|
||||||
@@ -17,7 +18,7 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d
|
|||||||
- `README.md` — authoritative source documentation for everything in this repo. Independent of `DOCKER_HUB.md`: the Hub doc is hand-maintained in the generator's `HUB_TEMPLATE` and intentionally slim, linking back to the gitea README for depth.
|
- `README.md` — authoritative source documentation for everything in this repo. Independent of `DOCKER_HUB.md`: the Hub doc is hand-maintained in the generator's `HUB_TEMPLATE` and intentionally slim, linking back to the gitea README for depth.
|
||||||
- `.gitea/README.md` — **read this first** if you're touching CI. Architectural overview of the build pipeline (production vs split-base), wall-clock estimates, NPM_CONFIG_PREFIX gotcha, runner expectations, migration plan.
|
- `.gitea/README.md` — **read this first** if you're touching CI. Architectural overview of the build pipeline (production vs split-base), wall-clock estimates, NPM_CONFIG_PREFIX gotcha, runner expectations, migration plan.
|
||||||
- `.gitea/workflows/validate.yml` — lightweight amd64 build + smoke test on push to main and PRs. Also runs the DOCKER_HUB.md sync check.
|
- `.gitea/workflows/validate.yml` — lightweight amd64 build + smoke test on push to main and PRs. Also runs the DOCKER_HUB.md sync check.
|
||||||
- `.gitea/workflows/docker-publish-split.yml` — production CI pipeline on tag push (`v*`). Two-phase split-base: computes base hash, conditionally builds base, runs 4 parallel smoke tests, then 4 parallel multi-arch variant builds, promotes `base-latest` alias, updates Docker Hub description.
|
- `.gitea/workflows/docker-publish-split.yml` — production CI pipeline on tag push (`v*`). Two-phase split-base: computes base hash, conditionally builds base, runs 5 parallel smoke tests, then 5 parallel multi-arch variant builds, promotes `base-latest` alias, updates Docker Hub description.
|
||||||
|
|
||||||
## Versioning scheme
|
## Versioning scheme
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ Tags follow `v{opencode_version}[letter]` — e.g. `v1.14.20` for the first buil
|
|||||||
|
|
||||||
Cautionary example: 2026-05-28 morning, `v1.15.12` was cut while opencode-ai was still at `1.15.11`. The commit message itself acknowledged "OPENCODE_VERSION stays at 1.15.11" but tagged `v1.15.12` anyway. Re-cut as `v1.15.11c` the same afternoon (see CHANGELOG). The `v1.15.12` git tag and Hub images stayed as historical artifacts; the slip cost a CI cycle and a CHANGELOG-rewrite. **Run the npm view check at the top of every release-day cut.**
|
Cautionary example: 2026-05-28 morning, `v1.15.12` was cut while opencode-ai was still at `1.15.11`. The commit message itself acknowledged "OPENCODE_VERSION stays at 1.15.11" but tagged `v1.15.12` anyway. Re-cut as `v1.15.11c` the same afternoon (see CHANGELOG). The `v1.15.12` git tag and Hub images stayed as historical artifacts; the slip cost a CI cycle and a CHANGELOG-rewrite. **Run the npm view check at the top of every release-day cut.**
|
||||||
|
|
||||||
CI produces eight Docker Hub tags per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos`, `vX.Y.Z[n]-with-pi`, `latest-with-pi`, `vX.Y.Z[n]-omos-with-pi`, `latest-omos-with-pi` — one tag pair (versioned + floating alias) per build variant.
|
CI produces eight Docker Hub tags **under `opencode-devbox`** per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos`, `vX.Y.Z[n]-with-pi`, `latest-with-pi`, `vX.Y.Z[n]-omos-with-pi`, `latest-omos-with-pi` — one tag pair (versioned + floating alias) per opencode-bearing variant (four variants). A fifth build, `pi-only`, is built+smoked here but pushed into the **`joakimp/pi-devbox`** repo as `base-pi-only-vX.Y.Z` (+ `base-pi-only` on tag builds), where it becomes the base for that image.
|
||||||
|
|
||||||
When bumping the opencode version, bump `OPENCODE_VERSION` in `Dockerfile.variant` and update the comment in `.env.example` if it names a specific model/version for context.
|
When bumping the opencode version, bump `OPENCODE_VERSION` in `Dockerfile.variant` and update the comment in `.env.example` if it names a specific model/version for context.
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ cd /tmp && npm pack @earendil-works/pi-coding-agent@0.75.5 && tar -xzf earendil-
|
|||||||
- **Resolved versions are logged by the smoke test** — `scripts/smoke-test.sh` prints a "Resolved component versions" table as its first step. CI logs always capture what got baked into a given image even when ARGs default to `latest`.
|
- **Resolved versions are logged by the smoke test** — `scripts/smoke-test.sh` prints a "Resolved component versions" table as its first step. CI logs always capture what got baked into a given image even when ARGs default to `latest`.
|
||||||
- **`PI_VERSION` and `OMOS_VERSION` MUST be passed by CI as concrete versions**, not left at the `latest` default. The npm install steps in `Dockerfile.variant` (`npm install -g @earendil-works/pi-coding-agent` / `oh-my-opencode-slim@${OMOS_VERSION}`) produce identical layer-hashes when the ARG values are byte-identical across builds; combined with the registry buildcache (`base-buildcache`) the layer gets reused even when `latest` would have resolved to a newer upstream. This is the same class of bug that bit pi-devbox v0.74.0 → v0.75.5 (silent same-bytes-across-releases regression discovered 2026-05-23, fixed in pi-devbox v0.75.5b). It is currently *masked* in opencode-devbox by `OPENCODE_VERSION` being a hard-coded ARG that bumps every release — that bump invalidates the parent-chain cache key for the downstream pi/omos layers — but the masking would fail the moment a `vN.N.Nb` opencode-version-unchanged release ships that only bumps pi or omos. Preventative fix: `.gitea/workflows/docker-publish-split.yml` has a `resolve-versions` job that runs `npm view @earendil-works/pi-coding-agent version` and `npm view oh-my-opencode-slim version`, exposing concrete values as outputs that every variant smoke + build job consumes via build-args. Smoke tests assert via `EXPECTED_PI_VERSION` / `EXPECTED_OMOS_VERSION` env vars — would catch the regression on the next release rather than four releases later. **If you change the variant build-args list, the resolve-versions job, or the smoke EXPECTED_*_VERSION wiring, audit all affected jobs in lockstep.**
|
- **`PI_VERSION` and `OMOS_VERSION` MUST be passed by CI as concrete versions**, not left at the `latest` default. The npm install steps in `Dockerfile.variant` (`npm install -g @earendil-works/pi-coding-agent` / `oh-my-opencode-slim@${OMOS_VERSION}`) produce identical layer-hashes when the ARG values are byte-identical across builds; combined with the registry buildcache (`base-buildcache`) the layer gets reused even when `latest` would have resolved to a newer upstream. This is the same class of bug that bit pi-devbox v0.74.0 → v0.75.5 (silent same-bytes-across-releases regression discovered 2026-05-23, fixed in pi-devbox v0.75.5b). It is currently *masked* in opencode-devbox by `OPENCODE_VERSION` being a hard-coded ARG that bumps every release — that bump invalidates the parent-chain cache key for the downstream pi/omos layers — but the masking would fail the moment a `vN.N.Nb` opencode-version-unchanged release ships that only bumps pi or omos. Preventative fix: `.gitea/workflows/docker-publish-split.yml` has a `resolve-versions` job that runs `npm view @earendil-works/pi-coding-agent version` and `npm view oh-my-opencode-slim version`, exposing concrete values as outputs that every variant smoke + build job consumes via build-args. Smoke tests assert via `EXPECTED_PI_VERSION` / `EXPECTED_OMOS_VERSION` env vars — would catch the regression on the next release rather than four releases later. **If you change the variant build-args list, the resolve-versions job, or the smoke EXPECTED_*_VERSION wiring, audit all affected jobs in lockstep.**
|
||||||
- **Registry buildkit cache-export is currently disabled** — do NOT re-add `cache-from`/`cache-to` to the `build-base` step in `.gitea/workflows/docker-publish-split.yml` without first verifying that buildkit's `mode=max` cache-export to `registry-1.docker.io` no longer returns HTTP 400 from the Hub CDN edge. The regression surfaced ~2026-05-23 and broke five consecutive opencode-devbox publish attempts (runs #332/333/334/336 + a rerun); root-caused on 2026-05-28 by a manual host-side publish that reproduced the same 400 only on `--cache-to` while image push worked fine. Failure shape is stable (`Offset:0` in the `_state` token, HTML response body = CDN-tier rejection, not registry backend), repo-specific (we're the only repo writing `:base-buildcache` mode=max), and explains why pinning `setup-buildx-action@v4.0.0` didn't help (action pin doesn't change the bundled buildkit version on the catthehacker runner image). Trade-off: dockerfile.base changes pay a full ~3 min rebuild instead of pulling cached layers; unchanged bases short-circuit at the Hub-probe step in `base-decide` and never re-build anyway. Variants don't use registry cache so they're unaffected. Re-enable condition: upstream moby/buildkit fix lands AND a low-risk test run succeeds without 400s. See CHANGELOG v1.15.12 `Unreleased` block for the full diagnostic chain. Manual escape-hatch publish procedure: `docs/manual-host-publish.md`.
|
- **Registry buildkit cache-export is currently disabled** — do NOT re-add `cache-from`/`cache-to` to the `build-base` step in `.gitea/workflows/docker-publish-split.yml` without first verifying that buildkit's `mode=max` cache-export to `registry-1.docker.io` no longer returns HTTP 400 from the Hub CDN edge. The regression surfaced ~2026-05-23 and broke five consecutive opencode-devbox publish attempts (runs #332/333/334/336 + a rerun); root-caused on 2026-05-28 by a manual host-side publish that reproduced the same 400 only on `--cache-to` while image push worked fine. Failure shape is stable (`Offset:0` in the `_state` token, HTML response body = CDN-tier rejection, not registry backend), repo-specific (we're the only repo writing `:base-buildcache` mode=max), and explains why pinning `setup-buildx-action@v4.0.0` didn't help (action pin doesn't change the bundled buildkit version on the catthehacker runner image). Trade-off: dockerfile.base changes pay a full ~3 min rebuild instead of pulling cached layers; unchanged bases short-circuit at the Hub-probe step in `base-decide` and never re-build anyway. Variants don't use registry cache so they're unaffected. Re-enable condition: upstream moby/buildkit fix lands AND a low-risk test run succeeds without 400s. See CHANGELOG v1.15.12 `Unreleased` block for the full diagnostic chain. Manual escape-hatch publish procedure: `docs/manual-host-publish.md`.
|
||||||
- **Push steps wrap `docker buildx build --push` in a 3-attempt retry loop** (15s, 30s backoff) for transient `registry-1.docker.io` blips — rate limits, brief 5xx, CDN flap. Implemented as inline `shell: bash` steps with `docker buildx build` raw rather than `docker/build-push-action@v7` so the loop is visible and tweakable. Affects the 1 base + 4 variant push steps in `.gitea/workflows/docker-publish-split.yml`; smoke-test builds (`load: true`, no push) are untouched. **This does NOT mask deterministic failures** — a true regression (like the cache-export 400 of 2026-05-23..28) fails all 3 attempts identically and the job still fails. Orthogonal to the cache-export disablement above: cache-export was about a deterministic protocol mismatch, retry is about absorbing genuine transients. Both are belt-and-braces with the `ci-release-watcher` skill's transient-rerun heuristic. If you change the matrix of push steps, keep the retry wrapper consistent across them — the pattern is duplicated rather than factored out because Gitea Actions doesn't support reusable composite shell steps cleanly.
|
- **Push steps wrap `docker buildx build --push` in a 3-attempt retry loop** (15s, 30s backoff) for transient `registry-1.docker.io` blips — rate limits, brief 5xx, CDN flap. Implemented as inline `shell: bash` steps with `docker buildx build` raw rather than `docker/build-push-action@v7` so the loop is visible and tweakable. Affects the 1 base + 5 variant push steps in `.gitea/workflows/docker-publish-split.yml`; smoke-test builds (`load: true`, no push) are untouched. **This does NOT mask deterministic failures** — a true regression (like the cache-export 400 of 2026-05-23..28) fails all 3 attempts identically and the job still fails. Orthogonal to the cache-export disablement above: cache-export was about a deterministic protocol mismatch, retry is about absorbing genuine transients. Both are belt-and-braces with the `ci-release-watcher` skill's transient-rerun heuristic. If you change the matrix of push steps, keep the retry wrapper consistent across them — the pattern is duplicated rather than factored out because Gitea Actions doesn't support reusable composite shell steps cleanly.
|
||||||
- **Shell scripts use `set -euo pipefail`** — both entrypoints are strict. Errors in volume chown or SSH permission operations are intentionally suppressed with `|| true`.
|
- **Shell scripts use `set -euo pipefail`** — both entrypoints are strict. Errors in volume chown or SSH permission operations are intentionally suppressed with `|| true`.
|
||||||
- **MemPalace install path** — installed via `uv tool install` into `/opt/uv-tools/mempalace/`. Both the `mempalace` CLI and the `mempalace-mcp` MCP server binary are shipped as entry points by the mempalace package itself and placed on PATH by uv as shims whose shebangs point at the venv's Python. No hand-rolled wrapper is needed. Do not use `pip install --break-system-packages` — that was the previous approach and has been removed. Do not use `["python3", "-m", "mempalace.mcp_server"]` in `opencode.jsonc` — system Python can't import from the uv venv.
|
- **MemPalace install path** — installed via `uv tool install` into `/opt/uv-tools/mempalace/`. Both the `mempalace` CLI and the `mempalace-mcp` MCP server binary are shipped as entry points by the mempalace package itself and placed on PATH by uv as shims whose shebangs point at the venv's Python. No hand-rolled wrapper is needed. Do not use `pip install --break-system-packages` — that was the previous approach and has been removed. Do not use `["python3", "-m", "mempalace.mcp_server"]` in `opencode.jsonc` — system Python can't import from the uv venv.
|
||||||
- **generate-config.py idempotency** — the script MUST never overwrite an existing `opencode.jsonc` or legacy `opencode.json`. Config persists in the `devbox-opencode-config` named volume; accidentally clobbering that file would destroy hand-edits. The smoke test asserts this.
|
- **generate-config.py idempotency** — the script MUST never overwrite an existing `opencode.jsonc` or legacy `opencode.json`. Config persists in the `devbox-opencode-config` named volume; accidentally clobbering that file would destroy hand-edits. The smoke test asserts this.
|
||||||
@@ -98,11 +99,12 @@ cd /tmp && npm pack @earendil-works/pi-coding-agent@0.75.5 && tar -xzf earendil-
|
|||||||
- `update-description` job runs only when both builds succeed (`needs: [build-base, build-omos]`).
|
- `update-description` job runs only when both builds succeed (`needs: [build-base, build-omos]`).
|
||||||
- Tags must be pushed to trigger the publish workflow. The validate workflow runs on push to main and PRs.
|
- Tags must be pushed to trigger the publish workflow. The validate workflow runs on push to main and PRs.
|
||||||
- Smoke tests run on amd64 only (single-arch load into the local daemon). The multi-arch push happens after smoke passes.
|
- Smoke tests run on amd64 only (single-arch load into the local daemon). The multi-arch push happens after smoke passes.
|
||||||
- **Gitea Actions runner has ~40 GB disk, often 70%+ used at job start.** All eight `load: true` jobs (`validate-base`, `validate-omos`, `validate-with-pi`, `validate-omos-with-pi`, `smoke-base`, `smoke-omos`, `smoke-with-pi`, `smoke-omos-with-pi`) include a `Reclaim runner disk` step that strips catthehacker-resident toolchains and prunes stale docker state before `setup-buildx-action`. Build jobs use a lighter version (push-by-digest doesn't need `docker system prune`). Don't remove these steps without testing on a fresh runner.
|
- **Gitea Actions runner has ~40 GB disk, often 70%+ used at job start.** All ten `load: true` jobs (`validate-base`, `validate-omos`, `validate-with-pi`, `validate-omos-with-pi`, `validate-pi-only`, `smoke-base`, `smoke-omos`, `smoke-with-pi`, `smoke-omos-with-pi`, `smoke-pi-only`) include a `Reclaim runner disk` step that strips catthehacker-resident toolchains and prunes stale docker state before `setup-buildx-action`. Build jobs use a lighter version (push-by-digest doesn't need `docker system prune`). Don't remove these steps without testing on a fresh runner.
|
||||||
- **`docker/build-push-action@v7` with `platforms: linux/amd64,linux/arm64` handles multi-arch push natively in a single job** — produces a proper manifest list, no matrix or merge step needed. An earlier revision split into per-arch matrix jobs with digest artifacts, but that pattern requires `actions/{upload,download}-artifact@v4+` which Gitea Actions doesn't support (see below).
|
- **`docker/build-push-action@v7` with `platforms: linux/amd64,linux/arm64` handles multi-arch push natively in a single job** — produces a proper manifest list, no matrix or merge step needed. An earlier revision split into per-arch matrix jobs with digest artifacts, but that pattern requires `actions/{upload,download}-artifact@v4+` which Gitea Actions doesn't support (see below).
|
||||||
- **`actions/upload-artifact` and `actions/download-artifact` must stay at @v3 on Gitea.** v4+ uses a GitHub-Enterprise-specific Artifact API; runs fail with `GHESNotSupportedError`. If you need artifacts for a new reason (build logs, SBOMs, etc.), pin @v3 explicitly.
|
- **`actions/upload-artifact` and `actions/download-artifact` must stay at @v3 on Gitea.** v4+ uses a GitHub-Enterprise-specific Artifact API; runs fail with `GHESNotSupportedError`. If you need artifacts for a new reason (build logs, SBOMs, etc.), pin @v3 explicitly.
|
||||||
- **Step scripts run under `/bin/sh` (dash), not bash.** Avoid bash-isms like `${VAR//a/b}` parameter-pattern substitution; use POSIX alternatives (`tr`, `sed`) or declare `shell: bash` on the step.
|
- **Step scripts run under `/bin/sh` (dash), not bash.** Avoid bash-isms like `${VAR//a/b}` parameter-pattern substitution; use POSIX alternatives (`tr`, `sed`) or declare `shell: bash` on the step.
|
||||||
- **`BUILDKIT_PROGRESS=plain`** is set at workflow level on `docker-publish-split.yml` so arm64-under-QEMU builds log each layer line-by-line. The default collapsed progress UI hides which step is stalled, which made diagnosing earlier hangs expensive.
|
- **`BUILDKIT_PROGRESS=plain`** is set at workflow level on `docker-publish-split.yml` so arm64-under-QEMU builds log each layer line-by-line. The default collapsed progress UI hides which step is stalled, which made diagnosing earlier hangs expensive.
|
||||||
|
- **`STRICT_REGISTRATION` gates the fork/recall *registration* smoke assertions.** `smoke-test.sh`'s two pi-extension registration checks (that `pi-fork`/`pi-observational-memory` registered in `~/.pi/agent/settings.json`) depend on the *base* entrypoint running `pi install /opt/<pkg>`. `validate.yml` builds variants from the **published** `base-latest`, which lags the in-repo entrypoint until a release rebuilds the base — so those checks would false-negative there. They are therefore warn-only unless `STRICT_REGISTRATION=1`: `validate.yml` leaves it unset (warn), and `docker-publish-split.yml` (which builds the base fresh in the same run) sets `STRICT_REGISTRATION: "1"` on the three pi-bearing smoke jobs to enforce them. Build-time `/opt` + `node_modules` checks stay hard in both paths. If you touch the registration checks or the base-freshness model, keep this flag wiring in lockstep across both workflows.
|
||||||
|
|
||||||
## Testing changes
|
## Testing changes
|
||||||
|
|
||||||
|
|||||||
+203
-1
@@ -8,7 +8,209 @@ Tags follow `v{opencode_version}[letter]` — bare tag for the first build on a
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
_(no changes since v1.15.12)_
|
_(no changes since v1.15.13e)_
|
||||||
|
|
||||||
|
## v1.15.13e — 2026-06-04
|
||||||
|
|
||||||
|
Letter-suffix rebuild on opencode `1.15.13` (version unchanged). Picks up
|
||||||
|
**pi `0.78.1`** (resolved fresh by CI's `resolve-versions` job) plus the LAN-jump
|
||||||
|
key-persistence work, an entrypoint ownership fix for the new `devbox-ssh-local`
|
||||||
|
volume, a CI smoke false-negative fix, and documentation. Touches `entrypoint.sh`
|
||||||
|
and `setup-lan-access.sh` (both in the base hash), so `base-latest` /
|
||||||
|
`base-pi-only` advance and the fixes propagate to `pi-devbox`.
|
||||||
|
|
||||||
|
### Docs: per-host `ControlPath` overrides break `pi --ssh` (read-only `~/.ssh`)
|
||||||
|
|
||||||
|
Documented a gotcha in the README "Reaching your LAN" section: the bind-mounted
|
||||||
|
`~/.ssh/config` is read before the baked `Host *` default, and SSH uses the
|
||||||
|
first `ControlPath` it sees. A per-host block that sets `ControlPath` under
|
||||||
|
`~/.ssh/` (a common CGNAT-multiplexing pattern, e.g. `~/.ssh/cm/%r@%h:%p`) wins
|
||||||
|
but then fails inside the container because `~/.ssh` is mounted read-only — the
|
||||||
|
master socket can't bind. This silently breaks `pi --ssh <host>`: the SSH layer
|
||||||
|
fails and pi falls back to running its tools locally in the container. Fix is
|
||||||
|
host-side — drop the per-host `ControlPath` or repoint it at the writable
|
||||||
|
`/tmp/sshcm/%r@%h:%p` (works on both host and container, preserves multiplexing).
|
||||||
|
No image change; documentation only.
|
||||||
|
|
||||||
|
### Fixed: validate.yml false-negative on fork/recall registration checks
|
||||||
|
|
||||||
|
The push-to-main `validate.yml` builds variants FROM the published `base-latest`
|
||||||
|
image, which lags the entrypoint in the current commit until a release tag
|
||||||
|
rebuilds the base. The fork/recall *registration* smoke checks depend on the
|
||||||
|
base entrypoint running `pi install /opt/<pkg>`, so a stale `base-latest` reded
|
||||||
|
those runs with a false negative even when the variant layer was correct.
|
||||||
|
`smoke-test.sh` now gates the two registration assertions behind
|
||||||
|
`STRICT_REGISTRATION` (warn-only when unset). `validate.yml` leaves it unset;
|
||||||
|
the release pipeline (`docker-publish-split.yml`), which builds the base fresh
|
||||||
|
in the same run, sets `STRICT_REGISTRATION=1` on the pi-bearing smoke jobs to
|
||||||
|
enforce them. The build-time `/opt` + `node_modules` checks stay hard in both
|
||||||
|
paths.
|
||||||
|
|
||||||
|
### Added: persist the LAN-jump key + one-line authorize hint (authorize once per machine)
|
||||||
|
|
||||||
|
The jump keypair (`~/.ssh-local/devbox_jump_ed25519`) was stored on the
|
||||||
|
container's ephemeral overlay, so `docker compose up --force-recreate` (every
|
||||||
|
image update) regenerated it — forcing you to re-authorize the new key on the
|
||||||
|
host each time. The compose files now persist `~/.ssh-local` via a named volume
|
||||||
|
(`devbox-ssh-local`), matching the pattern already used for `.pi`, shell
|
||||||
|
history, etc. The key is generated **once** and reused across updates, so you
|
||||||
|
authorize it on the host **once per machine**.
|
||||||
|
|
||||||
|
`setup-lan-access.sh` now also prints a ready-to-paste authorize line whenever
|
||||||
|
it generates a **new** key (not just when `HOST_SSH_USER` is unset), e.g.
|
||||||
|
`echo 'ssh-ed25519 …' >> ~/.ssh/authorized_keys` — no helper file to locate, no
|
||||||
|
workspace path to guess. It stays silent once the key is persisted.
|
||||||
|
|
||||||
|
### Fixed: chown the `devbox-ssh-local` volume so the jump key can be generated
|
||||||
|
|
||||||
|
The previous change persisted `~/.ssh-local` via a named volume, but the
|
||||||
|
entrypoint's volume-ownership loop was never updated to include it. Docker
|
||||||
|
creates named volumes as `root:root`, so on a fresh volume `~/.ssh-local`
|
||||||
|
stayed root-owned while `setup-lan-access.sh` runs as `developer` — both its
|
||||||
|
`mkdir cm` and `ssh-keygen` failed silently (`|| true` / `|| exit 0`), leaving
|
||||||
|
**no jump key and no config**, breaking LAN access on the first recreate after
|
||||||
|
the persistence change. `entrypoint.sh` now chowns `~/.ssh-local` to the
|
||||||
|
developer user alongside the other named-volume mount points.
|
||||||
|
|
||||||
|
### Docs: document the optional `~/.config/devbox-shell` mount in the compose template
|
||||||
|
|
||||||
|
`docker-compose.yml` now carries a commented-out `~/.config/devbox-shell` bind
|
||||||
|
mount with an explanatory note. It's the recommended home for host-owned shell
|
||||||
|
config: the image's `~/.bash_aliases` sources `~/.config/devbox-shell/bash_aliases`
|
||||||
|
if present, and `setup-lan-access.sh` reads `~/.config/devbox-shell/ssh-lan.conf`
|
||||||
|
for named-peer `ProxyJump host` overrides. A directory mount is preferred over
|
||||||
|
the single-file `~/.bash_aliases` mount because it survives editors' atomic-save.
|
||||||
|
Template comment only; no behavior change.
|
||||||
|
|
||||||
|
## v1.15.13d — 2026-06-04
|
||||||
|
|
||||||
|
LAN-access fixes + ergonomics. Letter-suffix rebuild on opencode `1.15.13`
|
||||||
|
(version unchanged). Touches `setup-lan-access.sh`, which is in the base hash,
|
||||||
|
so `base-latest` / `base-pi-only` advance and the fix propagates to `pi-devbox`.
|
||||||
|
|
||||||
|
### Fixed: LAN-access `Include` was scoped to the `host`/`mac` block (named peers ignored)
|
||||||
|
|
||||||
|
The generated `~/.ssh-local/config` placed `Include ~/.ssh/config` *inside* the
|
||||||
|
`Host host mac` block. Because SSH scopes an `Include` to the enclosing
|
||||||
|
`Host`/`Match` block, the user's `~/.ssh/config` was only consulted when
|
||||||
|
targeting `host`/`mac` — so `dssh pve` / `dssh <peer>` by name silently fell
|
||||||
|
back to SSH defaults (wrong user, unresolved hostname) and never applied the
|
||||||
|
peer's settings or any `ProxyJump`. Fixed by emitting a bare `Host *` scope
|
||||||
|
reset before every `Include`.
|
||||||
|
|
||||||
|
### Fixed: read-only `~/.ssh/cm` ControlPath broke multiplexed hosts
|
||||||
|
|
||||||
|
The bind-mounted `~/.ssh/config` commonly sets `ControlPath ~/.ssh/cm/...`
|
||||||
|
(CGNAT flow-cap multiplexing), but `~/.ssh` is read-only in the container, so
|
||||||
|
every `ControlMaster`-enabled host (e.g. `pmx-jh`, `proxmox*`, `synlig`) failed
|
||||||
|
with `cannot bind to path … Read-only file system`. The generated config now
|
||||||
|
sets `ControlPath ~/.ssh-local/cm/%r@%h:%p` in the top `Host *` block
|
||||||
|
(first-value-wins) so master sockets land in the writable sidecar.
|
||||||
|
|
||||||
|
### Added: host-owned `ssh-lan.conf` for named-peer jump overrides
|
||||||
|
|
||||||
|
When the host bind-mounts `~/.config/devbox-shell/ssh-lan.conf`, the generated
|
||||||
|
config now Includes it *before* `~/.ssh/config`. Put `ProxyJump host` overrides
|
||||||
|
there (first-value-wins inherits HostName/User/IdentityFile from `~/.ssh/config`)
|
||||||
|
instead of editing the shared `~/.ssh/config` — which would break the host's own
|
||||||
|
direct access to those peers and is read-only from the container anyway. New
|
||||||
|
[`ssh-lan.conf.example`](ssh-lan.conf.example).
|
||||||
|
|
||||||
|
### Added: `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` opt-in RFC1918 auto-jump
|
||||||
|
|
||||||
|
Emits a catch-all that ProxyJumps any private (RFC1918) IP through the host, so
|
||||||
|
bare `dssh user@<ip>` reaches whatever LAN the (roaming) host is currently on,
|
||||||
|
without naming peers. Matches the typed address (not the resolved HostName), so
|
||||||
|
named hosts carrying their own ProxyJump are unaffected; public IPs stay direct.
|
||||||
|
|
||||||
|
All three land in `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh`,
|
||||||
|
which is counted in the base hash → advances `base-latest` and propagates to
|
||||||
|
`pi-devbox` (built `FROM` the base).
|
||||||
|
|
||||||
|
## v1.15.13c — 2026-06-03
|
||||||
|
|
||||||
|
Follow-up to v1.15.13b: relocates the pi-only build out of the `opencode-devbox`
|
||||||
|
repo (Option B) and fixes the base size threshold that blocked `promote-base-latest`.
|
||||||
|
|
||||||
|
### Changed: `pi-only` build now publishes to the `joakimp/pi-devbox` repo (not `opencode-devbox`)
|
||||||
|
|
||||||
|
The `pi-only` variant (added in v1.15.13b) was published under `opencode-devbox`
|
||||||
|
as `latest-pi-only` / `vX.Y.Z-pi-only` — an "opencode-devbox" tag that contains
|
||||||
|
**no opencode**, which confused users browsing the tag list.
|
||||||
|
|
||||||
|
- The `build-variant-pi-only` CI job now pushes the artifact into the
|
||||||
|
**`joakimp/pi-devbox`** repo as `base-pi-only-vX.Y.Z` (+ floating `base-pi-only`
|
||||||
|
on tag builds) instead of `opencode-devbox:*-pi-only`. New `PI_IMAGE` workflow env.
|
||||||
|
- It is still built from the same `Dockerfile.variant` (single source of truth)
|
||||||
|
and still smoke-tested by `smoke-pi-only` / `validate-pi-only` before publish.
|
||||||
|
- `opencode-devbox` now publishes **eight** tags per release (four opencode-bearing
|
||||||
|
variants) plus `base-latest`; the pi-only pair lives in the pi-devbox repo.
|
||||||
|
- De-advertised the pi-only tag from the README, `DOCKER_HUB.md` (HUB_TEMPLATE),
|
||||||
|
and AGENTS docs.
|
||||||
|
- The old `opencode-devbox:latest-pi-only` / `vX.Y.Z-pi-only` tags from v1.15.13b
|
||||||
|
are superseded and should be deleted from Docker Hub.
|
||||||
|
|
||||||
|
### Fixed: base image size threshold (unblocks `promote-base-latest`)
|
||||||
|
|
||||||
|
- Bumped the `base` variant smoke size threshold 2500 → 2600 MB. In the v1.15.13b
|
||||||
|
run the base crept to 2506 MB (LAN-access script + updated entrypoint + apt
|
||||||
|
drift) and tripped the deliberately zero-headroom 2500 ceiling, which failed
|
||||||
|
`smoke-base` and cascaded into skipping `build-variant-base` **and**
|
||||||
|
`promote-base-latest` — so `base-latest` never advanced. (`base-<hash>` and the
|
||||||
|
omos/with-pi/omos-with-pi/pi-only variants did publish on the fresh base.)
|
||||||
|
|
||||||
|
## v1.15.13b — 2026-06-03
|
||||||
|
|
||||||
|
Container-level rebuild on opencode `1.15.13` (unchanged) and pi `0.78.0` (unchanged) — adds host-OS-agnostic LAN access, the `fork`/`recall` pi extensions, and a new `pi-only` variant. Letter-suffix release per the `v{opencode_version}[letter]` scheme since no upstream version moved.
|
||||||
|
|
||||||
|
### Added: host-OS-agnostic LAN access (base image)
|
||||||
|
|
||||||
|
The container can now reach LAN peers that the **host** can reach, regardless of host OS — addressing the macOS/Docker-Desktop limitation where a container in the Linux VM cannot see the host's directly-attached LAN.
|
||||||
|
|
||||||
|
- New `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh`, invoked (non-fatally) by `entrypoint-user.sh` on every start.
|
||||||
|
- **Detection:** on VM-backed hosts (macOS OrbStack / Docker Desktop, Windows Docker Desktop — detected via `host.docker.internal` resolution) it generates a writable `~/.ssh-local/config` that uses the host as an SSH **jump**. On native Linux Docker (LAN reachable directly) it is a **no-op**.
|
||||||
|
- **Mechanism, not policy:** ships a generic `host` (alias `mac`) jump entry + a generated jump key in the writable `~/.ssh-local/` sidecar (necessary because `~/.ssh` is bind-mounted read-only). Your own targets stay in your bind-mounted `~/.ssh/config` (add `ProxyJump host`), pulled in via `Include ~/.ssh/config`.
|
||||||
|
- New env knobs: `DEVBOX_LAN_ACCESS` (`auto`|`jump`|`off`, default `auto`), `HOST_SSH_USER`, `DEVBOX_HOST_ALIAS`. When `HOST_SSH_USER` is unset the entrypoint prints the public key to authorize on the host.
|
||||||
|
- New `dssh` / `dscp` aliases in `.bash_aliases` (wrap `ssh -F ~/.ssh-local/config`), guarded so they only appear when the jump config was generated.
|
||||||
|
- Because this touches `Dockerfile.base` inputs (`rootfs/`, `entrypoint-user.sh`), the base image rebuilds and `base-latest` advances.
|
||||||
|
|
||||||
|
### Added: pi-fork (`fork`) + pi-observational-memory (`recall`) in pi variants
|
||||||
|
|
||||||
|
The `with-pi` and `omos-with-pi` variants now bake in two pi extensions from `github.com/elpapi42`:
|
||||||
|
|
||||||
|
- `Dockerfile.variant` clones both repos to `/opt/pi-fork` and `/opt/pi-observational-memory` and runs `npm install` there at **build** time (a local-path `pi install` does not npm-install, so deps must be present for the extension to load).
|
||||||
|
- `entrypoint-user.sh` registers them at runtime via `pi install /opt/<pkg>` (instant, in-place, idempotent; `fork`/`recall` tools bind on the next pi start).
|
||||||
|
- CI (`resolve-versions`) resolves the `master` HEAD of each repo to a concrete commit SHA and passes it as `PI_FORK_REF` / `PI_OBSMEM_REF` build-args — same registry-buildcache cache-hit guard used for `PI_VERSION` / `OMOS_VERSION`.
|
||||||
|
- New build-args: `PI_FORK_REPO`, `PI_FORK_REF`, `PI_OBSMEM_REPO`, `PI_OBSMEM_REF`.
|
||||||
|
- Smoke test asserts the `/opt` clones + baked `node_modules` exist and that both packages register in `settings.json`. Size thresholds bumped: `with-pi` 2700→2900 MB, `omos-with-pi` 3700→3900 MB (fork's `@earendil-works` peer deps add ~150 MB).
|
||||||
|
|
||||||
|
### Added: `pi-only` variant (basis for `pi-devbox`)
|
||||||
|
|
||||||
|
New fifth published variant built with `INSTALL_OPENCODE=false`, `INSTALL_PI=true` — pi + companions (toolkit, extensions, `fork`, `recall`) and all base tooling, but **without** opencode (~145 MB lighter than `with-pi`).
|
||||||
|
|
||||||
|
- Published as `latest-pi-only` / `vX.Y.Z-pi-only` (multi-arch). New CI jobs `smoke-pi-only` and `build-variant-pi-only`; wired into `promote-base-latest` / `update-description` needs.
|
||||||
|
- This is the **single source of truth** for the separate [`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox) image, which now `FROM`s `latest-pi-only` instead of duplicating the pi-install logic. Lets pi-devbox stay lean and pi-focused while the install logic lives in one place.
|
||||||
|
- Smoke size threshold: 2750 MB (`with-pi` minus opencode).
|
||||||
|
|
||||||
|
_Versions unchanged: opencode-ai `1.15.13`, pi `0.78.0` (both still latest at time of writing)._
|
||||||
|
|
||||||
|
## v1.15.13 — 2026-05-29
|
||||||
|
|
||||||
|
First container build on `opencode-ai@1.15.13` upstream release (published 2026-05-29). Also picks up pi `0.77.0` → `0.78.0` (resolved from npm at build time).
|
||||||
|
|
||||||
|
### Bumped: opencode-ai 1.15.12 → 1.15.13
|
||||||
|
|
||||||
|
**Core**
|
||||||
|
- Gateway Anthropic Opus 4.7+ adaptive reasoning now keeps summarized thinking instead of returning empty thinking blocks (bugfix).
|
||||||
|
- Sessions can now store custom metadata through the API and SDK ([@shantur](https://github.com/shantur)).
|
||||||
|
- Config now loads from the opened location upward, so directory-specific settings and provider policies apply more predictably.
|
||||||
|
|
||||||
|
**TUI**
|
||||||
|
- Wrapped inline tool rows now stay aligned, and failed inline tools can expand their error details in place (bugfix).
|
||||||
|
|
||||||
|
### Bumped: pi 0.77.0 → 0.78.0 (resolved from npm at build time)
|
||||||
|
|
||||||
|
See [pi-devbox v0.78.0](https://github.com/joakimp/pi-devbox/releases/tag/v0.78.0) for full pi release notes.
|
||||||
|
|
||||||
## v1.15.12 — 2026-05-29
|
## v1.15.12 — 2026-05-29
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ Designed for teams who want a reproducible coding-agent setup that runs the same
|
|||||||
|
|
||||||
All variants support `linux/amd64` and `linux/arm64`.
|
All variants support `linux/amd64` and `linux/arm64`.
|
||||||
|
|
||||||
|
> A fifth, pi-without-opencode build is produced from the same `Dockerfile.variant`
|
||||||
|
> (`INSTALL_OPENCODE=false`) but is **not** published under this repo — it ships as
|
||||||
|
> the separate [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)
|
||||||
|
> image so an "opencode-devbox" tag never lacks opencode.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
For a fully-configured environment with persistent state (opencode config, mempalace memory, neovim plugins, bash history) surviving container recreation, use docker-compose. **You don't need to clone the repo** — just grab two template files:
|
For a fully-configured environment with persistent state (opencode config, mempalace memory, neovim plugins, bash history) surviving container recreation, use docker-compose. **You don't need to clone the repo** — just grab two template files:
|
||||||
|
|||||||
+36
-2
@@ -12,6 +12,12 @@
|
|||||||
# omos true true false
|
# omos true true false
|
||||||
# with-pi true false true
|
# with-pi true false true
|
||||||
# omos-with-pi true true true
|
# omos-with-pi true true true
|
||||||
|
# pi-only false false true
|
||||||
|
#
|
||||||
|
# The `pi-only` variant is the single source of truth for the pi-devbox
|
||||||
|
# image (pi + companions, no opencode). It exists so pi-devbox can FROM it
|
||||||
|
# without inheriting opencode, while the pi install logic stays defined
|
||||||
|
# here in one place.
|
||||||
#
|
#
|
||||||
# Pass `--build-arg BASE_IMAGE=<repo>:base-<hash>` to select the base.
|
# Pass `--build-arg BASE_IMAGE=<repo>:base-<hash>` to select the base.
|
||||||
# The CI workflow computes the base hash from Dockerfile.base + rootfs/
|
# The CI workflow computes the base hash from Dockerfile.base + rootfs/
|
||||||
@@ -36,7 +42,7 @@ ARG USER_NAME=developer
|
|||||||
# edit, so the cache-hit class of bug that bit pi-devbox v0.74.0..
|
# edit, so the cache-hit class of bug that bit pi-devbox v0.74.0..
|
||||||
# v0.75.5 cannot apply here.
|
# v0.75.5 cannot apply here.
|
||||||
ARG INSTALL_OPENCODE=true
|
ARG INSTALL_OPENCODE=true
|
||||||
ARG OPENCODE_VERSION=1.15.12
|
ARG OPENCODE_VERSION=1.15.13
|
||||||
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
|
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
|
||||||
NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \
|
NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \
|
||||||
opencode --version ; \
|
opencode --version ; \
|
||||||
@@ -62,6 +68,17 @@ ARG INSTALL_PI=false
|
|||||||
ARG PI_VERSION=latest
|
ARG PI_VERSION=latest
|
||||||
ARG PI_TOOLKIT_REF=main
|
ARG PI_TOOLKIT_REF=main
|
||||||
ARG PI_EXTENSIONS_REF=main
|
ARG PI_EXTENSIONS_REF=main
|
||||||
|
# pi-fork (fork tool) + pi-observational-memory (recall tool) live on GitHub
|
||||||
|
# under elpapi42. Refs default to the tracked branch for local dev; CI resolves
|
||||||
|
# them to concrete commit SHAs (see resolve-versions in docker-publish-split.yml)
|
||||||
|
# so the build-arg string changes when upstream moves — same registry-buildcache
|
||||||
|
# cache-hit footgun the PI_VERSION/OMOS_VERSION pins guard against. The clone
|
||||||
|
# helper for these uses `git fetch <ref>` (not `--branch`) so it accepts both
|
||||||
|
# branch names and raw commit SHAs.
|
||||||
|
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 if [ "${INSTALL_PI}" = "true" ]; then \
|
RUN if [ "${INSTALL_PI}" = "true" ]; then \
|
||||||
set -e && \
|
set -e && \
|
||||||
git_clone_retry() { \
|
git_clone_retry() { \
|
||||||
@@ -74,6 +91,17 @@ RUN if [ "${INSTALL_PI}" = "true" ]; then \
|
|||||||
done; \
|
done; \
|
||||||
return 1; \
|
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 \
|
if [ "${PI_VERSION}" = "latest" ]; then \
|
||||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
|
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
|
||||||
else \
|
else \
|
||||||
@@ -82,8 +110,14 @@ RUN if [ "${INSTALL_PI}" = "true" ]; then \
|
|||||||
pi --version && \
|
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-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_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-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-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)" ; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Optional: Go ─────────────────────────────────────────────────────
|
# ── Optional: Go ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ docker compose exec -u developer devbox aws --version
|
|||||||
| `GIT_USER_EMAIL` | Git commit author email | — |
|
| `GIT_USER_EMAIL` | Git commit author email | — |
|
||||||
| `WORKSPACE_PATH` | Host path to mount | `.` |
|
| `WORKSPACE_PATH` | Host path to mount | `.` |
|
||||||
| `SSH_KEY_PATH` | Host SSH key directory | `~/.ssh` |
|
| `SSH_KEY_PATH` | Host SSH key directory | `~/.ssh` |
|
||||||
|
| `DEVBOX_LAN_ACCESS` | LAN-access mode: `auto` (jump only on VM-backed hosts), `jump` (always), `off` | `auto` |
|
||||||
|
| `HOST_SSH_USER` | Username to SSH into the host as (required for the LAN jump) | — |
|
||||||
|
| `DEVBOX_HOST_ALIAS` | Hostname used to reach the container host | `host.docker.internal` |
|
||||||
|
| `DEVBOX_LAN_AUTOJUMP_PRIVATE` | `1` = ProxyJump *any* RFC1918 (private) IP through the host, so bare `dssh user@<ip>` works on whatever LAN the host is currently on | `0` |
|
||||||
| `USER_UID` | Override container user UID | Auto-detect from `/workspace` |
|
| `USER_UID` | Override container user UID | Auto-detect from `/workspace` |
|
||||||
| `USER_GID` | Override container user GID | Auto-detect from `/workspace` |
|
| `USER_GID` | Override container user GID | Auto-detect from `/workspace` |
|
||||||
| `LANG` | System locale | `en_US.UTF-8` |
|
| `LANG` | System locale | `en_US.UTF-8` |
|
||||||
@@ -144,6 +148,61 @@ docker compose exec -u developer devbox aws --version
|
|||||||
| `OMOS_RESET` | Force regenerate OMOS config on next start | `false` |
|
| `OMOS_RESET` | Force regenerate OMOS config on next start | `false` |
|
||||||
| `SKILLSET_CONTAINER_PATH` | Path to skillset repo inside container (for auto-deploy when not at /workspace/skillset) | Auto-detect |
|
| `SKILLSET_CONTAINER_PATH` | Path to skillset repo inside container (for auto-deploy when not at /workspace/skillset) | Auto-detect |
|
||||||
|
|
||||||
|
### Reaching your LAN from the container
|
||||||
|
|
||||||
|
The devbox works the same way whether the host is **native Linux Docker** or a **VM-backed** runtime (macOS OrbStack / Docker Desktop, or Docker Desktop on Windows) — but their networking differs:
|
||||||
|
|
||||||
|
- **Native Linux Docker:** the host NATs container egress onto its LAN, so other devices on your LAN are reachable directly. Nothing to configure.
|
||||||
|
- **VM-backed (macOS / Docker Desktop):** the container runs in a Linux VM behind the host's network stack. The host's *directly-attached* LAN peers are **not** bridged into the container by default — only the host itself and *routed* subnets are reachable.
|
||||||
|
|
||||||
|
On every start the entrypoint detects which case applies. On VM-backed hosts it generates a writable `~/.ssh-local/config` that uses the **host as an SSH jump** to reach LAN peers; on native Linux it does nothing. The jump keypair lives in `~/.ssh-local`, which is persisted by the `devbox-ssh-local` named volume — so it's generated **once** and reused across container updates.
|
||||||
|
|
||||||
|
**To enable it on a VM-backed host (one-time setup per machine):**
|
||||||
|
|
||||||
|
1. Set `HOST_SSH_USER=<your host username>` in `.env`.
|
||||||
|
2. Start the container once. When it generates the jump key it prints a ready-to-paste line — run it **on the host** to authorize the key:
|
||||||
|
```bash
|
||||||
|
echo 'ssh-ed25519 AAAA…devbox-jump@…' >> ~/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
3. Ensure the host's SSH server is on (on macOS: System Settings → General → Sharing → Remote Login).
|
||||||
|
4. Reach the host itself with `dssh host`. (`dssh`/`dscp` wrap `ssh -F ~/.ssh-local/config`.)
|
||||||
|
|
||||||
|
Because the key is persisted, you do this **once per machine** — not after every `docker compose up --force-recreate`. You'll only see the authorize line again if you reset the `devbox-ssh-local` volume.
|
||||||
|
|
||||||
|
That alone gets you `container → host`. To reach **named LAN peers** by name, give them a `ProxyJump host` override. Don't add it to the shared `~/.ssh/config` entries — the host itself reaches those peers *directly*, and a jump-through-`host` would break the host's own access (and that file is mounted read-only anyway). Instead, drop the overrides in a **host-owned** file that the container Includes ahead of your `~/.ssh/config`:
|
||||||
|
|
||||||
|
```sshconfig
|
||||||
|
# ~/.config/devbox-shell/ssh-lan.conf — on the host, bind-mounted in
|
||||||
|
# Only ProxyJump goes here; HostName/User/IdentityFile are inherited
|
||||||
|
# (first-value-wins) from the matching block in your ~/.ssh/config.
|
||||||
|
Host my-nas pve pbs
|
||||||
|
ProxyJump host
|
||||||
|
```
|
||||||
|
|
||||||
|
Now `dssh my-nas` routes container → host → LAN peer, pulling HostName/User/key from your existing `~/.ssh/config`. See [`ssh-lan.conf.example`](ssh-lan.conf.example).
|
||||||
|
|
||||||
|
**Roaming / unnamed peers.** Because the jump always targets `host` (= the host on whatever LAN it's currently joined to), you can reach the *current* LAN from anywhere. To make bare `dssh user@<private-ip>` jump automatically without naming peers, set `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` — it ProxyJumps any RFC1918 address through the host. It matches the address you *type* (not the resolved HostName), so named hosts that already carry their own ProxyJump are unaffected.
|
||||||
|
|
||||||
|
**Public IPs go direct.** The container has normal internet egress, so a host with a public IP (or one reached via a *public* jump host) connects straight out — the local `host` jump is not involved. e.g. a `Host bastion` whose `HostName` is public, and everything that `ProxyJump bastion`, works from the container by name with no extra setup.
|
||||||
|
|
||||||
|
> This ships the **mechanism** only — your specific target hosts are facts about *your* network (and a laptop roams between several), so they live in your own host-side config, never baked into the image. Set `DEVBOX_LAN_ACCESS=off` to disable, or `=jump` to force it (e.g. native Linux with `extra_hosts: ["host.docker.internal:host-gateway"]`).
|
||||||
|
|
||||||
|
#### Gotcha: per-host `ControlPath` and `pi --ssh`
|
||||||
|
|
||||||
|
The base image bakes a `Host *` default (`/etc/ssh/ssh_config.d/00-devbox-controlmaster.conf`) that points `ControlPath` at the writable, per-container `/tmp/sshcm/` (created mode-700 on every start by `entrypoint-user.sh`). Multiplexing therefore works out of the box. **But your bind-mounted `~/.ssh/config` is read first, and SSH uses the first value it sees** — so any per-host block that sets its own `ControlPath` under `~/.ssh/` (a common CGNAT-multiplexing pattern, e.g. `ControlPath ~/.ssh/cm/%r@%h:%p`) **wins, and then fails inside the container** because `~/.ssh` is mounted **read-only** — the master socket can't bind (`cannot bind … Read-only file system`).
|
||||||
|
|
||||||
|
This bites `pi --ssh <host>` especially: the SSH layer fails to establish the master and pi silently falls back to running its `read`/`write`/`edit`/`bash` tools **locally in the container** instead of on the remote (watch for the missing `SSH ⚡` in the status bar — and `hostname` returning the container ID).
|
||||||
|
|
||||||
|
**Fix (host-side, one line):** in your host's `~/.ssh/config`, either drop the per-host `ControlPath` (to inherit the writable baked default) or point it at a path that's writable inside the container too:
|
||||||
|
|
||||||
|
```sshconfig
|
||||||
|
Host my-remote
|
||||||
|
# was: ControlPath ~/.ssh/cm/%r@%h:%p ← read-only in the container
|
||||||
|
ControlPath /tmp/sshcm/%r@%h:%p # writable on both host and container
|
||||||
|
```
|
||||||
|
|
||||||
|
`/tmp/sshcm/` is also writable on the host (macOS/Linux), so native (non-container) `ssh`/`pi --ssh` from the host keeps working and CGNAT multiplexing is preserved (`ControlMaster`/`ControlPersist` unchanged — only the socket *directory* moves). Note SSH does not create the `ControlPath` parent dir; the container makes `/tmp/sshcm` every start, but on the host run `mkdir -p /tmp/sshcm` once if it doesn't already exist.
|
||||||
|
|
||||||
### Custom opencode config
|
### Custom opencode config
|
||||||
|
|
||||||
Opencode configuration is persisted automatically via the named volume `devbox-opencode-config`. This volume is mounted at `/home/developer/.config/opencode` by default — no host directory setup required. All changes to `opencode.jsonc`, skills, and (on the OMOS variant) `oh-my-opencode-slim.json` survive container recreation.
|
Opencode configuration is persisted automatically via the named volume `devbox-opencode-config`. This volume is mounted at `/home/developer/.config/opencode` by default — no host directory setup required. All changes to `opencode.jsonc`, skills, and (on the OMOS variant) `oh-my-opencode-slim.json` survive container recreation.
|
||||||
@@ -432,7 +491,7 @@ All six agents should respond if your provider authentication is working.
|
|||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
Pre-built pi-enabled images are available on Docker Hub as `joakimp/opencode-devbox:latest-with-pi` (base + pi) and `joakimp/opencode-devbox:latest-omos-with-pi` (OMOS + pi). Pulling one of those tags is the fastest path. Alternatively, build from source:
|
Pre-built pi-enabled images are available on Docker Hub as `joakimp/opencode-devbox:latest-with-pi` (base + pi) and `joakimp/opencode-devbox:latest-omos-with-pi` (OMOS + pi). Pulling one of those tags is the fastest path. If you want pi **without** opencode, use the separate, leaner [`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox) image instead (it's built from the same `Dockerfile.variant` with `INSTALL_OPENCODE=false`, published in its own repo so an opencode-devbox tag never ships without opencode). Alternatively, build from source:
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ services:
|
|||||||
# allowing both native and containerized opencode on the same machine.
|
# allowing both native and containerized opencode on the same machine.
|
||||||
- devbox-opencode-config:/home/developer/.config/opencode
|
- devbox-opencode-config:/home/developer/.config/opencode
|
||||||
- devbox-pi-config:/home/developer/.pi
|
- devbox-pi-config:/home/developer/.pi
|
||||||
|
# Persist the generated LAN-jump keypair (~/.ssh-local) across recreates.
|
||||||
|
# setup-lan-access.sh generates this key once and reuses it; persisting
|
||||||
|
# it means you authorize it on the host ONCE rather than re-authorizing
|
||||||
|
# after every `docker compose up --force-recreate`.
|
||||||
|
- devbox-ssh-local:/home/developer/.ssh-local
|
||||||
|
|
||||||
# NOTE: Do NOT bind-mount ~/.agents/skills/ from the host. The
|
# NOTE: Do NOT bind-mount ~/.agents/skills/ from the host. The
|
||||||
# container manages its own skills directory independently — the
|
# container manages its own skills directory independently — the
|
||||||
@@ -95,6 +100,14 @@ services:
|
|||||||
# - ~/.bash_aliases:/home/developer/.bash_aliases:ro
|
# - ~/.bash_aliases:/home/developer/.bash_aliases:ro
|
||||||
# - ~/.inputrc:/home/developer/.inputrc:ro
|
# - ~/.inputrc:/home/developer/.inputrc:ro
|
||||||
|
|
||||||
|
# Optional: host-owned shell config + LAN jump overrides (recommended
|
||||||
|
# over the single-file ~/.bash_aliases mount above — it's a directory,
|
||||||
|
# so it survives editors' atomic-save). The image's ~/.bash_aliases
|
||||||
|
# sources ~/.config/devbox-shell/bash_aliases if present, and
|
||||||
|
# setup-lan-access.sh reads ~/.config/devbox-shell/ssh-lan.conf for
|
||||||
|
# named-peer `ProxyJump host` overrides (see ssh-lan.conf.example).
|
||||||
|
# - ~/.config/devbox-shell:/home/developer/.config/devbox-shell:ro
|
||||||
|
|
||||||
# Optional: persist uv data (Python installs, tool installs)
|
# Optional: persist uv data (Python installs, tool installs)
|
||||||
# Without this, 'uv python install' must be re-run after container removal.
|
# Without this, 'uv python install' must be re-run after container removal.
|
||||||
- devbox-uv:/home/developer/.local/share/uv
|
- devbox-uv:/home/developer/.local/share/uv
|
||||||
@@ -126,6 +139,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
devbox-opencode-config:
|
devbox-opencode-config:
|
||||||
devbox-pi-config:
|
devbox-pi-config:
|
||||||
|
devbox-ssh-local:
|
||||||
devbox-data:
|
devbox-data:
|
||||||
devbox-state:
|
devbox-state:
|
||||||
devbox-shell-history:
|
devbox-shell-history:
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ After the constants are set, the script runs a 5-step procedure. No editing need
|
|||||||
1. **Preflight** — buildx present, tag exists, `HEAD == tag`, multi-arch builder created if missing.
|
1. **Preflight** — buildx present, tag exists, `HEAD == tag`, multi-arch builder created if missing.
|
||||||
2. **Base build (conditional)** — probe `${IMAGE}:base-${BASE_HASH}` on the Hub; if missing, build it multi-arch and push. **No `--cache-from` / `--cache-to`.** That's the whole point of this escape. If the base push itself fails the same way CI did, stop — the regression has spread to image push and you need a different host or account, not this runbook.
|
2. **Base build (conditional)** — probe `${IMAGE}:base-${BASE_HASH}` on the Hub; if missing, build it multi-arch and push. **No `--cache-from` / `--cache-to`.** That's the whole point of this escape. If the base push itself fails the same way CI did, stop — the regression has spread to image push and you need a different host or account, not this runbook.
|
||||||
3. **Promote `base-latest`** — `docker buildx imagetools create` re-tags by manifest reference. No rebuild.
|
3. **Promote `base-latest`** — `docker buildx imagetools create` re-tags by manifest reference. No rebuild.
|
||||||
4. **Variants × 4** — sequential (not parallel; one host's egress can't saturate four multi-arch pushes safely). Each variant is `Dockerfile.variant` `FROM ${IMAGE}:base-${BASE_HASH}` plus the appropriate `INSTALL_OMOS` / `INSTALL_PI` build-args, tagged `${RELEASE_TAG}${suffix}` and `latest${suffix}`.
|
4. **Variants × 5** — sequential (not parallel; one host's egress can't saturate five multi-arch pushes safely). Each variant is `Dockerfile.variant` `FROM ${IMAGE}:base-${BASE_HASH}` plus the appropriate `INSTALL_OPENCODE` / `INSTALL_OMOS` / `INSTALL_PI` build-args, tagged `${RELEASE_TAG}${suffix}` and `latest${suffix}`.
|
||||||
5. **Verify** — prints the digest of all 10 expected tags (8 variant + base-hash + base-latest). Spot-check that each `vX.Y.Z*` and its `latest*` alias share a digest.
|
5. **Verify** — prints the digest of all 12 expected tags (10 variant + base-hash + base-latest). Spot-check that each `vX.Y.Z*` and its `latest*` alias share a digest.
|
||||||
|
|
||||||
Expected wall time on a recent Mac: ~25-40 min (base ~3 min if rebuilt, each variant ~3-7 min mostly QEMU arm64 emulation).
|
Expected wall time on a recent Mac: ~25-40 min (base ~3 min if rebuilt, each variant ~3-7 min mostly QEMU arm64 emulation).
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,12 @@
|
|||||||
# Mirrors what .gitea/workflows/docker-publish-split.yml would do:
|
# Mirrors what .gitea/workflows/docker-publish-split.yml would do:
|
||||||
# 1. Build & push Dockerfile.base → joakimp/opencode-devbox:base-<hash>
|
# 1. Build & push Dockerfile.base → joakimp/opencode-devbox:base-<hash>
|
||||||
# 2. Promote → joakimp/opencode-devbox:base-latest
|
# 2. Promote → joakimp/opencode-devbox:base-latest
|
||||||
# 3. Build & push 4 variants on top of base-<hash>:
|
# 3. Build & push 5 variants on top of base-<hash>:
|
||||||
# :v1.15.12 :latest (INSTALL_OPENCODE only)
|
# :v1.15.12 :latest (INSTALL_OPENCODE only)
|
||||||
# :v1.15.12-omos :latest-omos (+ OMOS)
|
# :v1.15.12-omos :latest-omos (+ OMOS)
|
||||||
# :v1.15.12-with-pi :latest-with-pi (+ pi)
|
# :v1.15.12-with-pi :latest-with-pi (+ pi)
|
||||||
# :v1.15.12-omos-with-pi :latest-omos-with-pi (+ both)
|
# :v1.15.12-omos-with-pi :latest-omos-with-pi (+ both)
|
||||||
|
# :v1.15.12-pi-only :latest-pi-only (pi, no opencode)
|
||||||
#
|
#
|
||||||
# Usage on your host:
|
# Usage on your host:
|
||||||
# 1. Make sure Orbstack/Docker Desktop is running with multi-arch enabled
|
# 1. Make sure Orbstack/Docker Desktop is running with multi-arch enabled
|
||||||
@@ -51,7 +52,7 @@ fi
|
|||||||
|
|
||||||
# -------- 1. base (if needed) --------
|
# -------- 1. base (if needed) --------
|
||||||
if [[ "$SKIP_BASE" == "0" ]]; then
|
if [[ "$SKIP_BASE" == "0" ]]; then
|
||||||
echo "==> [1/5] Build & push Dockerfile.base → ${IMAGE}:${BASE_TAG}"
|
echo "==> [1/7] Build & push Dockerfile.base → ${IMAGE}:${BASE_TAG}"
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform "$PLATFORMS" \
|
--platform "$PLATFORMS" \
|
||||||
-f Dockerfile.base \
|
-f Dockerfile.base \
|
||||||
@@ -61,14 +62,15 @@ if [[ "$SKIP_BASE" == "0" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# -------- 2. promote base-latest --------
|
# -------- 2. promote base-latest --------
|
||||||
echo "==> [2/5] Promote ${IMAGE}:${BASE_TAG} → ${IMAGE}:base-latest"
|
echo "==> [2/7] Promote ${IMAGE}:${BASE_TAG} → ${IMAGE}:base-latest"
|
||||||
docker buildx imagetools create -t "${IMAGE}:base-latest" "${IMAGE}:${BASE_TAG}"
|
docker buildx imagetools create -t "${IMAGE}:base-latest" "${IMAGE}:${BASE_TAG}"
|
||||||
|
|
||||||
# -------- 3-5. variants --------
|
# -------- 3-5. variants --------
|
||||||
build_variant() {
|
build_variant() {
|
||||||
local suffix="$1" # "" | "-omos" | "-with-pi" | "-omos-with-pi"
|
local suffix="$1" # "" | "-omos" | "-with-pi" | "-omos-with-pi" | "-pi-only"
|
||||||
local install_omos="$2"
|
local install_omos="$2"
|
||||||
local install_pi="$3"
|
local install_pi="$3"
|
||||||
|
local install_opencode="${4:-true}"
|
||||||
local extra_args=()
|
local extra_args=()
|
||||||
[[ "$install_pi" == "true" ]] && extra_args+=(--build-arg "PI_VERSION=${PI_VERSION}")
|
[[ "$install_pi" == "true" ]] && extra_args+=(--build-arg "PI_VERSION=${PI_VERSION}")
|
||||||
[[ "$install_omos" == "true" ]] && extra_args+=(--build-arg "OMOS_VERSION=${OMOS_VERSION}")
|
[[ "$install_omos" == "true" ]] && extra_args+=(--build-arg "OMOS_VERSION=${OMOS_VERSION}")
|
||||||
@@ -81,7 +83,7 @@ build_variant() {
|
|||||||
--platform "$PLATFORMS" \
|
--platform "$PLATFORMS" \
|
||||||
-f Dockerfile.variant \
|
-f Dockerfile.variant \
|
||||||
--build-arg "BASE_IMAGE=${IMAGE}:${BASE_TAG}" \
|
--build-arg "BASE_IMAGE=${IMAGE}:${BASE_TAG}" \
|
||||||
--build-arg "INSTALL_OPENCODE=true" \
|
--build-arg "INSTALL_OPENCODE=${install_opencode}" \
|
||||||
--build-arg "INSTALL_OMOS=${install_omos}" \
|
--build-arg "INSTALL_OMOS=${install_omos}" \
|
||||||
--build-arg "INSTALL_PI=${install_pi}" \
|
--build-arg "INSTALL_PI=${install_pi}" \
|
||||||
${extra_args[@]+"${extra_args[@]}"} \
|
${extra_args[@]+"${extra_args[@]}"} \
|
||||||
@@ -91,18 +93,21 @@ build_variant() {
|
|||||||
.
|
.
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "==> [3/5] Variant: base (opencode only)"
|
echo "==> [3/7] Variant: base (opencode only)"
|
||||||
build_variant "" false false
|
build_variant "" false false
|
||||||
|
|
||||||
echo "==> [4/5] Variant: omos"
|
echo "==> [4/7] Variant: omos"
|
||||||
build_variant "-omos" true false
|
build_variant "-omos" true false
|
||||||
|
|
||||||
echo "==> [4/5] Variant: with-pi"
|
echo "==> [5/7] Variant: with-pi"
|
||||||
build_variant "-with-pi" false true
|
build_variant "-with-pi" false true
|
||||||
|
|
||||||
echo "==> [5/5] Variant: omos-with-pi"
|
echo "==> [6/7] Variant: omos-with-pi"
|
||||||
build_variant "-omos-with-pi" true true
|
build_variant "-omos-with-pi" true true
|
||||||
|
|
||||||
|
echo "==> [7/7] Variant: pi-only (pi without opencode)"
|
||||||
|
build_variant "-pi-only" false true false
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "==> Done. Verifying tags on Hub:"
|
echo "==> Done. Verifying tags on Hub:"
|
||||||
for t in \
|
for t in \
|
||||||
@@ -110,6 +115,7 @@ for t in \
|
|||||||
"${RELEASE_TAG}-omos" "latest-omos" \
|
"${RELEASE_TAG}-omos" "latest-omos" \
|
||||||
"${RELEASE_TAG}-with-pi" "latest-with-pi" \
|
"${RELEASE_TAG}-with-pi" "latest-with-pi" \
|
||||||
"${RELEASE_TAG}-omos-with-pi" "latest-omos-with-pi" \
|
"${RELEASE_TAG}-omos-with-pi" "latest-omos-with-pi" \
|
||||||
|
"${RELEASE_TAG}-pi-only" "latest-pi-only" \
|
||||||
"${BASE_TAG}" "base-latest"
|
"${BASE_TAG}" "base-latest"
|
||||||
do
|
do
|
||||||
d=$(docker manifest inspect "${IMAGE}:${t}" 2>/dev/null | python3 -c "import json,sys,hashlib; m=json.load(sys.stdin); print(m.get('digest','-'))" 2>/dev/null || echo "MISSING")
|
d=$(docker manifest inspect "${IMAGE}:${t}" 2>/dev/null | python3 -c "import json,sys,hashlib; m=json.load(sys.stdin); print(m.get('digest','-'))" 2>/dev/null || echo "MISSING")
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# Plan: LAN-access mechanism + pi-fork/pi-observational-memory in the builds
|
||||||
|
|
||||||
|
Status: PROPOSED (2026-06-03, decisions folded in). Author: pi (devbox session).
|
||||||
|
Scope: opencode-devbox base + variant, pi-devbox. Two independent work items.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layering decision
|
||||||
|
|
||||||
|
| Capability | Lives in | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| **LAN-access (smart-detect host-jump)** | opencode-devbox **base** | Both opencode-devbox and pi-devbox inherit it; not pi-specific. |
|
||||||
|
| **pi-fork + pi-observational-memory** | **pi layer** (variant `with-pi`/`omos-with-pi` + pi-devbox/Dockerfile) | Only meaningful when `pi` is present. Runtime deploy via the shared base `entrypoint-user.sh`, guarded by `command -v pi`. |
|
||||||
|
|
||||||
|
Guiding principle for LAN access: **ship the mechanism, not the policy.**
|
||||||
|
The image provides a generic `host` jump alias + writable SSH config + detection.
|
||||||
|
A user's *specific* targets (e.g. pve/pve-2) come from their bind-mounted
|
||||||
|
`~/.ssh/config` (`ProxyJump host`) or an env list — never hardcoded in the image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ITEM A — LAN access (opencode-devbox base)
|
||||||
|
|
||||||
|
### Why it can't "just work" unattended
|
||||||
|
- macOS (OrbStack / Docker Desktop): container is in a Linux VM behind the host's
|
||||||
|
stack. Directly-attached LAN peers are not bridged by default; only the host +
|
||||||
|
routed subnets are reachable.
|
||||||
|
- Linux Docker: default bridge already NATs container egress onto the host's LAN,
|
||||||
|
so LAN peers are usually directly reachable. The jump is unnecessary.
|
||||||
|
- The jump path needs the host running sshd + the container's pubkey authorized.
|
||||||
|
The average DockerHub t"kick the tires" user has neither → setup must be
|
||||||
|
**opt-in / non-fatal**, never block startup.
|
||||||
|
|
||||||
|
### New file: `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh`
|
||||||
|
COPY'd automatically (base already does `COPY rootfs/usr/local/lib/opencode-devbox/`).
|
||||||
|
|
||||||
|
Behavior, driven by `DEVBOX_LAN_ACCESS=auto|jump|off` (default `auto`):
|
||||||
|
|
||||||
|
1. `off` → return immediately.
|
||||||
|
2. Detect environment:
|
||||||
|
- VM-backed Docker (OrbStack / Docker Desktop) iff `getent hosts host.docker.internal`
|
||||||
|
resolves (OrbStack also exposes `host.orb.internal`). Native Linux → no resolution
|
||||||
|
(unless the user added `extra_hosts: host.docker.internal:host-gateway`).
|
||||||
|
3. `auto` + native Linux → do nothing (direct LAN works); print one info line.
|
||||||
|
4. `auto` + VM-backed, or `jump` forced →
|
||||||
|
- Create writable `~/.ssh-local/{,cm/}`, `chmod 700`.
|
||||||
|
- Generate `~/.ssh-local/devbox_jump_ed25519` if absent (preserve across restarts).
|
||||||
|
- Render `~/.ssh-local/config`:
|
||||||
|
```
|
||||||
|
Host *
|
||||||
|
UserKnownHostsFile ~/.ssh-local/known_hosts
|
||||||
|
StrictHostKeyChecking accept-new
|
||||||
|
Host host mac # 'mac' kept as friendly alias
|
||||||
|
HostName host.docker.internal
|
||||||
|
User ${HOST_SSH_USER} # REQUIRED for auth; see below
|
||||||
|
IdentityFile ~/.ssh-local/devbox_jump_ed25519
|
||||||
|
IdentitiesOnly yes
|
||||||
|
ControlMaster auto
|
||||||
|
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||||
|
ControlPersist 4h
|
||||||
|
# Optional per-target blocks generated from DEVBOX_LAN_HOSTS (see below)
|
||||||
|
Include ~/.ssh/config # user's bind-mounted targets still resolve
|
||||||
|
```
|
||||||
|
- If `HOST_SSH_USER` unset → still render config but print a clear hint block:
|
||||||
|
the generated **public key** + the one-liner to authorize it on the host
|
||||||
|
(`echo '<pubkey>' >> ~/.ssh/authorized_keys`) + "enable Remote Login".
|
||||||
|
- Idempotent: re-render config each start (cheap); never regenerate the key.
|
||||||
|
- DECISION #5: NO `DEVBOX_LAN_HOSTS` env. Keep the image policy-free. Users add
|
||||||
|
`ProxyJump host` to their own target entries in the bind-mounted `~/.ssh/config`
|
||||||
|
(pulled in by the `Include ~/.ssh/config` line).
|
||||||
|
|
||||||
|
### `entrypoint-user.sh`
|
||||||
|
Call `setup-lan-access.sh` right after the existing `/tmp/sshcm` block
|
||||||
|
(non-fatal: `… || true`). It's environment-gated so it self-skips on Linux.
|
||||||
|
|
||||||
|
### `rootfs/home/developer/.bash_aliases` (per your note — alias goes HERE)
|
||||||
|
Append, guarded:
|
||||||
|
```bash
|
||||||
|
# dssh — ssh using the container's writable LAN-access config (host-jump).
|
||||||
|
# Only useful when setup-lan-access.sh generated ~/.ssh-local/config.
|
||||||
|
if [ -r "$HOME/.ssh-local/config" ]; then
|
||||||
|
alias dssh='ssh -F "$HOME/.ssh-local/config"'
|
||||||
|
alias dscp='scp -F "$HOME/.ssh-local/config"'
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
Migration caveat: skel `.bash_aliases` is only copied when absent, so existing
|
||||||
|
volumes/containers won't get `dssh` until they `rm ~/.bash_aliases` and recreate,
|
||||||
|
OR drop the alias into the host-shared `~/.config/devbox-shell/bash_aliases`
|
||||||
|
(already sourced at the top of the skel file).
|
||||||
|
|
||||||
|
### Dockerfile.base
|
||||||
|
No structural change required (script ships via existing rootfs COPY). Optionally
|
||||||
|
document `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` / `DEVBOX_LAN_HOSTS` in `.env.example`
|
||||||
|
and README.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ITEM B — pi-fork + pi-observational-memory (pi layer)
|
||||||
|
|
||||||
|
Sources (pinned this week):
|
||||||
|
- `github.com/elpapi42/pi-fork` (registers `fork`; ~v0.1.0)
|
||||||
|
- `github.com/elpapi42/pi-observational-memory` (registers `recall`; default branch **master**, v3.0.2)
|
||||||
|
|
||||||
|
### B1 RESOLVED (verified live 2026-06-03 in this container)
|
||||||
|
- `pi install <local-path>` is INSTANT (~0.5s): NO copy, NO npm install. pi registers
|
||||||
|
the path and loads the extension IN PLACE from that dir.
|
||||||
|
- settings.json stores a RELATIVE path (e.g. `../../../opt/pi-fork` from ~/.pi/agent).
|
||||||
|
Points into the image-layer `/opt` → stable across volume recreate. Good.
|
||||||
|
- Idempotent: a second `pi install <same path>` does NOT duplicate the entry.
|
||||||
|
- CONSEQUENCE: because pi does NOT npm-install a local path, deps must already exist
|
||||||
|
at `/opt/<pkg>/node_modules`. pi-fork imports `@sinclair/typebox` + `@earendil-works/*`
|
||||||
|
peers; git-install produced a 148 MB node_modules. So we MUST `npm install` inside
|
||||||
|
each `/opt/<pkg>` AT BUILD TIME.
|
||||||
|
- BAKE RECIPE: clone to /opt -> `npm install` there (build) -> `pi install /opt/<pkg>`
|
||||||
|
at runtime (instant, idempotent).
|
||||||
|
- (Optional size win, verify-first: prune to external-only deps if pi provides the
|
||||||
|
`@earendil-works/*` peers from its own runtime resolution. ~148M is mostly those.)
|
||||||
|
|
||||||
|
### DECISION #3: refactor to remove duplication
|
||||||
|
`pi-devbox/Dockerfile` currently duplicates the pi-install + /opt-clone logic from
|
||||||
|
`Dockerfile.variant`. Refactor `pi-devbox/Dockerfile` to `FROM` the `with-pi` variant
|
||||||
|
image so pi-install logic (incl. the new fork/obsmem clones) lives in ONE place.
|
||||||
|
|
||||||
|
> **Implementation update (2026-06-03):** `FROM with-pi` would have dragged opencode
|
||||||
|
> into pi-devbox (all opencode-devbox variants set `INSTALL_OPENCODE=true`), making it
|
||||||
|
> nearly identical to `latest-with-pi`. So a 5th variant **`pi-only`**
|
||||||
|
> (`INSTALL_OPENCODE=false`, `INSTALL_PI=true`) was added to opencode-devbox, and
|
||||||
|
> pi-devbox now `FROM`s `latest-pi-only`. Same single-source-of-truth win, but
|
||||||
|
> pi-devbox stays lean (no opencode, ~145 MB lighter than with-pi).
|
||||||
|
>
|
||||||
|
> **Update 2 (2026-06-03, Option B):** publishing the pi-only variant as
|
||||||
|
> `opencode-devbox:latest-pi-only` meant an "opencode-devbox" Hub tag that
|
||||||
|
> contains no opencode — confusing. Final scheme: the pi-only build is still
|
||||||
|
> produced by opencode-devbox CI (single source of truth) but its
|
||||||
|
> `build-variant-pi-only` job pushes into the **`joakimp/pi-devbox`** repo as
|
||||||
|
> the internal building-block tag `base-pi-only` (+ `base-pi-only-vX.Y.Z`), and
|
||||||
|
> pi-devbox now `FROM`s `joakimp/pi-devbox:base-pi-only`. No opencode-less tag
|
||||||
|
> ever appears under opencode-devbox; pi-only is de-advertised from
|
||||||
|
> opencode-devbox's README/DOCKER_HUB. New `PI_IMAGE` workflow env.
|
||||||
|
|
||||||
|
### Build time — clone to /opt + npm install (mirror pi-toolkit/extensions pattern)
|
||||||
|
Add to the single `INSTALL_PI=true` block in `opencode-devbox/Dockerfile.variant`
|
||||||
|
(after refactor, pi-devbox inherits it):
|
||||||
|
```dockerfile
|
||||||
|
ARG PI_FORK_REPO=https://github.com/elpapi42/pi-fork.git
|
||||||
|
ARG PI_FORK_REF=<pin: tag or commit SHA>
|
||||||
|
ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
|
||||||
|
ARG PI_OBSMEM_REF=master # pin to SHA in CI to dodge cache-hit footgun
|
||||||
|
# ... inside the INSTALL_PI / pi-install RUN, after the pi-toolkit/extensions clones:
|
||||||
|
git_clone_retry "$PI_FORK_REPO" "$PI_FORK_REF" /opt/pi-fork && \
|
||||||
|
git_clone_retry "$PI_OBSMEM_REPO" "$PI_OBSMEM_REF" /opt/pi-observational-memory && \
|
||||||
|
(cd /opt/pi-fork && npm install --no-audit --no-fund) && \
|
||||||
|
(cd /opt/pi-observational-memory && npm install --no-audit --no-fund) && \
|
||||||
|
echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
|
||||||
|
echo "pi-obsmem at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)"
|
||||||
|
```
|
||||||
|
NOTE: `git_clone_retry` uses `--branch "$ref"`, which accepts tags & branches but
|
||||||
|
NOT arbitrary commit SHAs. For SHA pinning use `git clone <url> <dest> && git -C
|
||||||
|
<dest> checkout <sha>` for these two repos.
|
||||||
|
|
||||||
|
### Why not bake the install result
|
||||||
|
`~/.pi` is a named volume mounted at runtime — anything `pi install`'d into
|
||||||
|
`~/.pi/agent/...` at BUILD time is hidden by the volume. Same reason
|
||||||
|
pi-toolkit/extensions deploy at runtime via `entrypoint-user.sh`. So:
|
||||||
|
|
||||||
|
### Runtime deploy — `entrypoint-user.sh` (shared base, in the `command -v pi` block)
|
||||||
|
After the pi-extensions `install.sh` call, add an idempotent install of each /opt pkg:
|
||||||
|
```bash
|
||||||
|
for pkg in /opt/pi-fork /opt/pi-observational-memory; do
|
||||||
|
[ -d "$pkg" ] || continue
|
||||||
|
name=$(basename "$pkg")
|
||||||
|
# skip if already registered in settings.json packages
|
||||||
|
if ! grep -q "$name" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
|
||||||
|
(cd "$HOME" && pi install "$pkg") || echo "WARN: pi install $name failed (continuing)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
`fork` + `recall` tools register on the NEXT pi start after deploy (exts bind at
|
||||||
|
startup). First deploy after a volume recreate pays an `npm install` cost
|
||||||
|
(pi-fork pulls ~133 deps) — acceptable, one-time per volume lifetime.
|
||||||
|
|
||||||
|
OPEN ITEM B1 (verify before finalizing): exact `pi install <local-path>` semantics
|
||||||
|
— does it copy/symlink, and does it npm-install at run each time? If it re-resolves
|
||||||
|
deps every start, pre-populate `/opt/<pkg>/node_modules` at build (`npm install
|
||||||
|
--omit=dev`) and confirm the runtime install reuses it. Quick test in this container:
|
||||||
|
`pi install /opt/pi-fork` twice, observe settings.json + timing + tool registration.
|
||||||
|
|
||||||
|
### CI — `.gitea/workflows/docker-publish-split.yml` (DECISION #2: latest-but-pinned)
|
||||||
|
- USE LATEST CONTENT, BUT RESOLVE TO A SHA IN CI (same pattern as PI_VERSION/OMOS).
|
||||||
|
The existing `resolve-versions` job curls npm `latest` for pi/omos to defeat the
|
||||||
|
build-arg cache-hit footgun. Add an analogous resolve for the two git repos:
|
||||||
|
query the GitHub API for the HEAD commit SHA of the tracked branch (master) and
|
||||||
|
pass it as `PI_FORK_REF` / `PI_OBSMEM_REF` build-args, so the layer hash changes
|
||||||
|
when upstream moves AND we still get newest-at-build-time.
|
||||||
|
- Passing a bare branch name would be byte-identical across builds -> stale cached
|
||||||
|
layer (the documented footgun). SHA resolution fixes both.
|
||||||
|
- Pass the new build-args in the `with-pi` and `omos-with-pi` build steps.
|
||||||
|
- The resolved SHAs print in build logs (and ideally as image labels) so a bad
|
||||||
|
upstream is diagnosable and we can pin back to a known-good SHA.
|
||||||
|
|
||||||
|
### Version coupling risk (carry-over from prior session)
|
||||||
|
pi-fork/obsmem extensions are coupled to the host pi version (AGENTS.md warns).
|
||||||
|
pi-fork had a `fix/effort-string-enum-schema` branch from recent API churn. So:
|
||||||
|
- Pin against the SAME `PI_VERSION` the image ships.
|
||||||
|
- smoke-test must assert the tools actually register (below), not just that files exist.
|
||||||
|
|
||||||
|
### Smoke test — `scripts/smoke-test.sh`
|
||||||
|
Add (for `with-pi`/`omos-with-pi`/pi-devbox):
|
||||||
|
1. `/opt/pi-fork/package.json` and `/opt/pi-observational-memory/package.json` exist.
|
||||||
|
2. Run a container, then assert `~/.pi/agent/settings.json` "packages" includes both.
|
||||||
|
3. Best-effort: headless `pi` tool-list contains `fork` and `recall` (if pi exposes a
|
||||||
|
non-interactive list; otherwise step 2 is the gate).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions — RESOLVED 2026-06-03
|
||||||
|
1. **B1**: VERIFIED. Local-path install is instant/in-place; bake `npm install` into
|
||||||
|
`/opt/<pkg>` at build; runtime `pi install /opt/<pkg>` is instant + idempotent. ✓
|
||||||
|
2. **Latest-but-pinned**: track latest (master HEAD), resolve to SHA in CI build-arg. ✓
|
||||||
|
3. **Refactor**: pi-devbox/Dockerfile -> `FROM` the with-pi variant; pi-install in ONE place. ✓
|
||||||
|
4. **LAN default** `DEVBOX_LAN_ACCESS=auto`: generate config + print authorize hint when
|
||||||
|
`HOST_SSH_USER` unset; silent no-op on native Linux. ✓
|
||||||
|
5. **No `DEVBOX_LAN_HOSTS`**: rely on user's bind-mounted `~/.ssh/config` (`ProxyJump host`). ✓
|
||||||
|
|
||||||
|
## Remaining verify-before-merge items
|
||||||
|
- Confirm the fork/recall extensions LOAD at runtime from `/opt/<pkg>` WITH the baked
|
||||||
|
node_modules (smoke test asserts tool registration, not just files).
|
||||||
|
- Optional: confirm whether pi supplies `@earendil-works/*` peers at runtime so /opt
|
||||||
|
node_modules can be pruned to external-only deps (size optimization, ~148M -> small).
|
||||||
|
|
||||||
|
## Rollout order
|
||||||
|
1. Verify B1 in this live container (cheap, no build).
|
||||||
|
2. Land ITEM A in base (rootfs script + entrypoint call + alias) → rebuild base → smoke.
|
||||||
|
3. Land ITEM B in variant + pi-devbox + CI resolve + smoke assertions.
|
||||||
|
4. CHANGELOG + tag both repos; CI rebuild; verify fork+recall+dssh survive a volume recreate.
|
||||||
@@ -12,6 +12,16 @@ set -euo pipefail
|
|||||||
mkdir -p /tmp/sshcm
|
mkdir -p /tmp/sshcm
|
||||||
chmod 700 /tmp/sshcm
|
chmod 700 /tmp/sshcm
|
||||||
|
|
||||||
|
# ── LAN access: generic host-OS-agnostic reachability helper ────────
|
||||||
|
# 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/opencode-devbox/setup-lan-access.sh ]; then
|
||||||
|
bash /usr/local/lib/opencode-devbox/setup-lan-access.sh || true
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Shell defaults: copy baked files from /etc/skel-devbox/ if absent
|
# ── Shell defaults: copy baked files from /etc/skel-devbox/ if absent
|
||||||
# Respects host bind-mounts and user customizations — existing files
|
# Respects host bind-mounts and user customizations — existing files
|
||||||
# are never overwritten. To restore defaults: rm ~/.bash_aliases (or
|
# are never overwritten. To restore defaults: rm ~/.bash_aliases (or
|
||||||
@@ -96,6 +106,24 @@ if command -v pi &>/dev/null; then
|
|||||||
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
|
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
|
||||||
"$HOME/.pi/agent/extensions/mempalace.ts"
|
"$HOME/.pi/agent/extensions/mempalace.ts"
|
||||||
fi
|
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>`. Verified 2026-06-03: 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
|
fi
|
||||||
|
|
||||||
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ for dir in \
|
|||||||
/home/"$USER_NAME"/.config/opencode \
|
/home/"$USER_NAME"/.config/opencode \
|
||||||
/home/"$USER_NAME"/.config/nvim \
|
/home/"$USER_NAME"/.config/nvim \
|
||||||
/home/"$USER_NAME"/.pi \
|
/home/"$USER_NAME"/.pi \
|
||||||
|
/home/"$USER_NAME"/.ssh-local \
|
||||||
/home/"$USER_NAME"/.agents/skills; do
|
/home/"$USER_NAME"/.agents/skills; do
|
||||||
[ -d "$dir" ] || continue
|
[ -d "$dir" ] || continue
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,17 @@ alias gs='git status'
|
|||||||
alias gd='git diff'
|
alias gd='git diff'
|
||||||
alias gl='git log --oneline --graph --decorate -20'
|
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
|
# Safety: confirm before destructive ops
|
||||||
alias rm='rm -i'
|
alias rm='rm -i'
|
||||||
alias mv='mv -i'
|
alias mv='mv -i'
|
||||||
|
|||||||
+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
|
||||||
@@ -69,6 +69,11 @@ Designed for teams who want a reproducible coding-agent setup that runs the same
|
|||||||
|
|
||||||
All variants support `linux/amd64` and `linux/arm64`.
|
All variants support `linux/amd64` and `linux/arm64`.
|
||||||
|
|
||||||
|
> A fifth, pi-without-opencode build is produced from the same `Dockerfile.variant`
|
||||||
|
> (`INSTALL_OPENCODE=false`) but is **not** published under this repo — it ships as
|
||||||
|
> the separate [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)
|
||||||
|
> image so an "opencode-devbox" tag never lacks opencode.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
For a fully-configured environment with persistent state (opencode config, mempalace memory, neovim plugins, bash history) surviving container recreation, use docker-compose. **You don't need to clone the repo** — just grab two template files:
|
For a fully-configured environment with persistent state (opencode config, mempalace memory, neovim plugins, bash history) surviving container recreation, use docker-compose. **You don't need to clone the repo** — just grab two template files:
|
||||||
|
|||||||
+62
-5
@@ -8,7 +8,7 @@
|
|||||||
# - Generated opencode.json has the expected shape
|
# - Generated opencode.json has the expected shape
|
||||||
# - MCP wrapper works (when mempalace is installed)
|
# - MCP wrapper works (when mempalace is installed)
|
||||||
#
|
#
|
||||||
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos|with-pi|omos-with-pi]
|
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos|with-pi|omos-with-pi|pi-only]
|
||||||
#
|
#
|
||||||
# Exit codes:
|
# Exit codes:
|
||||||
# 0 all checks passed
|
# 0 all checks passed
|
||||||
@@ -23,13 +23,26 @@ if [ "${2:-}" = "--variant" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$IMAGE" ]; then
|
if [ -z "$IMAGE" ]; then
|
||||||
echo "usage: $0 <image> [--variant base|omos|with-pi|omos-with-pi]" >&2
|
echo "usage: $0 <image> [--variant base|omos|with-pi|omos-with-pi|pi-only]" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FAILED=0
|
FAILED=0
|
||||||
pass() { echo " ✓ $1"; }
|
pass() { echo " ✓ $1"; }
|
||||||
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
||||||
|
warn() { echo " ⚠ $1" >&2; }
|
||||||
|
|
||||||
|
# Registration assertions (fork/recall installed by the BASE image's
|
||||||
|
# entrypoint-user.sh via `pi install /opt/<pkg>`) depend on the base, not the
|
||||||
|
# variant layer built here. validate.yml builds variants FROM the published
|
||||||
|
# base-latest, which can lag the entrypoint in the current commit (the base
|
||||||
|
# only rebuilds on a release tag), so a stale base-latest would red the
|
||||||
|
# push-to-main run with a false negative. These checks are therefore warn-only
|
||||||
|
# by default; the release pipeline (docker-publish-split.yml) builds the base
|
||||||
|
# fresh in the same run and sets STRICT_REGISTRATION=1 to enforce them hard.
|
||||||
|
# The build-time /opt + node_modules checks below stay hard in every path —
|
||||||
|
# those are produced by the variant layer and must always be correct.
|
||||||
|
STRICT_REGISTRATION="${STRICT_REGISTRATION:-0}"
|
||||||
|
|
||||||
run() {
|
run() {
|
||||||
# Run a command inside the image and capture its output.
|
# Run a command inside the image and capture its output.
|
||||||
@@ -171,6 +184,13 @@ if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v pi" >/dev/null 2>&
|
|||||||
fi
|
fi
|
||||||
run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD"
|
run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD"
|
||||||
run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD"
|
run "pi-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): cloned to
|
||||||
|
# /opt with node_modules baked at build time (a local-path `pi install` does
|
||||||
|
# NOT npm-install, so deps MUST already be present for the extension to load).
|
||||||
|
run "pi-fork clone + node_modules" \
|
||||||
|
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules && echo ok"
|
||||||
|
run "pi-observational-memory clone + node_modules" \
|
||||||
|
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules && echo ok"
|
||||||
|
|
||||||
# Run the full entrypoint as developer to verify install.sh deployment.
|
# Run the full entrypoint as developer to verify install.sh deployment.
|
||||||
# Spin up a long-running container so we can `docker exec` into it from
|
# Spin up a long-running container so we can `docker exec` into it from
|
||||||
@@ -199,6 +219,19 @@ if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v pi" >/dev/null 2>&
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Like exec_test but warn-only unless STRICT_REGISTRATION=1 (see note at top).
|
||||||
|
exec_test_reg() {
|
||||||
|
local label="$1"; shift
|
||||||
|
local out
|
||||||
|
if out=$(docker exec -u developer "$CID" sh -c "$*" 2>&1); then
|
||||||
|
pass "$label ($(echo "$out" | head -1))"
|
||||||
|
elif [ "$STRICT_REGISTRATION" = "1" ]; then
|
||||||
|
fail "$label: $out"
|
||||||
|
else
|
||||||
|
warn "$label (warn-only — stale base-latest? set STRICT_REGISTRATION=1 to enforce): $out"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
exec_test "~/.pi/agent/keybindings.json (pi-toolkit)" \
|
exec_test "~/.pi/agent/keybindings.json (pi-toolkit)" \
|
||||||
'test -L $HOME/.pi/agent/keybindings.json && echo ok'
|
'test -L $HOME/.pi/agent/keybindings.json && echo ok'
|
||||||
exec_test "~/.pi/agent/extensions/*.ts ≥ 4 (pi-extensions)" \
|
exec_test "~/.pi/agent/extensions/*.ts ≥ 4 (pi-extensions)" \
|
||||||
@@ -208,6 +241,21 @@ if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v pi" >/dev/null 2>&
|
|||||||
exec_test "~/.pi/agent/settings.json (template bootstrap)" \
|
exec_test "~/.pi/agent/settings.json (template bootstrap)" \
|
||||||
'test -f $HOME/.pi/agent/settings.json && echo ok'
|
'test -f $HOME/.pi/agent/settings.json && echo ok'
|
||||||
|
|
||||||
|
# pi-fork + pi-observational-memory are registered by entrypoint-user.sh via
|
||||||
|
# `pi install /opt/<pkg>` (records a relative path into settings.json
|
||||||
|
# packages). That runs slightly after the keybindings marker, so wait for it.
|
||||||
|
for _ in $(seq 1 15); do
|
||||||
|
if docker exec "$CID" grep -q pi-observational-memory \
|
||||||
|
/home/developer/.pi/agent/settings.json 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
exec_test_reg "pi-fork registered in settings.json (fork tool)" \
|
||||||
|
'grep -q pi-fork $HOME/.pi/agent/settings.json && echo ok'
|
||||||
|
exec_test_reg "pi-observational-memory registered in settings.json (recall tool)" \
|
||||||
|
'grep -q pi-observational-memory $HOME/.pi/agent/settings.json && echo ok'
|
||||||
|
|
||||||
docker rm -f "$CID" >/dev/null 2>&1 || true
|
docker rm -f "$CID" >/dev/null 2>&1 || true
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
else
|
else
|
||||||
@@ -336,12 +384,21 @@ echo " Uncompressed size: ${SIZE_MB} MB"
|
|||||||
# omos-with-pi bumped 3500→3700 on v1.15.4b — omos+pi compounded as both
|
# omos-with-pi bumped 3500→3700 on v1.15.4b — omos+pi compounded as both
|
||||||
# upstream packages grew (opencode 1.15.0→1.15.4, pi 0.74.0→0.75.3) and
|
# upstream packages grew (opencode 1.15.0→1.15.4, pi 0.74.0→0.75.3) and
|
||||||
# the variant landed just over 3500 in v1.15.4's smoke.
|
# the variant landed just over 3500 in v1.15.4's smoke.
|
||||||
|
# with-pi 2700→2900 and omos-with-pi 3700→3900: baking pi-fork +
|
||||||
|
# pi-observational-memory node_modules into /opt (fork pulls its
|
||||||
|
# @earendil-works peer deps, ~150 MB) adds to both pi-bearing variants.
|
||||||
|
# base 2500→2600 on v1.15.13c — base crept to 2506 MB (LAN-access script +
|
||||||
|
# updated entrypoint + routine apt-get upgrade drift), tripping the
|
||||||
|
# deliberately zero-headroom 2500 ceiling and skipping promote-base-latest.
|
||||||
# omos variant to ~3.1 GB. Functional smoke checks all pass; this is a
|
# omos variant to ~3.1 GB. Functional smoke checks all pass; this is a
|
||||||
# guardrail, not a performance limit.
|
# guardrail, not a performance limit.
|
||||||
THRESHOLD=2500
|
THRESHOLD=2600
|
||||||
[ "$VARIANT" = "omos" ] && THRESHOLD=3300
|
[ "$VARIANT" = "omos" ] && THRESHOLD=3300
|
||||||
[ "$VARIANT" = "with-pi" ] && THRESHOLD=2700
|
[ "$VARIANT" = "with-pi" ] && THRESHOLD=2900
|
||||||
[ "$VARIANT" = "omos-with-pi" ] && THRESHOLD=3700
|
[ "$VARIANT" = "omos-with-pi" ] && THRESHOLD=3900
|
||||||
|
# pi-only = with-pi minus opencode (its platform binary is ~145 MB), so it
|
||||||
|
# lands a bit under base. Threshold 2750 leaves the same headroom pattern.
|
||||||
|
[ "$VARIANT" = "pi-only" ] && THRESHOLD=2750
|
||||||
if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then
|
if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then
|
||||||
fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT"
|
fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# ssh-lan.conf.example — host-owned LAN-peer jump overrides for opencode-devbox
|
||||||
|
# ============================================================================
|
||||||
|
# WHAT THIS IS
|
||||||
|
# On a VM-backed host (macOS OrbStack / Docker Desktop) the container can't
|
||||||
|
# reach the host's LAN directly; it tunnels through the host via the `host`
|
||||||
|
# SSH jump that the entrypoint sets up (see the README "Reaching your LAN"
|
||||||
|
# section). To reach your LAN peers *by name*, they need `ProxyJump host`.
|
||||||
|
#
|
||||||
|
# WHY NOT JUST EDIT ~/.ssh/config?
|
||||||
|
# The host itself reaches those peers DIRECTLY — adding `ProxyJump host`
|
||||||
|
# there would break the host's own access (and ~/.ssh is mounted read-only
|
||||||
|
# into the container anyway). So container-only jump overrides live HERE.
|
||||||
|
#
|
||||||
|
# HOW IT'S WIRED
|
||||||
|
# If this file exists at ~/.config/devbox-shell/ssh-lan.conf on the host
|
||||||
|
# (the same bind-mounted devbox-shell bridge dir used for shared aliases),
|
||||||
|
# the generated ~/.ssh-local/config Includes it BEFORE your ~/.ssh/config.
|
||||||
|
# SSH's first-value-wins rule means ProxyJump is taken from here, while
|
||||||
|
# HostName / User / IdentityFile are inherited from the matching block in
|
||||||
|
# your ~/.ssh/config. So you only list the names + the jump — nothing else.
|
||||||
|
#
|
||||||
|
# SETUP
|
||||||
|
# 1. Copy to your host: cp ssh-lan.conf.example ~/.config/devbox-shell/ssh-lan.conf
|
||||||
|
# 2. Bind-mount ~/.config/devbox-shell into the container (most setups
|
||||||
|
# already do this for shared shell aliases).
|
||||||
|
# 3. List the host aliases (as named in your ~/.ssh/config) that should be
|
||||||
|
# reached through the host jump.
|
||||||
|
# 4. Restart the container, then: dssh <name>
|
||||||
|
#
|
||||||
|
# NOTE: these are facts about ONE host's LAN. A roaming laptop sees different
|
||||||
|
# networks — keep this per-host, never in the image. For ad-hoc private IPs on
|
||||||
|
# whatever LAN you're currently on, prefer DEVBOX_LAN_AUTOJUMP_PRIVATE=1
|
||||||
|
# instead of naming every peer.
|
||||||
|
|
||||||
|
# Example — names must match Host blocks already defined in your ~/.ssh/config:
|
||||||
|
Host pve pve-2 pbs-vm my-nas
|
||||||
|
ProxyJump host
|
||||||
|
|
||||||
|
# You can also give a peer its own settings here if it isn't in ~/.ssh/config
|
||||||
|
# at all (then specify everything, not just ProxyJump):
|
||||||
|
# Host lab-box
|
||||||
|
# HostName 192.168.1.77
|
||||||
|
# User admin
|
||||||
|
# IdentityFile ~/.ssh/id_ed25519
|
||||||
|
# ProxyJump host
|
||||||
Reference in New Issue
Block a user