Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72298ae77e | |||
| c8217814c8 | |||
| ff6e17b732 | |||
| c6f9d1148b | |||
| 56e6a782e3 | |||
| 49d3e113ee | |||
| f1e879ca6c | |||
| 9c31c641d6 | |||
| d9dc85d825 | |||
| 0b78ab4a94 | |||
| 440218fc4c | |||
| a56a5846a5 | |||
| 053dac5308 | |||
| c71c03f0f1 | |||
| 1e98b53113 | |||
| 30380abdef | |||
| 237588253f | |||
| fc034ceade | |||
| f09a4f382a | |||
| f61b5a4977 | |||
| 870da12c92 | |||
| cb50e6ea60 | |||
| 1fe5b5df91 | |||
| 6cc2670a93 | |||
| 51ec4a88cf | |||
| be2a16834c | |||
| a16da2f041 | |||
| 608304c3de | |||
| 668592da0d | |||
| 3cbcb44cf5 | |||
| 73a7f96056 | |||
| f7c34091b1 | |||
| 4cce39d167 | |||
| 72d2c99885 | |||
| 80e57d732b | |||
| 19f8c043bd | |||
| 90e5a1f5d0 | |||
| b6e4d89a2c |
+33
-29
@@ -31,6 +31,39 @@ WORKSPACE_PATH=~/projects
|
||||
# Path to SSH keys on host
|
||||
SSH_KEY_PATH=~/.ssh
|
||||
|
||||
# ── LAN access from the container (host-OS-agnostic) ─────────────────
|
||||
# On VM-backed hosts (macOS OrbStack / Docker Desktop, 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) ─────────────────────────
|
||||
# If you have a skillset repo, the entrypoint auto-deploys skills and
|
||||
# instructions on container start using relative symlinks (portable
|
||||
@@ -67,32 +100,3 @@ SSH_KEY_PATH=~/.ssh
|
||||
# OMOS_TMUX=false # Enable tmux multiplexer integration
|
||||
# OMOS_SKILLS=true # Install recommended skills (simplify, agent-browser, cartography)
|
||||
# OMOS_RESET=false # Force regenerate oh-my-opencode-slim config on next start
|
||||
|
||||
# ── pi coding-agent (alternative/complementary harness) ─────────────────
|
||||
# Requires image built with INSTALL_PI=true.
|
||||
# When the image is built with both INSTALL_OPENCODE=true (default) and
|
||||
# INSTALL_PI=true, both harnesses share the same mempalace install and
|
||||
# palace path — wing data is mutually visible to either harness.
|
||||
#
|
||||
# Pi version is baked at build time via PI_VERSION (default: latest at
|
||||
# build). The baked `pi` binary is at /usr/bin/pi (system npm prefix);
|
||||
# rebuild the image to upgrade it. NPM_CONFIG_PREFIX is set to
|
||||
# /home/developer/.pi/npm-global, so anything installed via
|
||||
# `pi install npm:...` or `npm install -g` as the developer user
|
||||
# (themes, skills, extensions, including a user-installed pi itself)
|
||||
# lands on the named volume and survives container recreate AND image
|
||||
# rebuilds. A user-installed pi wins via PATH order over the baked one.
|
||||
#
|
||||
# Pi config (settings.json, extensions toggle state, sessions, auth) persists in the
|
||||
# devbox-pi-config named volume mounted at ~/.pi/.
|
||||
#
|
||||
# To launch pi from a `compose run` invocation:
|
||||
# docker compose run --rm devbox pi
|
||||
# To attach to a running container:
|
||||
# docker compose exec -u developer devbox pi
|
||||
# Default `compose run` (no args) drops to bash; pick the harness yourself.
|
||||
#
|
||||
# Build args (set in docker-compose.yml or via --build-arg on docker build):
|
||||
# INSTALL_PI=true # default false; opt-in
|
||||
# PI_VERSION=latest # pin a specific version, e.g. 0.73.0
|
||||
# INSTALL_OPENCODE=false # build a pi-only image (still has Bun in -omos)
|
||||
|
||||
+78
-46
@@ -8,14 +8,16 @@ the build pipeline is shaped the way it is, you're in the right place.
|
||||
|
||||
| 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/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/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 two 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 both variants + `DOCKER_HUB.md` sync check. ~30 min. Fires on every push to `main`. |
|
||||
|
||||
## 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 **two image variants** (`base`, `omos`) × **two architectures** (amd64, arm64), publishing **four tags per release** + the floating `base-latest`. 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.
|
||||
> pi was removed in v2.0.0; it now builds in its own `joakimp/pi-devbox` repo. Before v2.0.0 a fifth `pi-only` build was produced here and pushed into that repo as `base-pi-only` — that coupling is gone.
|
||||
|
||||
The two 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:
|
||||
|
||||
@@ -30,10 +32,10 @@ The split-base architecture is what the `docker-publish-split.yml` workflow exer
|
||||
┌──────────────────┐
|
||||
│ base-decide │ compute base-<hash>;
|
||||
│ │ probe Docker Hub.
|
||||
│ hash inputs: │
|
||||
│ Dockerfile.base│
|
||||
│ rootfs/ │
|
||||
│ entrypoint*.sh │
|
||||
│ hash inputs: │ (resolve-versions
|
||||
│ Dockerfile.base│ runs in parallel:
|
||||
│ rootfs/ │ npm view omos
|
||||
│ entrypoint*.sh │ → concrete version)
|
||||
└────────┬─────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
@@ -47,18 +49,18 @@ The split-base architecture is what the `docker-publish-split.yml` workflow exer
|
||||
└────────┬─────────┘ to Docker Hub.
|
||||
│
|
||||
┌───────────────────────┼───────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────────┐
|
||||
│smoke-base│ │smoke-omos│ ... │smoke-omos-pi │ amd64 only,
|
||||
└────┬─────┘ └────┬─────┘ └──────┬───────┘ parallel.
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────────┐
|
||||
│build- │ │build- │ │build- │ multi-arch,
|
||||
│variant- │ │variant- │ ... │variant- │ parallel,
|
||||
│base │ │omos │ │omos-with-pi │ tag push.
|
||||
└────┬─────┘ └────┬─────┘ └──────┬───────┘
|
||||
└───────────────────────┴──────────────────────┘
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────┐
|
||||
│smoke-base│ │smoke-omos│ amd64 only,
|
||||
└────┬─────┘ └────┬─────┘ parallel.
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────┐
|
||||
│build- │ │build- │ multi-arch,
|
||||
│variant- │ │variant- │ parallel,
|
||||
│base │ │omos │ tag push.
|
||||
└────┬─────┘ └────┬─────┘
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
@@ -73,19 +75,28 @@ The split-base architecture is what the `docker-publish-split.yml` workflow exer
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
### Step 1: `base-decide`
|
||||
### Step 1: `base-decide` (and `resolve-versions` in parallel)
|
||||
|
||||
Compute a SHA-256 hash over the inputs that determine the base image's
|
||||
content:
|
||||
**`base-decide`** computes a SHA-256 hash over the inputs that determine
|
||||
the base image's content:
|
||||
|
||||
```sh
|
||||
{
|
||||
cat Dockerfile.base
|
||||
find rootfs -type f -print0 | sort -z | xargs -0 cat
|
||||
find rootfs -type f \
|
||||
! -path '*/__pycache__/*' \
|
||||
! -name '*.pyc' \
|
||||
! -name '.DS_Store' \
|
||||
! -name '._*' \
|
||||
-print0 | sort -z | xargs -0 cat
|
||||
cat entrypoint.sh entrypoint-user.sh
|
||||
} | sha256sum | cut -c1-12
|
||||
```
|
||||
|
||||
Junk filters keep the local recompute reproducible against CI's clean
|
||||
checkout — `__pycache__/*.pyc` and macOS metadata files (`.DS_Store`,
|
||||
`._AppleDouble`) are gitignored but still walked by `find -type f`.
|
||||
|
||||
The 12-character truncated hash becomes `base-<hash>`. Probe Docker Hub
|
||||
for this tag via `docker manifest inspect`:
|
||||
|
||||
@@ -97,6 +108,28 @@ This is the core cache-reuse mechanism. Version-bump-only releases
|
||||
that change anything in the base — apt packages, AWS CLI, Node version,
|
||||
locale list, entrypoint scripts — pay the full base-build cost once.
|
||||
|
||||
**`resolve-versions`** runs alongside `base-decide` (no `needs:`
|
||||
dependency between them) and resolves the floating npm packages whose
|
||||
`*_VERSION` build-args default to `latest`:
|
||||
|
||||
```sh
|
||||
OMOS_VERSION=$(npm view oh-my-opencode-slim version)
|
||||
```
|
||||
|
||||
The output (`omos_version`) is consumed by the omos variant smoke and
|
||||
build jobs. **Why this exists:** without it, the `npm install -g` RUN
|
||||
layer in `Dockerfile.variant` hashes
|
||||
identically across builds (same ARG default, same command string), so
|
||||
the registry buildcache silently reuses the layer from whatever upstream
|
||||
version was current when the cache was first populated. This is the
|
||||
cache-hit silent-regression class of bug that shipped pi-devbox v0.74.0
|
||||
through v0.75.5 with identical image bytes (fixed in pi-devbox v0.75.5b
|
||||
2026-05-23). Currently masked here by `OPENCODE_VERSION` bumping every
|
||||
release (parent-chain cache-key invalidation), but masking would fail on
|
||||
a `vN.N.Nb` opencode-version-unchanged release that only bumps omos.
|
||||
Smoke jobs additionally assert `EXPECTED_OMOS_VERSION` against the
|
||||
resolved value.
|
||||
|
||||
### Step 2: `build-base` (conditional)
|
||||
|
||||
Only runs when `need_build=true`. Multi-arch (amd64 + arm64) build of
|
||||
@@ -107,23 +140,21 @@ when only one or two layers changed.
|
||||
The base image is **not** tagged `base-latest` here — that promotion
|
||||
happens at the very end after all variants succeed (see step 5).
|
||||
|
||||
### Step 3: `smoke-*` (×4, parallel)
|
||||
### Step 3: `smoke-*` (×2, parallel)
|
||||
|
||||
For each variant: build amd64-only against the base tag, load into
|
||||
local docker, run [`scripts/smoke-test.sh`](../scripts/smoke-test.sh).
|
||||
Variant build-args:
|
||||
|
||||
| variant | INSTALL_OPENCODE | INSTALL_OMOS | INSTALL_PI |
|
||||
|---|---|---|---|
|
||||
| `base` | true | false | false |
|
||||
| `omos` | true | true | false |
|
||||
| `with-pi` | true | false | true |
|
||||
| `omos-with-pi` | true | true | true |
|
||||
| variant | INSTALL_OPENCODE | INSTALL_OMOS |
|
||||
|---|---|---|
|
||||
| `base` | true | false |
|
||||
| `omos` | true | true |
|
||||
|
||||
Smoke runs `--variant <name>` to enable variant-specific assertions.
|
||||
Gate the publish: a smoke failure for variant X blocks `build-variant-X`.
|
||||
|
||||
### Step 4: `build-variant-*` (×4, parallel)
|
||||
### Step 4: `build-variant-*` (×2, parallel)
|
||||
|
||||
For each variant that passed smoke: multi-arch (amd64 + arm64) build of
|
||||
`Dockerfile.variant`, pushed to Docker Hub with the user-facing release
|
||||
@@ -133,8 +164,6 @@ tags:
|
||||
|---|---|
|
||||
| `build-variant-base` | `vX.Y.Z`, `latest` |
|
||||
| `build-variant-omos` | `vX.Y.Z-omos`, `latest-omos` |
|
||||
| `build-variant-with-pi` | `vX.Y.Z-with-pi`, `latest-with-pi` |
|
||||
| `build-variant-omos-with-pi` | `vX.Y.Z-omos-with-pi`, `latest-omos-with-pi` |
|
||||
|
||||
The `latest*` aliases are only updated when `promote_latest=true` (the
|
||||
manual dispatch input) — for test runs, `promote_latest=false` keeps the
|
||||
@@ -142,7 +171,7 @@ production aliases pointing at the previous good release.
|
||||
|
||||
### Step 5: `promote-base-latest`
|
||||
|
||||
Once all four variants successfully publish, re-tag `base-<hash>` as
|
||||
Once both variants successfully publish, re-tag `base-<hash>` as
|
||||
`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,
|
||||
and is atomic.
|
||||
@@ -150,7 +179,7 @@ and is atomic.
|
||||
The reason this happens *after* variants succeed (rather than alongside
|
||||
`build-base`) is so a partial failure leaves `base-latest` pointing at
|
||||
the previous known-good base. External consumers who pin to
|
||||
`base-latest` (e.g. the planned pi-devbox repo) never see a broken base.
|
||||
`base-latest` never see a broken base.
|
||||
|
||||
### Step 6: `update-description`
|
||||
|
||||
@@ -162,18 +191,21 @@ field via the Hub REST API. Same step as the production pipeline.
|
||||
The base sets
|
||||
|
||||
```
|
||||
ENV NPM_CONFIG_PREFIX=/home/developer/.pi/npm-global
|
||||
ENV NPM_CONFIG_PREFIX=/home/developer/.config/opencode/npm-global
|
||||
```
|
||||
|
||||
This is intentional — it makes `pi install npm:<pkg>` and `npm install -g`
|
||||
land on the `devbox-pi-config` named volume at runtime, so user-installed
|
||||
packages survive container recreate AND image rebuild.
|
||||
This is intentional — it makes `npm install -g` land on the
|
||||
`devbox-opencode-config` named volume at runtime, so user-installed
|
||||
packages survive container recreate AND image rebuild. (Before v2.0.0
|
||||
this prefix lived at `~/.pi/npm-global` on the now-removed
|
||||
`devbox-pi-config` volume; `entrypoint-user.sh` migrates the old path
|
||||
once.)
|
||||
|
||||
But the *variant build* inherits this prefix at build time. If left as-is,
|
||||
`npm install -g opencode-ai@$VERSION` in `Dockerfile.variant` would
|
||||
install opencode into `/home/developer/.pi/npm-global/...`, which is then
|
||||
**shadowed by the volume mount at runtime** → opencode disappears from
|
||||
PATH on first start.
|
||||
install opencode into `/home/developer/.config/opencode/npm-global/...`,
|
||||
which is then **shadowed by the volume mount at runtime** → opencode
|
||||
disappears from PATH on first start.
|
||||
|
||||
Fix: each `npm install -g` in `Dockerfile.variant` overrides the prefix
|
||||
per-RUN:
|
||||
@@ -184,7 +216,7 @@ RUN NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION}
|
||||
|
||||
Baked binaries land on `/usr/bin/...` (system prefix), survive the volume
|
||||
mount. Runtime-installed user packages still land on
|
||||
`~/.pi/npm-global/...`. Both visible on PATH.
|
||||
`~/.config/opencode/npm-global/...`. Both visible on PATH.
|
||||
|
||||
## Cache strategy
|
||||
|
||||
@@ -206,7 +238,7 @@ matters more.
|
||||
|
||||
| Scenario | Production pipeline | Split-base pipeline |
|
||||
|---|---|---|
|
||||
| Version-bump-only release (only opencode/pi/omos version changed) | ~165–180 min | **~30–40 min** (base cache hit) |
|
||||
| Version-bump-only release (only opencode/omos version changed) | ~165–180 min | **~30–40 min** (base cache hit) |
|
||||
| Base-touching release (apt/Node/Debian/entrypoint change) | ~165–180 min | **~70–90 min** (base rebuilds) |
|
||||
|
||||
The split-base pipeline pays its dues on base-touching releases (which are
|
||||
@@ -220,7 +252,7 @@ on every push to `main` and on PRs. It:
|
||||
|
||||
1. Runs `scripts/generate-dockerhub-md.py --check` to enforce
|
||||
`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 two variants amd64-only (no multi-arch, no push)
|
||||
and runs `scripts/smoke-test.sh`.
|
||||
|
||||
This catches regressions before they reach a tag push. Wall clock ~30 min.
|
||||
|
||||
@@ -7,10 +7,10 @@ name: Publish Docker Image
|
||||
# 1. base-decide compute base hash from Dockerfile.base + rootfs/
|
||||
# + entrypoints; probe Docker Hub for existing tag.
|
||||
# 2. build-base only if probe missed; multi-arch push of base-<hash>.
|
||||
# 3. smoke-* (×4) amd64-only build of each variant FROMing the base
|
||||
# 3. smoke-* (×2) amd64-only build of each variant FROMing the base
|
||||
# tag; runs scripts/smoke-test.sh.
|
||||
# 4. build-variant-* multi-arch push of each variant tag (the user-
|
||||
# (×4) facing release tags, unchanged in shape).
|
||||
# (×2) facing release tags, unchanged in shape).
|
||||
# 5. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`
|
||||
# (manifest copy, no rebuild).
|
||||
# 6. update-description patch Docker Hub description (unchanged).
|
||||
@@ -63,10 +63,19 @@ jobs:
|
||||
run: |
|
||||
# Hash inputs that determine the base image's contents.
|
||||
# Order is fixed via `find -print0 | sort -z` for reproducibility.
|
||||
# Junk filters: __pycache__/*.pyc and macOS metadata (.DS_Store,
|
||||
# ._AppleDouble) are gitignored locally but still picked up by
|
||||
# `find rootfs -type f`, which would diverge the local hash from
|
||||
# CI's clean checkout. Exclude them defensively here.
|
||||
HASH=$(
|
||||
{
|
||||
cat Dockerfile.base
|
||||
find rootfs -type f -print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
|
||||
find rootfs -type f \
|
||||
! -path '*/__pycache__/*' \
|
||||
! -name '*.pyc' \
|
||||
! -name '.DS_Store' \
|
||||
! -name '._*' \
|
||||
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
|
||||
cat entrypoint.sh entrypoint-user.sh
|
||||
} | sha256sum | cut -c1-12
|
||||
)
|
||||
@@ -93,6 +102,39 @@ jobs:
|
||||
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} missing — will build."
|
||||
fi
|
||||
|
||||
# ── Phase 1b: resolve floating npm versions (omos) to concrete
|
||||
# versions so the variant build-args carry a different value when an
|
||||
# upstream package bumps. Without this, when OMOS_VERSION defaults to
|
||||
# 'latest', the docker/build-push-action build-arg string is byte-
|
||||
# identical across builds, so the resulting layer-hash is identical,
|
||||
# so the registry buildcache silently reuses the layer from whatever
|
||||
# omos version was current when the cache was first populated. Same
|
||||
# class of bug as pi-devbox v0.74.0..v0.75.5 (fixed in v0.75.5b
|
||||
# 2026-05-23). Currently masked because OPENCODE_VERSION is hard-coded
|
||||
# in Dockerfile.variant and bumps every release — invalidating the
|
||||
# parent-chain cache key for the omos layer — but that masking would
|
||||
# fail the moment we cut a vN.N.Nb opencode-version-unchanged release
|
||||
# that only bumps omos. Fix is preventative.
|
||||
resolve-versions:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
outputs:
|
||||
omos_version: ${{ steps.resolve.outputs.omos_version }}
|
||||
steps:
|
||||
- name: Resolve omos version from npm registry
|
||||
id: resolve
|
||||
run: |
|
||||
set -eu
|
||||
# Query the npm registry directly via curl+jq rather than `npm view`.
|
||||
# catthehacker/ubuntu:act-latest ships Node/npm under /opt/acttoolcache/
|
||||
# and adds it to PATH only via /etc/environment — which act_runner never
|
||||
# sources (it reads the Docker image's ENV instructions, not /etc/environment).
|
||||
# curl and jq are both guaranteed present in every job in this workflow.
|
||||
OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.version')
|
||||
echo "omos_version=${OMOS_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved OMOS_VERSION=${OMOS_VERSION}"
|
||||
|
||||
# ── Phase 2: build & push base (multi-arch), only when needed ──────
|
||||
build-base:
|
||||
needs: [base-decide]
|
||||
@@ -139,17 +181,44 @@ jobs:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base (multi-arch)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
# Registry cache for faster repeat base rebuilds (e.g. Node bump).
|
||||
cache-from: type=registry,ref=${{ env.IMAGE }}:base-buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE }}:base-buildcache,mode=max
|
||||
- name: Build and push base (multi-arch) — with retry
|
||||
shell: bash
|
||||
env:
|
||||
BASE_TAG_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 3-attempt retry around `docker buildx build --push` for transient
|
||||
# registry-1.docker.io blips. Does NOT mask deterministic failures:
|
||||
# a true regression (e.g. cache-export 400 hit 2026-05-23..28) will
|
||||
# fail all 3 attempts identically and the job still fails — by
|
||||
# design.
|
||||
# Registry cache disabled: buildkit's cache-export (mode=max) hits a
|
||||
# reproducible HTTP 400 from registry-1.docker.io on the resumable-
|
||||
# upload PUT (state-token format mismatch on Hub CDN, suspected to
|
||||
# have started ~2026-05-23). Image push itself works fine. We pay
|
||||
# the full base build on every Dockerfile.base change, but the base
|
||||
# tag itself is content-addressed (base-<hash>) so unchanged bases
|
||||
# short-circuit at the probe step and never re-build anyway. Re-
|
||||
# enable when upstream resolves; tracked in CHANGELOG v1.15.12.
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.base \
|
||||
--push \
|
||||
--tag "${BASE_TAG_FULL}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" -lt 3 ]]; then
|
||||
backoff=$(( attempt * 15 ))
|
||||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||
sleep "${backoff}"
|
||||
fi
|
||||
done
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
# ── Phase 3: amd64 smoke per variant (gates the multi-arch publish) ─
|
||||
# Each smoke job builds amd64-only against the base tag and runs
|
||||
@@ -197,15 +266,15 @@ jobs:
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=false
|
||||
INSTALL_PI=false
|
||||
- name: Smoke test (amd64)
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base
|
||||
|
||||
smoke-omos:
|
||||
needs: [base-decide, build-base]
|
||||
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:
|
||||
@@ -239,90 +308,10 @@ jobs:
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=false
|
||||
- run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos
|
||||
|
||||
smoke-with-pi:
|
||||
needs: [base-decide, build-base]
|
||||
if: |
|
||||
always() &&
|
||||
needs.base-decide.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-with-pi
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=false
|
||||
INSTALL_PI=true
|
||||
- run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi
|
||||
|
||||
smoke-omos-with-pi:
|
||||
needs: [base-decide, build-base]
|
||||
if: |
|
||||
always() &&
|
||||
needs.base-decide.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-omos-with-pi
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=true
|
||||
- run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi
|
||||
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
|
||||
- env:
|
||||
EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos
|
||||
|
||||
# ── Phase 4: multi-arch publish per variant ────────────────────────
|
||||
|
||||
@@ -361,21 +350,42 @@ jobs:
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=false
|
||||
INSTALL_PI=false
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
- 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 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
# 3-attempt retry around `docker buildx build --push` (see build-base
|
||||
# step for full rationale). Variant: base (opencode 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=true" \
|
||||
--build-arg "INSTALL_OMOS=false" \
|
||||
"${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-omos:
|
||||
needs: [base-decide, smoke-omos]
|
||||
needs: [base-decide, smoke-omos, resolve-versions]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -409,114 +419,40 @@ jobs:
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=false
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
|
||||
build-variant-with-pi:
|
||||
needs: [base-decide, smoke-with-pi]
|
||||
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
|
||||
- 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 }}
|
||||
OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
|
||||
run: |
|
||||
VERSION="${{ env.RELEASE_TAG }}"
|
||||
{ echo "tags<<EOF"
|
||||
echo "${IMAGE}:${VERSION}-with-pi"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${IMAGE}:latest-with-pi"
|
||||
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: omos.
|
||||
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=true" \
|
||||
--build-arg "INSTALL_OMOS=true" \
|
||||
--build-arg "OMOS_VERSION=${OMOS_VERSION}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
exit 0
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=false
|
||||
INSTALL_PI=true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
|
||||
build-variant-omos-with-pi:
|
||||
needs: [base-decide, smoke-omos-with-pi]
|
||||
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: |
|
||||
VERSION="${{ env.RELEASE_TAG }}"
|
||||
{ echo "tags<<EOF"
|
||||
echo "${IMAGE}:${VERSION}-omos-with-pi"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${IMAGE}:latest-omos-with-pi"
|
||||
if [[ "${attempt}" -lt 3 ]]; then
|
||||
backoff=$(( attempt * 15 ))
|
||||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||
sleep "${backoff}"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
done
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
# ── Phase 5: promote base-<hash> → base-latest (manifest copy only) ─
|
||||
promote-base-latest:
|
||||
@@ -524,8 +460,6 @@ jobs:
|
||||
- base-decide
|
||||
- build-variant-base
|
||||
- build-variant-omos
|
||||
- build-variant-with-pi
|
||||
- build-variant-omos-with-pi
|
||||
# Skip on cache-hit base builds: when need_build=false, base-latest
|
||||
# already points at the same digest as base-<hash>, so the retag is
|
||||
# a tautology and any transient failure of it is purely cosmetic.
|
||||
@@ -534,7 +468,7 @@ jobs:
|
||||
#
|
||||
# `always()` wrapper + explicit base-variant success check protects
|
||||
# against the gitea-Actions default of "skipped need => skip dependent":
|
||||
# a partial-publish run (e.g., omos-with-pi smoke fails) shouldn't
|
||||
# a partial-publish run (e.g., omos smoke fails) shouldn't
|
||||
# prevent the base-latest alias from advancing on a real base rebuild.
|
||||
if: |
|
||||
always() &&
|
||||
@@ -576,10 +510,8 @@ jobs:
|
||||
needs:
|
||||
- build-variant-base
|
||||
- build-variant-omos
|
||||
- build-variant-with-pi
|
||||
- build-variant-omos-with-pi
|
||||
# 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 smoke threshold) prevent Hub
|
||||
# description refresh for the other variants that did publish.
|
||||
# Without this `always()` wrapper, gitea Actions' default behavior
|
||||
# of "skipped need => skip dependent" cascades from any failed/
|
||||
@@ -596,6 +528,24 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Update Docker Hub description
|
||||
run: |
|
||||
# Substitute {{OPENCODE_VERSION}} placeholders in DOCKER_HUB.md so
|
||||
# the Hub page always shows which opencode version is baked into
|
||||
# :latest. The placeholder lives in DOCKER_HUB.md (committed); CI
|
||||
# fills it at publish time from the pinned ARG in
|
||||
# Dockerfile.variant — the same value that was baked into the
|
||||
# image — so the page and the image never drift. (Mirrors the
|
||||
# {{PI_VERSION}} pattern in pi-devbox's docker-publish.yml.)
|
||||
OPENCODE_VERSION=$(sed -n 's/^ARG OPENCODE_VERSION=//p' Dockerfile.variant | head -1)
|
||||
if [ -z "${OPENCODE_VERSION}" ]; then
|
||||
echo "::error::Could not extract OPENCODE_VERSION from Dockerfile.variant"
|
||||
exit 1
|
||||
fi
|
||||
cp DOCKER_HUB.md /tmp/hub-full.md
|
||||
sed -i "s/{{OPENCODE_VERSION}}/${OPENCODE_VERSION}/g" /tmp/hub-full.md
|
||||
if grep -q '{{OPENCODE_VERSION}}' /tmp/hub-full.md; then
|
||||
echo "::error::DOCKER_HUB.md still contains unsubstituted {{OPENCODE_VERSION}} markers"
|
||||
exit 1
|
||||
fi
|
||||
TOKEN=$(curl -s -X POST https://hub.docker.com/v2/auth/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"identifier":"${{ vars.DOCKERHUB_USERNAME }}","secret":"${{ secrets.DOCKERHUB_TOKEN }}"}' \
|
||||
@@ -605,7 +555,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
HTTP_CODE=$(jq -n \
|
||||
--rawfile full DOCKER_HUB.md \
|
||||
--rawfile full /tmp/hub-full.md \
|
||||
--arg short "Portable AI dev environment for opencode. Debian-based with git, Node.js, AWS CLI, and SSH support." \
|
||||
'{"full_description": $full, "description": $short}' | \
|
||||
curl -s -o /tmp/hub-response.txt -w "%{http_code}" -X PATCH \
|
||||
|
||||
@@ -195,120 +195,3 @@ jobs:
|
||||
- name: Smoke test
|
||||
run: |
|
||||
bash scripts/smoke-test.sh opencode-devbox:ci-omos --variant omos
|
||||
|
||||
validate-with-pi:
|
||||
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 with-pi 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_PI=true
|
||||
tags: opencode-devbox:ci-with-pi
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
bash scripts/smoke-test.sh opencode-devbox:ci-with-pi --variant with-pi
|
||||
|
||||
validate-omos-with-pi:
|
||||
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 omos+with-pi 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_OMOS=true
|
||||
INSTALL_PI=true
|
||||
tags: opencode-devbox:ci-omos-with-pi
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
bash scripts/smoke-test.sh opencode-devbox:ci-omos-with-pi --variant omos-with-pi
|
||||
|
||||
@@ -4,12 +4,22 @@
|
||||
|
||||
Docker image packaging [opencode](https://opencode.ai) into a production-ready dev container. Image variants are published to Docker Hub via Gitea Actions CI. Not a library or application — this is infrastructure (Dockerfiles, entrypoint scripts, docker-compose, documentation).
|
||||
|
||||
> **pi was removed in v2.0.0** (deprecated since v1.17.2). The `INSTALL_PI`
|
||||
> build arg, the `with-pi` / `omos-with-pi` / `pi-only` variants, the
|
||||
> `base-pi-only` published tag, and all `~/.pi`-related wiring are gone. pi
|
||||
> now ships from its own repo (`joakimp/pi-devbox`). Do not add pi
|
||||
> functionality here. The removal history + the `NPM_CONFIG_PREFIX`
|
||||
> relocation (`~/.pi/npm-global` → `~/.config/opencode/npm-global`, with a
|
||||
> one-time migration shim in `entrypoint-user.sh`) are recorded in
|
||||
> `docs/CLEANUP-v2.0.0.md` and the v2.0.0 CHANGELOG entry.
|
||||
|
||||
## 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.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.
|
||||
- `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.
|
||||
- `Dockerfile.variant` — `FROM`s the base and adds only opencode/omos installs gated by build args: `INSTALL_OPENCODE` (default true), `INSTALL_OMOS`, and `INSTALL_MEMPALACE`. All GitHub-sourced binaries are pinned with version ARGs. Two variants: `base` (`INSTALL_OPENCODE=true`) and `omos` (`+INSTALL_OMOS=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.
|
||||
- `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`), a one-time npm-global prefix migration shim (legacy `~/.pi/npm-global` → `~/.config/opencode/npm-global`), 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).
|
||||
- `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).
|
||||
@@ -17,7 +27,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.
|
||||
- `.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/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 2 parallel smoke tests, then 2 parallel multi-arch variant builds, promotes `base-latest` alias, updates Docker Hub description.
|
||||
|
||||
## Versioning scheme
|
||||
|
||||
@@ -27,11 +37,39 @@ Tags follow `v{opencode_version}[letter]` — e.g. `v1.14.20` for the first buil
|
||||
- **No letter suffix** on the first build of a new opencode version — the bare `v{opencode_version}` tag is the canonical release.
|
||||
- **Letter suffix is the build ordinal**, starting at `b` for the second build. The letter `a` is **never used** — think of the suffix as counting rebuilds: `b = 2nd, c = 3rd, d = 4th, …`. For opencode version `1.14.20`: first build `v1.14.20`, second `v1.14.20b`, third `v1.14.20c`, and so on.
|
||||
- A letter suffix is only used for container-level rebuilds — tooling changes, CVE fixes, doc-driven rebuilds, entrypoint bugfixes — that don't change the underlying opencode version.
|
||||
- **Pre-flight check before cutting any non-letter-suffixed tag** — verify the bump is real:
|
||||
```bash
|
||||
npm view opencode-ai version # must equal the X.Y.Z in your tag
|
||||
```
|
||||
If the npm version equals the *previous* release's `X.Y.Z`, you're cutting a letter-suffix rebuild (`vX.Y.Zc`, `vX.Y.Zd`, …), not a new minor. **A bare `vX.Y.Z` tag is a claim that opencode upstream just released `X.Y.Z`** — if that claim is wrong, future opencode releases will collide with your tag namespace and the version-tracking story breaks.
|
||||
|
||||
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.
|
||||
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 four Docker Hub tags **under `opencode-devbox`** per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos` — one tag pair (versioned + floating alias) per variant (two variants: `base`, `omos`).
|
||||
|
||||
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.
|
||||
|
||||
## Upstream sources — where to look up release notes
|
||||
|
||||
When drafting a release CHANGELOG entry, pull notes from the **canonical upstream repo for each tracked package**. Getting this wrong leads to thin or wrong release notes; the image bytes are unaffected but the documentation suffers.
|
||||
|
||||
| Package | Canonical upstream | What you'll find there |
|
||||
|---|---|---|
|
||||
| `opencode-ai` (npm) | <https://github.com/anomalyco/opencode/releases> | Per-version release notes with Core / TUI / Desktop / SDK sections, contributor attributions. Some versions have empty bodies (internal/no-user-visible); most do not. |
|
||||
| Other floated tools (gosu, fzf, bat, eza, zoxide, uv, nvim, gitea-mcp, Go, oh-my-opencode-slim) | Each project's own GitHub releases page | Usually less material per release; quote selectively. |
|
||||
|
||||
**Trap to avoid:** there is a `github.com/sst/opencode` repo that some search results surface; that's a fork (and probably the historical name people associate with opencode given the upstream lineage). It does NOT track the same release timeline. Use `anomalyco/opencode` for opencode release notes.
|
||||
|
||||
Fetch pattern (saved here for muscle memory):
|
||||
|
||||
```bash
|
||||
# Latest stable opencode-ai versions on npm
|
||||
npm view opencode-ai time --json | python3 -c 'import sys,json,re; d=json.load(sys.stdin); print(*sorted([(v,t) for v,t in d.items() if re.fullmatch(r"\d+\.\d+\.\d+",v)], key=lambda x:x[1], reverse=True)[:6], sep="\n")'
|
||||
|
||||
# Release notes for a specific version
|
||||
curl -s https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.15.10 | python3 -c 'import sys,json; print(json.load(sys.stdin).get("body","(empty)"))'
|
||||
```
|
||||
|
||||
## Critical conventions
|
||||
|
||||
- **entrypoint.sh volume ownership loop** — when adding a new named volume mount point, add it to the `for dir in ...` loop in `entrypoint.sh` so root-owned volumes get chowned on startup. The loop writes a `.devbox-owner` sentinel after a successful chown so subsequent starts skip the recursive walk. Users should not touch these files.
|
||||
@@ -43,25 +81,29 @@ When bumping the opencode version, bump `OPENCODE_VERSION` in `Dockerfile.varian
|
||||
- `.env.example` must be hand-updated to match Dockerfile/entrypoint behavior — it is not auto-generated.
|
||||
|
||||
Release-day checklist: README → (regenerate DOCKER_HUB.md only if HUB_TEMPLATE changed) → promote CHANGELOG Unreleased → grep AGENTS.md for stale counts → commit → tag → push tag.
|
||||
- **GitHub/Gitea-sourced binaries float by default** — gosu, fzf, git-lfs, nvim, bat, eza, zoxide, uv, gitea-mcp, Go, oh-my-opencode-slim all default to `latest`. Each build-time install step reads the `/releases/latest` Location redirect (or the go.dev JSON feed for Go) and derives the concrete version. Use the same `ARCH` case-switch pattern for multi-arch support (amd64/arm64). Intentional pins: `OPENCODE_VERSION` (drives the image tag), `NODE_VERSION=22` (major pin), `DEBIAN_VERSION=trixie-slim` (OS base). Adding a new upstream tool: follow the existing floated-version pattern, don't hardcode a specific tag.
|
||||
|
||||
**Between releases the same coupling applies.** Doc drift is not just a release-day concern — a workflow tweak, entrypoint change, or `generate-config.py` refactor can leave any of these four files lying. Before committing a non-release change, grep the docs for references to what you touched: `git diff --name-only HEAD | xargs -I{} grep -l 'thing-you-changed' README.md AGENTS.md DOCKER_HUB.md .gitea/README.md .env.example`. If a doc says "four variants" / "two phases" / "runs on amd64 only" and your change made that no longer true, fix it in the same commit.
|
||||
- **GitHub/Gitea-sourced binaries float by default** — gosu, fzf, git-lfs, gitleaks, nvim, bat, eza, zoxide, uv, gitea-mcp, Go, oh-my-opencode-slim all default to `latest`. Each build-time install step reads the `/releases/latest` Location redirect (or the go.dev JSON feed for Go) and derives the concrete version. Use the same `ARCH` case-switch pattern for multi-arch support (amd64/arm64) — mind project-specific arch-name deviations (gitleaks uses `x64`, bat/eza/zoxide use `x86_64`/`aarch64`, gosu uses `amd64`/`arm64`). Intentional pins: `OPENCODE_VERSION` (drives the image tag), `NODE_VERSION=22` (major pin), `DEBIAN_VERSION=trixie-slim` (OS base). Adding a new upstream tool: follow the existing floated-version pattern, don't hardcode a specific tag.
|
||||
- **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`.
|
||||
- **`OMOS_VERSION` MUST be passed by CI as a concrete version**, not left at the `latest` default. The npm install step in `Dockerfile.variant` (`oh-my-opencode-slim@${OMOS_VERSION}`) produces an identical layer-hash when the ARG value is 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 omos layer — but the masking would fail the moment a `vN.N.Nb` opencode-version-unchanged release ships that only bumps omos. Preventative fix: `.gitea/workflows/docker-publish-split.yml` has a `resolve-versions` job that runs `npm view oh-my-opencode-slim version`, exposing the concrete value as an output that the omos smoke + build jobs consume via build-args. Smoke tests assert via the `EXPECTED_OMOS_VERSION` env var — would catch the regression on the next release rather than several 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`.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **Skillset auto-deploy** — on every container start, `entrypoint-user.sh` looks for a skillset repo (detection order: `$SKILLSET_CONTAINER_PATH` → `$HOME/skillset` → `/workspace/skillset`) and runs `deploy-skills.sh --bootstrap --prune-stale`. This creates relative symlinks in `~/.agents/skills/` and `~/.config/opencode/instructions/`. Do NOT bind-mount `~/.agents/skills/` from the host — the container manages its own skills with relative symlinks that differ from the host's. The named volume `devbox-opencode-config` persists the deployed config across restarts.
|
||||
- **Config persistence via named volume** — `devbox-opencode-config` is a Docker named volume mounted at `~/.config/opencode/`. It is NOT a host bind mount by default. This separation allows both native and containerized opencode to coexist on the same machine without symlink conflicts. Users who need to override can replace the named volume with a host bind mount in their compose file. **Same pattern for pi:** `devbox-pi-config` is mounted at `~/.pi/` and persists user toggles (`/ext`-disabled extensions), `~/.pi/agent/settings.json` edits, and — because `NPM_CONFIG_PREFIX` is set to `~/.pi/npm-global` — anything installed via `pi install npm:...` or `npm install -g` as the developer user, across container recreate AND image rebuild.
|
||||
- **pi install contract** — `INSTALL_PI=true` (default false) opt-in build arg. The baked `pi` binary is npm-installed globally to `/usr` at build time (system prefix). At runtime, `NPM_CONFIG_PREFIX=/home/developer/.pi/npm-global` is set in the image ENV with that prefix's `bin/` prepended to `PATH` — so any `pi install npm:...` or `npm install -g` invoked by the developer user lands on the named volume and survives everything except `docker compose down -v`. The new ENVs are declared *after* all build-time `npm install -g` calls in the Dockerfile so they don't redirect the baked installs into a path that the volume mount would later shadow. If the user runs `npm install -g @earendil-works/pi-coding-agent` themselves, the user-installed copy on the volume wins via `PATH` order; otherwise image rebuild is the upgrade path for the baked pi (same contract as `OPENCODE_VERSION`). The pi-toolkit and pi-extensions repos are git-cloned into `/opt/` at build time, then their `install.sh` runs from `entrypoint-user.sh` on each container start to symlink into `~/.pi/agent/` (which lives on the named volume). The mempalace pi-bridge is symlinked manually from `/opt/mempalace-toolkit/extensions/pi/mempalace.ts` — we do NOT call mempalace-toolkit's full `install.sh` because its `install_skill` step would race with skillset auto-deploy `--prune-stale`.
|
||||
- **Pi deploy ordering matters in entrypoint-user.sh** — `pi-toolkit` runs first (creates `keybindings.json` symlink and writes pi-env.zsh), then `pi-extensions`, then `settings.json` template bootstrap, then mempalace bridge symlink. mempalace-toolkit's `check_pi_toolkit` probe (when called from the host install path) expects keybindings to already be present — not currently called from container, but ordering matches host convention.
|
||||
- **Default CMD is `bash -l`** — not a harness. `docker compose run --rm devbox` drops the user into a login shell to choose: `aws sso login`, then `opencode` or `pi` (or any tool). Pass the harness explicitly to launch directly: `docker compose run --rm devbox opencode` / `docker compose run --rm devbox pi`. `docker compose exec` bypasses entrypoint+CMD entirely (existing user workflow unchanged).
|
||||
- **Config persistence via named volume** — `devbox-opencode-config` is a Docker named volume mounted at `~/.config/opencode/`. It is NOT a host bind mount by default. This separation allows both native and containerized opencode to coexist on the same machine without symlink conflicts. Users who need to override can replace the named volume with a host bind mount in their compose file. Because `NPM_CONFIG_PREFIX` is set to `~/.config/opencode/npm-global` (relocated from the legacy `~/.pi/npm-global` in v2.0.0), anything installed via `npm install -g` as the developer user also lands on this volume and survives container recreate AND image rebuild.
|
||||
- **npm-global prefix relocation (v2.0.0 breaking change)** — the user-writable global npm prefix moved from `~/.pi/npm-global` to `~/.config/opencode/npm-global`. The old path lived on the `devbox-pi-config` volume (only mounted in `docker-compose.yml`); the new path is on `devbox-opencode-config`, which is a persistent named volume in BOTH `docker-compose.yml` and `docker-compose.shared.yml`. `entrypoint-user.sh` carries a one-time migration shim: if `~/.pi/npm-global` exists and the marker `~/.config/opencode/npm-global/.migrated-from-dot-pi` is absent, it `cp -an` the old `lib/`/`bin/`/`share/` into the new prefix (never overwriting fresh installs) and writes the marker. Baked binaries stay on `/usr` (the variant Dockerfile runs each `npm install -g` with `NPM_CONFIG_PREFIX=/usr`) so the volume mount doesn't shadow them. The `ENV NPM_CONFIG_PREFIX`/`PATH` lines in `Dockerfile.base` are declared *after* all build-time installs.
|
||||
- **Default CMD is `bash -l`** — not a harness. `docker compose run --rm devbox` drops the user into a login shell to choose: `aws sso login`, then `opencode` (or any tool). Pass the harness explicitly to launch directly: `docker compose run --rm devbox opencode`. `docker compose exec` bypasses entrypoint+CMD entirely (existing user workflow unchanged).
|
||||
- **Docker Hub description update** — uses `/v2/auth/token` endpoint (not the deprecated `/v2/users/login`). Auth uses `identifier`/`secret` fields, returns `access_token`, sent as `Bearer`. Short description must be ≤100 bytes.
|
||||
|
||||
## CI quirks
|
||||
|
||||
- Both build jobs include an IPv4 preference step (`gai.conf` + `driver-opts: network=host` for buildx) to work around intermittent IPv6 failures on the Gitea runners.
|
||||
- `update-description` job runs only when both builds succeed (`needs: [build-base, build-omos]`).
|
||||
- `update-description` job runs when the base variant published (`needs: [build-variant-base, build-variant-omos]`, gated with `always()` + an explicit `build-variant-base.result == 'success'` check so a partial-publish run still refreshes the Hub description).
|
||||
- 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.
|
||||
- **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 `load: true` jobs (`validate-base`, `validate-omos`, `smoke-base`, `smoke-omos`) 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).
|
||||
- **`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.
|
||||
|
||||
+645
@@ -8,6 +8,651 @@ Tags follow `v{opencode_version}[letter]` — bare tag for the first build on a
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v2.0.0 — 2026-06-13
|
||||
|
||||
**Major release: pi is fully removed from opencode-devbox** (deprecated in
|
||||
v1.17.2), and the user-writable global npm prefix is relocated off the
|
||||
pi-specific `~/.pi` path. Also bumps opencode. The major version signals the
|
||||
two breaking changes below.
|
||||
|
||||
### Bumped: opencode-ai 1.17.2 → 1.17.4
|
||||
|
||||
`OPENCODE_VERSION` ARG in `Dockerfile.variant`.
|
||||
|
||||
### Removed: all pi support
|
||||
|
||||
pi ships as its own self-contained image,
|
||||
[`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox). Everything
|
||||
pi-related is now gone from this repo:
|
||||
|
||||
- The `INSTALL_PI` build arg and all `PI_*` args (`PI_VERSION`,
|
||||
`PI_TOOLKIT_REF`, `PI_EXTENSIONS_REF`, `PI_FORK_*`, `PI_OBSMEM_*`).
|
||||
- The `with-pi`, `omos-with-pi`, and `pi-only` build variants. **Only `base`
|
||||
and `omos` remain** — four published tags per release (`vX.Y.Z`, `latest`,
|
||||
`vX.Y.Z-omos`, `latest-omos`).
|
||||
- The `base-pi-only[-vX.Y.Z]` tag that this repo's CI published into the
|
||||
`joakimp/pi-devbox` repo. The publisher job is deleted; the orphaned
|
||||
`base-pi-only*` tags on the pi-devbox Hub repo can now be purged (see
|
||||
`docs/CLEANUP-v2.0.0.md`).
|
||||
- All `~/.pi` entrypoint wiring (pi-toolkit / pi-extensions deploy,
|
||||
settings.json bootstrap, mempalace pi-bridge symlink, pi-fork / pi-obsmem
|
||||
registration), the `~/.pi` volume-ownership entry, and the three pi
|
||||
smoke / validate / build-variant CI jobs.
|
||||
|
||||
**Migration:** pull `joakimp/pi-devbox:latest` instead of any `*-with-pi` /
|
||||
`pi-only` opencode-devbox tag. opencode-only users are unaffected by the pi
|
||||
removal itself.
|
||||
|
||||
### Breaking: global npm prefix relocated `~/.pi/npm-global` → `~/.config/opencode/npm-global`
|
||||
|
||||
`NPM_CONFIG_PREFIX` (and the matching `PATH` entry) moved off the
|
||||
pi-specific path. The new location lives on the `devbox-opencode-config`
|
||||
named volume, which — unlike the old `devbox-pi-config` — is a **persistent
|
||||
named volume in both `docker-compose.yml` and `docker-compose.shared.yml`**,
|
||||
so runtime `npm install -g` still survives container recreate and image
|
||||
rebuild.
|
||||
|
||||
**Impact on upgraders:** any tool you previously `npm install -g`'d landed in
|
||||
`~/.pi/npm-global` and will **drop off `PATH`** under v2.0.0.
|
||||
|
||||
**Mitigation (automatic):** `entrypoint-user.sh` carries a one-time
|
||||
migration shim. On first start, if `~/.pi/npm-global` exists and the marker
|
||||
`~/.config/opencode/npm-global/.migrated-from-dot-pi` is absent, it copies
|
||||
the old `lib/`/`bin/`/`share/` contents into the new prefix (never
|
||||
overwriting freshly-installed packages) and writes the marker. For the shim
|
||||
to see your old packages, the legacy `devbox-pi-config` volume must still be
|
||||
mounted at `~/.pi` for that first start — the shipped `docker-compose.yml`
|
||||
leaves it commented with instructions; uncomment the mount for one start if
|
||||
you had global npm tools to migrate, then remove it. Fresh installs carry no
|
||||
`~/.pi` dead weight. If you don't need the old packages, ignore all of this
|
||||
and re-`npm install -g` anything you want.
|
||||
|
||||
### Docs / CI
|
||||
|
||||
- `DOCKER_HUB.md` now shows the baked opencode version via a
|
||||
`{{OPENCODE_VERSION}}` placeholder substituted by CI at publish time
|
||||
(mirrors pi-devbox's `{{PI_VERSION}}` pattern) — the Hub page can no longer
|
||||
drift from the image.
|
||||
- README, AGENTS.md, `.gitea/README.md`, `.env.example`, and the
|
||||
`docs/manual-host-publish.*` runbook updated to the two-variant reality
|
||||
(variant tables, CI job lists, the npm-prefix gotcha, ASCII pipeline
|
||||
diagram, tag counts).
|
||||
- CI: removed the three pi smoke jobs, three pi build-variant jobs, the
|
||||
`pi-only` publish-to-pi-devbox job, and the pi/fork/obsmem resolution in
|
||||
`resolve-versions` (now resolves omos only). `validate.yml` drops its
|
||||
three pi validate jobs.
|
||||
|
||||
## v1.17.2 — 2026-06-10
|
||||
|
||||
First container build on **opencode-ai `1.17.2`** (from `1.16.2`). This
|
||||
release also **deprecates all pi support** ahead of its removal in v2.0.0,
|
||||
and hardens the mempalace install.
|
||||
|
||||
### Bumped: opencode-ai 1.16.2 → 1.17.2
|
||||
|
||||
`OPENCODE_VERSION` ARG in `Dockerfile.variant`. Bare `v1.17.2` tag per the
|
||||
`v{opencode_version}` scheme.
|
||||
|
||||
### Deprecated: pi support (removed in v2.0.0)
|
||||
|
||||
pi has been decoupled into its own self-contained image,
|
||||
[`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox) (v1.0.0+,
|
||||
which no longer FROMs `base-pi-only`). The pi paths in opencode-devbox are
|
||||
now dead weight and are **deprecated as of v1.17.2, scheduled for removal in
|
||||
v2.0.0**:
|
||||
|
||||
- The `INSTALL_PI` build arg and all `PI_*` args.
|
||||
- The `with-pi`, `omos-with-pi`, and `pi-only` build variants.
|
||||
- The `base-pi-only[-vX.Y.Z]` tag published (to the `joakimp/pi-devbox`
|
||||
repo) from this repo's CI.
|
||||
- All `~/.pi`-related entrypoint wiring (pi-toolkit / pi-extensions deploy,
|
||||
settings.json bootstrap, mempalace pi-bridge symlink, pi-fork / pi-obsmem
|
||||
registration).
|
||||
|
||||
What this release does:
|
||||
|
||||
- Building with `INSTALL_PI=true` now prints a **build-time deprecation
|
||||
warning** to stderr.
|
||||
- README, DOCKER_HUB.md, and AGENTS.md mark the pi variants deprecated and
|
||||
point to `joakimp/pi-devbox`.
|
||||
- The full removal plan is documented in `docs/CLEANUP-v2.0.0.md`.
|
||||
|
||||
**Migration:** pull `joakimp/pi-devbox:latest` directly instead of any
|
||||
`*-with-pi` / `pi-only` opencode-devbox tag. opencode-only users are
|
||||
unaffected by this release.
|
||||
|
||||
#### ⚠ Heads-up for v2.0.0: global npm prefix relocation
|
||||
|
||||
Today `NPM_CONFIG_PREFIX` points at the pi-specific `~/.pi/npm-global`
|
||||
(backed by the `devbox-pi-config` named volume), so `npm install -g` as the
|
||||
developer user persists across recreates. **v2.0.0 will move the prefix to a
|
||||
neutral opencode path** (e.g. `~/.config/opencode/npm-global`). Consequences
|
||||
for existing opencode users at the v2.0.0 upgrade:
|
||||
|
||||
- Previously global-installed npm tools remain on disk in the old volume but
|
||||
**drop off `PATH`** until migrated.
|
||||
- The new prefix path is not currently a named volume, so persistence needs a
|
||||
compose/volume update.
|
||||
|
||||
v2.0.0 will ship a **one-time migration shim** (copies old prefix contents to
|
||||
the new path on first run) and an updated volume mapping. This notice is the
|
||||
one-release-cycle advance warning.
|
||||
|
||||
### Hardened: mempalace install pinned + diary_write schema workaround
|
||||
|
||||
- `MEMPALACE_VERSION` is now an explicit ARG (default `3.4.0`) in
|
||||
`Dockerfile.base`; the install uses `mempalace==${MEMPALACE_VERSION}`
|
||||
instead of an unpinned `uv tool install mempalace`. An unpinned install is
|
||||
what silently swept in the broken `diary_write` schema. Mirrors pi-devbox.
|
||||
- Added an idempotent, self-deactivating post-install patch that strips the
|
||||
**top-level `anyOf`** from `mempalace_diary_write`'s `input_schema`.
|
||||
Mempalace 3.3.x/3.4.0 advertise `anyOf:[{required:[entry]},{required:[content]}]`
|
||||
at the schema root, which the Anthropic tools API rejects outright
|
||||
(`input_schema does not support oneOf, allOf, or anyOf at the top level`),
|
||||
breaking pi/Claude tool registration at session start. The handler still
|
||||
accepts `content` server-side. Upstream: MemPalace/mempalace#1728, PR #1735.
|
||||
Remove once a fixed mempalace release is pinned.
|
||||
|
||||
### Fixed: smoke-test pi-extensions readiness race (test-only, no image change)
|
||||
|
||||
`scripts/smoke-test.sh`'s entrypoint-deploy wait loop gated only on
|
||||
`keybindings.json` (written by pi-toolkit, which runs *before* pi-extensions),
|
||||
so the `~/.pi/agent/extensions/*.ts ≥ 4` assertion could sample mid-deploy and
|
||||
see fewer than 4 files under parallel build load. Observed on v1.16.2 run 370:
|
||||
`smoke-with-pi` saw `<4` while `smoke-omos-with-pi` / `smoke-pi-only` (identical
|
||||
pi-extensions `357fcc6`) both saw 8, skipping `build-variant-with-pi`. The wait
|
||||
loop now blocks until the *last*-deployed artifact (the `mempalace.ts` bridge
|
||||
symlink) exists **and** the extension count has settled ≥ 4 (up to 45s).
|
||||
|
||||
## v1.16.2 — 2026-06-08
|
||||
|
||||
First container build on the `opencode-ai@1.16.x` minor release (rolls up
|
||||
`1.16.0` → `1.16.1` → `1.16.2`, all published 2026-06-05). Also picks up
|
||||
**pi `0.78.1` → `0.79.0`** (resolved fresh by CI's `resolve-versions` job) in
|
||||
the `with-pi`, `omos-with-pi`, and `pi-only` variants.
|
||||
|
||||
### Bumped: opencode-ai 1.15.13 → 1.16.2
|
||||
|
||||
`OPENCODE_VERSION` ARG in `Dockerfile.variant`. Highlights from the upstream
|
||||
release (full notes: <https://github.com/anomalyco/opencode/releases>):
|
||||
|
||||
- **1.16.0** — ~38% faster startup (@StarpTech); managed workspace cloning that
|
||||
keeps dirty/untracked files; move sessions between workspaces/directories;
|
||||
proper OpenAI-via-Bedrock support; skill discovery + file-based agent loading;
|
||||
`run --replay` for interactive session replay. Plus TUI/desktop polish and
|
||||
numerous bugfixes (shell cancellation races, Windows path normalization, ACP
|
||||
cancel/abort).
|
||||
- **1.16.1** — internal/no user-visible notes (empty release body).
|
||||
- **1.16.2** — reasoning summaries only run on supporting providers (avoids
|
||||
GPT-5 request failures); edit operations refuse loose matches that could
|
||||
overwrite the wrong code; Bedrock hang-before-first-token fix; diff-viewer
|
||||
hunk navigation; subagents can be backgrounded; Snowflake Cortex provider.
|
||||
|
||||
### Picks up: pi 0.78.1 → 0.79.0
|
||||
|
||||
Resolved at build time for the pi-bearing variants. Headlines: project-trust
|
||||
prompting for project-local settings/resources/instructions/packages (with
|
||||
`--approve`/`--no-approve` and a `project_trust` extension event), cache-hit
|
||||
rate in the interactive footer, richer SDK/RPC extension surfaces, plus a
|
||||
stack of TUI and provider fixes. Full notes ship in the npm tarball's
|
||||
`CHANGELOG.md`.
|
||||
|
||||
### Smoke size thresholds bumped +150 MB (preemptive)
|
||||
|
||||
Ahead of the combined minor opencode + pi bump, all opencode-bearing variant
|
||||
thresholds in `scripts/smoke-test.sh` were raised: `base` 2600 → 2750,
|
||||
`omos` 3300 → 3450, `with-pi` 2900 → 3050, `omos-with-pi` 3900 → 4050; and
|
||||
`pi-only` 2750 → 2850 (+100, pi-only carries only the pi bump). Both `base`
|
||||
(last 2506 MB) and `omos` (last 3206 MB) were on ~94 MB headroom, and a minor
|
||||
opencode bump has tripped these ceilings before (v1.15.0 omos, v1.15.4
|
||||
omos-with-pi), causing a partial publish + letter-suffix recovery cycle.
|
||||
Restoring ~250 MB headroom avoids that. CI's smoke size print records actual
|
||||
landed sizes — tighten later if they come in well under.
|
||||
|
||||
## 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
|
||||
|
||||
First container build on the genuine `opencode-ai@1.15.12` upstream release (published 2026-05-28). Also bumps pi `0.76.0` → `0.77.0`.
|
||||
|
||||
> **Note on the `v1.15.12` git tag:** an earlier `v1.15.12` git tag existed at commit `be2a168` as a historical artifact from the 2026-05-28 versioning slip (re-cut as `v1.15.11c` once the slip was caught). The corresponding Hub `v1.15.12*` images were manually deleted at the time. Now that opencode upstream has actually released 1.15.12, the tag is being re-used at HEAD per the `v{opencode_version}[letter]` scheme — the old tag was force-overwritten locally and on origin. Commit `be2a168` and the v1.15.11c CHANGELOG block (which references the slip) remain in history.
|
||||
|
||||
### Bumped: opencode-ai 1.15.11 → 1.15.12
|
||||
|
||||
Notable upstream changes (from the [anomalyco/opencode v1.15.12 release](https://github.com/anomalyco/opencode/releases/tag/v1.15.12)):
|
||||
|
||||
- **Core** — ACP integrations can send prompts/slash-commands/usage updates through `acp-next`; experimental WebSocket transport for OpenAI Responses (`OPENCODE_EXPERIMENTAL_WEBSOCKETS=true`); adaptive reasoning enabled for Anthropic Opus 4.7+.
|
||||
- **Bugfixes** — colons allowed in passwords; faster warm `acp-next` model/config switches; OpenAI WebSocket response timeouts kept active with retries before fallback; `acp-next` permission prompts handled correctly; persisted session directory used for existing-session requests; remote workspace request bodies forwarded correctly; custom base URLs supported for OpenAI WebSocket Responses.
|
||||
- **TUI** — workspace management dialog; session navigation works while prompt modes are open; thinking spinner restored; subagent retry status surfaced; opening editors from non-Git project paths fixed.
|
||||
- **Desktop** — tab-layout setting; home empty state and V2 font usage improved; tab close buttons showing reliably.
|
||||
|
||||
### Bumped: pi 0.76.0 → 0.77.0
|
||||
|
||||
Notable upstream changes (from pi's CHANGELOG):
|
||||
|
||||
- **Claude Opus 4.8 support** — model metadata + adaptive-thinking coverage updated.
|
||||
- **Selective tool disablement** — `--exclude-tools` / `-xt` disables specific built-in, extension, or custom tools while keeping the rest available.
|
||||
- **Headless Codex subscription login** — `/login` can use device-code auth for ChatGPT Plus/Pro Codex subscriptions.
|
||||
- **Streaming-aware extension input** — `InputEvent.streamingBehavior` lets extensions distinguish idle prompts, mid-stream steers, and queued follow-ups.
|
||||
- **Bugfixes** — startup timing output excludes `createAgentSessionRuntime`; OpenRouter DeepSeek V4 `xhigh` reasoning preserved; SIGTERM/SIGHUP run extension `session_shutdown` cleanly; keyboard protocol negotiation ignores delayed terminal responses; Windows MSYS2 ucrt64 startup crash fixed; API-key/header config resolution treats plain strings as literals with `$ENV_VAR` interpolation; session disposal aborts in-flight work; numerous provider-specific reasoning/metadata fixes (Codex Responses replay, OpenAI/OpenRouter GPT-5.5 Pro, Kimi K2.6, Xiaomi Token Plan).
|
||||
|
||||
### Inheritance from base
|
||||
|
||||
No base change — `base-latest` is reused unchanged from v1.15.11c (`base-decide` short-circuits at the Hub-probe step). SSH ControlMaster on a writable socket path, gitleaks, and git-crypt continue to ride along from the base.
|
||||
|
||||
### Workflow status
|
||||
|
||||
This is the first opencode-version-bump publish exercising the afternoon-of-2026-05-28 workflow changes (cache-export removal + 3-attempt retry wrapper) end-to-end on a real upstream release. v1.15.11c proved the publish path mechanically; v1.15.12 is the first one with both an opencode bump and a pi bump driving fresh variant layers.
|
||||
|
||||
## v1.15.11c — 2026-05-28
|
||||
|
||||
**Re-cut of v1.15.12 to fix a versioning-scheme violation.** The morning's v1.15.12 release was tagged in error: `opencode-ai` stayed at `1.15.11` upstream (no 1.15.12 exists on npm), so per the project's `v{opencode_version}[letter]` scheme this should have been the third container build on opencode 1.15.11 — `v1.15.11c` — not a new minor version bump. The `v1.15.12` git tag and the eight `v1.15.12*` / `latest*` Docker Hub images remain as historical artifacts but are superseded by this release. Future builds on opencode 1.15.11 continue the letter sequence as `v1.15.11d`, `v1.15.11e`, … — v1.15.12 will only be reused if and when opencode upstream actually releases 1.15.12.
|
||||
|
||||
Content inherited from v1.15.12 (see that block below for the full diagnostic chain on the v4.0.0 pin disproof and the manual host-side publish):
|
||||
|
||||
- pi `0.75.5` → `0.76.0`.
|
||||
- `setup-buildx-action` pin reverted from `@v4.0.0` back to `@v4` (the v1.15.11b regression hypothesis was disproven).
|
||||
- Inheritance from base: SSH ControlMaster on a writable socket path, gitleaks, git-crypt.
|
||||
- Cache-hit silent same-bytes regression fix carried forward from v0.75.5b's pattern.
|
||||
|
||||
Additional changes since v1.15.12 (afternoon 2026-05-28 followup work):
|
||||
|
||||
### Hub-push regression — root cause identified, CI fixed
|
||||
|
||||
The `400 Bad request` from `registry-1.docker.io` that broke CI publishing across runs #332/333/334/336 (and forced v1.15.12 to ship via manual host-side push) is **buildkit's registry cache-export with `mode=max`**, not the image push itself.
|
||||
|
||||
**Diagnostic that nailed it:** the manual v1.15.12 publish from an Orbstack host reproduced the exact same 400 — but only on the cache-export step. Image layers pushed cleanly (911s for the base, all variants succeeded). Dropping `--cache-to` from the manual script let the publish complete. Running the same buildx version against the same Hub account from the same network, the only differential was cache export vs. image export.
|
||||
|
||||
This explains every observation:
|
||||
|
||||
- Failure shape stable across attempts (`Offset:0`, HTML body, CDN-tier rejection): cache-export protocol-level mismatch, not transient network or per-blob corruption.
|
||||
- Repo-specific (`joakimp/opencode-devbox` only): we're the only Hub repo currently writing a `:base-buildcache` tag with `mode=max`.
|
||||
- Started ~2026-05-23: lines up with buildx 0.34.x rolling out and bundling moby/buildkit v0.30.0, which changed the `_state` token format on resumable cache uploads.
|
||||
- Image push works fine: cache-export is a separate codepath using a different manifest/layer scheme.
|
||||
- Action-pin to `setup-buildx-action@v4.0.0` didn't help: that pin pulls older actions-toolkit, but the bundled buildkit was still 0.34.x via Buildx CLI on the runner image. Pin was correctly disproven by run #336.
|
||||
|
||||
### Workflow change
|
||||
|
||||
- **`.gitea/workflows/docker-publish-split.yml`** — registry cache (`cache-from`/`cache-to`) removed from the `build-base` step. Comment in place documenting the regression and the re-enable condition. Variants don't use registry cache so they're untouched. The base tag is content-addressed (`base-<hash>` derived from Dockerfile.base + rootfs/* + entrypoint*.sh) so unchanged bases short-circuit at the Hub-probe step in `base-decide` and never re-build anyway — the lost cache only affects the rare case of a Dockerfile.base change, where we now pay the full ~3 min build instead of pulling cached layers. Acceptable trade-off vs. broken publishes.
|
||||
|
||||
Next tag push (e.g. v1.15.13) is expected to publish cleanly via Gitea CI again. validate.yml on this main push will be the first real-time test of the smoke side; full publish path will be tested on the next opencode bump or by a deliberate letter-suffix re-tag.
|
||||
|
||||
### Status of earlier suspects
|
||||
|
||||
- ~~`setup-buildx-action@v4.1.0`~~ — disproven by v1.15.11b CI run #336 with v4.0.0 pin failing identically. Pin reverted in v1.15.12. Not the regressor.
|
||||
- ~~`@docker/actions-toolkit 0.79.0 → 0.90.0`~~ — rolled back via the action pin; same failure. Not the regressor.
|
||||
- ~~Account / repo / Hub-CDN globally~~ — local pushes from developer host succeed. Always was healthy.
|
||||
- ~~`catthehacker/ubuntu:act-latest`~~ / ~~act-runner egress~~ — manual publish from host reproduced the same 400, ruling out runner-side network. Not the cause.
|
||||
- **Confirmed:** buildkit cache-export protocol (mode=max) hitting Hub-CDN edge rejection. Workaround: don't export cache to registry. Long-term: track moby/buildkit upstream for protocol fix or switch to GHA cache (not portable to Gitea Actions).
|
||||
|
||||
### Docs: manual host-publish runbook + script archive
|
||||
|
||||
- `docs/manual-host-publish.sh` — the literal script that shipped v1.15.12 from a developer Mac via Orbstack, preserved as-is.
|
||||
- `docs/manual-host-publish.md` — runbook explaining when to reach for the escape hatch, the four constants to edit (`RELEASE_TAG`, `BASE_HASH`, `PI_VERSION`, `OMOS_VERSION`), three sources for `BASE_HASH` (CI's `base-decide` log = canonical, Hub `base-latest` probe, local recompute matching CI's exact recipe including `__pycache__`/`.DS_Store`/`._*` junk filters), and adaptations for pi-devbox / letter-suffix rebuilds / partial-failure single-variant recovery.
|
||||
- `AGENTS.md` — new Critical conventions bullet documenting that `cache-from`/`cache-to` is currently disabled, why, and the re-enable condition.
|
||||
|
||||
### CI: workflow-level retry around `docker buildx build --push`
|
||||
|
||||
All five push steps in `.gitea/workflows/docker-publish-split.yml` (1 base + 4 variants) are now wrapped in a 3-attempt retry loop with backoff (15s, 30s) as belt-and-braces against transient `registry-1.docker.io` blips. Replaces the `docker/build-push-action@v7` invocations with `shell: bash` steps that run `docker buildx build --push` directly so the loop is visible and tweakable. Smoke-test build steps (`load: true`, no push) are unchanged — they don't suffer from registry-side flakiness.
|
||||
|
||||
Does **not** mask deterministic failures: a true regression (e.g. the cache-export 400 documented above) will fail all 3 attempts identically and the job still fails by design. Belt-and-braces with the workflow-level retry-on-failure rerun heuristic in the `ci-release-watcher` skill, which catches transient-shaped runner-side failures separately. No image-side change.
|
||||
|
||||
### AGENTS.md addition: pre-flight scheme check
|
||||
|
||||
New "Versioning scheme" subsection documenting the **mandatory `npm view opencode-ai version` pre-flight check** before cutting any non-letter-suffixed tag, with this slip cited as the cautionary example.
|
||||
|
||||
---
|
||||
|
||||
## v1.15.12 — 2026-05-28
|
||||
|
||||
> **Note (2026-05-28 PM):** this tag violates the project's `v{opencode_version}[letter]` versioning scheme — there is no `opencode-ai@1.15.12` on npm; OPENCODE_VERSION stayed at 1.15.11 across this build. Re-cut as `v1.15.11c` at HEAD per the scheme. The git tag and Hub images for `v1.15.12*` remain as historical artifacts but are superseded by `v1.15.11c`. See the `v1.15.11c` block above for the corrected release notes.
|
||||
|
||||
Manual-published release. Reverts the `setup-buildx-action@v4.0.0` pin from v1.15.11b (hypothesis was disproven — see below) and bumps the bundled `pi-coding-agent` to 0.76.0 via the floating `PI_VERSION=latest` resolution.
|
||||
|
||||
### Why "manual-published"
|
||||
|
||||
v1.15.11b reproduced the exact same Hub `400 Bad request` regression as v1.15.11 (CI run #336, build-base failed twice including a Gitea auto-rerun), confirming `setup-buildx-action@v4.1.0` is **not** the regressor. After four consecutive identical CI failures across two days, the SSH-CM and gitleaks fixes were shipped by hand from a developer host's Orbstack/Docker-Desktop — a path we already knew worked in ~25s for the same multi-arch build to the same Hub account.
|
||||
|
||||
This release ships the same content the runner-side build would have shipped; it just bypasses the broken runner-network → Hub-CDN combo. CI auto-publishing remains broken pending separate runner-side investigation (see [AGENTS.md — known issues](AGENTS.md)).
|
||||
|
||||
### Workflow change
|
||||
|
||||
- **`.gitea/workflows/docker-publish-split.yml`** — all nine `setup-buildx-action@v4.0.0` pins reverted to `@v4`. The pin added no value (failure reproduced) and was holding us off action improvements.
|
||||
|
||||
### Bumped: pi-coding-agent (latest → 0.76.0)
|
||||
|
||||
`PI_VERSION=latest` in `Dockerfile.variant` resolves at build time. 0.76.0 was published 2026-05-27 20:03 UTC. No Dockerfile edit needed; floating-`latest` is intentional so each opencode-devbox release pulls the freshest pi without a manual bump.
|
||||
|
||||
### Hub-push regression — ruled out / still suspect
|
||||
|
||||
**Ruled out:**
|
||||
- `setup-buildx-action@v4.1.0` — v4.0.0 reproduces the failure identically.
|
||||
- `@docker/actions-toolkit 0.79.0 → 0.90.0` — rolled back via the action pin; same failure.
|
||||
- Account / repo / Hub-CDN globally — local pushes from a developer host succeed.
|
||||
- Multi-arch as such — pi-devbox v0.75.5b pushed multi-arch on 2026-05-23.
|
||||
|
||||
**Still suspect:**
|
||||
- `catthehacker/ubuntu:act-latest` runner image (floating, not pinned in workflows).
|
||||
- act-runner host network egress from `runner-2` (sustained CDN-edge rejection from this specific source IP).
|
||||
- buildx 0.34.x's signed `_state` token format hitting a Hub-edge WAF/length rule that didn't apply to 0.33.x.
|
||||
- Hub-side per-repo state for `joakimp/opencode-devbox` specifically (other Hub repos from the same account work).
|
||||
|
||||
Four failing runs share the exact failure shape: HTTP 400 with HTML body (CDN-tier, not registry backend) on the very first PUT (`Offset:0`) of the resumable layer-blob upload. UUIDs and `_state` signatures differ across attempts — only the failure pattern is stable.
|
||||
|
||||
---
|
||||
|
||||
## v1.15.11b — 2026-05-27
|
||||
|
||||
Container-level rebuild of v1.15.11. The original v1.15.11 release-day publish failed three times in a row (CI runs #332/333/334) with identical `400 Bad request` responses from `registry-1.docker.io` on the buildx layer-blob PUT. Build itself succeeded 30/30 each time; only the multi-arch push failed. Triaged on 2026-05-27 evening:
|
||||
|
||||
- **Local multi-arch buildx push from a developer host succeeds in ~25s** — same Hub account, same multi-arch path. Account, repo, and Hub-CDN are all healthy.
|
||||
- **Last known-good Gitea Actions Hub push: 2026-05-23 ~20:26 UTC** (`pi-devbox v0.75.5b`). All Gitea-runner-driven pushes since 2026-05-24 have failed identically.
|
||||
- **Smoking gun candidate:** `docker/setup-buildx-action@v4` floats to `v4.1.0` (published 2026-05-22 16:00 UTC). Action-resolver caches on the runner appear to have rolled forward to v4.1.0 sometime between the May 23 success and the first May 24 failure. v4.1.0 ships a newer bundled buildx/buildkit which may be using a different push protocol that trips Hub's CDN URI-length cap (the failing `_state` query string is ~1.4 KB).
|
||||
|
||||
### Workflow change
|
||||
|
||||
- **`.gitea/workflows/docker-publish-split.yml`** — all nine `docker/setup-buildx-action@v4` uses pinned to `@v4.0.0`. `setup-qemu-action@v3` left floating since QEMU wasn't in the suspected blast radius and was working on May 23. If v4.0.0 publishes cleanly we keep the pin and file an upstream buildkit/buildx issue documenting the regression.
|
||||
|
||||
No other source changes — same `OPENCODE_VERSION=1.15.11`, same `Dockerfile.base` and `Dockerfile.variant`, same SSH-CM bake, same gitleaks. v1.15.11 (the original tag) is preserved in the repo as a historical marker of the first publish attempt; v1.15.11b is the canonical release.
|
||||
|
||||
### v1.15.11
|
||||
|
||||
First release on opencode 1.15.11. Also bakes in four devbox-side fixes accumulated since v1.15.10 (SSH ControlMaster on a writable path, gitleaks added to base, CI resolve-versions hardening, CI cache-hit regression fix). Downstream pi-devbox inherits all of these on its next build against `base-latest`.
|
||||
|
||||
### Bumped: opencode 1.15.10 → 1.15.11
|
||||
|
||||
`OPENCODE_VERSION` ARG bumped in `Dockerfile.variant`. Highlights from the upstream release (full notes: <https://github.com/anomalyco/opencode/releases/tag/v1.15.11>):
|
||||
|
||||
- **Core / Improvements** — new `headerTimeout` config for provider requests (10s default for default OpenAI setups); experimental background agents now push updates without polling; remote-backed projects resolve a stable project identity; `modalities.input` / `modalities.output` can be set independently.
|
||||
- **Core / Bugfixes** — dynamically added MCP servers now disconnect cleanly on removal; Google tool calling fixed after upstream tool-ID regression; resumed sessions no longer continue orphaned interrupted tools; OpenAI reasoning summaries render as separate blocks; the `shell` tool now advertises its configured timeout to the model; config loading falls back cleanly when user info is unavailable.
|
||||
- **TUI** — prompt resizes with terminal width (new prompt-size config); accelerated diff-viewer scrolling; external editors open from the worktree directory when available.
|
||||
- **Desktop** — refined v2 home screen, prompt, status popover, and session controls; fixed V2 titlebar errors when a session sync cache was deleted; web deployments no longer run desktop health checks; duplicate server connections are merged.
|
||||
- **Extensions** — new `dispose` hook for plugins; Codex plugin now sends the expected session-ID header.
|
||||
|
||||
No `opencode-devbox`-side changes were required to consume 1.15.11 — pure version bump.
|
||||
|
||||
### Base: SSH ControlMaster default on a writable socket path
|
||||
|
||||
Devboxes typically mount `~/.ssh` from the host as **read-only** (security: keys remain readable but agents can't tamper with config / known_hosts / authorized_keys / plant a malicious ProxyCommand). OpenSSH's default `ControlPath` lands inside `~/.ssh/cm/`, which is unwritable on such mounts — so any attempt to use `ControlMaster auto` (or anything that wants to multiplex) fails with:
|
||||
|
||||
```
|
||||
unix_listener: cannot bind to path /home/.../.ssh/cm/...: Read-only file system
|
||||
kex_exchange_identification: Connection closed by remote host
|
||||
```
|
||||
|
||||
The second line is downstream: when ControlMaster fails the ssh client falls back to a fresh TCP connection, and on residential CGNAT (most European ISPs) the per-(src,dst) concurrent-flow cap (~4) silently drops further SYNs once exceeded — manifesting as banner-exchange timeouts that look like a remote problem.
|
||||
|
||||
- **`Dockerfile.base`** — new section right after the apt block bakes `/etc/ssh/ssh_config.d/00-devbox-controlmaster.conf` with `Host *` defaults: `ControlMaster auto`, `ControlPath /tmp/sshcm/%r@%h:%p`, `ControlPersist 10m`, plus `ServerAliveInterval 30` / `ServerAliveCountMax 6` for resilience to mid-stream NAT timeouts. `/tmp` is per-container and always writable, so the read-only `~/.ssh` mount is left untouched. Debian's stock `/etc/ssh/ssh_config` includes `ssh_config.d/*.conf` *before* its own `Host *` block, so user `~/.ssh/config` overrides still win.
|
||||
- **`entrypoint-user.sh`** — creates `/tmp/sshcm` mode 700 on every container start. `/tmp` is per-container so the dir doesn't survive recreation; baking it into a Dockerfile layer would be wrong. Mode 700 is required — OpenSSH refuses to use a `ControlPath` directory others can write to.
|
||||
- **`scripts/smoke-test.sh`** — two new assertions: (a) the conf file exists at the expected path; (b) `ssh -G example.invalid` reports a `controlpath` rooted at `/tmp/sshcm/`. The second catches the silent regression where something later in the SSH config chain shadows the bake-in.
|
||||
- **No size/threshold impact:** the conf file is ~250 bytes.
|
||||
|
||||
Downstream pi-devbox and any other variant inherits this on its next build against `base-latest`. Discovered while running a recon-shell from inside pi-devbox to a Proxmox node — fresh ssh hit banner timeout, debug output pointed at the read-only socket dir.
|
||||
|
||||
_(Originally landed on `main` 2026-05-24 as commit `668592d`; first ships in v1.15.11.)_
|
||||
|
||||
### Base: gitleaks added; git-crypt confirmed already installed
|
||||
|
||||
`gitleaks` is now baked into `Dockerfile.base` (Go-compiled binary fetched from GitHub releases, same `/releases/latest` redirect-resolution pattern as gosu/fzf/git-lfs/etc.). It pairs with `git-crypt`, which has been installed via apt all along but wasn't asserted by smoke or called out in user-facing docs. Several of the user's repos use both as part of their secret-management setup (gitleaks pre-commit hook + git-crypt for selectively-encrypted canonical config); having them in the devbox means `pi install`-style hooks fire correctly inside the container instead of warning that gitleaks is missing.
|
||||
|
||||
- **`Dockerfile.base`** — new `GITLEAKS_VERSION=latest` ARG + install RUN block right after `git-lfs`. Arch suffix is `x64` (not `x86_64` or `amd64`) on this project; comment in the Dockerfile flags the deviation. Adds ~21 MB to the base layer.
|
||||
- **`scripts/smoke-test.sh`** — adds `git-crypt` and `gitleaks` to the "Resolved component versions" table and to the "Core binaries" assertion list. Now fails fast if either binary disappears from the base.
|
||||
- **`README.md`** — "What's in the image" tree updated to name `gitleaks` alongside `git-crypt` in the dev-tools line.
|
||||
- **No threshold bumps:** 21 MB on a 2500–3700 MB envelope is noise; existing variant thresholds keep their headroom.
|
||||
|
||||
This is a base-layer change — `base-decide` will compute a fresh `base-<hash>`, `build-base` will run on the next release (no cache hit), and all four variants will rebuild against the new base. **Downstream pi-devbox** picks up gitleaks automatically on its next release that resolves `joakimp/opencode-devbox:base-latest` to the new digest — no Dockerfile change needed there.
|
||||
|
||||
### CI: preventative fix for PI_VERSION/OMOS_VERSION cache-hit silent regression
|
||||
|
||||
Mirrors the pi-devbox v0.75.5b fix (2026-05-23) onto the four-variant pipeline here. The `with-pi`, `omos`, and `omos-with-pi` variants all install upstream npm packages (`@earendil-works/pi-coding-agent`, `oh-my-opencode-slim`) whose `*_VERSION` build-args defaulted to `latest`. When the build-arg string is byte-identical across builds, the resulting layer-hash is identical, and the registry buildcache (`base-buildcache` / variant cache-from chain) silently reuses the layer from whatever upstream version was current when the cache was first populated — the same mechanism that caused pi-devbox v0.74.0 through v0.75.5 to ship the same image bytes.
|
||||
|
||||
Currently masked here because `OPENCODE_VERSION` is a hard-coded ARG that bumps every release — changing a parent layer invalidates the downstream cache key for the pi/omos install layers. Masking would fail the moment we cut a `vN.N.Nb` opencode-version-unchanged release that only bumps pi or omos. Filed as a parked followup that bedtime; fixing it preventatively now.
|
||||
|
||||
- **`.gitea/workflows/docker-publish-split.yml`** — new `resolve-versions` job runs `npm view @earendil-works/pi-coding-agent version` and `npm view oh-my-opencode-slim version`, exposing concrete strings as job outputs. All six affected jobs (`smoke-omos`, `smoke-with-pi`, `smoke-omos-with-pi`, `build-variant-omos`, `build-variant-with-pi`, `build-variant-omos-with-pi`) now `needs:` it and pass the concrete versions as `PI_VERSION` / `OMOS_VERSION` build-args. `smoke-base` and `build-variant-base` are unaffected (no pi or omos).
|
||||
- **`scripts/smoke-test.sh`** — new `run_expect` helper asserts an expected substring in command output. The pi-version check uses `EXPECTED_PI_VERSION` when set; the omos check uses `EXPECTED_OMOS_VERSION` against `npm ls -g`. Both env vars are wired from `resolve-versions` outputs in the smoke jobs. Catches the regression on the next release rather than four releases later.
|
||||
- **`Dockerfile.variant`** — comment block above each affected `ARG` (`OPENCODE_VERSION`, `PI_VERSION`, `OMOS_VERSION`) documenting the cache-hit footgun + which ones are CI-resolved vs source-pinned.
|
||||
- **`AGENTS.md`** — new convention bullet explaining the cache-hit class of bug and naming the resolve-versions job + EXPECTED_*_VERSION wiring as the contract to keep in lockstep.
|
||||
|
||||
No image-content change expected on the next release vs what `latest` would have resolved to anyway — this is purely about making sure the cache invalidates correctly going forward.
|
||||
|
||||
## v1.15.10 — 2026-05-23
|
||||
|
||||
opencode 1.15.6 → 1.15.10 bump (four upstream patch releases over two days). Plus implicit pi 0.75.4 → 0.75.5 in the `with-pi` and `omos-with-pi` variants since `PI_VERSION=latest` resolves at build time.
|
||||
|
||||
No image-content changes beyond the version bumps; cache hit expected on `base-35ee5fe7861a` (no `Dockerfile.base` or `rootfs/` edits since v1.14.50b).
|
||||
|
||||
### Notable upstream opencode changes
|
||||
|
||||
Sourced from <https://github.com/anomalyco/opencode/releases> (the upstream this devbox tracks).
|
||||
|
||||
**v1.15.7** — Grok OAuth (SuperGrok) sign-in including device-code login (@Jaaneek). V2 session APIs gain safe error responses with reference IDs (UnknownError, SessionNotFoundError, ServiceUnavailableError) so generic 500s no longer leak config details. Codex OAuth refreshes deduped to avoid repeated refresh failures (@cooper-oai). Native OpenAI OAuth requests restored. Tool schema failures now surface as friendly tool errors. PDF attachment support for Grok. Restored OpenAI reasoning streams. TUI: clearer collapsed-thinking punctuation, new sessions default to local project, single-select question checkmarks no longer collide with labels. Desktop: pinch zoom, new home view + session entry flow + titlebar, log export.
|
||||
|
||||
**v1.15.8** — Upstream release body empty; assumed internal/no user-visible changes.
|
||||
|
||||
**v1.15.9** — Redesigned diff viewer with file tree, **enabled by default**. MCP OAuth configs can set callback port and include configured scopes in client metadata (@sebin). Vertex Anthropic provider uses working `.rep.googleapis.com` endpoints for US/EU multi-region (@JPFrancoia). Many "show clearer error" improvements (default model invalid, missing PTY session, skill invocation failure, installation upgrade failure, project not found via HTTP API, MCP server not found, session busy). Native reasoning continuation metadata preserved across turns. TUI: copy worktree path from command palette, refined diff viewer shortcuts, spinner color aligned with active agent (@OpeOginni). Desktop: tab navigation in titlebar, session status in titlebar, multi-colon callback URL fix (@OpeOginni), debounced VCS refreshes.
|
||||
|
||||
**v1.15.10** — Single fix: restored the legacy production desktop flows for opening projects and starting sessions.
|
||||
|
||||
### Devbox-side notes
|
||||
|
||||
- **Bump:** opencode 1.15.6 → 1.15.10 (`OPENCODE_VERSION` in `Dockerfile.variant`).
|
||||
- **Implicit pi bump:** `with-pi` and `omos-with-pi` variants pick up pi 0.75.5 (one patch release with cleaner read-tool cards, async file tools, more reliable package updates, Bedrock token cap fix, etc.). See [pi-devbox v0.75.5 CHANGELOG](https://gitea.jordbo.se/joakimp/pi-devbox/src/branch/main/CHANGELOG.md) for the full list.
|
||||
- **Smoke threshold check:** `omos-with-pi` threshold remains at 3700 MB (set v1.15.4b 2026-05-18). Four opencode patches plus one pi patch typically add only a few MB across both; not expected to trip. If it does, recovery is the well-worn letter-suffix pattern (v1.15.10b with threshold bump).
|
||||
- Built on the same CI path as v1.15.6 (pinned-crane install on real-base-rebuild, skip-promote-on-cache-hit, update-description-always-on-base-success) — all expected to remain quiet on this cache-hit run.
|
||||
|
||||
### Note on this CHANGELOG vs the v1.15.10 tag snapshot
|
||||
|
||||
The v1.15.10 tag itself was pushed before the upstream release notes were located (originally I checked `sst/opencode` which is a fork; the canonical upstream is `anomalyco/opencode`). The image content under the tag is correct, but the CHANGELOG snapshot at the tag was thinner. This expanded version is on `main` going forward; the tag's snapshot will not be retroactively rewritten.
|
||||
|
||||
## v1.15.6 — 2026-05-21
|
||||
|
||||
opencode 1.15.4 → 1.15.6 bump (two upstream patch releases) plus two workflow improvements that landed on `main` between v1.15.4b and now. No image-content changes beyond the version bump; cache hit expected on `base-35ee5fe7861a` (no `Dockerfile.base` or `rootfs/` edits).
|
||||
|
||||
- **Bump:** opencode 1.15.4 → 1.15.6 (`OPENCODE_VERSION` in `Dockerfile.variant`). The `with-pi` and `omos-with-pi` variants will also implicitly pick up pi 0.75.3 → 0.75.4 since `PI_VERSION=latest` resolves at build time.
|
||||
- **CI: defensive `__pycache__` and macOS-metadata filter in `base-decide` hash compute.** `find rootfs -type f` previously included gitignored junk like `rootfs/__pycache__/*.pyc`, `.DS_Store`, and `._AppleDouble` files — which CI's clean checkout never sees. This bit us during v1.15.4 debugging when a stale `generate-config.cpython-314.pyc` on the local rootfs/ produced `base-3605aa6b6ab1` while CI computed `base-35ee5fe7861a`. The filter is a no-op on a clean tree (verified to still produce `35ee5fe7861a` post-filter), but defends against future stale-pyc / Finder-touched-rootfs hash mismatches. `.gitea/README.md` updated in lockstep. (commit `b6e4d89`)
|
||||
- **AGENTS.md: documentation drift sweep as explicit pre-commit workflow step.** Codifies the rule that non-release commits must also grep docs for stale claims about behaviour they change, with concrete repo-specific drift hotspots. Companion clause added across the wider repo set (cloud-init, ansible, pi-devbox, pi-extensions, pi-toolkit, cli_utils, proxmox) the same day. (commit `90e5a1f`)
|
||||
- **First release that exercises both the pinned-crane install (T14, v1.15.3) and the skip-promote-on-cache-hit guard (T15, v1.15.4) on this CI run path** — still cache-hit on base, so `promote-base-latest` should remain skipped via T15 and the pinned crane install will only fire when a real base rebuild happens.
|
||||
|
||||
## v1.15.4b — 2026-05-18
|
||||
|
||||
Recovery release for v1.15.4 — the `omos-with-pi` variant landed at >3500 MB and tripped the smoke threshold, so `smoke-omos-with-pi` and `build-variant-omos-with-pi` were skipped. The other three variants (base, omos, with-pi) published cleanly. Plus a latent workflow bug fix exposed by the partial publish.
|
||||
|
||||
+15
-11
@@ -2,19 +2,25 @@
|
||||
|
||||
Portable AI developer environment for [opencode](https://opencode.ai). Debian-based, with git, SSH, Node.js, AWS CLI v2, and common dev tools pre-installed.
|
||||
|
||||
> **Current `:latest` ships opencode `{{OPENCODE_VERSION}}`** (the baked version is asserted by smoke tests, so this page never drifts from the image).
|
||||
|
||||
Designed for teams who want a reproducible coding-agent setup that runs the same on every laptop and CI runner — without forcing each developer to install Bun, Node, AWS CLI, mempalace, or maintain shell config drift across machines.
|
||||
|
||||
## Image Variants
|
||||
|
||||
| Tag | Description |
|
||||
|---|---|
|
||||
| `latest` / `vX.Y.Z` | Base image — opencode, Node.js, AWS CLI, dev tools |
|
||||
| `latest` / `vX.Y.Z` | Base image — opencode `{{OPENCODE_VERSION}}`, Node.js, AWS CLI, dev tools |
|
||||
| `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun |
|
||||
| `latest-with-pi` / `vX.Y.Z-with-pi` | Base + [pi](https://github.com/earendil-works/pi) as alternative/complementary harness (shares the mempalace install with opencode) |
|
||||
| `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | OMOS + pi together |
|
||||
|
||||
All variants support `linux/amd64` and `linux/arm64`.
|
||||
|
||||
> **Looking for pi?** As of v2.0.0 the pi coding-agent is no longer bundled in
|
||||
> opencode-devbox. It ships as the dedicated
|
||||
> [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox) image, which
|
||||
> shares the same mempalace memory layer. See
|
||||
> <https://gitea.jordbo.se/joakimp/pi-devbox>.
|
||||
|
||||
## 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:
|
||||
@@ -28,7 +34,7 @@ curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.
|
||||
docker compose run --rm devbox
|
||||
```
|
||||
|
||||
This drops you straight into opencode with your project mounted at `/workspace`. Use `bash` as the command (e.g. `docker compose run --rm devbox bash`) to land in a shell first — useful for `aws sso login`, `pi` (on `*-with-pi` variants), or multi-harness workflows.
|
||||
This drops you straight into opencode with your project mounted at `/workspace`. Use `bash` as the command (e.g. `docker compose run --rm devbox bash`) to land in a shell first — useful for `aws sso login` or multi-agent workflows.
|
||||
|
||||
**One-shot run, no persistence:**
|
||||
|
||||
@@ -48,11 +54,10 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Bedroc
|
||||
## What's Inside
|
||||
|
||||
- **[opencode](https://opencode.ai)** — primary coding-agent harness. Multi-provider (Anthropic, OpenAI, Bedrock, Google, Groq, etc.).
|
||||
- **[pi](https://github.com/earendil-works/pi)** *(in `*-with-pi` variants)* — lightweight TUI coding-agent that coexists with opencode and shares the same mempalace install. Includes the `mcp-loader` extension so any local-stdio or remote streamable-HTTP MCP server (searxng, gitea, context7, …) can be added by editing `~/.pi/agent/settings.json`.
|
||||
- **[mempalace](https://github.com/MemPalace/mempalace)** — persistent AI memory layer (ChromaDB + SQLite). Wing/diary/knowledge-graph entries are mutually visible to opencode and pi.
|
||||
- **[mempalace](https://github.com/MemPalace/mempalace)** — persistent AI memory layer (ChromaDB + SQLite). Wing/diary/knowledge-graph entries are shareable with the sibling [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox) image when both point at the same palace.
|
||||
- **[oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim)** *(in `*-omos` variants)* — multi-agent orchestration on top of opencode (council, fallback chains, named agents).
|
||||
- **AWS CLI v2** with SSO support, **Node.js LTS**, **Bun** (OMOS variants), **uv** (Python), **gosu** for clean UID/GID adjustment to match your host workspace.
|
||||
- **MCP wrappers** for mempalace pre-installed and pre-wired to both harnesses.
|
||||
- **MCP wrappers** for mempalace pre-installed and pre-wired to opencode.
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -70,8 +75,7 @@ https://gitea.jordbo.se/joakimp/opencode-devbox#aws-bedrock-authentication
|
||||
|
||||
| Volume | Mount | Survives |
|
||||
|---|---|---|
|
||||
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild |
|
||||
| `devbox-pi-config` | `~/.pi` | container recreate, image rebuild — incl. user-installed pi packages via `pi install` (`NPM_CONFIG_PREFIX` points into the volume) |
|
||||
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild — incl. user-installed npm globals via `npm install -g` (`NPM_CONFIG_PREFIX` points into the volume) |
|
||||
| `devbox-palace` (uncomment) | `~/.mempalace` | container recreate, image rebuild — palace data is precious, treat as primary storage |
|
||||
| `devbox-chroma-cache` | `~/.cache/chroma` | container recreate (model cache, disposable — re-downloads in seconds) |
|
||||
|
||||
@@ -88,7 +92,7 @@ Full persistence reference, including multi-user (`SIGNUM`) isolation and host b
|
||||
|
||||
## Sibling images
|
||||
|
||||
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** — pi-only image built on top of this image's base layer. Smaller (~700 MB) and version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly. Use this if you want pi without opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
|
||||
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** — the pi coding-agent in its own self-contained image, built on a shared Debian base. Version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly and can share this image's mempalace palace. Use it if you want pi instead of (or alongside) opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
|
||||
|
||||
## License
|
||||
|
||||
@@ -96,4 +100,4 @@ MIT. See <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/LICENS
|
||||
|
||||
---
|
||||
|
||||
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate.
|
||||
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate. The `{{OPENCODE_VERSION}}` placeholder is filled by CI at publish time.
|
||||
|
||||
+116
-17
@@ -1,15 +1,14 @@
|
||||
# opencode-devbox — base image (variant-independent layers)
|
||||
#
|
||||
# This Dockerfile produces an image tagged base-<hash>, used as the parent
|
||||
# for all four published variants (base, omos, with-pi, omos-with-pi).
|
||||
# It contains everything that does not depend on variant-specific
|
||||
# build-args (INSTALL_OPENCODE, INSTALL_OMOS, INSTALL_PI). The variant
|
||||
# Dockerfile (Dockerfile.variant) FROMs the base and adds only those
|
||||
# deltas.
|
||||
# for all published variants (base, omos). It contains everything that
|
||||
# does not depend on variant-specific build-args (INSTALL_OPENCODE,
|
||||
# INSTALL_OMOS). The variant Dockerfile (Dockerfile.variant) FROMs the
|
||||
# base and adds only those deltas.
|
||||
#
|
||||
# The base is rebuilt only when this file or anything it COPYs in
|
||||
# changes (rootfs/, entrypoint*.sh). Version bumps to OPENCODE_VERSION,
|
||||
# OMOS_VERSION, PI_VERSION, etc. do NOT trigger a base rebuild.
|
||||
# OMOS_VERSION, etc. do NOT trigger a base rebuild.
|
||||
#
|
||||
# To force a base rebuild for fresh apt packages without other code
|
||||
# changes, bump the BASE_REBUILD_DATE comment below. The hash is
|
||||
@@ -71,6 +70,44 @@ RUN apt-get update && \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── SSH client defaults: ControlMaster on a writable socket path ──────
|
||||
# Why this exists: the devbox typically mounts ~/.ssh from the host as
|
||||
# read-only (security: keys are readable, but agents can't tamper with
|
||||
# config / known_hosts / authorized_keys / plant a malicious ProxyCommand).
|
||||
# OpenSSH's default ControlPath is ~/.ssh/cm/... which is unwritable on
|
||||
# such mounts, so any attempt to use ControlMaster fails. Symptoms:
|
||||
# unix_listener: cannot bind to path /home/.../.ssh/cm/...: Read-only file system
|
||||
# kex_exchange_identification: Connection closed by remote host
|
||||
# The latter manifests downstream of CGNAT per-destination flow caps
|
||||
# (~4 concurrent flows on most European residential ISPs) which silently
|
||||
# drop further SYNs once exceeded — making fresh ssh attempts fail with
|
||||
# banner-exchange timeouts that look like a remote problem.
|
||||
#
|
||||
# Fix: set a system-wide default ControlPath in /tmp (per-container,
|
||||
# tmpfs-friendly, always writable) so multiplexing Just Works without
|
||||
# touching the read-only ~/.ssh mount. Per-host overrides in user's
|
||||
# ~/.ssh/config still win — Debian's default /etc/ssh/ssh_config has
|
||||
# `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block,
|
||||
# so user config can override these defaults if desired.
|
||||
#
|
||||
# ControlPersist=10m means the master socket sticks around 10 min after
|
||||
# the last session closes, so consecutive ssh calls in a workflow reuse
|
||||
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
|
||||
# (mode 700) on each container start.
|
||||
RUN mkdir -p /etc/ssh/ssh_config.d && \
|
||||
printf '%s\n' \
|
||||
'# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \
|
||||
'# Override per-host in ~/.ssh/config if the master socket location' \
|
||||
'# needs to differ.' \
|
||||
'Host *' \
|
||||
' ControlMaster auto' \
|
||||
' ControlPath /tmp/sshcm/%r@%h:%p' \
|
||||
' ControlPersist 10m' \
|
||||
' ServerAliveInterval 30' \
|
||||
' ServerAliveCountMax 6' \
|
||||
> /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf && \
|
||||
chmod 644 /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf
|
||||
|
||||
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
|
||||
#
|
||||
# Version policy for the binaries below:
|
||||
@@ -126,6 +163,24 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;;
|
||||
git lfs install --system && \
|
||||
git-lfs --version
|
||||
|
||||
# gitleaks — secret scanner (used as a pre-commit hook in several of the
|
||||
# repos this devbox is meant to operate on; pairs with git-crypt below).
|
||||
# Distributed as a Go-compiled tarball; arch suffix is `x64` (not `x86_64`
|
||||
# or `amd64`) on this project — mind the deviation from the surrounding
|
||||
# tools' naming.
|
||||
ARG GITLEAKS_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x64" ;; arm64) echo "arm64" ;; *) echo "x64" ;; esac) && \
|
||||
V="${GITLEAKS_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing gitleaks ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/download/v${V}/gitleaks_${V}_linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin gitleaks && \
|
||||
chmod +x /usr/local/bin/gitleaks && \
|
||||
gitleaks version
|
||||
|
||||
# neovim — modern text editor
|
||||
ARG NVIM_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
||||
@@ -203,14 +258,50 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64"
|
||||
# Always installed in the base (variant-independent). Set
|
||||
# INSTALL_MEMPALACE=false at base-build time to shave ~300 MB.
|
||||
ARG INSTALL_MEMPALACE=true
|
||||
# Pin mempalace explicitly (mirrors pi-devbox). An unpinned
|
||||
# `uv tool install mempalace` is what silently swept in the broken
|
||||
# diary_write top-level-anyOf schema (3.3.x/3.4.0) that breaks the
|
||||
# Anthropic tools API; pinning makes every bump a deliberate, reviewable
|
||||
# diff. Bump this in lockstep with pi-devbox's MEMPALACE_VERSION.
|
||||
ARG MEMPALACE_VERSION=3.4.0
|
||||
ENV UV_TOOL_DIR=/opt/uv-tools
|
||||
ENV UV_TOOL_BIN_DIR=/usr/local/bin
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
mkdir -p /opt/uv-tools && \
|
||||
uv tool install --no-cache mempalace && \
|
||||
uv tool install --no-cache "mempalace==${MEMPALACE_VERSION}" && \
|
||||
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
|
||||
fi
|
||||
|
||||
# ── workaround: strip top-level anyOf from mempalace_diary_write schema ──
|
||||
# Mempalace 3.3.x/3.4.0 advertise diary_write's input_schema with a
|
||||
# top-level `anyOf: [{required:[entry]}, {required:[content]}]` to express
|
||||
# "either entry or content must be supplied". Anthropic's tools API rejects
|
||||
# top-level anyOf/oneOf/allOf, so pi/Claude fail at session start with
|
||||
# `tools.<n>.custom.input_schema: input_schema does not support oneOf,
|
||||
# allOf, or anyOf at the top level`.
|
||||
#
|
||||
# Patch the advertised schema to require ["agent_name", "entry"] and remove
|
||||
# the anyOf block. The handler keeps accepting `content` server-side as a
|
||||
# kwarg alias so existing callers still work.
|
||||
#
|
||||
# Idempotent and self-deactivating: once upstream releases the fix the
|
||||
# regex no longer matches and this RUN is a silent no-op.
|
||||
# Upstream tracking:
|
||||
# https://github.com/MemPalace/mempalace/issues/1728
|
||||
# https://github.com/MemPalace/mempalace/pull/1735
|
||||
# TODO: remove this RUN once a mempalace release containing PR #1735 is on
|
||||
# PyPI and installed by the line above.
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
MP_FILE="$(find /opt/uv-tools/mempalace -path '*/mempalace/mcp_server.py' | head -n1)" && \
|
||||
if [ -z "$MP_FILE" ]; then echo "mempalace mcp_server.py not found" >&2; exit 1; fi && \
|
||||
perl -0777 -i -pe 's/(?:[ \t]*\#[^\n]*\n)*[ \t]*"required":\s*\[\s*"agent_name"\s*\]\s*,\s*\n[ \t]*"anyOf":\s*\[\s*\n[ \t]*\{\s*"required":\s*\[\s*"entry"\s*\]\s*\}\s*,\s*\n[ \t]*\{\s*"required":\s*\[\s*"content"\s*\]\s*\}\s*,?\s*\n[ \t]*\]\s*,\s*\n/ "required": ["agent_name", "entry"],\n/s' "$MP_FILE" && \
|
||||
if grep -q '"required": \["agent_name", "entry"\]' "$MP_FILE"; then \
|
||||
echo "mempalace diary_write anyOf workaround: applied (or already clean)"; \
|
||||
else \
|
||||
echo "WARN: mempalace diary_write anyOf workaround did not match expected schema — upstream may have changed shape" >&2; \
|
||||
fi ; \
|
||||
fi
|
||||
|
||||
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
|
||||
ARG INSTALL_MEMPALACE_TOOLKIT=true
|
||||
ARG MEMPALACE_TOOLKIT_REF=main
|
||||
@@ -283,7 +374,7 @@ RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
|
||||
# Create standard directories
|
||||
RUN mkdir -p /workspace \
|
||||
/home/${USER_NAME}/.config/opencode/skills \
|
||||
/home/${USER_NAME}/.pi/agent/extensions \
|
||||
/home/${USER_NAME}/.config/opencode/npm-global \
|
||||
/home/${USER_NAME}/.agents/skills \
|
||||
/home/${USER_NAME}/.local/share/opencode \
|
||||
/home/${USER_NAME}/.cache/bash \
|
||||
@@ -303,20 +394,28 @@ print('chromadb embedding model warmed: all-MiniLM-L6-v2')" && \
|
||||
ls -lh /home/${USER_NAME}/.cache/chroma/onnx_models/all-MiniLM-L6-v2/ ; \
|
||||
fi
|
||||
|
||||
# ── User-writable npm global prefix on the devbox-pi-config volume ──
|
||||
# ── User-writable npm global prefix on the devbox-opencode-config volume ──
|
||||
# By default npm's global prefix is /usr (writable only by root) so any
|
||||
# `pi install npm:<pkg>` or `npm install -g <pkg>` invoked by the
|
||||
# developer user would EACCES. Pointing the prefix into ~/.pi places
|
||||
# user-installed packages on the named volume, which means they survive
|
||||
# container recreation AND image rebuilds.
|
||||
# `npm install -g <pkg>` invoked by the developer user would EACCES.
|
||||
# Pointing the prefix into ~/.config/opencode places user-installed
|
||||
# packages on the devbox-opencode-config named volume, which means they
|
||||
# survive container recreation AND image rebuilds.
|
||||
#
|
||||
# NOTE (v2.0.0): this prefix previously lived at ~/.pi/npm-global — a
|
||||
# pi-specific path. With pi removed (see docs/CLEANUP-v2.0.0.md) it now
|
||||
# lives under ~/.config/opencode, which is a persistent named volume in
|
||||
# BOTH docker-compose.yml and docker-compose.shared.yml (the old ~/.pi
|
||||
# volume was only in the former). A one-time migration shim in
|
||||
# entrypoint-user.sh copies any existing ~/.pi/npm-global contents to the
|
||||
# new prefix on first start so user-installed globals are not lost.
|
||||
#
|
||||
# IMPORTANT: in this split-build layout the variant Dockerfile inherits
|
||||
# this prefix at build time. To keep the baked binaries on /usr (so the
|
||||
# ~/.pi volume mount doesn't shadow them), the variant Dockerfile MUST
|
||||
# run each `npm install -g` with NPM_CONFIG_PREFIX=/usr in the per-RUN
|
||||
# volume mount doesn't shadow them), the variant Dockerfile MUST run each
|
||||
# `npm install -g` with NPM_CONFIG_PREFIX=/usr in the per-RUN
|
||||
# environment. See Dockerfile.variant.
|
||||
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global
|
||||
ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
|
||||
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.config/opencode/npm-global
|
||||
ENV PATH="/home/${USER_NAME}/.config/opencode/npm-global/bin:${PATH}"
|
||||
|
||||
# ── Shell defaults (bash history, aliases, readline) ─────────────────
|
||||
RUN mkdir -p /etc/skel-devbox
|
||||
|
||||
+27
-45
@@ -3,26 +3,29 @@
|
||||
# FROMs a base-<hash> image produced by Dockerfile.base and adds only
|
||||
# the variant-specific tools (opencode, pi, oh-my-opencode-slim, Go).
|
||||
#
|
||||
# The four published variants are produced from THIS Dockerfile by
|
||||
# The two published variants are produced from THIS Dockerfile by
|
||||
# varying build args:
|
||||
#
|
||||
# variant INSTALL_OPENCODE INSTALL_OMOS INSTALL_PI
|
||||
# ───────────────── ──────────────── ──────────── ──────────
|
||||
# base true false false
|
||||
# omos true true false
|
||||
# with-pi true false true
|
||||
# omos-with-pi true true true
|
||||
# variant INSTALL_OPENCODE INSTALL_OMOS
|
||||
# ──────── ──────────────── ────────────
|
||||
# base true false
|
||||
# omos true true
|
||||
#
|
||||
# pi was removed in v2.0.0 (it had been deprecated since v1.17.2). It now
|
||||
# ships from its own self-contained image: joakimp/pi-devbox:latest
|
||||
# (https://gitea.jordbo.se/joakimp/pi-devbox). See docs/CLEANUP-v2.0.0.md
|
||||
# for the removal history.
|
||||
#
|
||||
# Pass `--build-arg BASE_IMAGE=<repo>:base-<hash>` to select the base.
|
||||
# The CI workflow computes the base hash from Dockerfile.base + rootfs/
|
||||
# + entrypoint*.sh and feeds it in.
|
||||
#
|
||||
# IMPORTANT: the base image sets NPM_CONFIG_PREFIX to
|
||||
# /home/developer/.pi/npm-global so runtime `pi install npm:...` and
|
||||
# `npm install -g` by the developer user lands on the named volume.
|
||||
# At BUILD time we want the baked binaries on /usr so they survive the
|
||||
# volume mount. Each `npm install -g` below therefore prefixes the
|
||||
# command with `NPM_CONFIG_PREFIX=/usr`.
|
||||
# /home/developer/.config/opencode/npm-global so runtime `npm install -g`
|
||||
# by the developer user lands on the named volume. At BUILD time we want
|
||||
# the baked binaries on /usr so they survive the volume mount. Each
|
||||
# `npm install -g` below therefore prefixes the command with
|
||||
# `NPM_CONFIG_PREFIX=/usr`.
|
||||
|
||||
ARG BASE_IMAGE
|
||||
FROM ${BASE_IMAGE}
|
||||
@@ -31,45 +34,17 @@ ARG TARGETARCH
|
||||
ARG USER_NAME=developer
|
||||
|
||||
# ── Install opencode via npm ─────────────────────────────────────────
|
||||
# OPENCODE_VERSION is intentionally pinned in this Dockerfile (not
|
||||
# 'latest'). It drives the release tag and gets bumped via a source
|
||||
# edit, so the cache-hit class of bug that bit pi-devbox v0.74.0..
|
||||
# v0.75.5 cannot apply here.
|
||||
ARG INSTALL_OPENCODE=true
|
||||
ARG OPENCODE_VERSION=1.15.4
|
||||
ARG OPENCODE_VERSION=1.17.4
|
||||
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \
|
||||
opencode --version ; \
|
||||
fi
|
||||
|
||||
# ── Optional: pi coding-agent ────────────────────────────────────────
|
||||
# pi-toolkit and pi-extensions are cloned into /opt/. entrypoint-user.sh
|
||||
# runs each repo's install.sh on container start so symlinks land under
|
||||
# ~/.pi/agent/ on the named volume.
|
||||
ARG INSTALL_PI=false
|
||||
ARG PI_VERSION=latest
|
||||
ARG PI_TOOLKIT_REF=main
|
||||
ARG PI_EXTENSIONS_REF=main
|
||||
RUN if [ "${INSTALL_PI}" = "true" ]; then \
|
||||
set -e && \
|
||||
git_clone_retry() { \
|
||||
url="$1"; ref="$2"; dest="$3"; \
|
||||
for i in 1 2 3 4 5; do \
|
||||
if git clone --depth 1 --branch "$ref" "$url" "$dest"; then return 0; fi; \
|
||||
rm -rf "$dest"; \
|
||||
echo "git clone $url failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
return 1; \
|
||||
} && \
|
||||
if [ "${PI_VERSION}" = "latest" ]; then \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
|
||||
else \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
|
||||
fi && \
|
||||
pi --version && \
|
||||
git_clone_retry https://gitea.jordbo.se/joakimp/pi-toolkit.git "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
|
||||
git_clone_retry https://gitea.jordbo.se/joakimp/pi-extensions.git "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
|
||||
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
|
||||
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" ; \
|
||||
fi
|
||||
|
||||
# ── Optional: Go ─────────────────────────────────────────────────────
|
||||
ARG INSTALL_GO=false
|
||||
ARG GO_VERSION=latest
|
||||
@@ -89,6 +64,13 @@ RUN if [ "${INSTALL_GO}" = "true" ]; then \
|
||||
|
||||
# ── Optional: oh-my-opencode-slim (multi-agent orchestration) ────────
|
||||
# Installs Bun runtime and the oh-my-opencode-slim npm package.
|
||||
# OMOS_VERSION has a cache-hit footgun when left at the `latest` default
|
||||
# in registry-cached CI builds: the resulting build-arg string is byte-
|
||||
# identical across builds, so the layer-hash is identical, so the
|
||||
# registry buildcache silently reuses the layer from whatever omos
|
||||
# version was current when the cache was first populated. CI resolves it
|
||||
# via `npm view oh-my-opencode-slim version` and passes the concrete
|
||||
# value as a build-arg (see resolve-versions in docker-publish-split.yml).
|
||||
ARG INSTALL_OMOS=false
|
||||
ARG OMOS_VERSION=latest
|
||||
RUN if [ "${INSTALL_OMOS}" = "true" ]; then \
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Portable AI developer environment in a Docker container. Run [opencode](https://opencode.ai) on any Docker-capable machine with configurable LLM providers, dev tools, and host filesystem access.
|
||||
|
||||
> **Looking for pi?** As of **v2.0.0** the [pi](https://github.com/earendil-works/pi) coding-agent is no longer bundled here. It now ships as its own self-contained image, **[`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox)** (also on [Docker Hub](https://hub.docker.com/r/joakimp/pi-devbox)), built on a shared Debian base and able to share this image's mempalace palace. Pull `joakimp/pi-devbox:latest` instead of the old `*-with-pi` / `pi-only` tags. See the [v2.0.0 CHANGELOG](CHANGELOG.md) for the migration details (including the `~/.pi/npm-global` → `~/.config/opencode/npm-global` prefix move).
|
||||
|
||||
## Why?
|
||||
|
||||
The official `ghcr.io/anomalyco/opencode` image (now archived) was Alpine-based and minimal — no git, no dev tools, broken PTY support due to musl/glibc incompatibility. This project provides a **Debian-based, production-ready** alternative using the current v1.x release.
|
||||
@@ -25,7 +27,7 @@ $EDITOR .env
|
||||
docker compose run --rm devbox
|
||||
```
|
||||
|
||||
This pulls `joakimp/opencode-devbox:latest` from Docker Hub, mounts `WORKSPACE_PATH` at `/workspace`, and drops you straight into opencode. Use `bash` instead of (no command) to land in a shell first — useful for `aws sso login`, `pi`, `omos`, etc.
|
||||
This pulls `joakimp/opencode-devbox:latest` from Docker Hub, mounts `WORKSPACE_PATH` at `/workspace`, and drops you straight into opencode. Use `bash` instead of (no command) to land in a shell first — useful for `aws sso login`, `omos`, etc.
|
||||
|
||||
**Want to hack on the image itself, follow upstream changes, or rebuild from source?** Clone the repo:
|
||||
|
||||
@@ -132,6 +134,10 @@ docker compose exec -u developer devbox aws --version
|
||||
| `GIT_USER_EMAIL` | Git commit author email | — |
|
||||
| `WORKSPACE_PATH` | Host path to mount | `.` |
|
||||
| `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_GID` | Override container user GID | Auto-detect from `/workspace` |
|
||||
| `LANG` | System locale | `en_US.UTF-8` |
|
||||
@@ -144,6 +150,61 @@ docker compose exec -u developer devbox aws --version
|
||||
| `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 |
|
||||
|
||||
### 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 read-only `~/.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 any in-container tool that opens an SSH connection to a remote host (git over SSH, `rsync`, remote-execution agents): the master fails to establish and the connection either errors or silently degrades.
|
||||
|
||||
**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` 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
|
||||
|
||||
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.
|
||||
@@ -361,10 +422,7 @@ docker compose build --build-arg NVIM_VERSION=0.12.1 # pin to a specific versi
|
||||
| `INSTALL_MEMPALACE` | `true` | [MemPalace](https://github.com/MemPalace/mempalace) local AI memory system (~300 MB — disable to shrink image if you don't need MCP memory) |
|
||||
| `INSTALL_MEMPALACE_TOOLKIT` | `true` | [mempalace-toolkit](https://gitea.jordbo.se/joakimp/mempalace-toolkit) bash wrappers (`mempalace-session`, `mempalace-docs`). Cloned at build time from `MEMPALACE_TOOLKIT_REF` (default `main`). Requires `INSTALL_MEMPALACE=true`. |
|
||||
| `INSTALL_OMOS` | `false` | [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration (installs Bun and plugin) |
|
||||
| `INSTALL_OPENCODE` | `true` | Install opencode. Set `false` to build a pi-only image (still includes Bun if `INSTALL_OMOS=true`; for a fully stripped pi-only image see the `pi-devbox` repo). |
|
||||
| `INSTALL_PI` | `false` | Install [pi](https://github.com/earendil-works/pi) as alternative/complementary harness. Both clones [pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit) (~5 MB) and [pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions) (~1 MB) into `/opt/`; entrypoint deploys them on container start. ~150 MB total image growth. |
|
||||
| `PI_VERSION` | `latest` | npm version of `@earendil-works/pi-coding-agent`. Floats by default (image rebuild = pi update). |
|
||||
| `PI_TOOLKIT_REF`, `PI_EXTENSIONS_REF` | `main` | Git refs for the toolkit/extensions clones. Pin to a tag/commit for reproducibility. |
|
||||
| `INSTALL_OPENCODE` | `true` | Install opencode. Set `false` to build a base with no harness (still includes Bun if `INSTALL_OMOS=true`). |
|
||||
| `OPENCODE_VERSION` | *(pinned per release)* | opencode npm version. Drives the image tag and is intentionally not floated. |
|
||||
| `NODE_VERSION` | `22` | Node.js major version. Pinned to protect against upstream breaking changes across majors. |
|
||||
| `GOSU_VERSION`, `FZF_VERSION`, `GIT_LFS_VERSION`, `NVIM_VERSION`, `BAT_VERSION`, `EZA_VERSION`, `ZOXIDE_VERSION`, `UV_VERSION`, `GITEA_MCP_VERSION`, `GO_VERSION`, `OMOS_VERSION` | `latest` | All GitHub/Gitea/go.dev-hosted binaries resolve to the newest upstream release at build time. Override with a specific version to pin. Resolved versions are logged in CI output. |
|
||||
@@ -426,79 +484,6 @@ ping all agents
|
||||
|
||||
All six agents should respond if your provider authentication is working.
|
||||
|
||||
## pi (alternative/complementary harness)
|
||||
|
||||
[pi](https://github.com/earendil-works/pi) is a lightweight TUI coding-agent that can run alongside opencode in the same container. Both harnesses share the mempalace install and palace data — wing/diary entries created by one are visible to the other.
|
||||
|
||||
### 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:
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
docker compose build --build-arg INSTALL_PI=true
|
||||
# Or: pin a pi version
|
||||
docker compose build --build-arg INSTALL_PI=true --build-arg PI_VERSION=0.73.0
|
||||
# Or: pi-only image (no opencode, smaller)
|
||||
docker compose build --build-arg INSTALL_PI=true --build-arg INSTALL_OPENCODE=false
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
The default `compose run --rm devbox` invocation drops to a login bash so you can choose:
|
||||
|
||||
```bash
|
||||
docker compose run --rm devbox # bash, then `pi` or `opencode` or `aws sso login`
|
||||
docker compose run --rm devbox pi # launch pi directly
|
||||
docker compose run --rm devbox opencode
|
||||
```
|
||||
|
||||
For an attached `compose up -d` container, both harnesses are reachable via `compose exec`:
|
||||
|
||||
```bash
|
||||
docker compose exec -u developer devbox pi
|
||||
docker compose exec -u developer devbox opencode
|
||||
docker compose exec -u developer devbox bash
|
||||
```
|
||||
|
||||
### What gets installed
|
||||
|
||||
- **`pi` CLI** — npm-installed globally at build time. Version pinned by `PI_VERSION`.
|
||||
- **pi-toolkit** — keybindings.json (mosh/tmux newline fixes), pi-env.zsh (AWS env loader), settings.json template. Cloned to `/opt/pi-toolkit`; deployed to `~/.pi/agent/` on first container start.
|
||||
- **pi-extensions** — 7 extensions, cloned to `/opt/pi-extensions` and symlinked into `~/.pi/agent/extensions/`:
|
||||
- `confirm-destructive` — confirm-prompt before dangerous bash commands and session actions.
|
||||
- `ext-toggle` — `/ext` slash command to list and enable/disable extensions at runtime (rename-to-disable; survives `/reload`).
|
||||
- `git-checkpoint` — per-turn `git stash` checkpoint, restorable on `/fork`.
|
||||
- `mcp-loader` — generic MCP server loader. Reads an `mcp` block from `~/.pi/agent/settings.json` (same shape as opencode and Claude Desktop) and connects to each declared server, exposing the tools as native pi tools. Supports both **local stdio** subprocesses (`uvx mcp-searxng`, `gitea-mcp`, …) and **remote streamable-HTTP** servers per MCP spec 2025-03-26 (e.g. `https://mcp.context7.com/mcp`). Adds a `/mcp` slash command for runtime status / toggle (same UX as `/ext`). See [`pi-extensions/AGENTS.md`](https://gitea.jordbo.se/joakimp/pi-extensions/src/branch/main/AGENTS.md) for transport details and the `headers` config for auth tokens.
|
||||
- `notify` — native terminal notification when the agent finishes.
|
||||
- `ssh-controlmaster` — transparent SSH remote execution via persistent ControlMaster socket (when pi is launched with `--ssh user@host`).
|
||||
- `todo` — `todo` tool for the agent + `/todos` for the user.
|
||||
- **mempalace bridge** — separate `mempalace.ts` extension symlinked from the cloned `mempalace-toolkit`. Provides pi's MCP tools for palace search/diary/knowledge-graph with bespoke agent-identity injection from `$MEMPALACE_AGENT_NAME`. Coexists with `mcp-loader` rather than replacing it — don't list `mempalace` in settings.json's `mcp` block too, or you'll get duplicate tool registrations.
|
||||
- **MCP servers (none baked in beyond mempalace)** — the loader registers nothing by default. Add servers by editing `~/.pi/agent/settings.json` and `/reload`. Examples (mcp-searxng for web search, context7 for live library docs) are in the `pi-extensions` README.
|
||||
|
||||
### Persistence
|
||||
|
||||
`~/.pi/` is mounted on the `devbox-pi-config` named volume. Everything below survives container recreate **and** image rebuilds:
|
||||
|
||||
- `~/.pi/agent/settings.json` (provider/model, theme selection, the `mcp` block, and the `packages` array tracking installed pi packages).
|
||||
- `~/.pi/agent/extensions/` (hand-placed extensions and the symlinks deployed by `pi-extensions/install.sh`).
|
||||
- `~/.pi/agent/sessions/`, `~/.pi/agent/auth.json`.
|
||||
- `~/.pi/agent/git/<host>/<path>/` (pi packages installed via `pi install git:...`).
|
||||
- `~/.pi/npm-global/` (pi packages installed via `pi install npm:...`, plus any `npm install -g` invoked as the `developer` user). `NPM_CONFIG_PREFIX` is pre-set in the image, the prefix's `bin/` is on `PATH`, and the directory itself lives on the volume — so user-installed themes, skills, and extensions survive everything short of `docker compose down -v`.
|
||||
|
||||
The **baked** pi binary (and pi-toolkit / pi-extensions repos under `/opt/`) live on the image filesystem, not the volume. Image rebuild is the upgrade path for those — same contract as `OPENCODE_VERSION`. If you `npm install -g @earendil-works/pi-coding-agent` yourself, the user-installed copy on the volume wins via `PATH` order and survives image rebuilds.
|
||||
|
||||
### Configuration
|
||||
|
||||
The entrypoint copies `pi-toolkit/settings.example.json` to `~/.pi/agent/settings.json` on first start. Edit it to set provider/model:
|
||||
|
||||
```bash
|
||||
docker compose exec -u developer devbox $EDITOR ~/.pi/agent/settings.json
|
||||
```
|
||||
|
||||
The AWS env loader (`pi-env.zsh`) reads `~/.config/pi/.env` if you bind-mount one; otherwise pi uses container env vars passed via `.env`.
|
||||
|
||||
## AWS Bedrock Authentication
|
||||
|
||||
When using AWS Bedrock as your LLM provider, you need:
|
||||
@@ -762,7 +747,7 @@ Container (Debian trixie)
|
||||
├── oh-my-opencode-slim (optional — multi-agent orchestration plugin, includes Bun)
|
||||
├── AWS CLI v2 (SSO + Bedrock auth)
|
||||
├── neovim 0.12, tmux, htop, bat, eza, zoxide, uv, rustup, make, gcc, g++, rsync
|
||||
├── git, git-crypt, age, ssh, ripgrep, fd, fzf, jq, curl, tree
|
||||
├── git, git-crypt, age, gitleaks, ssh, ripgrep, fd, fzf, jq, curl, tree
|
||||
├── Node.js (for MCP servers)
|
||||
├── Bun (optional — included with oh-my-opencode-slim)
|
||||
├── entrypoint.sh (UID adjustment, git config, provider setup)
|
||||
|
||||
+22
-4
@@ -25,8 +25,6 @@ services:
|
||||
# args:
|
||||
# INSTALL_GO: "false"
|
||||
# INSTALL_OMOS: "false"
|
||||
# INSTALL_PI: "false"
|
||||
# # PI_VERSION: "latest"
|
||||
# # INSTALL_OPENCODE: "true"
|
||||
container_name: opencode-devbox
|
||||
stdin_open: true
|
||||
@@ -58,7 +56,18 @@ services:
|
||||
# the container's skill/instruction symlinks independent from the host,
|
||||
# allowing both native and containerized opencode on the same machine.
|
||||
- devbox-opencode-config:/home/developer/.config/opencode
|
||||
- devbox-pi-config:/home/developer/.pi
|
||||
# Legacy pi config volume (pi was removed in v2.0.0). Left commented so
|
||||
# fresh installs carry no dead weight. If you are UPGRADING from a
|
||||
# pre-v2.0.0 image and had global npm packages installed via
|
||||
# `npm install -g` (they lived under ~/.pi/npm-global), uncomment this
|
||||
# for ONE container start: entrypoint-user.sh's migration shim copies
|
||||
# them to ~/.config/opencode/npm-global, after which you can remove it.
|
||||
# - 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
|
||||
# container manages its own skills directory independently — the
|
||||
@@ -95,6 +104,14 @@ services:
|
||||
# - ~/.bash_aliases:/home/developer/.bash_aliases: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)
|
||||
# Without this, 'uv python install' must be re-run after container removal.
|
||||
- devbox-uv:/home/developer/.local/share/uv
|
||||
@@ -125,7 +142,8 @@ services:
|
||||
|
||||
volumes:
|
||||
devbox-opencode-config:
|
||||
devbox-pi-config:
|
||||
# devbox-pi-config: # legacy (pi removed v2.0.0) — uncomment with the mount above only to migrate old global npm packages
|
||||
devbox-ssh-local:
|
||||
devbox-data:
|
||||
devbox-state:
|
||||
devbox-shell-history:
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
# PR-5: Retire pi from opencode-devbox
|
||||
|
||||
After pi-devbox has shipped v1.0.0 as a fully independent image (with
|
||||
its own base + variant Dockerfiles, CI, smoke tests, and docs), the
|
||||
pi-related paths in opencode-devbox become dead weight. This PR
|
||||
removes them.
|
||||
|
||||
## Pre-conditions before merging
|
||||
|
||||
This PR should land **only after** all of the following are stable:
|
||||
|
||||
1. `pi-devbox v1.0.0` published, smoke tests passing, in active use
|
||||
for at least one release cycle.
|
||||
2. Anyone consuming `joakimp/pi-devbox:base-pi-only` directly (e.g.
|
||||
forks pinned to it) has been notified and migrated.
|
||||
3. The deprecation warning (PR-1 of this work — see below) has been
|
||||
live for at least one release cycle so consumers have visible
|
||||
notice.
|
||||
|
||||
## Files / sections to remove from opencode-devbox
|
||||
|
||||
### `Dockerfile.variant`
|
||||
|
||||
Remove these blocks entirely:
|
||||
|
||||
- The `INSTALL_PI` / `PI_VERSION` / `PI_TOOLKIT_REF` /
|
||||
`PI_EXTENSIONS_REF` / `PI_FORK_REPO` / `PI_FORK_REF` /
|
||||
`PI_OBSMEM_REPO` / `PI_OBSMEM_REF` build-args.
|
||||
- The `RUN if [ "${INSTALL_PI}" = "true" ]; then ...` block (entire
|
||||
block — git_clone_retry, git_fetch_ref, npm install
|
||||
pi-coding-agent, the four /opt/pi-* clones, the npm installs in
|
||||
/opt/pi-fork and /opt/pi-observational-memory, and the four
|
||||
rev-parse echoes).
|
||||
- All comments referencing pi-only as "the single source of truth for
|
||||
the pi-devbox image" (the variant matrix table, the explanatory
|
||||
paragraph, and the "rationale" comments at the top of the file
|
||||
about pi-only existing for pi-devbox to FROM).
|
||||
|
||||
Update the variant matrix table at the top of `Dockerfile.variant`:
|
||||
|
||||
```
|
||||
variant INSTALL_OPENCODE INSTALL_OMOS
|
||||
───────────────── ──────────────── ────────────
|
||||
base true false
|
||||
omos true true
|
||||
```
|
||||
|
||||
(only two variants now; pi-only and the with-pi/omos-with-pi axis are
|
||||
gone).
|
||||
|
||||
### `entrypoint-user.sh`
|
||||
|
||||
Remove:
|
||||
|
||||
- The pi-toolkit and pi-extensions install hooks (the section that
|
||||
runs `(cd /opt/pi-toolkit && ./install.sh --yes)` etc.).
|
||||
- The `~/.pi/agent/settings.json` seeding from
|
||||
`/opt/pi-toolkit/settings.example.json`.
|
||||
- Any other pi-conditional blocks (search for `INSTALL_PI`, `pi-toolkit`,
|
||||
`pi-extensions`, `~/.pi/`).
|
||||
|
||||
Verify that the AWS Bedrock auth bootstrap (the pi-toolkit AWS env
|
||||
loader) is not relied on by opencode users. If it is, lift it out of
|
||||
the pi-toolkit dependency (it's small and self-contained).
|
||||
|
||||
### `Dockerfile.base`
|
||||
|
||||
Remove:
|
||||
|
||||
- The `mkdir -p /home/${USER_NAME}/.pi/agent/extensions` line in the
|
||||
standard-directories block. Replace with the equivalent opencode-
|
||||
specific paths if any aren't already present (`~/.config/opencode`
|
||||
is already there).
|
||||
- `NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global` — change to
|
||||
`/home/${USER_NAME}/.config/opencode/npm-global` or a more neutral
|
||||
path. Update the corresponding `PATH` env var.
|
||||
|
||||
Also update the long base-image header comment to remove the
|
||||
"variants for pi-devbox" rationale.
|
||||
|
||||
### CI (`.gitea/workflows/docker-publish-split.yml` or equivalent)
|
||||
|
||||
Remove:
|
||||
|
||||
- The `pi-only` variant build job.
|
||||
- The `with-pi` and `omos-with-pi` variant build jobs (they're
|
||||
redundant with the standalone pi-devbox now).
|
||||
- The `base-pi-only` tag publish step (which pushes to
|
||||
`joakimp/pi-devbox:base-pi-only` from this repo).
|
||||
- The `resolve-pi-version` job step (no longer needed).
|
||||
- Smoke-test invocations with `--variant pi-only`, `--variant with-pi`,
|
||||
`--variant omos-with-pi`.
|
||||
|
||||
Remaining variants in CI: `base`, `omos`. The "with-pi" axis is
|
||||
fully retired.
|
||||
|
||||
### `scripts/smoke-test.sh`
|
||||
|
||||
Remove:
|
||||
|
||||
- The `--variant pi-only`, `--variant with-pi`, `--variant
|
||||
omos-with-pi` branches.
|
||||
- pi-related assertions: `pi --version`, the
|
||||
`~/.pi/agent/extensions/*.ts ≥ 4` check, the mempalace.ts bridge
|
||||
gate (mempalace itself stays, but its bridge into pi is no longer
|
||||
this image's concern).
|
||||
|
||||
Remaining variant axis in smoke tests: `base`, `omos`.
|
||||
|
||||
### `README.md` (and `AGENTS.md`, `DOCKER_HUB.md`)
|
||||
|
||||
- Remove the pi-only variant from the "Image variants" table.
|
||||
- Remove the with-pi / omos-with-pi variants if they were documented.
|
||||
- Remove all sections about pi-toolkit, pi-extensions, pi-fork,
|
||||
pi-observational-memory, ~/.pi paths, and pi-related env vars.
|
||||
- Remove the "this image also produces base-pi-only for pi-devbox"
|
||||
notes.
|
||||
- Add a single-paragraph **"Looking for pi?"** section pointing to
|
||||
`joakimp/pi-devbox`.
|
||||
|
||||
### `Dockerfile` references in `pi-devbox` repo (cleanup of cross-repo coupling)
|
||||
|
||||
This isn't a change to opencode-devbox, but it's part of the same
|
||||
deprecation:
|
||||
|
||||
- Once pi-devbox v1.0.0 is the single source of truth, remove
|
||||
pi-devbox/Dockerfile (the 5-line shim with the long
|
||||
`joakimp/pi-devbox:base-pi-only` rationale comment). It's replaced
|
||||
by `Dockerfile.base` + `Dockerfile.variant` produced by PR-1 of
|
||||
this work.
|
||||
|
||||
### Purge the orphaned `base-pi-only*` Hub tags (manual, post-merge)
|
||||
|
||||
Until PR-5, the `build-variant-pi-only` job re-publishes
|
||||
`joakimp/pi-devbox:base-pi-only` (floating) and a fresh
|
||||
`base-pi-only-vX.Y.Z` on **every** opencode-devbox release. These tags
|
||||
are orphaned legacy artifacts: pi-devbox v1.0.0+ builds its own
|
||||
debian-based `base-<hash>` and **no pi-devbox build input references
|
||||
`base-pi-only`** (verified 2026-06-10 — only historical mentions remain
|
||||
in pi-devbox's CHANGELOG/AGENTS/DOCKER_HUB). Nothing consumes them.
|
||||
|
||||
Once PR-5 deletes the `build-variant-pi-only` job, the publisher is
|
||||
gone — so this is the moment to purge the accumulated tags from the
|
||||
**`joakimp/pi-devbox`** Docker Hub repo (NOT opencode-devbox):
|
||||
|
||||
- Delete the floating `base-pi-only` tag.
|
||||
- Delete every versioned snapshot: `base-pi-only-v1.17.2`,
|
||||
`base-pi-only-v1.16.2`, `base-pi-only-v1.15.13e`, … (all of them).
|
||||
|
||||
Do this **after** PR-5 is merged and the first post-PR-5 release has
|
||||
built, to confirm no new `base-pi-only*` tag reappears. If you purge
|
||||
before PR-5, the next opencode-devbox release simply recreates the
|
||||
floating tag (whack-a-mole). Deletion is via the Hub UI
|
||||
(`hub.docker.com/r/joakimp/pi-devbox/tags`) or the Hub API
|
||||
(`DELETE /v2/repositories/joakimp/pi-devbox/tags/<tag>/` with a Hub PAT).
|
||||
|
||||
## Two-step deprecation path (recommended)
|
||||
|
||||
Rather than a single big-bang removal, use a deprecation cycle:
|
||||
|
||||
### Step 1 — pre-PR (lands at the same time as pi-devbox v1.0.0)
|
||||
|
||||
Add a deprecation warning to opencode-devbox:
|
||||
|
||||
1. **Build-time message** — when `INSTALL_PI=true`,
|
||||
`INSTALL_PI_DEPRECATED=warn` is the default; the variant build
|
||||
prints to stderr:
|
||||
```
|
||||
===========================================================
|
||||
DEPRECATION WARNING: INSTALL_PI is deprecated in opencode-devbox
|
||||
and will be removed in v2.0.0. Use joakimp/pi-devbox:latest
|
||||
instead. See https://gitea.jordbo.se/joakimp/pi-devbox
|
||||
===========================================================
|
||||
```
|
||||
2. **CHANGELOG** entry on opencode-devbox: "INSTALL_PI build-arg path
|
||||
deprecated; will be removed in v2.0.0."
|
||||
3. **README and DOCKER_HUB** updates: mark `pi-only`, `with-pi`,
|
||||
`omos-with-pi` variants as deprecated, point to pi-devbox.
|
||||
4. The `base-pi-only` tag continues to be published but with a
|
||||
notice in the description: "Internal artifact for pi-devbox.
|
||||
Deprecated; pull joakimp/pi-devbox:latest directly."
|
||||
|
||||
### Step 2 — removal PR (this document)
|
||||
|
||||
Lands one release cycle (or one calendar month, whichever is later)
|
||||
after step 1. Removes everything listed in the per-file sections
|
||||
above. Tagged as opencode-devbox v2.0.0 (the major bump signals the
|
||||
breaking change).
|
||||
|
||||
## Risk assessment
|
||||
|
||||
### What could go wrong
|
||||
|
||||
- **Someone is consuming `base-pi-only` directly** without going
|
||||
through pi-devbox. The deprecation warning + one-cycle delay should
|
||||
surface this.
|
||||
- **Mempalace bridge in pi-extensions** — this stays in pi-devbox; no
|
||||
impact on opencode-devbox.
|
||||
- **Shared base assumptions** — opencode-devbox's
|
||||
`~/.pi/npm-global` NPM_CONFIG_PREFIX was a pi-specific design. In
|
||||
the cleanup we move it to a neutral path. Existing opencode-devbox
|
||||
users get a one-time migration: their `npm install -g` packages
|
||||
installed at the old path stop being on PATH. Document this in the
|
||||
v2.0.0 changelog and add a one-liner that copies the old prefix
|
||||
contents to the new one if the old one exists.
|
||||
|
||||
### What's safe
|
||||
|
||||
- The base apt set, the Go-binary installs, MemPalace, the SSH
|
||||
ControlMaster setup, the entrypoint UID/GID dance — all of these
|
||||
stay. They're not pi-specific.
|
||||
- The `omos` variant — fully unaffected.
|
||||
- Existing opencode-only users — no change to their workflow.
|
||||
|
||||
## Verification
|
||||
|
||||
After PR-5 lands, the following should be true:
|
||||
|
||||
- `grep -ri "INSTALL_PI\|pi-toolkit\|pi-extensions\|pi-fork\|pi-observational-memory\|base-pi-only" .` in opencode-devbox returns no matches.
|
||||
- `docker history joakimp/opencode-devbox:latest` shows no pi-related layers.
|
||||
- The opencode-devbox CI matrix builds only `base` and `omos` variants.
|
||||
- A post-PR-5 release does NOT recreate any `joakimp/pi-devbox:base-pi-only*` tag (publisher confirmed gone), after which those orphaned tags are purged from the pi-devbox Hub repo.
|
||||
- pi-devbox CI is unaffected (it's a different repo).
|
||||
- Both repos build cleanly in their own CI without referencing the other.
|
||||
|
||||
## Estimated effort
|
||||
|
||||
- Step 1 (deprecation warnings): ~2 hours.
|
||||
- Step 2 (removal): ~4 hours including local testing of opencode-only
|
||||
build paths.
|
||||
- One release cycle of monitoring between them.
|
||||
|
||||
Total: ~1 working day of focused effort, spread over a calendar month.
|
||||
|
||||
## Order in the broader plan
|
||||
|
||||
1. PR-1 on pi-devbox — copy base + variant Dockerfiles, strip
|
||||
opencode/omos paths, tag v1.0.0.
|
||||
2. PR-2 on pi-devbox — add pandoc, graphviz, imagemagick, tldr, yq.
|
||||
3. PR-3 on pi-devbox — add `:latest-studio` variant.
|
||||
4. (Optional) PR-4 on pi-devbox — add `:latest-studio-tex` variant.
|
||||
5. PR-pre on opencode-devbox — deprecation warnings (step 1 above).
|
||||
6. **PR-5 on opencode-devbox — actual removal (this document, step 2).**
|
||||
|
||||
PRs 1–4 are independent and can land in any order on pi-devbox. PR-pre
|
||||
should land alongside or shortly after pi-devbox v1.0.0 (PR-1) so
|
||||
consumers know to migrate. PR-5 lands one release cycle after PR-pre.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Manual host-side publish — escape hatch when CI is broken
|
||||
|
||||
This runbook is the procedure for publishing an opencode-devbox release **directly from a developer host** when the Gitea Actions → Docker Hub path is broken. Used in anger on 2026-05-28 to ship `v1.15.12` after five consecutive CI publish failures (runs #332/333/334/336 + a rerun) and as a parallel diagnostic that pinpointed the root cause (buildkit `cache-export mode=max` returning HTTP 400 from the Hub CDN).
|
||||
|
||||
The procedure is also a **diagnostic probe**. If the host-side publish succeeds where CI fails, the failure is somewhere in the runner → Hub path (cache-export, runner egress, runner-image, action versions). If host-side fails the same way, the failure is in your local buildx + Hub combination and you need a different escape (different network, different account, file an upstream).
|
||||
|
||||
## When to reach for this
|
||||
|
||||
- Tag pushed, CI keeps failing on `docker buildx build --push`, the failure shape is stable across reruns.
|
||||
- Failure body looks like a registry-tier rejection (HTTP 4xx, HTML response body, repeats on every retry) — i.e. not a transient.
|
||||
- You've already disproved the obvious suspects (action pin, runner image, network) per the [`ci-release-watcher` skill](../../../.agents/skills/ci-release-watcher/SKILL.md) playbook.
|
||||
- You need the release **shipped today** and don't want to wait for a CI fix to land + re-trigger.
|
||||
|
||||
If CI is broken because **a workflow change you just made is bad**, fix the workflow and re-tag with a letter suffix. This runbook is for when the workflow looks correct but the publish path itself is broken.
|
||||
|
||||
## Prerequisites on the host
|
||||
|
||||
- Docker (or Orbstack on macOS) with `docker buildx` available — multi-arch publish needs `setup-qemu` equivalent. Orbstack ships QEMU emulators for both archs by default; on Linux install `qemu-user-static` and run `docker run --privileged --rm tonistiigi/binfmt --install all` once per host.
|
||||
- `docker login` credentials for `joakimp` on Docker Hub (PAT or password). Confirm with `docker info | grep Username`.
|
||||
- A clone of `opencode-devbox` checked out at the **exact tag** you want to publish. `git status` clean. `git describe --tags --exact-match HEAD` should print the tag.
|
||||
- Network connectivity to `registry-1.docker.io` from the host. Verify with `curl -sI https://registry-1.docker.io/v2/ | head -1` (expects `401 Unauthorized` — that's the v2 API saying "auth required", which means you can reach it).
|
||||
|
||||
## How to use this runbook
|
||||
|
||||
A working reference script lives next to this doc: **[`docs/manual-host-publish.sh`](manual-host-publish.sh)**. It is the literal script that shipped opencode-devbox v1.15.12 on 2026-05-28 from a developer Mac via Orbstack, with the BASE_HASH and version pins of that release. To publish a different release, **copy it to a new file, edit four constants at the top, and run it**:
|
||||
|
||||
```bash
|
||||
cp docs/manual-host-publish.sh /tmp/manual-publish-vX.Y.Z.sh
|
||||
# Edit at top of file:
|
||||
# RELEASE_TAG="vX.Y.Z"
|
||||
# BASE_HASH="<12-char hash from CI's base-decide step>"
|
||||
# OMOS_VERSION="<from npm registry, see step 2 below>"
|
||||
bash /tmp/manual-publish-vX.Y.Z.sh
|
||||
```
|
||||
|
||||
The sections below explain what the script does and what you need to know to edit those three constants safely.
|
||||
|
||||
## 1. Pin RELEASE_TAG
|
||||
|
||||
The git tag you're publishing. Must match a tag in the local clone:
|
||||
|
||||
```bash
|
||||
git fetch && git checkout v1.15.13 # whatever you're publishing
|
||||
git describe --tags --exact-match HEAD
|
||||
```
|
||||
|
||||
The script asserts `HEAD == ${RELEASE_TAG}^{commit}` before doing anything destructive. If you've drifted, fix it with `git checkout` before running.
|
||||
|
||||
## 2. Pin OMOS_VERSION
|
||||
|
||||
Gitea CI's `resolve-versions` job queries the npm registry at workflow time and threads the concrete version through the omos variant build, mitigating the silent same-bytes-across-releases regression class documented in `AGENTS.md`. Do the same by hand:
|
||||
|
||||
```bash
|
||||
curl -sf https://registry.npmjs.org/oh-my-opencode-slim/latest | jq -r .version
|
||||
```
|
||||
|
||||
Paste the version string into the script's `OMOS_VERSION` constant. Don't leave the script defaulting to `latest` — the registry buildcache will silently reuse a stale layer if the build-arg byte-equals a previous build.
|
||||
|
||||
## 3. Pin BASE_HASH
|
||||
|
||||
This is the 12-char hash that CI's `base-decide` job computes from `Dockerfile.base` + `rootfs/**` + `entrypoint*.sh`. Three ways to get it, in order of preference:
|
||||
|
||||
**A. From a prior CI run on the same commit** (cheapest — if the Gitea Actions run that triggered on this tag got far enough to log `base-decide`'s output, just read it):
|
||||
|
||||
```
|
||||
Gitea Actions → the run for vX.Y.Z → base-decide job → "Compute base tag" step → last line:
|
||||
Computed base tag: base-XXXXXXXXXXXX
|
||||
```
|
||||
|
||||
This is the canonical source. The whole reason for the manual escape is that *something later in CI broke* — `base-decide` itself is fast, deterministic, and almost always succeeds.
|
||||
|
||||
**B. From an existing image on the Hub** if a recent release already published a `base-<hash>` tag and the inputs haven't changed, you can copy that hash. Confirm with `docker manifest inspect joakimp/opencode-devbox:base-latest` and read the digest — if it matches a `base-<hash>` you already see on the Hub, that hash is yours.
|
||||
|
||||
**C. Compute it locally**, replicating CI's exact recipe (the script in `.gitea/workflows/docker-publish-split.yml` `base-decide.compute`):
|
||||
|
||||
```bash
|
||||
{
|
||||
cat Dockerfile.base
|
||||
find rootfs -type f \
|
||||
! -path '*/__pycache__/*' \
|
||||
! -name '*.pyc' \
|
||||
! -name '.DS_Store' \
|
||||
! -name '._*' \
|
||||
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
|
||||
cat entrypoint.sh entrypoint-user.sh
|
||||
} | sha256sum | cut -c1-12
|
||||
```
|
||||
|
||||
The junk-file filters (`__pycache__`, `.DS_Store`, `._*` AppleDouble) matter — they are gitignored but `find -type f` picks them up locally and would diverge your hash from CI's clean checkout. Don't skip them.
|
||||
|
||||
If method C disagrees with method A, **trust A** and find out why your local tree differs. The hash in CI is what's on the Hub; that's what variants must FROM.
|
||||
|
||||
## What the script does (high level)
|
||||
|
||||
After the constants are set, the script runs a 5-step procedure. No editing needed inside the body; the whole flow is parameterised by the four constants above plus `IMAGE` (which is fixed to `joakimp/opencode-devbox`).
|
||||
|
||||
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.
|
||||
3. **Promote `base-latest`** — `docker buildx imagetools create` re-tags by manifest reference. No rebuild.
|
||||
4. **Variants × 2** — sequential (not parallel; one host's egress can't saturate multiple multi-arch pushes safely). Each variant is `Dockerfile.variant` `FROM ${IMAGE}:base-${BASE_HASH}` plus the appropriate `INSTALL_OPENCODE` / `INSTALL_OMOS` build-args, tagged `${RELEASE_TAG}${suffix}` and `latest${suffix}`.
|
||||
5. **Verify** — prints the digest of all 6 expected tags (4 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).
|
||||
|
||||
## Optional: update DOCKER_HUB.md description
|
||||
|
||||
CI's `update-description` job posts the rendered Hub description via the Hub API. The manual script does **not** do this — the release works fine without it. If you want parity, copy the curl invocation from the `update-description` job in `.gitea/workflows/docker-publish-split.yml` and run it from the host with a Hub PAT loaded into `HUB_PAT`. Cosmetic; can wait until CI is healthy and the next release pushes a fresh description automatically.
|
||||
|
||||
## After: capture diagnostic value
|
||||
|
||||
The whole point of running this manually is the diagnostic. Three things to record before moving on:
|
||||
|
||||
1. **Did the host publish succeed?** If yes and CI was failing on the same exact code, you've localised the failure to the runner side (cache-export, network, runner image). If no, the failure is in your local buildx + Hub combination and CI is a victim, not a cause.
|
||||
2. **What was different from CI?** Document at minimum: `docker buildx version`, the host's `buildx ls` output (driver name + version), whether you used `--cache-to` or not, and which network you were on.
|
||||
3. **File the upstream.** If the diagnostic narrowed the failure to a specific buildkit/buildx behaviour, file at `moby/buildkit` or `docker/buildx` with: stable failure shape, the exact request URL fragment (`Offset:0` / `_state=...` / digest if visible), the timeline boundary when failures started, and what worked vs what failed in your repro. The 2026-05-28 cache-export-mode=max regression is a worked example.
|
||||
|
||||
Restore CI as the primary publish path as soon as the underlying regression is fixed or worked around at workflow level. This runbook should be exercised rarely.
|
||||
|
||||
## Variants of this runbook
|
||||
|
||||
- **pi-devbox** — same idea, simpler: only one image (`joakimp/pi-devbox`), one tag pair (`vX.Y.Z` + `latest`), no split base. Adapt the script: drop the `BASE_HASH` constant + steps 2-3 + the variant function; replace with a single `docker buildx build --file Dockerfile --build-arg PI_VERSION=... --tag joakimp/pi-devbox:${RELEASE_TAG} --tag joakimp/pi-devbox:latest --push .`.
|
||||
- **opencode-devbox letter-suffix rebuild** (e.g. `v1.15.12b`) — same procedure end-to-end. The `BASE_HASH` will probably be unchanged from the prior release if no rootfs/entrypoint/Dockerfile.base changes shipped, so the base-build step skips itself automatically via the Hub probe.
|
||||
- **Single-variant publish** for partial-failure recovery (e.g. CI succeeded for base + 3 variants but the 4th failed) — comment out the three completed `build_variant` calls in your copy of the script. Keep `imagetools create` for `base-latest` only if it didn't already promote. Then re-run.
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
# Manual publish of opencode-devbox — bypasses a broken Gitea-runner Hub push
|
||||
# by building & pushing from a developer host (Orbstack/Docker Desktop).
|
||||
#
|
||||
# Mirrors what .gitea/workflows/docker-publish-split.yml would do:
|
||||
# 1. Build & push Dockerfile.base → joakimp/opencode-devbox:base-<hash>
|
||||
# 2. Promote → joakimp/opencode-devbox:base-latest
|
||||
# 3. Build & push 2 variants on top of base-<hash>:
|
||||
# :vX.Y.Z :latest (INSTALL_OPENCODE only)
|
||||
# :vX.Y.Z-omos :latest-omos (+ OMOS)
|
||||
#
|
||||
# pi was removed in v2.0.0 — there are no pi variants here anymore.
|
||||
#
|
||||
# Usage on your host:
|
||||
# 1. Make sure Orbstack/Docker Desktop is running with multi-arch enabled
|
||||
# (docker buildx ls should show linux/amd64,linux/arm64).
|
||||
# 2. docker login docker.io (joakimp account)
|
||||
# 3. cd ~/path/to/opencode-devbox && git fetch && git checkout <RELEASE_TAG>
|
||||
# 4. Edit RELEASE_TAG / BASE_HASH / OMOS_VERSION below to match the release.
|
||||
# 5. bash /path/to/this/script.sh
|
||||
#
|
||||
# Total expected time: ~15-25 min on a recent Mac (2 multi-arch builds, base
|
||||
# layers cache after the first variant).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="joakimp/opencode-devbox"
|
||||
RELEASE_TAG="v2.0.0" # EDIT per release
|
||||
BASE_HASH="REPLACE_ME" # sha256 of Dockerfile.base + rootfs/* + entrypoints (computed by CI logic)
|
||||
BASE_TAG="base-${BASE_HASH}"
|
||||
OMOS_VERSION="latest" # resolve from npm oh-my-opencode-slim latest, then pin
|
||||
PLATFORMS="linux/amd64,linux/arm64"
|
||||
|
||||
# -------- preflight --------
|
||||
echo "==> Preflight"
|
||||
docker buildx version >/dev/null || { echo "buildx not available"; exit 1; }
|
||||
git rev-parse --verify "$RELEASE_TAG" >/dev/null 2>&1 || {
|
||||
echo "Tag $RELEASE_TAG not found locally. git fetch && git checkout $RELEASE_TAG first."; exit 1; }
|
||||
[[ "$(git rev-parse HEAD)" == "$(git rev-parse "${RELEASE_TAG}^{commit}")" ]] || {
|
||||
echo "HEAD is not at $RELEASE_TAG. git checkout $RELEASE_TAG first."; exit 1; }
|
||||
docker buildx inspect default >/dev/null 2>&1 || docker buildx create --use --name multi --driver docker-container
|
||||
|
||||
# Probe whether base-<hash> already exists on Hub (CI does this; saves 10 min if yes)
|
||||
if docker manifest inspect "${IMAGE}:${BASE_TAG}" >/dev/null 2>&1; then
|
||||
echo "==> Base tag ${IMAGE}:${BASE_TAG} already exists on Hub — skipping base rebuild"
|
||||
SKIP_BASE=1
|
||||
else
|
||||
echo "==> Base tag ${IMAGE}:${BASE_TAG} missing — will build"
|
||||
SKIP_BASE=0
|
||||
fi
|
||||
|
||||
# -------- 1. base (if needed) --------
|
||||
if [[ "$SKIP_BASE" == "0" ]]; then
|
||||
echo "==> [1/7] Build & push Dockerfile.base → ${IMAGE}:${BASE_TAG}"
|
||||
docker buildx build \
|
||||
--platform "$PLATFORMS" \
|
||||
-f Dockerfile.base \
|
||||
-t "${IMAGE}:${BASE_TAG}" \
|
||||
--push \
|
||||
.
|
||||
fi
|
||||
|
||||
# -------- 2. promote base-latest --------
|
||||
echo "==> [2/7] Promote ${IMAGE}:${BASE_TAG} → ${IMAGE}:base-latest"
|
||||
docker buildx imagetools create -t "${IMAGE}:base-latest" "${IMAGE}:${BASE_TAG}"
|
||||
|
||||
# -------- 3-4. variants --------
|
||||
build_variant() {
|
||||
local suffix="$1" # "" | "-omos"
|
||||
local install_omos="$2"
|
||||
local install_opencode="${3:-true}"
|
||||
local extra_args=()
|
||||
[[ "$install_omos" == "true" ]] && extra_args+=(--build-arg "OMOS_VERSION=${OMOS_VERSION}")
|
||||
|
||||
local versioned="${IMAGE}:${RELEASE_TAG}${suffix}"
|
||||
local floating="${IMAGE}:latest${suffix}"
|
||||
|
||||
echo "==> Build & push variant${suffix:-(default)} → ${versioned} + ${floating}"
|
||||
docker buildx build \
|
||||
--platform "$PLATFORMS" \
|
||||
-f Dockerfile.variant \
|
||||
--build-arg "BASE_IMAGE=${IMAGE}:${BASE_TAG}" \
|
||||
--build-arg "INSTALL_OPENCODE=${install_opencode}" \
|
||||
--build-arg "INSTALL_OMOS=${install_omos}" \
|
||||
${extra_args[@]+"${extra_args[@]}"} \
|
||||
-t "${versioned}" \
|
||||
-t "${floating}" \
|
||||
--push \
|
||||
.
|
||||
}
|
||||
|
||||
echo "==> [3/4] Variant: base (opencode only)"
|
||||
build_variant "" false
|
||||
|
||||
echo "==> [4/4] Variant: omos"
|
||||
build_variant "-omos" true
|
||||
|
||||
echo
|
||||
echo "==> Done. Verifying tags on Hub:"
|
||||
for t in \
|
||||
"${RELEASE_TAG}" "latest" \
|
||||
"${RELEASE_TAG}-omos" "latest-omos" \
|
||||
"${BASE_TAG}" "base-latest"
|
||||
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")
|
||||
printf " %-32s %s\n" "$t" "$d"
|
||||
done
|
||||
@@ -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.
|
||||
+47
-38
@@ -1,6 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── SSH ControlMaster socket dir ────────────────────────────────
|
||||
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
|
||||
# base image — that file declares ControlPath=/tmp/sshcm/%r@%h:%p; this
|
||||
# creates the directory with the right permissions on every container
|
||||
# start. /tmp is per-container so the dir doesn't survive recreation;
|
||||
# baking it into a Dockerfile layer would be wrong.
|
||||
# Mode 700 is required — OpenSSH refuses to use a ControlPath dir that
|
||||
# others can write to.
|
||||
mkdir -p /tmp/sshcm
|
||||
chmod 700 /tmp/sshcm
|
||||
|
||||
# ── LAN access: generic host-OS-agnostic reachability helper ────────
|
||||
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
|
||||
# reach the host's directly-attached LAN peers by default; this generates a
|
||||
# writable ~/.ssh-local/config that uses the host as an SSH jump. On native
|
||||
# Linux (LAN reachable directly) it is a no-op. Controlled by DEVBOX_LAN_ACCESS
|
||||
# (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the script header.
|
||||
if [ -r /usr/local/lib/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
|
||||
# Respects host bind-mounts and user customizations — existing files
|
||||
# are never overwritten. To restore defaults: rm ~/.bash_aliases (or
|
||||
@@ -15,6 +36,32 @@ if [ -d "$SKEL_DIR" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# ── v2.0.0 migration: relocate npm global prefix off the legacy ~/.pi path ──
|
||||
# Pre-v2.0.0 images set NPM_CONFIG_PREFIX=~/.pi/npm-global (a pi-specific
|
||||
# path). v2.0.0 removed pi and moved the prefix to
|
||||
# ~/.config/opencode/npm-global, which is a persistent named volume in every
|
||||
# compose layout (the old ~/.pi volume was only in docker-compose.yml). If a
|
||||
# user upgraded with the old ~/.pi volume still mounted, copy their
|
||||
# previously globally-installed npm packages to the new prefix once so they
|
||||
# remain on PATH. The marker keeps this idempotent and a no-op for fresh
|
||||
# installs; the whole block is harmless when the old path is absent.
|
||||
NEW_NPM_PREFIX="$HOME/.config/opencode/npm-global"
|
||||
OLD_NPM_PREFIX="$HOME/.pi/npm-global"
|
||||
MIGRATION_MARKER="$NEW_NPM_PREFIX/.migrated-from-dot-pi"
|
||||
if [ -d "$OLD_NPM_PREFIX" ] && [ ! -f "$MIGRATION_MARKER" ]; then
|
||||
echo "Migrating npm global prefix: ~/.pi/npm-global -> ~/.config/opencode/npm-global"
|
||||
mkdir -p "$NEW_NPM_PREFIX"
|
||||
# cp -n: never overwrite a file already in the new prefix (a freshly
|
||||
# installed package wins over the legacy copy).
|
||||
for sub in lib bin share; do
|
||||
if [ -d "$OLD_NPM_PREFIX/$sub" ]; then
|
||||
mkdir -p "$NEW_NPM_PREFIX/$sub"
|
||||
cp -an "$OLD_NPM_PREFIX/$sub/." "$NEW_NPM_PREFIX/$sub/" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
touch "$MIGRATION_MARKER" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── MemPalace: initialize palace for the workspace if mempalace is installed
|
||||
# Creates the palace directory structure on first run. Idempotent — skips
|
||||
# if palace already exists, so upgrades from older versions preserve
|
||||
@@ -49,44 +96,6 @@ fi
|
||||
# generated) and no-ops if OPENCODE_PROVIDER is unset.
|
||||
python3 /usr/local/lib/opencode-devbox/generate-config.py
|
||||
|
||||
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
|
||||
# Runs only when pi was baked into the image (INSTALL_PI=true at build).
|
||||
# Each install.sh is idempotent and backs up real files before linking,
|
||||
# so re-running across container restarts is safe.
|
||||
#
|
||||
# Order: pi-toolkit first (creates ~/.pi/agent/keybindings.json symlink
|
||||
# and writes the AWS env loader), then pi-extensions (symlinks our 6
|
||||
# extensions), then settings.json bootstrap from the toolkit template,
|
||||
# then the mempalace bridge symlink (one-liner; mempalace-toolkit's
|
||||
# install_skill is intentionally skipped to avoid racing with skillset
|
||||
# auto-deploy below).
|
||||
if command -v pi &>/dev/null; then
|
||||
if [ -d /opt/pi-toolkit ]; then
|
||||
(cd /opt/pi-toolkit && ./install.sh --yes) || \
|
||||
echo "WARN: pi-toolkit install.sh failed (continuing)"
|
||||
fi
|
||||
|
||||
if [ -d /opt/pi-extensions ]; then
|
||||
(cd /opt/pi-extensions && ./install.sh --yes) || \
|
||||
echo "WARN: pi-extensions install.sh failed (continuing)"
|
||||
fi
|
||||
|
||||
# Bootstrap settings.json from template if absent (pi rewrites this
|
||||
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
|
||||
if [ ! -f "$HOME/.pi/agent/settings.json" ] && \
|
||||
[ -f /opt/pi-toolkit/settings.example.json ]; then
|
||||
cp /opt/pi-toolkit/settings.example.json "$HOME/.pi/agent/settings.json"
|
||||
fi
|
||||
|
||||
# pi↔mempalace MCP bridge — single extension symlink.
|
||||
if [ -f /opt/mempalace-toolkit/extensions/pi/mempalace.ts ] && \
|
||||
command -v mempalace &>/dev/null && \
|
||||
[ ! -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
||||
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
|
||||
"$HOME/.pi/agent/extensions/mempalace.ts"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
||||
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
|
||||
# run the deploy script to create relative symlinks for skills and instructions.
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ for dir in \
|
||||
/home/"$USER_NAME"/.vscode-server \
|
||||
/home/"$USER_NAME"/.config/opencode \
|
||||
/home/"$USER_NAME"/.config/nvim \
|
||||
/home/"$USER_NAME"/.pi \
|
||||
/home/"$USER_NAME"/.ssh-local \
|
||||
/home/"$USER_NAME"/.agents/skills; do
|
||||
[ -d "$dir" ] || continue
|
||||
|
||||
|
||||
@@ -54,6 +54,17 @@ alias gs='git status'
|
||||
alias gd='git diff'
|
||||
alias gl='git log --oneline --graph --decorate -20'
|
||||
|
||||
# ── LAN access via the host (dssh) ───────────────────────────────────
|
||||
# When running on a VM-backed host (macOS OrbStack / Docker Desktop), the
|
||||
# entrypoint's setup-lan-access.sh generates ~/.ssh-local/config so the host
|
||||
# can be used as an SSH jump to reach LAN peers. These aliases wrap `ssh -F`
|
||||
# / `scp -F` against that config. Guarded so they only appear when the config
|
||||
# was actually generated (no-op / absent on native Linux hosts).
|
||||
if [ -r "$HOME/.ssh-local/config" ]; then
|
||||
alias dssh='ssh -F "$HOME/.ssh-local/config"'
|
||||
alias dscp='scp -F "$HOME/.ssh-local/config"'
|
||||
fi
|
||||
|
||||
# Safety: confirm before destructive ops
|
||||
alias rm='rm -i'
|
||||
alias mv='mv -i'
|
||||
|
||||
+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
|
||||
@@ -56,19 +56,25 @@ HUB_TEMPLATE = f"""# opencode-devbox
|
||||
|
||||
Portable AI developer environment for [opencode](https://opencode.ai). Debian-based, with git, SSH, Node.js, AWS CLI v2, and common dev tools pre-installed.
|
||||
|
||||
> **Current `:latest` ships opencode `{{{{OPENCODE_VERSION}}}}`** (the baked version is asserted by smoke tests, so this page never drifts from the image).
|
||||
|
||||
Designed for teams who want a reproducible coding-agent setup that runs the same on every laptop and CI runner — without forcing each developer to install Bun, Node, AWS CLI, mempalace, or maintain shell config drift across machines.
|
||||
|
||||
## Image Variants
|
||||
|
||||
| Tag | Description |
|
||||
|---|---|
|
||||
| `latest` / `vX.Y.Z` | Base image — opencode, Node.js, AWS CLI, dev tools |
|
||||
| `latest` / `vX.Y.Z` | Base image — opencode `{{{{OPENCODE_VERSION}}}}`, Node.js, AWS CLI, dev tools |
|
||||
| `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun |
|
||||
| `latest-with-pi` / `vX.Y.Z-with-pi` | Base + [pi](https://github.com/earendil-works/pi) as alternative/complementary harness (shares the mempalace install with opencode) |
|
||||
| `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | OMOS + pi together |
|
||||
|
||||
All variants support `linux/amd64` and `linux/arm64`.
|
||||
|
||||
> **Looking for pi?** As of v2.0.0 the pi coding-agent is no longer bundled in
|
||||
> opencode-devbox. It ships as the dedicated
|
||||
> [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox) image, which
|
||||
> shares the same mempalace memory layer. See
|
||||
> <https://gitea.jordbo.se/joakimp/pi-devbox>.
|
||||
|
||||
## 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:
|
||||
@@ -82,7 +88,7 @@ curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.
|
||||
docker compose run --rm devbox
|
||||
```
|
||||
|
||||
This drops you straight into opencode with your project mounted at `/workspace`. Use `bash` as the command (e.g. `docker compose run --rm devbox bash`) to land in a shell first — useful for `aws sso login`, `pi` (on `*-with-pi` variants), or multi-harness workflows.
|
||||
This drops you straight into opencode with your project mounted at `/workspace`. Use `bash` as the command (e.g. `docker compose run --rm devbox bash`) to land in a shell first — useful for `aws sso login` or multi-agent workflows.
|
||||
|
||||
**One-shot run, no persistence:**
|
||||
|
||||
@@ -102,11 +108,10 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Bedroc
|
||||
## What's Inside
|
||||
|
||||
- **[opencode](https://opencode.ai)** — primary coding-agent harness. Multi-provider (Anthropic, OpenAI, Bedrock, Google, Groq, etc.).
|
||||
- **[pi](https://github.com/earendil-works/pi)** *(in `*-with-pi` variants)* — lightweight TUI coding-agent that coexists with opencode and shares the same mempalace install. Includes the `mcp-loader` extension so any local-stdio or remote streamable-HTTP MCP server (searxng, gitea, context7, …) can be added by editing `~/.pi/agent/settings.json`.
|
||||
- **[mempalace](https://github.com/MemPalace/mempalace)** — persistent AI memory layer (ChromaDB + SQLite). Wing/diary/knowledge-graph entries are mutually visible to opencode and pi.
|
||||
- **[mempalace](https://github.com/MemPalace/mempalace)** — persistent AI memory layer (ChromaDB + SQLite). Wing/diary/knowledge-graph entries are shareable with the sibling [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox) image when both point at the same palace.
|
||||
- **[oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim)** *(in `*-omos` variants)* — multi-agent orchestration on top of opencode (council, fallback chains, named agents).
|
||||
- **AWS CLI v2** with SSO support, **Node.js LTS**, **Bun** (OMOS variants), **uv** (Python), **gosu** for clean UID/GID adjustment to match your host workspace.
|
||||
- **MCP wrappers** for mempalace pre-installed and pre-wired to both harnesses.
|
||||
- **MCP wrappers** for mempalace pre-installed and pre-wired to opencode.
|
||||
|
||||
## Authentication
|
||||
|
||||
@@ -124,8 +129,7 @@ Full Bedrock walkthrough (IAM roles, permissions, multi-account setups): see the
|
||||
|
||||
| Volume | Mount | Survives |
|
||||
|---|---|---|
|
||||
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild |
|
||||
| `devbox-pi-config` | `~/.pi` | container recreate, image rebuild — incl. user-installed pi packages via `pi install` (`NPM_CONFIG_PREFIX` points into the volume) |
|
||||
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild — incl. user-installed npm globals via `npm install -g` (`NPM_CONFIG_PREFIX` points into the volume) |
|
||||
| `devbox-palace` (uncomment) | `~/.mempalace` | container recreate, image rebuild — palace data is precious, treat as primary storage |
|
||||
| `devbox-chroma-cache` | `~/.cache/chroma` | container recreate (model cache, disposable — re-downloads in seconds) |
|
||||
|
||||
@@ -142,7 +146,7 @@ Full persistence reference, including multi-user (`SIGNUM`) isolation and host b
|
||||
|
||||
## Sibling images
|
||||
|
||||
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** — pi-only image built on top of this image's base layer. Smaller (~700 MB) and version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly. Use this if you want pi without opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
|
||||
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** — the pi coding-agent in its own self-contained image, built on a shared Debian base. Version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly and can share this image's mempalace palace. Use it if you want pi instead of (or alongside) opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
|
||||
|
||||
## License
|
||||
|
||||
@@ -150,7 +154,7 @@ MIT. See <{GITEA}/src/branch/main/LICENSE>.
|
||||
|
||||
---
|
||||
|
||||
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate.
|
||||
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate. The `{{{{OPENCODE_VERSION}}}}` placeholder is filled by CI at publish time.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
+62
-70
@@ -8,7 +8,7 @@
|
||||
# - Generated opencode.json has the expected shape
|
||||
# - 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]
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 all checks passed
|
||||
@@ -23,13 +23,20 @@ if [ "${2:-}" = "--variant" ]; then
|
||||
fi
|
||||
|
||||
if [ -z "$IMAGE" ]; then
|
||||
echo "usage: $0 <image> [--variant base|omos|with-pi|omos-with-pi]" >&2
|
||||
echo "usage: $0 <image> [--variant base|omos]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
FAILED=0
|
||||
pass() { echo " ✓ $1"; }
|
||||
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
||||
warn() { echo " ⚠ $1" >&2; }
|
||||
|
||||
# Registration assertions for fork/recall were removed in v2.0.0 along with
|
||||
# pi. STRICT_REGISTRATION is retained as an inert env var for backward
|
||||
# compatibility with any external caller that still sets it; it has no
|
||||
# effect now that no pi packages are deployed.
|
||||
STRICT_REGISTRATION="${STRICT_REGISTRATION:-0}"
|
||||
|
||||
run() {
|
||||
# Run a command inside the image and capture its output.
|
||||
@@ -43,6 +50,25 @@ run() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Stricter version of `run` that also asserts an expected substring in
|
||||
# the command's stdout. Used to catch the "image bytes silently identical
|
||||
# to previous release" class of regression — Docker layer-cache hit on
|
||||
# a bare `npm install -g <pkg>` (or @latest) because the build-arg
|
||||
# string is identical across builds, even when 'latest' would have
|
||||
# resolved differently. Discovered in pi-devbox 2026-05-23 (every
|
||||
# release v0.74.0..v0.75.5 shipped the same image bytes); preventatively
|
||||
# applied here for PI_VERSION + OMOS_VERSION.
|
||||
run_expect() {
|
||||
local label="$1"; local cmd="$2"; local expect="$3"
|
||||
local out
|
||||
out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" 2>&1) || true
|
||||
if echo "$out" | grep -Fq "$expect"; then
|
||||
pass "$label (got $expect)"
|
||||
else
|
||||
fail "$label — expected substring '$expect', got: $out"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Smoke test: $IMAGE (variant: $VARIANT) ==="
|
||||
echo
|
||||
echo "-- Resolved component versions --"
|
||||
@@ -53,9 +79,6 @@ docker run --rm --entrypoint="" "$IMAGE" sh -c '
|
||||
if command -v opencode >/dev/null 2>&1; then
|
||||
printf " %-15s %s\n" "opencode" "$(opencode --version 2>&1 | head -1)"
|
||||
fi
|
||||
if command -v pi >/dev/null 2>&1; then
|
||||
printf " %-15s %s\n" "pi" "$(pi --version 2>&1 | head -1)"
|
||||
fi
|
||||
printf " %-15s %s\n" "node" "$(node --version)"
|
||||
printf " %-15s %s\n" "npm" "$(npm --version)"
|
||||
printf " %-15s %s\n" "nvim" "$(nvim --version | head -1)"
|
||||
@@ -68,6 +91,8 @@ docker run --rm --entrypoint="" "$IMAGE" sh -c '
|
||||
printf " %-15s %s\n" "rg" "$(rg --version | head -1)"
|
||||
printf " %-15s %s\n" "gosu" "$(gosu --version)"
|
||||
printf " %-15s %s\n" "git-lfs" "$(git-lfs --version)"
|
||||
printf " %-15s %s\n" "git-crypt" "$(git-crypt --version 2>&1 | head -1)"
|
||||
printf " %-15s %s\n" "gitleaks" "$(gitleaks version 2>&1 | head -1)"
|
||||
printf " %-15s %s\n" "gitea-mcp" "$(gitea-mcp --version 2>&1 | head -1)"
|
||||
printf " %-15s %s\n" "aws" "$(aws --version 2>&1)"
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
@@ -83,7 +108,7 @@ docker run --rm --entrypoint="" "$IMAGE" sh -c '
|
||||
echo
|
||||
echo "-- Core binaries --"
|
||||
# opencode is gated on INSTALL_OPENCODE=true (default). When absent, the
|
||||
# image is a pi-only build (or a pure base — no harness at all).
|
||||
# image is a pure base with no harness at all.
|
||||
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v opencode" >/dev/null 2>&1; then
|
||||
run "opencode" "opencode --version"
|
||||
else
|
||||
@@ -103,11 +128,20 @@ run "fzf" "fzf --version"
|
||||
run "fd" "fd --version"
|
||||
run "rg" "rg --version | head -1"
|
||||
run "jq" "jq --version"
|
||||
run "git-crypt" "git-crypt --version | head -1"
|
||||
run "gitleaks" "gitleaks version"
|
||||
run "aws" "aws --version"
|
||||
run "gitea-mcp" "gitea-mcp --version"
|
||||
run "gosu" "gosu --version"
|
||||
run "tmux" "tmux -V"
|
||||
|
||||
# SSH ControlMaster baked defaults: the config file must exist (image-level)
|
||||
# and ssh -G must report ControlPath rooted at /tmp/sshcm/ for an arbitrary
|
||||
# host. Catches both regressions: someone removing the conf file, OR something
|
||||
# else later in the config chain shadowing the ControlPath setting.
|
||||
run "ssh-config-cm-file" "test -f /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf"
|
||||
run_expect "ssh-config-cm-path" "ssh -G example.invalid 2>/dev/null | grep -i ^controlpath" "/tmp/sshcm/"
|
||||
|
||||
echo
|
||||
echo "-- Optional / variant-gated --"
|
||||
# mempalace: present unless built with INSTALL_MEMPALACE=false
|
||||
@@ -128,70 +162,24 @@ elif docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev
|
||||
echo " - mempalace-toolkit not installed (INSTALL_MEMPALACE_TOOLKIT=false)"
|
||||
fi
|
||||
|
||||
# pi: present when built with INSTALL_PI=true. Verifies pi itself plus
|
||||
# the runtime-deployed pi-toolkit + pi-extensions + mempalace bridge
|
||||
# symlinks under ~/.pi/agent/. Note: extension symlinks are created by
|
||||
# entrypoint-user.sh on first start, so we test by running the entry
|
||||
# point chain (not just `docker run --entrypoint=""`).
|
||||
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v pi" >/dev/null 2>&1; then
|
||||
run "pi" "pi --version"
|
||||
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 the full entrypoint as developer to verify install.sh deployment.
|
||||
# Spin up a long-running container so we can `docker exec` into it from
|
||||
# the host — the `run` helper above invokes commands INSIDE the image
|
||||
# and has no docker CLI to nest with.
|
||||
CID=$(docker run -d --rm "$IMAGE" tail -f /dev/null)
|
||||
trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
# Wait for entrypoint-user.sh to finish deploying pi-toolkit + extensions.
|
||||
# Marker: keybindings.json symlink lands once pi-toolkit/install.sh has run.
|
||||
# Up to 30s — omos-with-pi has more setup work than base+pi.
|
||||
for _ in $(seq 1 30); do
|
||||
if docker exec "$CID" test -L /home/developer/.pi/agent/keybindings.json 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
exec_test() {
|
||||
local label="$1"; shift
|
||||
local out
|
||||
if out=$(docker exec -u developer "$CID" sh -c "$*" 2>&1); then
|
||||
pass "$label ($(echo "$out" | head -1))"
|
||||
else
|
||||
fail "$label: $out"
|
||||
fi
|
||||
}
|
||||
|
||||
exec_test "~/.pi/agent/keybindings.json (pi-toolkit)" \
|
||||
'test -L $HOME/.pi/agent/keybindings.json && echo ok'
|
||||
exec_test "~/.pi/agent/extensions/*.ts ≥ 4 (pi-extensions)" \
|
||||
'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"'
|
||||
exec_test "~/.pi/agent/extensions/mempalace.ts (bridge)" \
|
||||
'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok'
|
||||
exec_test "~/.pi/agent/settings.json (template bootstrap)" \
|
||||
'test -f $HOME/.pi/agent/settings.json && echo ok'
|
||||
|
||||
docker rm -f "$CID" >/dev/null 2>&1 || true
|
||||
trap - EXIT
|
||||
else
|
||||
echo " - pi not installed (INSTALL_PI=false)"
|
||||
fi
|
||||
|
||||
# bun: only in the omos and omos-with-pi variants
|
||||
if [ "$VARIANT" = "omos" ] || [ "$VARIANT" = "omos-with-pi" ]; then
|
||||
# bun: only in the omos variant
|
||||
if [ "$VARIANT" = "omos" ]; then
|
||||
run "bun (omos)" "bun --version"
|
||||
run "bunx symlink (omos)" "test -L /usr/local/bin/bunx && readlink /usr/local/bin/bunx"
|
||||
# oh-my-opencode-slim is npm-installed globally (not a bun install);
|
||||
# verify it shows up in the global module list. We must explicitly point
|
||||
# npm at the system prefix (/usr) here: the image's NPM_CONFIG_PREFIX env
|
||||
# is set to /home/developer/.pi/npm-global so user-installed packages
|
||||
# is set to /home/developer/.config/opencode/npm-global so user-installed
|
||||
# packages
|
||||
# land on the persistent volume — which means a default `npm ls -g`
|
||||
# queries the user prefix and would miss the baked binaries even though
|
||||
# they're correctly on PATH at /usr/bin.
|
||||
run "oh-my-opencode-slim" "NPM_CONFIG_PREFIX=/usr npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim"
|
||||
if [ -n "${EXPECTED_OMOS_VERSION:-}" ]; then
|
||||
run_expect "omos version matches build-arg" \
|
||||
"NPM_CONFIG_PREFIX=/usr npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim" \
|
||||
"$EXPECTED_OMOS_VERSION"
|
||||
fi
|
||||
else
|
||||
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v bun" >/dev/null 2>&1; then
|
||||
fail "bun should NOT be in base image but was found"
|
||||
@@ -289,20 +277,24 @@ SIZE_BYTES=$(docker image inspect --format='{{.Size}}' "$IMAGE")
|
||||
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
|
||||
echo " Uncompressed size: ${SIZE_MB} MB"
|
||||
|
||||
# Thresholds (uncompressed): base 2500 MB, omos 3300 MB, with-pi adds ~150 MB.
|
||||
# Thresholds (uncompressed): base 2600 MB, omos 3300 MB.
|
||||
# omos bumped 3000→3200 on v1.14.31c — mempalace-toolkit bake-in pushed the
|
||||
# baseline; bumped 3200→3300 on v1.15.0 — opencode 1.15.0 came in at
|
||||
# 3206 MB, leaving zero headroom for routine apt-get upgrade drift.
|
||||
# omos-with-pi bumped 3400→3500 on v1.15.0 alongside the omos bump.
|
||||
# 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
|
||||
# the variant landed just over 3500 in v1.15.4's smoke.
|
||||
# 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
|
||||
# guardrail, not a performance limit.
|
||||
THRESHOLD=2500
|
||||
[ "$VARIANT" = "omos" ] && THRESHOLD=3300
|
||||
[ "$VARIANT" = "with-pi" ] && THRESHOLD=2700
|
||||
[ "$VARIANT" = "omos-with-pi" ] && THRESHOLD=3700
|
||||
# v1.16.2: all thresholds bumped +150 MB preemptively ahead of the combined
|
||||
# opencode 1.15.13->1.16.2 (minor) + pi 0.78.1->0.79.0 (minor) bump. Both
|
||||
# base (2506/2600) and omos (3206/3300) were sitting on ~94 MB headroom and
|
||||
# a minor opencode bump has tripped them before (v1.15.0 omos). Restoring
|
||||
# ~250 MB headroom avoids a partial-publish + letter-suffix recovery cycle.
|
||||
# CI's smoke size print + resolved-versions table records the actual landed
|
||||
# sizes; tighten later if they come in low.
|
||||
THRESHOLD=2750
|
||||
[ "$VARIANT" = "omos" ] && THRESHOLD=3450
|
||||
if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then
|
||||
fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT"
|
||||
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