Compare commits
196 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 8f2c9f5112 | |||
| 60eb49469e | |||
| 18b9c9c549 | |||
| ad4a12b3ab | |||
| fde5a89e8b | |||
| 034830710c | |||
| d293ddc202 | |||
| 910378fe06 | |||
| f06a70a3bc | |||
| dba05da7d1 | |||
| 8359fef949 | |||
| a438c67f06 | |||
| 07e07ec611 | |||
| 7dc836ab66 | |||
| a3ff601bf0 | |||
| 6fde27c212 | |||
| b30ffc83bd | |||
| 896380bb9c | |||
| 911d6dd26b | |||
| 4c27e6fd8a | |||
| b5da6a5cf8 | |||
| f86c4b18cf | |||
| 9df126c7a9 | |||
| 148f4bce8c | |||
| cc98722d84 | |||
| d01cff38d5 | |||
| 8083cd1a6f | |||
| f46c4ed017 | |||
| bf811f2170 | |||
| c76b1e8aa3 | |||
| 23bf383a37 | |||
| 5006b01170 | |||
| f51e9f52a1 | |||
| a208b073b0 | |||
| a803fe4653 | |||
| 79b697dea0 | |||
| 3e3abc8672 | |||
| 59e58a9d00 | |||
| 26ce9aa490 | |||
| 3d4e739529 | |||
| a6b0b59946 | |||
| fc74a8f906 | |||
| 5a2d06340e | |||
| 23894bc19f | |||
| f0918ba915 | |||
| 1683650240 | |||
| 9d7c3e5ad8 | |||
| 23bae2ab7d | |||
| e0b6c2082f | |||
| 2c889b472e | |||
| 349bb633ff | |||
| 3b3533d40b | |||
| 113c9f0bb0 | |||
| 4efc4e8005 | |||
| 49fad7cad9 | |||
| ca44da71e1 | |||
| 8e605e87d4 | |||
| 7a8de0463f | |||
| adaf7ba2ff | |||
| d426e92745 | |||
| b9c08c3dbb | |||
| 45d7e02faf | |||
| 4de0bc9993 | |||
| b648d83928 | |||
| f2f8a70dae | |||
| c34cf3641b | |||
| 3a7ec45f4b | |||
| e1029bbf27 | |||
| 8c919074dd | |||
| bca403c540 | |||
| c182ada0dd | |||
| b9657415c4 | |||
| b37740bcce | |||
| 3982e9f18c | |||
| 4d0c270196 | |||
| aed5ff106b | |||
| 425d53cb57 | |||
| 60208b2203 | |||
| d65f8cc077 | |||
| 4560702550 | |||
| c851b4cc8d | |||
| 9bb93025f0 | |||
| c05ec7503c | |||
| 84b5ed4412 | |||
| 8535f73ad3 | |||
| e4063b5559 | |||
| cb4971b4a6 | |||
| 3d632ef02f | |||
| 3669bec8ff | |||
| f210d533eb | |||
| 00d4f1596d | |||
| 3c19b836cf | |||
| fffaeffb7a | |||
| b4d2f09e77 | |||
| d74adc14dc | |||
| 9fa8b5c1e3 | |||
| 3724519402 | |||
| a06dc5f47c | |||
| 967ce7df49 | |||
| c209d873ba | |||
| e52ac46237 | |||
| 83fb3d6de5 | |||
| d9d3a4c1d2 | |||
| 7b8c74852e | |||
| c32d50b364 | |||
| dd63607a3f | |||
| 3852d3b1ad | |||
| ddea23e80a | |||
| 466383b546 | |||
| f21cf87881 | |||
| 3c7df3f888 | |||
| 6fc74b1f19 | |||
| 05998bd6a2 | |||
| b1e25a45b2 | |||
| 16ff29101e | |||
| 81100fd5bb | |||
| 4893be4133 | |||
| 9ebff2e037 | |||
| 5bac08dd03 | |||
| addccd4a82 | |||
| 7b0f6ed880 | |||
| fa3bb12d44 | |||
| d091b6b50f | |||
| fb9629db2b | |||
| 265cbdb14c | |||
| 68204f573b | |||
| e0258a928e | |||
| 4bd543050a | |||
| b164c1b2f9 | |||
| c59c66087a | |||
| e679fa06e6 | |||
| d90dd76a46 | |||
| 2153aa5659 | |||
| 0e4525ca53 | |||
| 43cecab0f7 | |||
| 2d9fadf220 | |||
| f08480182a | |||
| 5ec47fdf4b | |||
| 210cb7d1a1 | |||
| 0a3e142b8f | |||
| 158e1590a6 | |||
| 271dc2eb35 | |||
| 875afe0039 | |||
| 9e381ebe32 | |||
| 3e048218c3 | |||
| 6ecd65d18d | |||
| e58962a72c | |||
| d2c0447147 | |||
| 77a7daf67f | |||
| b3cfe641bb | |||
| f7bd21b9fe | |||
| 1b97d98155 | |||
| de659fbc54 | |||
| d651a084de | |||
| 18b4df23e5 | |||
| 60c83568cd | |||
| a8b5f23dba | |||
| a6972becd1 | |||
| a183ad7ac6 | |||
| 017f7f1343 |
+93
-1
@@ -7,7 +7,7 @@
|
||||
OPENCODE_PROVIDER=anthropic
|
||||
|
||||
# Model override (optional, defaults per provider)
|
||||
# OPENCODE_MODEL=anthropic/claude-sonnet-4-5
|
||||
# OPENCODE_MODEL=anthropic/claude-sonnet-4-6
|
||||
|
||||
# ── API Keys (set the one matching your provider) ────────────────────
|
||||
# ANTHROPIC_API_KEY=
|
||||
@@ -31,9 +31,101 @@ 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
|
||||
# across host/container).
|
||||
#
|
||||
# Detection is automatic if the skillset lives directly at the workspace
|
||||
# root (i.e. WORKSPACE_PATH/skillset → /workspace/skillset in container).
|
||||
#
|
||||
# If the skillset lives in a subdirectory of your workspace, set
|
||||
# SKILLSET_CONTAINER_PATH to its location *inside the container*. This
|
||||
# is determined by the workspace mount: whatever is at
|
||||
# WORKSPACE_PATH/<subpath> on the host becomes /workspace/<subpath>
|
||||
# in the container.
|
||||
#
|
||||
# Examples:
|
||||
# Host skillset at ~/projects/skillset → already at /workspace/skillset (auto-detected, no config needed)
|
||||
# Host skillset at ~/projects/tools/skillset → SKILLSET_CONTAINER_PATH=/workspace/tools/skillset
|
||||
# Host skillset at ~/projects/local/skillset → SKILLSET_CONTAINER_PATH=/workspace/local/skillset
|
||||
#
|
||||
# Alternatively, mount the skillset repo at a dedicated path using the
|
||||
# SKILLSET_PATH volume in docker-compose.yml (see comments there). In
|
||||
# that case the entrypoint finds it at ~/skillset automatically.
|
||||
#
|
||||
# SKILLSET_CONTAINER_PATH=
|
||||
|
||||
# ── Locale (defaults to en_US.UTF-8) ─────────────────────────────────
|
||||
# LANG=sv_SE.UTF-8
|
||||
# LANGUAGE=sv_SE:sv
|
||||
# LC_ALL=sv_SE.UTF-8
|
||||
|
||||
# ── oh-my-opencode-slim (multi-agent orchestration) ──────────────────
|
||||
# Requires image built with INSTALL_OMOS=true
|
||||
# ENABLE_OMOS=false
|
||||
# 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)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# ── Shared machine setup ─────────────────────────────────────────────
|
||||
# SIGNUM isolates your container name and named volumes from other users.
|
||||
#
|
||||
# Own-account mode (each user has their own OS login):
|
||||
# Leave SIGNUM commented out — it defaults to your OS username ($USER).
|
||||
# SIGNUM=
|
||||
#
|
||||
# Shared-account mode (everyone logs in as the same OS user):
|
||||
# Uncomment and set to your unique identifier.
|
||||
# SIGNUM=your-signum-here
|
||||
|
||||
# ── Provider ─────────────────────────────────────────────────────────
|
||||
OPENCODE_PROVIDER=amazon-bedrock
|
||||
OPENCODE_MODEL=amazon-bedrock/eu.anthropic.claude-opus-4-6-v1
|
||||
AWS_REGION=eu-west-1
|
||||
AWS_PROFILE=default
|
||||
|
||||
# ── Git ──────────────────────────────────────────────────────────────
|
||||
GIT_USER_NAME=Your Name
|
||||
GIT_USER_EMAIL=your.name@example.com
|
||||
|
||||
# ── Paths (adjust to your layout) ───────────────────────────────────
|
||||
# Default: ~/src mounted as /workspace
|
||||
# WORKSPACE_PATH=~/src
|
||||
|
||||
# SSH keys — defaults to shared ~/.ssh
|
||||
# If you have per-user keys: SSH_KEY_PATH=~/<signum>/.ssh
|
||||
# SSH_KEY_PATH=~/.ssh
|
||||
|
||||
# ── Locale (defaults to en_US.UTF-8) ────────────────────────────────
|
||||
# LANG=sv_SE.UTF-8
|
||||
# LANGUAGE=sv_SE:sv
|
||||
# LC_ALL=sv_SE.UTF-8
|
||||
@@ -0,0 +1,314 @@
|
||||
# CI / Build Pipeline
|
||||
|
||||
This directory contains the gitea Actions workflows and the supporting
|
||||
documentation for opencode-devbox's CI. If you're investigating *why*
|
||||
the build pipeline is shaped the way it is, you're in the right place.
|
||||
|
||||
## Workflows in this directory
|
||||
|
||||
| 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 five parallel variant deltas. ~40–80 min wall clock depending on runner count and whether base needs rebuilding. |
|
||||
| [`workflows/validate.yml`](workflows/validate.yml) | `push: branches: main` + PR | **Lightweight gate.** amd64-only smoke test of all five variants + `DOCKER_HUB.md` sync check. ~30 min. Fires on every push to `main`. |
|
||||
|
||||
## Why the split-base pipeline exists
|
||||
|
||||
opencode-devbox builds **five image variants** (`base`, `omos`, `with-pi`, `omos-with-pi`, `pi-only`) × **two architectures** (amd64, arm64). Four opencode-bearing variants publish under this repo (**eight tags per release** + the floating `base-latest`); the `pi-only` build is pushed into the separate `joakimp/pi-devbox` repo as `base-pi-only` (so no opencode-less tag appears here). Today's runners are 2 self-hosted gitea Actions runners. arm64 builds are emulated under QEMU, which is the dominant cost (~3–5x slower than native).
|
||||
|
||||
The five variants share ~95% of their layers (Debian + apt + Node + AWS CLI + mempalace + dev tools + entrypoints). The original `Dockerfile` was a single multi-stage build with `INSTALL_*` build-args gating variant-specific RUNs. BuildKit's per-layer cache key is content-addressed, but as soon as a build-arg-gated `RUN` produces a different layer hash for variant A vs variant B, every subsequent layer also has a different parent → identical commands re-execute per variant. Result: minimal cross-variant cache reuse on a fresh build.
|
||||
|
||||
Two improvements were considered:
|
||||
|
||||
1. **Reorder the original Dockerfile** so all variant-gated RUNs land at the bottom — modest gain, ~10–20% wall-clock reduction. *Not pursued.*
|
||||
2. **Split into `Dockerfile.base` + `Dockerfile.variant`** with the base published as a long-lived shared image — significant gain, ~50–70% wall-clock reduction with hash-driven cache reuse. *Pursued.*
|
||||
|
||||
The split-base architecture is what the `docker-publish-split.yml` workflow exercises.
|
||||
|
||||
## How the split-base pipeline works
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ base-decide │ compute base-<hash>;
|
||||
│ │ probe Docker Hub.
|
||||
│ hash inputs: │ (resolve-versions
|
||||
│ Dockerfile.base│ runs in parallel:
|
||||
│ rootfs/ │ npm view pi/omos
|
||||
│ entrypoint*.sh │ → concrete versions)
|
||||
└────────┬─────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
│ need_build = true? │
|
||||
└─────────────┬─────────────┘
|
||||
yes │ no
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ build-base │ multi-arch build,
|
||||
│ │ push base-<hash>
|
||||
└────────┬─────────┘ 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.
|
||||
└────┬─────┘ └────┬─────┘ └──────┬───────┘
|
||||
└───────────────────────┴──────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ promote-base-latest │ crane copy
|
||||
│ │ base-<hash>
|
||||
│ │ → base-latest
|
||||
└────────┬─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ update-description │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
### Step 1: `base-decide` (and `resolve-versions` in parallel)
|
||||
|
||||
**`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 \
|
||||
! -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`:
|
||||
|
||||
- If it exists → set `need_build=false`. `build-base` is skipped entirely.
|
||||
- If it doesn't → set `need_build=true`. `build-base` runs.
|
||||
|
||||
This is the core cache-reuse mechanism. Version-bump-only releases
|
||||
(only `Dockerfile.variant` or build-args changed) hit the cache. 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
|
||||
PI_VERSION=$(npm view @earendil-works/pi-coding-agent version)
|
||||
OMOS_VERSION=$(npm view oh-my-opencode-slim version)
|
||||
```
|
||||
|
||||
The outputs (`pi_version`, `omos_version`) are consumed by every variant
|
||||
smoke and build job that installs pi or omos. **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 pi or
|
||||
omos. Smoke jobs additionally assert `EXPECTED_PI_VERSION` /
|
||||
`EXPECTED_OMOS_VERSION` against the resolved values.
|
||||
|
||||
### Step 2: `build-base` (conditional)
|
||||
|
||||
Only runs when `need_build=true`. Multi-arch (amd64 + arm64) build of
|
||||
`Dockerfile.base`, pushed to `joakimp/opencode-devbox:base-<hash>`.
|
||||
Registry cache via `--cache-from/--cache-to` reduces incremental rebuilds
|
||||
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)
|
||||
|
||||
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 |
|
||||
|
||||
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)
|
||||
|
||||
For each variant that passed smoke: multi-arch (amd64 + arm64) build of
|
||||
`Dockerfile.variant`, pushed to Docker Hub with the user-facing release
|
||||
tags:
|
||||
|
||||
| Build job | Tags pushed |
|
||||
|---|---|
|
||||
| `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
|
||||
production aliases pointing at the previous good release.
|
||||
|
||||
### Step 5: `promote-base-latest`
|
||||
|
||||
Once all five 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.
|
||||
|
||||
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.
|
||||
|
||||
### Step 6: `update-description`
|
||||
|
||||
Push the generated `DOCKER_HUB.md` to the Hub repo's `full_description`
|
||||
field via the Hub REST API. Same step as the production pipeline.
|
||||
|
||||
## NPM_CONFIG_PREFIX gotcha (variant override pattern)
|
||||
|
||||
The base sets
|
||||
|
||||
```
|
||||
ENV NPM_CONFIG_PREFIX=/home/developer/.pi/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.
|
||||
|
||||
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.
|
||||
|
||||
Fix: each `npm install -g` in `Dockerfile.variant` overrides the prefix
|
||||
per-RUN:
|
||||
|
||||
```dockerfile
|
||||
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.
|
||||
|
||||
## Cache strategy
|
||||
|
||||
Two registry caches are configured:
|
||||
|
||||
```yaml
|
||||
cache-from: type=registry,ref=joakimp/opencode-devbox:base-buildcache
|
||||
cache-to: type=registry,ref=joakimp/opencode-devbox:base-buildcache,mode=max
|
||||
|
||||
cache-from: type=registry,ref=joakimp/opencode-devbox:base-variant-buildcache
|
||||
cache-to: type=registry,ref=joakimp/opencode-devbox:base-variant-buildcache,mode=max
|
||||
```
|
||||
|
||||
`mode=max` exports cache for *all* layers, not just the final image's
|
||||
layers. Important for multi-arch builds where the cross-arch layer reuse
|
||||
matters more.
|
||||
|
||||
## Wall-clock estimates
|
||||
|
||||
| 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) |
|
||||
| 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
|
||||
infrequent — a few times a year for Debian / Node major version bumps).
|
||||
Most releases are version-bumps and ride the cache.
|
||||
|
||||
## Validate workflow
|
||||
|
||||
[`validate.yml`](workflows/validate.yml) is the lightweight gate that runs
|
||||
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 five 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.
|
||||
|
||||
## Runner expectations
|
||||
|
||||
- **Image:** `catthehacker/ubuntu:act-latest`. Each job runs inside a
|
||||
fresh container of this image. Don't assume any pre-installed
|
||||
toolchains beyond what catthehacker ships.
|
||||
- **Disk pressure:** the runner host has ~40 GB of usable overlay space,
|
||||
often 70%+ used at job start. Every job that does `load: true` (smoke)
|
||||
starts with a `Reclaim runner disk` step that strips
|
||||
catthehacker-resident toolchains (Android SDK, .NET, Swift, GHC, JVM,
|
||||
Boost, Chromium, PowerShell) and prunes stale docker state. Don't
|
||||
remove these steps without testing on a fresh runner.
|
||||
- **Concurrency:** 2 runners. Jobs in the same workflow run can fan out to
|
||||
both; jobs in *different* workflow runs are serialized by gitea's queue.
|
||||
The `concurrency: { group: ${{ workflow }}-${{ ref }}, cancel-in-progress: false }`
|
||||
setting keeps tag pushes from racing each other but allows
|
||||
per-PR/per-branch parallelism.
|
||||
- **Workflow visibility in UI:** gitea Actions only surfaces workflows
|
||||
from the **default branch** in the web UI's workflow list, even for
|
||||
`workflow_dispatch` triggers. Workflows on feature branches are
|
||||
invisible until merged to `main`.
|
||||
- **Disk reclaim quirk:** `actions/{upload,download}-artifact@v4+` does
|
||||
not work on Gitea (depends on a GitHub-only Artifact API). Stick to
|
||||
`@v3` if matrix-fanout-with-artifacts is ever needed. We avoided this
|
||||
by using `docker/build-push-action@v7` with comma-separated
|
||||
`platforms: linux/amd64,linux/arm64` — natively does multi-arch push
|
||||
in a single job, no artifact dance.
|
||||
|
||||
## Migration plan: split-base → production
|
||||
|
||||
1. **Validate the split-base dispatch.** Trigger
|
||||
`docker-publish-split.yml` manually with `release_tag=v0.0.0-split-test`
|
||||
and `promote_latest=false`. Confirm all jobs go green, image sizes
|
||||
match the production baseline within ~10%, and no unexpected layer
|
||||
rebuilds appear in `build-variant-*` logs after the FROM line.
|
||||
2. **Run a second dispatch** to confirm cache-hit behavior:
|
||||
`base-decide` should set `need_build=false`, `build-base` should be
|
||||
skipped entirely, total wall clock should drop to ~25–40 min.
|
||||
3. **Cut over** — *done as of v1.14.50.* `docker-publish-split.yml` now
|
||||
triggers on `push: tags: v*`. `docker-publish.yml` and original
|
||||
`Dockerfile` deleted.
|
||||
4. **Tag a release.** First production release on the new pipeline.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [`AGENTS.md`](../AGENTS.md) — domain facts, release-day checklist,
|
||||
documentation coupling rules. Read first when modifying CI behavior.
|
||||
- [`CHANGELOG.md`](../CHANGELOG.md) — build pipeline rewrite landed in v1.14.50.
|
||||
- `Dockerfile.base`, `Dockerfile.variant` — the split-base Dockerfiles.
|
||||
Comments at the top of each explain their role.
|
||||
- [`scripts/smoke-test.sh`](../scripts/smoke-test.sh) — invoked by all
|
||||
three workflows; this is the single source of truth for "what does a
|
||||
built image have to satisfy".
|
||||
- [`scripts/generate-dockerhub-md.py`](../scripts/generate-dockerhub-md.py)
|
||||
— generates `DOCKER_HUB.md` from `HUB_TEMPLATE`. `--check` enforces
|
||||
sync in `validate.yml`.
|
||||
@@ -0,0 +1,965 @@
|
||||
name: Publish Docker Image
|
||||
|
||||
# Two-phase split-base build pipeline. Replaces the original
|
||||
# docker-publish.yml single-Dockerfile pipeline.
|
||||
#
|
||||
# Pipeline shape:
|
||||
# 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
|
||||
# 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).
|
||||
# 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).
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Release tag to publish (e.g. v1.14.50). Used only for workflow_dispatch runs — tag-triggered runs derive the tag from github.ref.'
|
||||
required: false
|
||||
default: ''
|
||||
promote_latest:
|
||||
description: 'Update latest/* aliases (default true for tag-push, set false for manual test runs)'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
BUILDKIT_PROGRESS: plain
|
||||
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox
|
||||
# The pi-only variant is built here (single source of truth for the pi stack)
|
||||
# but published into the pi-devbox repo as an internal building-block tag,
|
||||
# NOT under opencode-devbox — so opencode-devbox never shows a tag with no
|
||||
# opencode in it. pi-devbox's own CI FROMs PI_IMAGE:base-pi-only.
|
||||
PI_IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/pi-devbox
|
||||
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag }}
|
||||
PROMOTE_LATEST: ${{ github.ref_type == 'tag' && 'true' || inputs.promote_latest }}
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────
|
||||
# Reusable disk-reclaim snippet — strips catthehacker toolchains and
|
||||
# stale docker state. Identical to the production workflow's pattern.
|
||||
# ───────────────────────────────────────────────────────────────────
|
||||
|
||||
jobs:
|
||||
# ── Phase 1: decide whether base needs rebuilding ──────────────────
|
||||
base-decide:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
outputs:
|
||||
base_tag: ${{ steps.compute.outputs.base_tag }}
|
||||
need_build: ${{ steps.probe.outputs.need_build }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute base tag from Dockerfile.base + dependencies
|
||||
id: compute
|
||||
run: |
|
||||
# Hash inputs that determine the base image's contents.
|
||||
# Order is fixed via `find -print0 | sort -z` for reproducibility.
|
||||
# Junk filters: __pycache__/*.pyc and macOS metadata (.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 \
|
||||
! -path '*/__pycache__/*' \
|
||||
! -name '*.pyc' \
|
||||
! -name '.DS_Store' \
|
||||
! -name '._*' \
|
||||
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
|
||||
cat entrypoint.sh entrypoint-user.sh
|
||||
} | sha256sum | cut -c1-12
|
||||
)
|
||||
BASE_TAG="base-${HASH}"
|
||||
echo "base_tag=${BASE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed base tag: ${BASE_TAG}"
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Probe Docker Hub for existing base tag
|
||||
id: probe
|
||||
run: |
|
||||
set +e
|
||||
docker manifest inspect "${IMAGE}:${{ steps.compute.outputs.base_tag }}" \
|
||||
> /dev/null 2>&1
|
||||
PROBE_RC=$?
|
||||
set -e
|
||||
if [ "${PROBE_RC}" = "0" ]; then
|
||||
echo "need_build=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} exists — skipping rebuild."
|
||||
else
|
||||
echo "need_build=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} missing — will build."
|
||||
fi
|
||||
|
||||
# ── Phase 1b: resolve floating npm versions (pi, omos) to concrete
|
||||
# versions so the variant build-args carry a different value when an
|
||||
# upstream package bumps. Without this, when PI_VERSION / OMOS_VERSION
|
||||
# default 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 pi/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 here because OPENCODE_VERSION
|
||||
# is hard-coded in Dockerfile.variant and bumps every release —
|
||||
# invalidating the parent-chain cache key for the pi/omos layers — but
|
||||
# that masking would fail the moment we cut a vN.N.Nb opencode-version-
|
||||
# unchanged release that only bumps pi or omos. Fix is preventative.
|
||||
resolve-versions:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
outputs:
|
||||
pi_version: ${{ steps.resolve.outputs.pi_version }}
|
||||
omos_version: ${{ steps.resolve.outputs.omos_version }}
|
||||
fork_ref: ${{ steps.resolve.outputs.fork_ref }}
|
||||
obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }}
|
||||
steps:
|
||||
- name: Resolve pi + omos versions 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.
|
||||
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version')
|
||||
OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.version')
|
||||
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "omos_version=${OMOS_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
# Resolve the pi-fork / pi-observational-memory git refs (default
|
||||
# branch master) to concrete commit SHAs so the build-arg string
|
||||
# changes whenever upstream moves — defeating the same registry-
|
||||
# buildcache cache-hit footgun that PI_VERSION/OMOS_VERSION guard
|
||||
# against. The Accept: application/vnd.github.sha media type returns
|
||||
# the bare SHA. Falls back to the branch name if the API is
|
||||
# unreachable/rate-limited (still functional, just cache-stale-prone).
|
||||
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || echo "master")
|
||||
OBSMEM_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || echo "master")
|
||||
[ -n "$FORK_REF" ] || FORK_REF=master
|
||||
[ -n "$OBSMEM_REF" ] || OBSMEM_REF=master
|
||||
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved PI_VERSION=${PI_VERSION}, OMOS_VERSION=${OMOS_VERSION}"
|
||||
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
|
||||
|
||||
# ── Phase 2: build & push base (multi-arch), only when needed ──────
|
||||
build-base:
|
||||
needs: [base-decide]
|
||||
if: needs.base-decide.outputs.need_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base (multi-arch) — with retry
|
||||
shell: bash
|
||||
env:
|
||||
BASE_TAG_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 3-attempt retry around `docker buildx build --push` for transient
|
||||
# registry-1.docker.io blips. Does NOT mask deterministic failures:
|
||||
# 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
|
||||
# scripts/smoke-test.sh. base-decide.outputs.base_tag is always set;
|
||||
# build-base may have been skipped (cache hit) but the tag exists either way.
|
||||
|
||||
smoke-base:
|
||||
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
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build amd64 variant for smoke
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: opencode-devbox:smoke-base
|
||||
build-args: |
|
||||
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, resolve-versions]
|
||||
if: |
|
||||
always() &&
|
||||
needs.base-decide.result == 'success' &&
|
||||
needs.resolve-versions.result == 'success' &&
|
||||
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- run: |
|
||||
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: opencode-devbox:smoke-omos
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=false
|
||||
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
|
||||
|
||||
smoke-with-pi:
|
||||
needs: [base-decide, build-base, resolve-versions]
|
||||
if: |
|
||||
always() &&
|
||||
needs.base-decide.result == 'success' &&
|
||||
needs.resolve-versions.result == 'success' &&
|
||||
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- run: |
|
||||
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: opencode-devbox:smoke-with-pi
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=false
|
||||
INSTALL_PI=true
|
||||
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
|
||||
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
- env:
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
STRICT_REGISTRATION: "1"
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi
|
||||
|
||||
smoke-omos-with-pi:
|
||||
needs: [base-decide, build-base, resolve-versions]
|
||||
if: |
|
||||
always() &&
|
||||
needs.base-decide.result == 'success' &&
|
||||
needs.resolve-versions.result == 'success' &&
|
||||
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- run: |
|
||||
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: opencode-devbox:smoke-omos-with-pi
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=true
|
||||
INSTALL_OMOS=true
|
||||
INSTALL_PI=true
|
||||
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
|
||||
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
|
||||
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
- env:
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
|
||||
STRICT_REGISTRATION: "1"
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi
|
||||
|
||||
smoke-pi-only:
|
||||
needs: [base-decide, build-base, resolve-versions]
|
||||
if: |
|
||||
always() &&
|
||||
needs.base-decide.result == 'success' &&
|
||||
needs.resolve-versions.result == 'success' &&
|
||||
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- run: |
|
||||
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: opencode-devbox:smoke-pi-only
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
INSTALL_OPENCODE=false
|
||||
INSTALL_OMOS=false
|
||||
INSTALL_PI=true
|
||||
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
|
||||
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
- env:
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
STRICT_REGISTRATION: "1"
|
||||
run: bash scripts/smoke-test.sh opencode-devbox:smoke-pi-only --variant pi-only
|
||||
|
||||
# ── Phase 4: multi-arch publish per variant ────────────────────────
|
||||
|
||||
build-variant-base:
|
||||
needs: [base-decide, smoke-base]
|
||||
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}"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${IMAGE}:latest"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push variant (with retry)
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.tags }}
|
||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
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" \
|
||||
--build-arg "INSTALL_PI=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, resolve-versions]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- run: |
|
||||
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
with: {platforms: arm64}
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Compute version-specific tags
|
||||
id: tags
|
||||
run: |
|
||||
VERSION="${{ env.RELEASE_TAG }}"
|
||||
{ echo "tags<<EOF"
|
||||
echo "${IMAGE}:${VERSION}-omos"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${IMAGE}:latest-omos"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push variant (with retry)
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.tags }}
|
||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
# 3-attempt retry (see build-base step for rationale). Variant: 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 "INSTALL_PI=false" \
|
||||
--build-arg "OMOS_VERSION=${OMOS_VERSION}" \
|
||||
"${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-with-pi:
|
||||
needs: [base-decide, smoke-with-pi, resolve-versions]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- run: |
|
||||
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
with: {platforms: arm64}
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Compute version-specific tags
|
||||
id: tags
|
||||
run: |
|
||||
VERSION="${{ env.RELEASE_TAG }}"
|
||||
{ echo "tags<<EOF"
|
||||
echo "${IMAGE}:${VERSION}-with-pi"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${IMAGE}:latest-with-pi"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push variant (with retry)
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.tags }}
|
||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
# 3-attempt retry (see build-base step for rationale). Variant: with-pi.
|
||||
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" \
|
||||
--build-arg "INSTALL_PI=true" \
|
||||
--build-arg "PI_VERSION=${PI_VERSION}" \
|
||||
--build-arg "PI_FORK_REF=${FORK_REF}" \
|
||||
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; 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-with-pi:
|
||||
needs: [base-decide, smoke-omos-with-pi, resolve-versions]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- run: |
|
||||
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
with: {platforms: arm64}
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Compute version-specific tags
|
||||
id: tags
|
||||
run: |
|
||||
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"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push variant (with retry)
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.tags }}
|
||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
|
||||
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
run: |
|
||||
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-with-pi.
|
||||
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 "INSTALL_PI=true" \
|
||||
--build-arg "PI_VERSION=${PI_VERSION}" \
|
||||
--build-arg "OMOS_VERSION=${OMOS_VERSION}" \
|
||||
--build-arg "PI_FORK_REF=${FORK_REF}" \
|
||||
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" -lt 3 ]]; then
|
||||
backoff=$(( attempt * 15 ))
|
||||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||
sleep "${backoff}"
|
||||
fi
|
||||
done
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
build-variant-pi-only:
|
||||
needs: [base-decide, smoke-pi-only, resolve-versions]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- run: |
|
||||
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
with: {platforms: arm64}
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Compute version-specific tags
|
||||
id: tags
|
||||
run: |
|
||||
# Option B: push the pi-only build into the pi-devbox repo as an
|
||||
# internal building-block tag (base-pi-only[-<version>]), NOT under
|
||||
# opencode-devbox. pi-devbox's CI FROMs ${PI_IMAGE}:base-pi-only.
|
||||
VERSION="${{ env.RELEASE_TAG }}"
|
||||
{ echo "tags<<EOF"
|
||||
echo "${PI_IMAGE}:base-pi-only-${VERSION}"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${PI_IMAGE}:base-pi-only"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push variant (with retry)
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.tags }}
|
||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
# 3-attempt retry (see build-base step for rationale). Variant: pi-only.
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.variant \
|
||||
--push \
|
||||
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
|
||||
--build-arg "INSTALL_OPENCODE=false" \
|
||||
--build-arg "INSTALL_OMOS=false" \
|
||||
--build-arg "INSTALL_PI=true" \
|
||||
--build-arg "PI_VERSION=${PI_VERSION}" \
|
||||
--build-arg "PI_FORK_REF=${FORK_REF}" \
|
||||
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" -lt 3 ]]; then
|
||||
backoff=$(( attempt * 15 ))
|
||||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||
sleep "${backoff}"
|
||||
fi
|
||||
done
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
# ── Phase 5: promote base-<hash> → base-latest (manifest copy only) ─
|
||||
promote-base-latest:
|
||||
needs:
|
||||
- base-decide
|
||||
- build-variant-base
|
||||
- build-variant-omos
|
||||
- build-variant-with-pi
|
||||
- build-variant-omos-with-pi
|
||||
- build-variant-pi-only
|
||||
# Skip on cache-hit base builds: when need_build=false, base-latest
|
||||
# already points at the same digest as base-<hash>, so the retag is
|
||||
# a tautology and any transient failure of it is purely cosmetic.
|
||||
# Manual workflow_dispatch with promote_latest=true overrides this
|
||||
# gate as an escape hatch (e.g., if base-latest got hand-deleted).
|
||||
#
|
||||
# `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
|
||||
# prevent the base-latest alias from advancing on a real base rebuild.
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-variant-base.result == 'success' &&
|
||||
(inputs.promote_latest == 'true' ||
|
||||
(github.ref_type == 'tag' && needs.base-decide.outputs.need_build == 'true'))
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
# Direct pinned install instead of imjasonh/setup-crane@v0.4. The
|
||||
# action's bootstrap script calls api.github.com/.../releases/latest
|
||||
# to discover the crane version, which periodically rate-limits and
|
||||
# produces tag=null → download from .../download/null/... → 404 →
|
||||
# 'gzip: unexpected end of file' → exit 2. Pinning removes the
|
||||
# runtime dependency on GitHub API entirely. Bump CRANE_VERSION
|
||||
# deliberately when you want updates.
|
||||
- name: Install crane (pinned)
|
||||
env:
|
||||
CRANE_VERSION: v0.21.6
|
||||
run: |
|
||||
set -eux
|
||||
curl -fsSL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin crane
|
||||
crane version
|
||||
- name: Login (crane)
|
||||
run: |
|
||||
crane auth login docker.io \
|
||||
-u ${{ vars.DOCKERHUB_USERNAME }} \
|
||||
-p "${{ secrets.DOCKERHUB_TOKEN }}"
|
||||
- name: Re-tag base-<hash> as base-latest
|
||||
run: |
|
||||
crane copy \
|
||||
${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} \
|
||||
${{ env.IMAGE }}:base-latest
|
||||
|
||||
# ── Phase 6: update Hub description (only on real release runs) ────
|
||||
update-description:
|
||||
needs:
|
||||
- build-variant-base
|
||||
- build-variant-omos
|
||||
- build-variant-with-pi
|
||||
- build-variant-omos-with-pi
|
||||
- build-variant-pi-only
|
||||
# Run when at least the base variant published — don't let a single
|
||||
# variant failure (e.g., omos-with-pi 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/
|
||||
# skipped build-variant-* into update-description, and the Hub
|
||||
# description goes stale on partial-publish releases.
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-variant-base.result == 'success' &&
|
||||
(github.ref_type == 'tag' || inputs.promote_latest == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Update Docker Hub description
|
||||
run: |
|
||||
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 }}"}' \
|
||||
| jq -r .access_token)
|
||||
if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
|
||||
echo "::error::Failed to authenticate with Docker Hub API"
|
||||
exit 1
|
||||
fi
|
||||
HTTP_CODE=$(jq -n \
|
||||
--rawfile full DOCKER_HUB.md \
|
||||
--arg short "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 \
|
||||
"https://hub.docker.com/v2/repositories/${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox/" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @-)
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "Response body:"
|
||||
cat /tmp/hub-response.txt
|
||||
echo "::error::Docker Hub description update failed with HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,115 +0,0 @@
|
||||
name: Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-base:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push (base)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest
|
||||
|
||||
build-omos:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push (omos)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
build-args: |
|
||||
INSTALL_OMOS=true
|
||||
tags: |
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:${{ steps.version.outputs.version }}-omos
|
||||
${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox:latest-omos
|
||||
|
||||
update-description:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-base, build-omos]
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update Docker Hub description
|
||||
run: |
|
||||
TOKEN=$(curl -s -X POST https://hub.docker.com/v2/users/login/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"${{ vars.DOCKERHUB_USERNAME }}","password":"${{ secrets.DOCKERHUB_TOKEN }}"}' \
|
||||
| jq -r .token)
|
||||
if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
|
||||
echo "::error::Failed to authenticate with Docker Hub API"
|
||||
exit 1
|
||||
fi
|
||||
HTTP_CODE=$(jq -n \
|
||||
--arg full "$(cat DOCKER_HUB.md)" \
|
||||
--arg short "Portable AI dev environment for opencode. Debian-based with git, Node.js, AWS CLI, and SSH support. Available in base and omos (multi-agent) variants." \
|
||||
'{"full_description": $full, "description": $short}' | \
|
||||
curl -s -o /dev/null -w "%{http_code}" -X PATCH \
|
||||
"https://hub.docker.com/v2/repositories/${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox/" \
|
||||
-H "Authorization: JWT $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @-)
|
||||
echo "Docker Hub API returned: $HTTP_CODE"
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "::error::Docker Hub description update failed with HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,381 @@
|
||||
name: Validate
|
||||
|
||||
# Lightweight validation on pushes to main. Builds single-arch (amd64),
|
||||
# runs the smoke test, and checks image size — without pushing anything
|
||||
# to Docker Hub. Tag pushes are handled by docker-publish-split.yml which
|
||||
# does the full multi-arch split-base build-and-push.
|
||||
#
|
||||
# Trade-off: variant builds here use the published `base-latest` image
|
||||
# from Docker Hub as their parent, NOT a locally-built base. This is
|
||||
# because `docker/build-push-action@v7` runs each invocation in its own
|
||||
# buildx container context, so an image loaded into the host docker
|
||||
# daemon by step N is not visible to step N+1's buildx invocation.
|
||||
# Building base + variant in the same job would require either pushing
|
||||
# the base to a registry or sharing a buildx instance across steps — both
|
||||
# significantly more complex than just using the published base.
|
||||
#
|
||||
# Consequence: PRs/pushes that change Dockerfile.base, rootfs/, or
|
||||
# entrypoint*.sh are NOT exercised by this workflow. The release path
|
||||
# (docker-publish-split.yml on tag push) does build the new base, so
|
||||
# release tags are the gate that fully validates base-image changes.
|
||||
# The base-change-warning job below surfaces a runtime warning when this
|
||||
# blind-spot applies.
|
||||
#
|
||||
# Because of this, the fork/recall *registration* smoke checks (which depend on
|
||||
# the base entrypoint running `pi install /opt/<pkg>`) are warn-only here:
|
||||
# smoke-test.sh leaves STRICT_REGISTRATION unset on this path, so a base-latest
|
||||
# that lags the entrypoint in the current commit can't red the run with a false
|
||||
# negative. The release smoke jobs build the base fresh and set
|
||||
# STRICT_REGISTRATION=1 to enforce those checks. The build-time /opt +
|
||||
# node_modules checks stay hard in both paths.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- 'CHANGELOG.md'
|
||||
- 'README.md'
|
||||
- 'DOCKER_HUB.md'
|
||||
- 'deploy/**'
|
||||
- '.gitleaks.toml'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
docs-check:
|
||||
# Fails if DOCKER_HUB.md is out of sync with what generate-dockerhub-md.py
|
||||
# would produce from README.md. Keeps the two docs from drifting.
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check DOCKER_HUB.md is in sync with README.md
|
||||
run: |
|
||||
python3 scripts/generate-dockerhub-md.py --check
|
||||
|
||||
base-change-warning:
|
||||
# Surfaces a warning when this commit changes base-image inputs
|
||||
# (Dockerfile.base, rootfs/, entrypoint*.sh). validate.yml uses
|
||||
# Hub's base-latest as the parent for variant builds, so changes to
|
||||
# those files are NOT exercised here — only release tags rebuild the
|
||||
# base via docker-publish-split.yml.
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect base-input changes
|
||||
run: |
|
||||
set -e
|
||||
if ! git diff --name-only HEAD~1 HEAD 2>/dev/null \
|
||||
| grep -qE '^(Dockerfile\.base|rootfs/|entrypoint.*\.sh)$'; then
|
||||
echo "No base-image inputs changed in this commit — validate.yml fully exercises the published base-latest."
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::This commit changes base-image inputs (Dockerfile.base, rootfs/, or entrypoint*.sh). validate.yml uses Hub's base-latest as the parent for variant builds, so the new base is NOT exercised by this workflow. Cut a release tag, or run a workflow_dispatch of docker-publish-split.yml against a test tag (e.g. v0.0.0-base-test, promote_latest=false) for end-to-end validation of the new base."
|
||||
echo "Changed base-input files:"
|
||||
git diff --name-only HEAD~1 HEAD | grep -E '^(Dockerfile\.base|rootfs/|entrypoint.*\.sh)$'
|
||||
|
||||
validate-base:
|
||||
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
|
||||
|
||||
# The runner's overlay disk starts ~70% full. `load: true` peak disk
|
||||
# is tarball + unpacked image + buildx cache, which tips it over
|
||||
# once the image crosses ~3 GB. Strip catthehacker-resident
|
||||
# toolchains we never use and any stale docker state up front.
|
||||
- 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 base 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
|
||||
tags: opencode-devbox:ci-base
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
bash scripts/smoke-test.sh opencode-devbox:ci-base --variant base
|
||||
|
||||
validate-omos:
|
||||
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 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
|
||||
tags: opencode-devbox:ci-omos
|
||||
|
||||
- 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
|
||||
|
||||
validate-pi-only:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: |
|
||||
echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache \
|
||||
/opt/microsoft \
|
||||
/opt/az \
|
||||
/opt/ghc \
|
||||
/usr/local/.ghcup \
|
||||
/usr/share/dotnet \
|
||||
/usr/share/swift \
|
||||
/usr/local/lib/android \
|
||||
/usr/local/share/powershell \
|
||||
/usr/local/share/chromium \
|
||||
/usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker system df || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Build pi-only image (amd64, load to local daemon)
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
build-args: |
|
||||
BASE_IMAGE=joakimp/opencode-devbox:base-latest
|
||||
INSTALL_OPENCODE=false
|
||||
INSTALL_PI=true
|
||||
tags: opencode-devbox:ci-pi-only
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
bash scripts/smoke-test.sh opencode-devbox:ci-pi-only --variant pi-only
|
||||
+14
@@ -3,3 +3,17 @@
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Docker buildx state (created by 'docker compose build')
|
||||
.docker/
|
||||
|
||||
# Personal cloud-init overrides (not shared)
|
||||
deploy/my-cloud-init.yml
|
||||
|
||||
# MemPalace per-project files (issue #185)
|
||||
mempalace.yaml
|
||||
entities.json
|
||||
|
||||
# Python bytecode (from running scripts/ and rootfs/.../*.py locally)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project overview
|
||||
|
||||
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 is deprecated here (since v1.17.2), removed in v2.0.0.** 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 slated for removal. pi now ships from its own repo
|
||||
> (`joakimp/pi-devbox`). Do not add new pi functionality here. Full
|
||||
> removal plan + the `NPM_CONFIG_PREFIX` relocation: see
|
||||
> `docs/CLEANUP-v2.0.0.md`. The pi-related descriptions below remain
|
||||
> accurate only until the v2.0.0 removal lands.
|
||||
|
||||
## 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. When `INSTALL_PI=true` it also clones `pi-fork` + `pi-observational-memory` (from `github.com/elpapi42`, refs `PI_FORK_REF`/`PI_OBSMEM_REF`) to `/opt` and runs `npm install` there at build time so the `fork`/`recall` extensions can load (a local-path `pi install` does not npm-install). The `pi-only` variant sets `INSTALL_OPENCODE=false`, `INSTALL_PI=true` — pi without opencode, the single source of truth for the separate `pi-devbox` image. It is built and smoke-tested here, but **published into the `joakimp/pi-devbox` repo** as the internal building-block tag `base-pi-only[-vX.Y.Z]` (NOT under `opencode-devbox`), so an opencode-devbox tag never ships without opencode.
|
||||
- `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu. Volume ownership loop covers `~/.pi/` when `INSTALL_PI=true`.
|
||||
- `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), LAN-access setup (delegated to `setup-lan-access.sh`), pi-toolkit + pi-extensions deploy (when pi installed), pi settings.json bootstrap, mempalace pi-bridge symlink, runtime `pi install /opt/{pi-fork,pi-observational-memory}` registration (idempotent), skillset auto-deploy from mounted skillset repo, OMOS setup.
|
||||
- `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh` — host-OS-agnostic LAN reachability helper. Detects VM-backed hosts (macOS OrbStack / Docker Desktop, via `host.docker.internal` resolution) and generates a writable `~/.ssh-local/config` using the host as an SSH jump; no-op on native Linux. Controlled by `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` / `DEVBOX_HOST_ALIAS` / `DEVBOX_LAN_AUTOJUMP_PRIVATE`. Ships the mechanism only (generic `host` jump alias); user targets stay host-side — named-peer `ProxyJump host` overrides go in a bind-mounted `~/.config/devbox-shell/ssh-lan.conf` (Included before `~/.ssh/config`), never baked into the image. **Scoping invariant:** every `Include` in the generated config MUST be preceded by a bare `Host *` reset — an `Include` is scoped to the enclosing `Host`/`Match` block, so without the reset the included config only applies when targeting `host`/`mac` and named peers fall back to SSH defaults. The top `Host *` block also overrides `UserKnownHostsFile` and `ControlPath` into the writable `~/.ssh-local` sidecar (first-value-wins), because the bind-mounted `~/.ssh` is read-only — otherwise multiplexed hosts (`ControlPath ~/.ssh/cm/...`) fail to create their master socket. Non-fatal. Counted in the base hash, so editing it advances `base-latest`.
|
||||
- `rootfs/usr/local/lib/opencode-devbox/generate-config.py` — generates `~/.config/opencode/opencode.jsonc` from env vars. Never overwrites an existing config (checks both `.json` and `.jsonc`). Auto-registers MCP servers for detected tools (mempalace via `mempalace-mcp`, gitea-mcp, context7 remote endpoint).
|
||||
- `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).
|
||||
- `DOCKER_HUB.md` — **auto-generated** from `HUB_TEMPLATE` in `scripts/generate-dockerhub-md.py`. Do not edit directly. Pushed to Docker Hub description via CI API call. Must stay under 25 kB. Short description field must be ≤100 bytes.
|
||||
- `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 5 parallel smoke tests, then 5 parallel multi-arch variant builds, promotes `base-latest` alias, updates Docker Hub description.
|
||||
|
||||
## Versioning scheme
|
||||
|
||||
Tags follow `v{opencode_version}[letter]` — e.g. `v1.14.20` for the first build on a new opencode release, and `v1.14.20b`, `v1.14.20c`, … for subsequent rebuilds on the same opencode version.
|
||||
|
||||
- The number tracks the opencode npm version (see `OPENCODE_VERSION` ARG in `Dockerfile.variant`).
|
||||
- **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.
|
||||
|
||||
Cautionary example: 2026-05-28 morning, `v1.15.12` was cut while opencode-ai was still at `1.15.11`. The commit message itself acknowledged "OPENCODE_VERSION stays at 1.15.11" but tagged `v1.15.12` anyway. Re-cut as `v1.15.11c` the same afternoon (see CHANGELOG). The `v1.15.12` git tag and Hub images stayed as historical artifacts; the slip cost a CI cycle and a CHANGELOG-rewrite. **Run the npm view check at the top of every release-day cut.**
|
||||
|
||||
CI produces eight Docker Hub tags **under `opencode-devbox`** per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos`, `vX.Y.Z[n]-with-pi`, `latest-with-pi`, `vX.Y.Z[n]-omos-with-pi`, `latest-omos-with-pi` — one tag pair (versioned + floating alias) per opencode-bearing variant (four variants). A fifth build, `pi-only`, is built+smoked here but pushed into the **`joakimp/pi-devbox`** repo as `base-pi-only-vX.Y.Z` (+ `base-pi-only` on tag builds), where it becomes the base for that image.
|
||||
|
||||
When bumping the opencode version, bump `OPENCODE_VERSION` in `Dockerfile.variant` and update the comment in `.env.example` if it names a specific model/version for context.
|
||||
|
||||
## 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. |
|
||||
| `@earendil-works/pi-coding-agent` (npm) | The `CHANGELOG.md` shipped inside the npm tarball: `npm pack @earendil-works/pi-coding-agent@<version>` then extract `package/CHANGELOG.md`. | Rich changelog with New Features / Added / Changed / Fixed sections per version. |
|
||||
| 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)"))'
|
||||
|
||||
# pi changelog
|
||||
cd /tmp && npm pack @earendil-works/pi-coding-agent@0.75.5 && tar -xzf earendil-works-pi-coding-agent-0.75.5.tgz package/CHANGELOG.md && head -40 package/CHANGELOG.md
|
||||
```
|
||||
|
||||
## 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.
|
||||
- **Documentation coupling on release** — four docs co-vary and drift in lockstep when not updated together:
|
||||
- `README.md` is the source of truth for user-facing build/run/config detail.
|
||||
- `DOCKER_HUB.md` is auto-generated from `HUB_TEMPLATE` in `scripts/generate-dockerhub-md.py`. CI's `--check` run fails if it's stale. Hub-facing copy is intentionally slim (~5.5 kB, ~78% headroom against the 25 kB Hub limit) — update the template here when image variants, quick-start flow, or the elevator pitch change. README.md no longer feeds into Hub, so README edits do NOT require regenerating DOCKER_HUB.md.
|
||||
- `CHANGELOG.md` records every release. When cutting a tag, **promote `## Unreleased` to `## vX.Y.Z[n] — YYYY-MM-DD` BEFORE pushing the tag** so the tag points at a CHANGELOG that names itself. Keep entries reverse-chronological (newest at top, after the `Unreleased` block). Doc-only updates that happen post-tag (Hub description live-patches, README clarifications) get a fresh `## Unreleased` block with a note that they don't trigger a new image build.
|
||||
- `AGENTS.md` (this file) carries domain facts that change on structural releases — tag-count statements, CI job lists, install contracts. After any change to `.gitea/workflows/*.yml` or the variant matrix, grep this file for stale numbers (`grep -nE "four|eight|all [0-9]"`).
|
||||
- `.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.
|
||||
|
||||
**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`.
|
||||
- **`PI_VERSION` and `OMOS_VERSION` MUST be passed by CI as concrete versions**, not left at the `latest` default. The npm install steps in `Dockerfile.variant` (`npm install -g @earendil-works/pi-coding-agent` / `oh-my-opencode-slim@${OMOS_VERSION}`) produce identical layer-hashes when the ARG values are byte-identical across builds; combined with the registry buildcache (`base-buildcache`) the layer gets reused even when `latest` would have resolved to a newer upstream. This is the same class of bug that bit pi-devbox v0.74.0 → v0.75.5 (silent same-bytes-across-releases regression discovered 2026-05-23, fixed in pi-devbox v0.75.5b). It is currently *masked* in opencode-devbox by `OPENCODE_VERSION` being a hard-coded ARG that bumps every release — that bump invalidates the parent-chain cache key for the downstream pi/omos layers — but the masking would fail the moment a `vN.N.Nb` opencode-version-unchanged release ships that only bumps pi or omos. Preventative fix: `.gitea/workflows/docker-publish-split.yml` has a `resolve-versions` job that runs `npm view @earendil-works/pi-coding-agent version` and `npm view oh-my-opencode-slim version`, exposing concrete values as outputs that every variant smoke + build job consumes via build-args. Smoke tests assert via `EXPECTED_PI_VERSION` / `EXPECTED_OMOS_VERSION` env vars — would catch the regression on the next release rather than four releases later. **If you change the variant build-args list, the resolve-versions job, or the smoke EXPECTED_*_VERSION wiring, audit all affected jobs in lockstep.**
|
||||
- **Registry buildkit cache-export is currently disabled** — do NOT re-add `cache-from`/`cache-to` to the `build-base` step in `.gitea/workflows/docker-publish-split.yml` without first verifying that buildkit's `mode=max` cache-export to `registry-1.docker.io` no longer returns HTTP 400 from the Hub CDN edge. The regression surfaced ~2026-05-23 and broke five consecutive opencode-devbox publish attempts (runs #332/333/334/336 + a rerun); root-caused on 2026-05-28 by a manual host-side publish that reproduced the same 400 only on `--cache-to` while image push worked fine. Failure shape is stable (`Offset:0` in the `_state` token, HTML response body = CDN-tier rejection, not registry backend), repo-specific (we're the only repo writing `:base-buildcache` mode=max), and explains why pinning `setup-buildx-action@v4.0.0` didn't help (action pin doesn't change the bundled buildkit version on the catthehacker runner image). Trade-off: dockerfile.base changes pay a full ~3 min rebuild instead of pulling cached layers; unchanged bases short-circuit at the Hub-probe step in `base-decide` and never re-build anyway. Variants don't use registry cache so they're unaffected. Re-enable condition: upstream moby/buildkit fix lands AND a low-risk test run succeeds without 400s. See CHANGELOG v1.15.12 `Unreleased` block for the full diagnostic chain. Manual escape-hatch publish procedure: `docs/manual-host-publish.md`.
|
||||
- **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).
|
||||
- **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]`).
|
||||
- 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 ten `load: true` jobs (`validate-base`, `validate-omos`, `validate-with-pi`, `validate-omos-with-pi`, `validate-pi-only`, `smoke-base`, `smoke-omos`, `smoke-with-pi`, `smoke-omos-with-pi`, `smoke-pi-only`) include a `Reclaim runner disk` step that strips catthehacker-resident toolchains and prunes stale docker state before `setup-buildx-action`. Build jobs use a lighter version (push-by-digest doesn't need `docker system prune`). Don't remove these steps without testing on a fresh runner.
|
||||
- **`docker/build-push-action@v7` with `platforms: linux/amd64,linux/arm64` handles multi-arch push natively in a single job** — produces a proper manifest list, no matrix or merge step needed. An earlier revision split into per-arch matrix jobs with digest artifacts, but that pattern requires `actions/{upload,download}-artifact@v4+` which Gitea Actions doesn't support (see below).
|
||||
- **`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.
|
||||
- **`BUILDKIT_PROGRESS=plain`** is set at workflow level on `docker-publish-split.yml` so arm64-under-QEMU builds log each layer line-by-line. The default collapsed progress UI hides which step is stalled, which made diagnosing earlier hangs expensive.
|
||||
- **`STRICT_REGISTRATION` gates the fork/recall *registration* smoke assertions.** `smoke-test.sh`'s two pi-extension registration checks (that `pi-fork`/`pi-observational-memory` registered in `~/.pi/agent/settings.json`) depend on the *base* entrypoint running `pi install /opt/<pkg>`. `validate.yml` builds variants from the **published** `base-latest`, which lags the in-repo entrypoint until a release rebuilds the base — so those checks would false-negative there. They are therefore warn-only unless `STRICT_REGISTRATION=1`: `validate.yml` leaves it unset (warn), and `docker-publish-split.yml` (which builds the base fresh in the same run) sets `STRICT_REGISTRATION: "1"` on the three pi-bearing smoke jobs to enforce them. Build-time `/opt` + `node_modules` checks stay hard in both paths. If you touch the registration checks or the base-freshness model, keep this flag wiring in lockstep across both workflows.
|
||||
|
||||
## Testing changes
|
||||
|
||||
The smoke test (`scripts/smoke-test.sh`) is the canonical check and runs automatically in CI. To run locally:
|
||||
|
||||
```bash
|
||||
# Base image
|
||||
docker compose build
|
||||
bash scripts/smoke-test.sh opencode-devbox --variant base
|
||||
|
||||
# OMOS image
|
||||
docker build --build-arg INSTALL_OMOS=true -t opencode-devbox:omos .
|
||||
bash scripts/smoke-test.sh opencode-devbox:omos --variant omos
|
||||
```
|
||||
|
||||
For manual/exploratory testing:
|
||||
1. `docker compose run --rm devbox bash`
|
||||
2. Check specific tools inside: `nvim --version`, `bat --version`, `uv --version`, `mempalace --help`, etc.
|
||||
3. For entrypoint changes: test with a non-1000 UID workspace to verify UID adjustment, volume ownership fixes, and the `.devbox-owner` sentinel behavior.
|
||||
4. For `generate-config.py` changes: run standalone with `HOME=/tmp/fake OPENCODE_PROVIDER=anthropic python3 rootfs/usr/local/lib/opencode-devbox/generate-config.py`.
|
||||
|
||||
## Commit style
|
||||
|
||||
Imperative mood, first line summarizes the change. Multi-line body explains "why" when non-obvious. Examples from history:
|
||||
- `Fix ownership of named volume mount points in entrypoint`
|
||||
- `Add uv package manager to base image for on-demand Python support`
|
||||
- `Upgrade base image from Debian bookworm to trixie (current stable)`
|
||||
+1006
File diff suppressed because it is too large
Load Diff
+63
-320
@@ -1,20 +1,43 @@
|
||||
# opencode-devbox — Docker Hub
|
||||
# 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.
|
||||
|
||||
## Image Variants
|
||||
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.
|
||||
|
||||
Two image variants are published for each release:
|
||||
## Image Variants
|
||||
|
||||
| Tag | Description |
|
||||
|---|---|
|
||||
| `latest` / `vX.Y.Z` | Base image — opencode, 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, Bun, and tmux |
|
||||
| `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` | **DEPRECATED (removed in v2.0.0)** — Base + [pi](https://github.com/earendil-works/pi). Use [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox) instead |
|
||||
| `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | **DEPRECATED (removed in v2.0.0)** — OMOS + pi together |
|
||||
|
||||
Both variants support `linux/amd64` and `linux/arm64`.
|
||||
All variants support `linux/amd64` and `linux/arm64`.
|
||||
|
||||
> **Looking for pi?** The `*-with-pi` / `pi-only` builds and the `base-pi-only`
|
||||
> tag are **deprecated since v1.17.2 and will be removed in v2.0.0**. pi now
|
||||
> ships as its own self-contained image:
|
||||
> [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox). Pull that
|
||||
> directly instead of any pi-bearing opencode-devbox tag.
|
||||
|
||||
## 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:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/opencode-devbox && cd ~/opencode-devbox
|
||||
curl -O https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/docker-compose.yml
|
||||
curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.example -o .env
|
||||
# Edit .env — set OPENCODE_PROVIDER, the matching API key,
|
||||
# WORKSPACE_PATH, GIT_USER_NAME, GIT_USER_EMAIL.
|
||||
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.
|
||||
|
||||
**One-shot run, no persistence:**
|
||||
|
||||
```bash
|
||||
docker run -it --rm \
|
||||
-e ANTHROPIC_API_KEY=your-key \
|
||||
@@ -26,337 +49,57 @@ docker run -it --rm \
|
||||
joakimp/opencode-devbox:latest
|
||||
```
|
||||
|
||||
This drops you straight into opencode with your project mounted at `/workspace`.
|
||||
Full setup guide — authentication for each provider (Anthropic, OpenAI, Bedrock SSO + static), persistence model, build args, troubleshooting: <https://gitea.jordbo.se/joakimp/opencode-devbox#readme>
|
||||
|
||||
## Interactive Shell
|
||||
## What's Inside
|
||||
|
||||
To get a shell first (useful for AWS SSO login or running other commands):
|
||||
- **[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.
|
||||
- **[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.
|
||||
|
||||
```bash
|
||||
docker run -it --rm \
|
||||
-e ANTHROPIC_API_KEY=your-key \
|
||||
-e OPENCODE_PROVIDER=anthropic \
|
||||
-v ~/projects:/workspace \
|
||||
-v ~/.ssh:/home/developer/.ssh:ro \
|
||||
joakimp/opencode-devbox:latest bash
|
||||
```
|
||||
## Authentication
|
||||
|
||||
Then run `opencode` when ready.
|
||||
The container reads provider credentials from environment variables and host-mounted config:
|
||||
|
||||
## Running Multiple Shells
|
||||
- **Anthropic / OpenAI / Groq / others:** set `OPENCODE_PROVIDER` and the corresponding `*_API_KEY` via `-e` or `.env`.
|
||||
- **AWS Bedrock (SSO):** mount `~/.aws` from the host, `OPENCODE_PROVIDER=amazon-bedrock`, then `aws sso login` inside the container. Tokens persist across container restarts via the host bind-mount.
|
||||
- **OAuth / device-code providers:** auth state lives in opencode's config, which is persisted via the `devbox-opencode-config` named volume.
|
||||
|
||||
Once opencode is running it takes over the terminal. To have a separate shell for `aws`, `git`, or other commands, run the container in the background and attach multiple times:
|
||||
Full Bedrock walkthrough (IAM roles, permissions, multi-account setups): see the [AWS Bedrock Authentication](
|
||||
https://gitea.jordbo.se/joakimp/opencode-devbox#aws-bedrock-authentication
|
||||
) section on gitea.
|
||||
|
||||
```bash
|
||||
# Start in background
|
||||
docker run -d --name devbox \
|
||||
-e ANTHROPIC_API_KEY=your-key \
|
||||
-e OPENCODE_PROVIDER=anthropic \
|
||||
-v ~/projects:/workspace \
|
||||
-v ~/.ssh:/home/developer/.ssh:ro \
|
||||
joakimp/opencode-devbox:latest sleep infinity
|
||||
## Persistence
|
||||
|
||||
# Shell 1: run opencode
|
||||
docker exec -it -u developer devbox opencode
|
||||
|
||||
# Shell 2 (separate terminal): aws, git, etc.
|
||||
docker exec -it -u developer devbox bash
|
||||
|
||||
# When done
|
||||
docker rm -f devbox
|
||||
```
|
||||
|
||||
> **Note:** Always use `-u developer` with `docker exec` — the container starts as root for UID adjustment, then drops to `developer`. Without `-u developer`, exec runs as root.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All configuration is done via environment variables, typically stored in a `.env` file.
|
||||
|
||||
### Provider Configuration
|
||||
|
||||
| Variable | Description | Default |
|
||||
| Volume | Mount | Survives |
|
||||
|---|---|---|
|
||||
| `OPENCODE_PROVIDER` | LLM provider (`anthropic`, `openai`, `amazon-bedrock`) | `anthropic` |
|
||||
| `OPENCODE_MODEL` | Model override | Provider default |
|
||||
| `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-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) |
|
||||
|
||||
### API Keys
|
||||
Workspace bind-mount (`/workspace`) is your project directory on the host, so source code is never inside the container.
|
||||
|
||||
Set the key matching your provider:
|
||||
Full persistence reference, including multi-user (`SIGNUM`) isolation and host bind-mount alternatives: see the [README on gitea](https://gitea.jordbo.se/joakimp/opencode-devbox#persistence).
|
||||
|
||||
| Variable | Provider |
|
||||
|---|---|
|
||||
| `ANTHROPIC_API_KEY` | Anthropic |
|
||||
| `OPENAI_API_KEY` | OpenAI |
|
||||
| `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` | AWS Bedrock (static creds) |
|
||||
## Where to Go Next
|
||||
|
||||
### AWS Bedrock
|
||||
- **Full README** with build args, every feature in detail, troubleshooting: <https://gitea.jordbo.se/joakimp/opencode-devbox>
|
||||
- **CHANGELOG** for version history: <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/CHANGELOG.md>
|
||||
- **Issues / source / docker-compose templates:** <https://gitea.jordbo.se/joakimp/opencode-devbox>
|
||||
- **Agent-facing internals** (for future maintainers / coding agents working in the repo): <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/AGENTS.md>
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `AWS_REGION` | AWS region | `us-east-1` |
|
||||
| `AWS_PROFILE` | AWS SSO profile name | `default` |
|
||||
## Sibling images
|
||||
|
||||
### Git
|
||||
- **[`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>
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `GIT_USER_NAME` | Git commit author name |
|
||||
| `GIT_USER_EMAIL` | Git commit author email |
|
||||
## License
|
||||
|
||||
### User ID Mapping
|
||||
MIT. See <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/LICENSE>.
|
||||
|
||||
The container runs as user `developer` (UID 1000 by default). If your host user has a different UID, file permission mismatches can occur on mounted volumes.
|
||||
---
|
||||
|
||||
The entrypoint automatically detects the owner of `/workspace` and adjusts the container user's UID/GID to match. You can also set it explicitly:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `USER_UID` | Container user UID | Auto-detect from `/workspace` owner |
|
||||
| `USER_GID` | Container user GID | Auto-detect from `/workspace` owner |
|
||||
|
||||
## Initial Setup
|
||||
|
||||
### 1. Create a project directory
|
||||
|
||||
```bash
|
||||
mkdir -p ~/projects
|
||||
```
|
||||
|
||||
### 2. Create a `.env` file
|
||||
|
||||
Create a `.env` file with your configuration. Examples for each provider:
|
||||
|
||||
**Anthropic:**
|
||||
```bash
|
||||
OPENCODE_PROVIDER=anthropic
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
GIT_USER_NAME=Your Name
|
||||
GIT_USER_EMAIL=you@example.com
|
||||
```
|
||||
|
||||
**OpenAI:**
|
||||
```bash
|
||||
OPENCODE_PROVIDER=openai
|
||||
OPENAI_API_KEY=sk-...
|
||||
GIT_USER_NAME=Your Name
|
||||
GIT_USER_EMAIL=you@example.com
|
||||
```
|
||||
|
||||
**AWS Bedrock (SSO):**
|
||||
```bash
|
||||
OPENCODE_PROVIDER=amazon-bedrock
|
||||
OPENCODE_MODEL=amazon-bedrock/anthropic.claude-sonnet-4-5-v1
|
||||
AWS_REGION=eu-west-1
|
||||
AWS_PROFILE=your-profile-name
|
||||
GIT_USER_NAME=Your Name
|
||||
GIT_USER_EMAIL=you@example.com
|
||||
```
|
||||
|
||||
### 3. AWS SSO setup (Bedrock users only)
|
||||
|
||||
AWS SSO requires a `~/.aws/config` file on the host with your SSO session configuration. If you already have this on another machine, copy it:
|
||||
|
||||
```bash
|
||||
scp -r user@other-machine:~/.aws ~/.aws
|
||||
```
|
||||
|
||||
Or configure from scratch:
|
||||
|
||||
```bash
|
||||
aws configure sso
|
||||
```
|
||||
|
||||
You'll be prompted for:
|
||||
- SSO session name
|
||||
- SSO start URL
|
||||
- SSO region
|
||||
- Registration scopes (typically `sso:account:access`)
|
||||
|
||||
The `~/.aws` directory must be mounted into the container (see docker-compose example below).
|
||||
|
||||
## Data Storage and Persistence
|
||||
|
||||
Understanding what survives container restarts and what doesn't:
|
||||
|
||||
| Path in container | Source | Survives restart? | Contains |
|
||||
|---|---|---|---|
|
||||
| `/workspace` | Host bind mount | ✅ Yes — lives on host | Your project files |
|
||||
| `/home/developer/.ssh` | Host bind mount (ro) | ✅ Yes — lives on host | SSH keys |
|
||||
| `/home/developer/.aws` | Host bind mount | ✅ Yes — lives on host | AWS credentials/SSO cache |
|
||||
| `/home/developer/.local/share/opencode` | Named volume (if configured) | ✅ Yes — Docker volume | Session history, memory, auth tokens |
|
||||
| `/home/developer/.config/opencode/opencode.json` | Generated by entrypoint | ❌ No — regenerated each start | Provider config, MCP server definitions |
|
||||
| `/home/developer/.config/opencode/oh-my-opencode-slim.json` | Generated by entrypoint (OMOS variant) | ❌ No — regenerated each start | Agent/model mappings |
|
||||
|
||||
### Key points
|
||||
|
||||
- **Project files** (`/workspace`) are always safe — they're your host filesystem.
|
||||
- **opencode config** is auto-generated from `OPENCODE_PROVIDER` env var on each start. It only sets provider and model — no MCP servers. To persist MCP server config, mount your own config file (see Custom opencode Config below).
|
||||
- **opencode data** (session history, memory) is lost with `--rm` unless you add a named volume.
|
||||
- **AWS SSO tokens** persist across restarts when `~/.aws` is mounted (recommended for Bedrock users).
|
||||
|
||||
## Custom opencode Config
|
||||
|
||||
For full control (MCP servers, custom models, keybindings), mount your own config:
|
||||
|
||||
```bash
|
||||
docker run -it --rm \
|
||||
-v ./my-opencode.json:/home/developer/.config/opencode/opencode.json:ro \
|
||||
... \
|
||||
joakimp/opencode-devbox:latest
|
||||
```
|
||||
|
||||
When a config file is mounted, the `OPENCODE_PROVIDER` auto-config is skipped.
|
||||
|
||||
## Using docker-compose
|
||||
|
||||
Create a directory with a `docker-compose.yml` and a `.env` file:
|
||||
|
||||
```bash
|
||||
mkdir opencode-devbox && cd opencode-devbox
|
||||
```
|
||||
|
||||
`.env` — your settings (never commit this):
|
||||
|
||||
```bash
|
||||
OPENCODE_PROVIDER=amazon-bedrock
|
||||
OPENCODE_MODEL=amazon-bedrock/anthropic.claude-sonnet-4-5-v1
|
||||
AWS_REGION=eu-west-1
|
||||
AWS_PROFILE=your-profile-name
|
||||
GIT_USER_NAME=Your Name
|
||||
GIT_USER_EMAIL=you@example.com
|
||||
```
|
||||
|
||||
`docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
devbox:
|
||||
image: joakimp/opencode-devbox:latest
|
||||
# For multi-agent orchestration, use the omos variant instead:
|
||||
# image: joakimp/opencode-devbox:latest-omos
|
||||
stdin_open: true
|
||||
tty: true
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TERM=xterm-256color
|
||||
volumes:
|
||||
- ~/projects:/workspace
|
||||
- ~/.ssh:/home/developer/.ssh:ro
|
||||
- devbox-data:/home/developer/.local/share/opencode
|
||||
# Mount AWS config for Bedrock SSO (required for amazon-bedrock provider)
|
||||
# - ~/.aws:/home/developer/.aws
|
||||
# Optional: mount your own opencode config (MCP servers, custom models, etc.)
|
||||
# - ./opencode.json:/home/developer/.config/opencode/opencode.json:ro
|
||||
# Optional: mount opencode skills from host
|
||||
# - ~/.config/opencode/skills:/home/developer/.config/opencode/skills:ro
|
||||
# - ~/.agents/skills:/home/developer/.agents/skills:ro
|
||||
|
||||
volumes:
|
||||
devbox-data:
|
||||
```
|
||||
|
||||
Docker Compose loads `.env` automatically from the same directory. All variables from `.env` are passed to the container via `env_file`. Do **not** hardcode provider settings in the `environment:` section — use `.env` instead.
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
# Start in background
|
||||
docker compose up -d
|
||||
|
||||
# Open a shell (always use -u developer with exec)
|
||||
docker compose exec -u developer devbox bash
|
||||
|
||||
# For Bedrock: authenticate, then start opencode
|
||||
aws sso login --sso-session <your-session> --use-device-code
|
||||
opencode
|
||||
|
||||
# Or run opencode directly (if no SSO needed)
|
||||
docker compose exec -u developer devbox opencode
|
||||
|
||||
# One-shot mode (creates and removes container)
|
||||
docker compose run --rm devbox # direct to opencode
|
||||
docker compose run --rm devbox bash # interactive shell
|
||||
```
|
||||
|
||||
## What's Included
|
||||
|
||||
### Base image (`latest`)
|
||||
|
||||
- **Debian bookworm-slim** — glibc, full terminal/PTY support
|
||||
- **opencode** — AI coding assistant
|
||||
- **Node.js 22** — for npx-based MCP servers
|
||||
- **AWS CLI v2** — SSO and Bedrock authentication
|
||||
- **Dev tools** — git, git-lfs, ssh, ripgrep, fd, fzf, jq, curl, wget, vim, tree
|
||||
- **Non-root user** — runs as `developer` with UID auto-matched to workspace owner (sudo available)
|
||||
|
||||
### OMOS image (`latest-omos`)
|
||||
|
||||
Everything in the base image, plus:
|
||||
|
||||
- **[oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim)** — multi-agent orchestration plugin
|
||||
- **Bun** — JavaScript runtime required by oh-my-opencode-slim
|
||||
- **tmux** — terminal multiplexer (used by OMOS for agent pane integration, but also useful on its own for managing multiple terminal sessions)
|
||||
- **6 specialized agents** — Orchestrator, Explorer, Oracle, Librarian, Designer, Fixer
|
||||
|
||||
### Additional runtimes (build from source)
|
||||
|
||||
When [building from source](https://gitea.jordbo.se/joakimp/opencode-devbox), additional runtimes are available via build args:
|
||||
|
||||
- **Python 3** (`INSTALL_PYTHON=true`) — Python 3 + pip + venv
|
||||
- **Go** (`INSTALL_GO=true`) — Go toolchain
|
||||
|
||||
## oh-my-opencode-slim (OMOS variant)
|
||||
|
||||
The `-omos` image variant includes [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim), which adds a multi-agent layer on top of opencode. An Orchestrator delegates tasks to specialized agents, each configurable with different models and providers.
|
||||
|
||||
### Quick start with OMOS
|
||||
|
||||
```bash
|
||||
docker run -it --rm \
|
||||
-e OPENAI_API_KEY=your-key \
|
||||
-e OPENCODE_PROVIDER=openai \
|
||||
-e ENABLE_OMOS=true \
|
||||
-v ~/projects:/workspace \
|
||||
-v ~/.ssh:/home/developer/.ssh:ro \
|
||||
joakimp/opencode-devbox:latest-omos
|
||||
```
|
||||
|
||||
On first start, the entrypoint configures oh-my-opencode-slim automatically. The default preset uses OpenAI models.
|
||||
|
||||
### OMOS environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `ENABLE_OMOS` | `false` | Activate oh-my-opencode-slim on container start |
|
||||
| `OMOS_TMUX` | `false` | Enable tmux pane integration (watch agents in split panes) |
|
||||
| `OMOS_SKILLS` | `true` | Install recommended skills (simplify, agent-browser, cartography) |
|
||||
| `OMOS_RESET` | `false` | Force regenerate config on next start (backs up existing config) |
|
||||
|
||||
### Custom OMOS configuration
|
||||
|
||||
Mount your own config to control which models power each agent:
|
||||
|
||||
```bash
|
||||
docker run -it --rm \
|
||||
-e ENABLE_OMOS=true \
|
||||
-v ./oh-my-opencode-slim.json:/home/developer/.config/opencode/oh-my-opencode-slim.json:ro \
|
||||
... \
|
||||
joakimp/opencode-devbox:latest-omos
|
||||
```
|
||||
|
||||
See the [oh-my-opencode-slim configuration docs](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/docs/configuration.md) for the full reference.
|
||||
|
||||
### Verifying agents
|
||||
|
||||
After starting opencode with OMOS enabled, run inside the opencode session:
|
||||
|
||||
```
|
||||
ping all agents
|
||||
```
|
||||
|
||||
All six agents should respond if your provider authentication is working.
|
||||
|
||||
## Source
|
||||
|
||||
Build from source or contribute: [opencode-devbox on Gitea](https://gitea.jordbo.se/joakimp/opencode-devbox)
|
||||
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate.
|
||||
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
# opencode-devbox — portable AI dev environment
|
||||
# Debian-based container with opencode and configurable dev tools
|
||||
|
||||
ARG DEBIAN_VERSION=bookworm-slim
|
||||
FROM debian:${DEBIAN_VERSION} AS base
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG OPENCODE_VERSION=1.4.3
|
||||
|
||||
LABEL maintainer="joakimp"
|
||||
LABEL description="Portable opencode developer container"
|
||||
LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/opencode-devbox"
|
||||
|
||||
# Avoid interactive prompts during build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# ── Core system packages ─────────────────────────────────────────────
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
openssh-client \
|
||||
gnupg \
|
||||
jq \
|
||||
ripgrep \
|
||||
fd-find \
|
||||
tree \
|
||||
less \
|
||||
vim-tiny \
|
||||
sudo \
|
||||
locales \
|
||||
procps \
|
||||
unzip \
|
||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
|
||||
|
||||
# gosu — privilege de-escalation (built with Go 1.24.6)
|
||||
ARG GOSU_VERSION=1.19
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
curl -fsSL "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${ARCH}" -o /usr/local/bin/gosu && \
|
||||
chmod +x /usr/local/bin/gosu && \
|
||||
gosu --version
|
||||
|
||||
# fzf — fuzzy finder (built with Go 1.23.12)
|
||||
ARG FZF_VERSION=0.71.0
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
curl -fsSL "https://github.com/junegunn/fzf/releases/download/v${FZF_VERSION}/fzf-${FZF_VERSION}-linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin fzf && \
|
||||
fzf --version
|
||||
|
||||
# git-lfs — Git Large File Storage (built with Go 1.25)
|
||||
ARG GIT_LFS_VERSION=3.7.1
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
curl -fsSL "https://github.com/git-lfs/git-lfs/releases/download/v${GIT_LFS_VERSION}/git-lfs-linux-${ARCH}-v${GIT_LFS_VERSION}.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/git-lfs-${GIT_LFS_VERSION}/git-lfs /usr/local/bin/git-lfs && \
|
||||
rm -rf /tmp/git-lfs-${GIT_LFS_VERSION} && \
|
||||
git lfs install --system && \
|
||||
git-lfs --version
|
||||
|
||||
# Set locale
|
||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LANGUAGE=en_US:en
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
|
||||
# ── Node.js (required for opencode v1.x install + MCP servers) ──────
|
||||
ARG NODE_VERSION=22
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
|
||||
apt-get install -y --no-install-recommends nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Install opencode via npm ─────────────────────────────────────────
|
||||
# v1.x is distributed as an npm package with platform-specific binaries
|
||||
RUN npm install -g opencode-ai@${OPENCODE_VERSION} && \
|
||||
opencode --version
|
||||
|
||||
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
|
||||
RUN ARCH=$(case "${TARGETARCH}" in \
|
||||
amd64) echo "x86_64" ;; \
|
||||
arm64) echo "aarch64" ;; \
|
||||
*) echo "x86_64" ;; \
|
||||
esac) && \
|
||||
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \
|
||||
unzip -q /tmp/awscli.zip -d /tmp && \
|
||||
/tmp/aws/install && \
|
||||
rm -rf /tmp/aws /tmp/awscli.zip && \
|
||||
aws --version
|
||||
|
||||
# ── Optional: Python ─────────────────────────────────────────────────
|
||||
ARG INSTALL_PYTHON=false
|
||||
RUN if [ "${INSTALL_PYTHON}" = "true" ]; then \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip python3-venv && \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
# ── Optional: Go ─────────────────────────────────────────────────────
|
||||
ARG INSTALL_GO=false
|
||||
ARG GO_VERSION=1.23.4
|
||||
RUN if [ "${INSTALL_GO}" = "true" ]; then \
|
||||
GOARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" | tar -C /usr/local -xz && \
|
||||
ln -s /usr/local/go/bin/go /usr/local/bin/go && \
|
||||
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \
|
||||
fi
|
||||
|
||||
# ── Optional: oh-my-opencode-slim (multi-agent orchestration) ────────
|
||||
# Installs Bun runtime, tmux, and the oh-my-opencode-slim npm package.
|
||||
# Runtime activation is controlled by ENABLE_OMOS env var in entrypoint.
|
||||
ARG INSTALL_OMOS=false
|
||||
ARG OMOS_VERSION=latest
|
||||
RUN if [ "${INSTALL_OMOS}" = "true" ]; then \
|
||||
apt-get update && apt-get install -y --no-install-recommends tmux && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash && \
|
||||
bun --version && \
|
||||
npm install -g oh-my-opencode-slim@${OMOS_VERSION}; \
|
||||
fi
|
||||
|
||||
# ── Non-root user ────────────────────────────────────────────────────
|
||||
ARG USER_NAME=developer
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
|
||||
RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
|
||||
useradd --uid ${USER_UID} --gid ${USER_GID} -m -s /bin/bash ${USER_NAME} && \
|
||||
echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/${USER_NAME}
|
||||
|
||||
# Create standard directories
|
||||
RUN mkdir -p /workspace \
|
||||
/home/${USER_NAME}/.config/opencode/skills \
|
||||
/home/${USER_NAME}/.agents/skills \
|
||||
/home/${USER_NAME}/.local/share/opencode \
|
||||
/home/${USER_NAME}/.ssh && \
|
||||
chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME}
|
||||
|
||||
# ── Entrypoint ────────────────────────────────────────────────────────
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh
|
||||
|
||||
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
||||
WORKDIR /workspace
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
CMD ["opencode"]
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# To force a base rebuild for fresh apt packages without other code
|
||||
# changes, bump the BASE_REBUILD_DATE comment below. The hash is
|
||||
# content-addressed over this file, so any byte change invalidates the
|
||||
# cache. Recommended cadence: once per release for security updates.
|
||||
#
|
||||
# BASE_REBUILD_DATE: 2026-05-14 (v1.14.50b — fresh apt + first promote-base-latest)
|
||||
#
|
||||
# See the project README's "Build pipeline" section for the rationale.
|
||||
|
||||
ARG DEBIAN_VERSION=trixie-slim
|
||||
FROM debian:${DEBIAN_VERSION} AS base
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
LABEL maintainer="joakimp"
|
||||
LABEL description="opencode-devbox — base image (variant-independent)"
|
||||
LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/opencode-devbox"
|
||||
|
||||
# Avoid interactive prompts during build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# ── Core system packages ─────────────────────────────────────────────
|
||||
# apt-get upgrade picks up any security/CVE fixes published between
|
||||
# debian:trixie-slim base-image rebuilds. Paired with the index update
|
||||
# and the install in the same layer so we don't bloat image history.
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y --no-install-recommends && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
openssh-client \
|
||||
gnupg \
|
||||
jq \
|
||||
ripgrep \
|
||||
fd-find \
|
||||
tree \
|
||||
less \
|
||||
htop \
|
||||
tmux \
|
||||
make \
|
||||
patch \
|
||||
diffutils \
|
||||
git-crypt \
|
||||
age \
|
||||
file \
|
||||
sudo \
|
||||
locales \
|
||||
procps \
|
||||
unzip \
|
||||
gcc \
|
||||
g++ \
|
||||
rsync \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
||||
&& 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:
|
||||
# • Default is `latest` — resolved at build time by following the
|
||||
# /releases/latest redirect on GitHub and reading the tag from the
|
||||
# Location header. This means every base rebuild picks up the newest
|
||||
# upstream release, with no risk of running months-old CVE-affected
|
||||
# binaries.
|
||||
# • Explicit pins still work: pass `--build-arg GOSU_VERSION=1.19` etc.
|
||||
# • Resolved versions are printed during build and re-checked by the
|
||||
# smoke test (scripts/smoke-test.sh), so drift is visible in CI logs.
|
||||
|
||||
# gosu — privilege de-escalation
|
||||
ARG GOSU_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GOSU_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing gosu ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/download/${V}/gosu-${ARCH}" -o /usr/local/bin/gosu && \
|
||||
chmod +x /usr/local/bin/gosu && \
|
||||
gosu --version
|
||||
|
||||
# fzf — fuzzy finder
|
||||
ARG FZF_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${FZF_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing fzf ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/download/v${V}/fzf-${V}-linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin fzf && \
|
||||
fzf --version
|
||||
|
||||
# git-lfs — Git Large File Storage
|
||||
ARG GIT_LFS_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GIT_LFS_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing git-lfs ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/download/v${V}/git-lfs-linux-${ARCH}-v${V}.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/git-lfs-${V}/git-lfs /usr/local/bin/git-lfs && \
|
||||
rm -rf /tmp/git-lfs-${V} && \
|
||||
git lfs install --system && \
|
||||
git-lfs --version
|
||||
|
||||
# gitleaks — 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) && \
|
||||
V="${NVIM_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing neovim ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/download/v${V}/nvim-linux-${ARCH}.tar.gz" | tar -xz -C /opt && \
|
||||
ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \
|
||||
nvim --version | head -1
|
||||
|
||||
# bat — syntax-highlighted cat replacement
|
||||
ARG BAT_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${BAT_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing bat ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/download/v${V}/bat-v${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/bat-v${V}-${ARCH}-unknown-linux-musl/bat /usr/local/bin/bat && \
|
||||
rm -rf /tmp/bat-v${V}-* && \
|
||||
bat --version
|
||||
|
||||
# eza — modern ls replacement
|
||||
ARG EZA_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${EZA_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing eza ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/download/v${V}/eza_${ARCH}-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin && \
|
||||
eza --version | head -1
|
||||
|
||||
# zoxide — smarter cd command
|
||||
ARG ZOXIDE_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${ZOXIDE_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing zoxide ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/download/v${V}/zoxide-${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /usr/local/bin zoxide && \
|
||||
zoxide --version
|
||||
|
||||
# uv — fast Python package manager (replaces pip, venv, pyenv)
|
||||
# Note: uv releases don't prefix tags with "v" (e.g. tag is "0.11.8").
|
||||
ARG UV_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${UV_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing uv ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/download/${V}/uv-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/uv-${ARCH}-unknown-linux-musl/uv /usr/local/bin/uv && \
|
||||
install /tmp/uv-${ARCH}-unknown-linux-musl/uvx /usr/local/bin/uvx && \
|
||||
rm -rf /tmp/uv-* && \
|
||||
uv --version
|
||||
|
||||
# ── MemPalace — local-first AI memory system ─────────────────────────
|
||||
# Provides semantic search over conversation history via 29 MCP tools.
|
||||
# Always installed in the base (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==${MEMPALACE_VERSION}" && \
|
||||
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
|
||||
fi
|
||||
|
||||
# ── workaround: strip top-level anyOf from mempalace_diary_write schema ──
|
||||
# Mempalace 3.3.x/3.4.0 advertise diary_write's input_schema with a
|
||||
# top-level `anyOf: [{required:[entry]}, {required:[content]}]` to express
|
||||
# "either entry or content must be supplied". Anthropic's tools API rejects
|
||||
# top-level anyOf/oneOf/allOf, so pi/Claude fail at session start with
|
||||
# `tools.<n>.custom.input_schema: input_schema does not support oneOf,
|
||||
# allOf, or anyOf at the top level`.
|
||||
#
|
||||
# Patch the advertised schema to require ["agent_name", "entry"] and remove
|
||||
# the anyOf block. The handler keeps accepting `content` server-side as a
|
||||
# kwarg alias so existing callers still work.
|
||||
#
|
||||
# Idempotent and self-deactivating: once upstream releases the fix the
|
||||
# regex no longer matches and this RUN is a silent no-op.
|
||||
# Upstream tracking:
|
||||
# https://github.com/MemPalace/mempalace/issues/1728
|
||||
# https://github.com/MemPalace/mempalace/pull/1735
|
||||
# TODO: remove this RUN once a mempalace release containing PR #1735 is on
|
||||
# PyPI and installed by the line above.
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
MP_FILE="$(find /opt/uv-tools/mempalace -path '*/mempalace/mcp_server.py' | head -n1)" && \
|
||||
if [ -z "$MP_FILE" ]; then echo "mempalace mcp_server.py not found" >&2; exit 1; fi && \
|
||||
perl -0777 -i -pe 's/(?:[ \t]*\#[^\n]*\n)*[ \t]*"required":\s*\[\s*"agent_name"\s*\]\s*,\s*\n[ \t]*"anyOf":\s*\[\s*\n[ \t]*\{\s*"required":\s*\[\s*"entry"\s*\]\s*\}\s*,\s*\n[ \t]*\{\s*"required":\s*\[\s*"content"\s*\]\s*\}\s*,?\s*\n[ \t]*\]\s*,\s*\n/ "required": ["agent_name", "entry"],\n/s' "$MP_FILE" && \
|
||||
if grep -q '"required": \["agent_name", "entry"\]' "$MP_FILE"; then \
|
||||
echo "mempalace diary_write anyOf workaround: applied (or already clean)"; \
|
||||
else \
|
||||
echo "WARN: mempalace diary_write anyOf workaround did not match expected schema — upstream may have changed shape" >&2; \
|
||||
fi ; \
|
||||
fi
|
||||
|
||||
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
|
||||
ARG INSTALL_MEMPALACE_TOOLKIT=true
|
||||
ARG MEMPALACE_TOOLKIT_REF=main
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
|
||||
git clone --depth 1 --branch "${MEMPALACE_TOOLKIT_REF}" \
|
||||
https://gitea.jordbo.se/joakimp/mempalace-toolkit.git /opt/mempalace-toolkit && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
|
||||
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
|
||||
mempalace-session --help >/dev/null && \
|
||||
mempalace-docs --help >/dev/null && \
|
||||
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
|
||||
fi
|
||||
|
||||
# rustup — Rust toolchain manager (init binary only; toolchains installed at runtime)
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://static.rust-lang.org/rustup/dist/${ARCH}-unknown-linux-gnu/rustup-init" -o /usr/local/bin/rustup-init && \
|
||||
chmod +x /usr/local/bin/rustup-init
|
||||
|
||||
# gitea-mcp — MCP server for Gitea API
|
||||
ARG GITEA_MCP_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${GITEA_MCP_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing gitea-mcp ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/download/v${V}/gitea-mcp_Linux_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin/ gitea-mcp && \
|
||||
chmod +x /usr/local/bin/gitea-mcp && \
|
||||
gitea-mcp --version
|
||||
|
||||
# Set locale — generate common UTF-8 locales (override via LANG/LC_ALL env vars)
|
||||
RUN sed -i -E '/(en_US|en_GB|sv_SE|da_DK|nb_NO|fi_FI|de_DE|fr_FR|es_ES|it_IT|pt_BR|nl_NL|pl_PL|ja_JP|ko_KR|zh_CN)\.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LANGUAGE=en_US:en
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
ENV EDITOR=nvim
|
||||
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
|
||||
|
||||
# ── Node.js (required for opencode/pi/omos at variant build + MCP servers) ──
|
||||
ARG NODE_VERSION=22
|
||||
RUN curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
|
||||
apt-get install -y --no-install-recommends nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
|
||||
RUN ARCH=$(case "${TARGETARCH}" in \
|
||||
amd64) echo "x86_64" ;; \
|
||||
arm64) echo "aarch64" ;; \
|
||||
*) echo "x86_64" ;; \
|
||||
esac) && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \
|
||||
unzip -q /tmp/awscli.zip -d /tmp && \
|
||||
/tmp/aws/install && \
|
||||
rm -rf /tmp/aws /tmp/awscli.zip && \
|
||||
aws --version
|
||||
|
||||
# ── Non-root user ────────────────────────────────────────────────────
|
||||
ARG USER_NAME=developer
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
|
||||
RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
|
||||
useradd --uid ${USER_UID} --gid ${USER_GID} -m -s /bin/bash ${USER_NAME} && \
|
||||
echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/${USER_NAME}
|
||||
|
||||
# Create standard directories
|
||||
RUN mkdir -p /workspace \
|
||||
/home/${USER_NAME}/.config/opencode/skills \
|
||||
/home/${USER_NAME}/.pi/agent/extensions \
|
||||
/home/${USER_NAME}/.agents/skills \
|
||||
/home/${USER_NAME}/.local/share/opencode \
|
||||
/home/${USER_NAME}/.cache/bash \
|
||||
/home/${USER_NAME}/.ssh && \
|
||||
chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME}
|
||||
|
||||
# ── Pre-warm chromadb embedding model ──────────────────────────────
|
||||
# Runs as gosu developer so Path.home() resolves correctly. Uses
|
||||
# the mempalace venv's python, which is the only one that has
|
||||
# chromadb importable (system python3 cannot reach the isolated venv).
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
gosu ${USER_NAME} /opt/uv-tools/mempalace/bin/python -c "\
|
||||
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2; \
|
||||
ef = ONNXMiniLM_L6_V2(); \
|
||||
_ = ef(['warmup']); \
|
||||
print('chromadb embedding model warmed: all-MiniLM-L6-v2')" && \
|
||||
ls -lh /home/${USER_NAME}/.cache/chroma/onnx_models/all-MiniLM-L6-v2/ ; \
|
||||
fi
|
||||
|
||||
# ── User-writable npm global prefix on the devbox-pi-config volume ──
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
# environment. See Dockerfile.variant.
|
||||
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global
|
||||
ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
|
||||
|
||||
# ── Shell defaults (bash history, aliases, readline) ─────────────────
|
||||
RUN mkdir -p /etc/skel-devbox
|
||||
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
|
||||
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
||||
|
||||
# ── Entrypoint ────────────────────────────────────────────────────────
|
||||
COPY rootfs/usr/local/lib/opencode-devbox/ /usr/local/lib/opencode-devbox/
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
|
||||
/usr/local/lib/opencode-devbox/*.py
|
||||
|
||||
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
||||
WORKDIR /workspace
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
CMD ["bash", "-l"]
|
||||
@@ -0,0 +1,178 @@
|
||||
# opencode-devbox — variant image
|
||||
#
|
||||
# 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
|
||||
# varying build args:
|
||||
#
|
||||
# variant INSTALL_OPENCODE INSTALL_OMOS INSTALL_PI
|
||||
# ───────────────── ──────────────── ──────────── ──────────
|
||||
# base true false false
|
||||
# omos true true false
|
||||
# with-pi *DEPR* true false true
|
||||
# omos-with-pi*DEPR* true true true
|
||||
# pi-only *DEPR* false false true
|
||||
#
|
||||
# DEPRECATION (since v1.17.2): the three pi-bearing variants (with-pi,
|
||||
# omos-with-pi, pi-only) and the INSTALL_PI build path are DEPRECATED and
|
||||
# will be REMOVED in v2.0.0. pi 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 plan.
|
||||
#
|
||||
# Until v2.0.0 the `pi-only` variant remains the source of truth for the
|
||||
# legacy pi build (pi + companions, no opencode); pi-devbox v1.0.0+ no
|
||||
# longer FROMs it.
|
||||
#
|
||||
# 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`.
|
||||
|
||||
ARG BASE_IMAGE
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
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.17.2
|
||||
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.
|
||||
# PI_VERSION should be passed explicitly by CI as a concrete version
|
||||
# (resolved from `npm view @earendil-works/pi-coding-agent version`,
|
||||
# see .gitea/workflows/docker-publish-split.yml § resolve-versions).
|
||||
# The default `latest` is for local dev convenience only — it has a
|
||||
# known cache-hit footgun when used in registry-cached CI builds: the
|
||||
# resulting build-arg string is byte-identical across builds, the
|
||||
# layer-hash is identical, and the registry buildcache silently reuses
|
||||
# the layer from whatever pi version was current when the cache was
|
||||
# first populated. Currently masked here because OPENCODE_VERSION (a
|
||||
# parent layer) bumps every release; will manifest the moment a
|
||||
# vN.N.Nb opencode-version-unchanged release ships. See pi-devbox
|
||||
# v0.75.5b 2026-05-23 for the discovery + canonical fix.
|
||||
ARG INSTALL_PI=false
|
||||
ARG PI_VERSION=latest
|
||||
ARG PI_TOOLKIT_REF=main
|
||||
ARG PI_EXTENSIONS_REF=main
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool) live on GitHub
|
||||
# under elpapi42. Refs default to the tracked branch for local dev; CI resolves
|
||||
# them to concrete commit SHAs (see resolve-versions in docker-publish-split.yml)
|
||||
# so the build-arg string changes when upstream moves — same registry-buildcache
|
||||
# cache-hit footgun the PI_VERSION/OMOS_VERSION pins guard against. The clone
|
||||
# helper for these uses `git fetch <ref>` (not `--branch`) so it accepts both
|
||||
# branch names and raw commit SHAs.
|
||||
ARG PI_FORK_REPO=https://github.com/elpapi42/pi-fork.git
|
||||
ARG PI_FORK_REF=master
|
||||
ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
|
||||
ARG PI_OBSMEM_REF=master
|
||||
RUN if [ "${INSTALL_PI}" = "true" ]; then \
|
||||
set -e && \
|
||||
printf '%s\n' \
|
||||
"===========================================================" \
|
||||
"DEPRECATION WARNING: INSTALL_PI is deprecated in opencode-devbox" \
|
||||
"(since v1.17.2) and will be REMOVED in v2.0.0. Use the dedicated" \
|
||||
"image joakimp/pi-devbox:latest instead." \
|
||||
"See https://gitea.jordbo.se/joakimp/pi-devbox" \
|
||||
"===========================================================" >&2 && \
|
||||
git_clone_retry() { \
|
||||
url="$1"; ref="$2"; dest="$3"; \
|
||||
for i in 1 2 3 4 5; do \
|
||||
if git clone --depth 1 --branch "$ref" "$url" "$dest"; then return 0; fi; \
|
||||
rm -rf "$dest"; \
|
||||
echo "git clone $url failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
return 1; \
|
||||
} && \
|
||||
git_fetch_ref() { \
|
||||
url="$1"; ref="$2"; dest="$3"; \
|
||||
rm -rf "$dest"; mkdir -p "$dest"; \
|
||||
git -C "$dest" init -q && git -C "$dest" remote add origin "$url" && \
|
||||
for i in 1 2 3 4 5; do \
|
||||
if git -C "$dest" fetch --depth 1 origin "$ref" && git -C "$dest" checkout -q FETCH_HEAD; then return 0; fi; \
|
||||
echo "git fetch $url@$ref failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
return 1; \
|
||||
} && \
|
||||
if [ "${PI_VERSION}" = "latest" ]; then \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
|
||||
else \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
|
||||
fi && \
|
||||
pi --version && \
|
||||
git_clone_retry https://gitea.jordbo.se/joakimp/pi-toolkit.git "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
|
||||
git_clone_retry https://gitea.jordbo.se/joakimp/pi-extensions.git "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
|
||||
git_fetch_ref "${PI_FORK_REPO}" "${PI_FORK_REF}" /opt/pi-fork && \
|
||||
git_fetch_ref "${PI_OBSMEM_REPO}" "${PI_OBSMEM_REF}" /opt/pi-observational-memory && \
|
||||
(cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \
|
||||
(cd /opt/pi-observational-memory && npm install --omit=dev --no-audit --no-fund) && \
|
||||
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
|
||||
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" && \
|
||||
echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
|
||||
echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)" ; \
|
||||
fi
|
||||
|
||||
# ── Optional: Go ─────────────────────────────────────────────────────
|
||||
ARG INSTALL_GO=false
|
||||
ARG GO_VERSION=latest
|
||||
RUN if [ "${INSTALL_GO}" = "true" ]; then \
|
||||
GOARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GO_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/?mode=json" | \
|
||||
awk -F'"' '/"version":/ { sub(/^go/,"",$4); print $4; exit }'); \
|
||||
fi && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing Go ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/go${V}.linux-${GOARCH}.tar.gz" | tar -C /usr/local -xz && \
|
||||
ln -s /usr/local/go/bin/go /usr/local/bin/go && \
|
||||
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \
|
||||
fi
|
||||
|
||||
# ── Optional: oh-my-opencode-slim (multi-agent orchestration) ────────
|
||||
# Installs Bun runtime and the oh-my-opencode-slim npm package.
|
||||
# OMOS_VERSION shares the same cache-hit footgun as PI_VERSION when
|
||||
# left at the `latest` default in registry-cached CI builds. CI
|
||||
# resolves it via `npm view oh-my-opencode-slim version` and passes
|
||||
# the concrete value as a build-arg. See PI_VERSION block above.
|
||||
ARG INSTALL_OMOS=false
|
||||
ARG OMOS_VERSION=latest
|
||||
RUN if [ "${INSTALL_OMOS}" = "true" ]; then \
|
||||
ARCH=$(uname -m) && \
|
||||
if [ "$ARCH" = "x86_64" ]; then \
|
||||
BUN_ARCH="x64-baseline"; \
|
||||
elif [ "$ARCH" = "aarch64" ]; then \
|
||||
BUN_ARCH="aarch64"; \
|
||||
fi && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-${BUN_ARCH}.zip" -o /tmp/bun.zip && \
|
||||
unzip -o /tmp/bun.zip -d /tmp/bun && \
|
||||
mv /tmp/bun/bun-linux-${BUN_ARCH}/bun /usr/local/bin/bun && \
|
||||
chmod +x /usr/local/bin/bun && \
|
||||
ln -sf bun /usr/local/bin/bunx && \
|
||||
rm -rf /tmp/bun /tmp/bun.zip && \
|
||||
bun --version && \
|
||||
test -L /usr/local/bin/bunx && \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g oh-my-opencode-slim@${OMOS_VERSION}; \
|
||||
fi
|
||||
|
||||
# WORKDIR / ENTRYPOINT / CMD inherited from base.
|
||||
@@ -8,8 +8,28 @@ The official `ghcr.io/anomalyco/opencode` image (now archived) was Alpine-based
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Just want to run it?** No git clone needed — grab the two template files:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/opencode-devbox && cd ~/opencode-devbox
|
||||
|
||||
# Pull docker-compose.yml and the .env template
|
||||
curl -O https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/docker-compose.yml
|
||||
curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.example -o .env
|
||||
|
||||
# Edit .env — at minimum: OPENCODE_PROVIDER, the matching API key,
|
||||
# WORKSPACE_PATH, GIT_USER_NAME, GIT_USER_EMAIL.
|
||||
$EDITOR .env
|
||||
|
||||
# Pull and run
|
||||
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.
|
||||
|
||||
**Want to hack on the image itself, follow upstream changes, or rebuild from source?** Clone the repo:
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone ssh://gitea.jordbo.se:2222/joakimp/opencode-devbox.git
|
||||
cd opencode-devbox
|
||||
|
||||
@@ -17,7 +37,7 @@ cd opencode-devbox
|
||||
cp .env.example .env
|
||||
# Edit .env with your provider, API key, workspace path, git config
|
||||
|
||||
# Install git hooks (secret scanning)
|
||||
# Install git hooks (secret scanning) before committing
|
||||
brew install gitleaks # macOS / Linuxbrew
|
||||
./setup-hooks.sh
|
||||
|
||||
@@ -27,19 +47,30 @@ docker compose run --rm devbox
|
||||
|
||||
## Features
|
||||
|
||||
- **Debian bookworm** base — glibc, full PTY/terminal support
|
||||
- **Debian trixie** base — glibc, full PTY/terminal support
|
||||
- **Configurable providers** — Anthropic, OpenAI, AWS Bedrock via env vars
|
||||
- **Host filesystem access** — bind mount any directory as `/workspace`
|
||||
- **SSH key forwarding** — git push/pull to private repos
|
||||
- **MCP server support** — Node.js included for `npx`-based MCP servers
|
||||
- **Non-root user** — runs as `developer` with UID auto-matched to workspace owner (sudo available)
|
||||
- **Optional runtimes** — Python, Go via build args (Node.js always included — required for opencode v1.x)
|
||||
- **Python via uv** — `uv` package manager included; install Python on demand with `uv python install`
|
||||
- **Rust via rustup** — `rustup-init` included; bootstrap Rust on demand with `rustup-init -y`
|
||||
- **Optional runtimes** — Python (apt), Go via build args (Node.js always included — required for opencode v1.x)
|
||||
- **Multi-agent orchestration** — optional [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) integration via build arg
|
||||
- **AWS CLI v2** — built-in SSO/Bedrock authentication with headless device-code flow
|
||||
- **Multi-arch** — amd64 and arm64
|
||||
|
||||
## Usage
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Bind-mounted directories must exist on the host before starting the container. Docker creates missing directories as root-owned, which causes permission issues.
|
||||
|
||||
```bash
|
||||
# Required: workspace for your projects
|
||||
mkdir -p ~/projects
|
||||
```
|
||||
|
||||
### Connecting to the container
|
||||
|
||||
From your laptop, SSH into the remote server where Docker is running, then start the container:
|
||||
@@ -101,32 +132,265 @@ 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` |
|
||||
| `LANGUAGE` | Language priority list | `en_US:en` |
|
||||
| `LC_ALL` | Override all locale settings | `en_US.UTF-8` |
|
||||
| `EDITOR` | Default text editor | `nvim` |
|
||||
| `ENABLE_OMOS` | Enable oh-my-opencode-slim multi-agent orchestration | `false` |
|
||||
| `OMOS_TMUX` | Enable tmux pane integration for OMOS | `false` |
|
||||
| `OMOS_SKILLS` | Install OMOS recommended skills on first run | `true` |
|
||||
| `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 `pi --ssh`
|
||||
|
||||
The base image bakes a `Host *` default (`/etc/ssh/ssh_config.d/00-devbox-controlmaster.conf`) that points `ControlPath` at the writable, per-container `/tmp/sshcm/` (created mode-700 on every start by `entrypoint-user.sh`). Multiplexing therefore works out of the box. **But your bind-mounted `~/.ssh/config` is read first, and SSH uses the first value it sees** — so any per-host block that sets its own `ControlPath` under `~/.ssh/` (a common CGNAT-multiplexing pattern, e.g. `ControlPath ~/.ssh/cm/%r@%h:%p`) **wins, and then fails inside the container** because `~/.ssh` is mounted **read-only** — the master socket can't bind (`cannot bind … Read-only file system`).
|
||||
|
||||
This bites `pi --ssh <host>` especially: the SSH layer fails to establish the master and pi silently falls back to running its `read`/`write`/`edit`/`bash` tools **locally in the container** instead of on the remote (watch for the missing `SSH ⚡` in the status bar — and `hostname` returning the container ID).
|
||||
|
||||
**Fix (host-side, one line):** in your host's `~/.ssh/config`, either drop the per-host `ControlPath` (to inherit the writable baked default) or point it at a path that's writable inside the container too:
|
||||
|
||||
```sshconfig
|
||||
Host my-remote
|
||||
# was: ControlPath ~/.ssh/cm/%r@%h:%p ← read-only in the container
|
||||
ControlPath /tmp/sshcm/%r@%h:%p # writable on both host and container
|
||||
```
|
||||
|
||||
`/tmp/sshcm/` is also writable on the host (macOS/Linux), so native (non-container) `ssh`/`pi --ssh` from the host keeps working and CGNAT multiplexing is preserved (`ControlMaster`/`ControlPersist` unchanged — only the socket *directory* moves). Note SSH does not create the `ControlPath` parent dir; the container makes `/tmp/sshcm` every start, but on the host run `mkdir -p /tmp/sshcm` once if it doesn't already exist.
|
||||
|
||||
### Custom opencode config
|
||||
|
||||
Mount your own `opencode.json` for full control (MCP servers, custom models, etc.):
|
||||
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.
|
||||
|
||||
When an existing `opencode.jsonc` is found in the volume, the `OPENCODE_PROVIDER` auto-config is skipped.
|
||||
|
||||
**Alternative: host bind-mount** — if you specifically want to share config from the host (e.g. to version-control it or sync across machines), replace the named volume with a bind mount:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./my-opencode.json:/home/developer/.config/opencode/opencode.json:ro
|
||||
- ~/.config/opencode:/home/developer/.config/opencode
|
||||
```
|
||||
|
||||
> **Portability note:** The mounted config runs inside a Linux container. Any absolute paths inside `opencode.jsonc` (for example, host-specific `plugin` entries like `file:///usr/local/lib/node_modules/...` or `file:///opt/homebrew/...`) will not resolve inside the container. Prefer bare package specifiers (e.g. `"oh-my-opencode-slim"`) that resolve via `node_modules` lookup, which works on both macOS and Linux hosts.
|
||||
|
||||
### Custom skills
|
||||
|
||||
Mount your host's opencode skills into the container:
|
||||
Skills are deployed automatically from a skillset repo on container start. The entrypoint detects the skillset location in this order:
|
||||
|
||||
1. `SKILLSET_CONTAINER_PATH` env var (explicit path to skillset repo inside container)
|
||||
2. `~/skillset` mount (if present)
|
||||
3. `/workspace/skillset` fallback (if your workspace contains a `skillset/` directory)
|
||||
|
||||
When a skillset repo is detected, its skills are symlinked into `~/.agents/skills/` automatically. No manual configuration needed.
|
||||
|
||||
> **Warning:** Do not bind-mount a host `~/.agents/skills` directory directly into the container. This conflicts with the symlink-based auto-deploy mechanism and causes broken skill references.
|
||||
|
||||
### Neovim configuration
|
||||
|
||||
The image includes neovim 0.12 with `EDITOR=nvim` set by default. To use your own neovim config (and have plugins auto-install via lazy.nvim on first start), mount it from the host:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ~/.config/opencode/skills:/home/developer/.config/opencode/skills:ro
|
||||
- ~/.agents/skills:/home/developer/.agents/skills:ro
|
||||
- ~/.config/nvim:/home/developer/.config/nvim:ro
|
||||
```
|
||||
|
||||
### Python development with uv
|
||||
|
||||
The image includes Python 3.13 (from Debian Trixie) and [uv](https://docs.astral.sh/uv/), a fast Python package manager that replaces pip, venv, and pyenv:
|
||||
|
||||
```bash
|
||||
# Python 3.13 is available out of the box
|
||||
python3 --version
|
||||
|
||||
# Use uv for package management
|
||||
uv venv
|
||||
uv pip install -r requirements.txt
|
||||
|
||||
# Or use uv's project workflow (reads pyproject.toml)
|
||||
uv sync
|
||||
|
||||
# Run a Python script
|
||||
uv run python script.py
|
||||
|
||||
# Install standalone Python tools
|
||||
uvx ruff check .
|
||||
|
||||
# Install a newer Python version (persists with devbox-uv volume)
|
||||
uv python install 3.14
|
||||
```
|
||||
|
||||
Python installations are stored in `~/.local/share/uv/`. To persist them across container restarts, add the `devbox-uv` named volume to your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- devbox-uv:/home/developer/.local/share/uv
|
||||
|
||||
volumes:
|
||||
devbox-uv:
|
||||
```
|
||||
|
||||
Project virtual environments (`.venv`) are stored in your workspace directory and persist automatically via the `/workspace` bind mount.
|
||||
|
||||
### Rust development with rustup
|
||||
|
||||
The image includes `rustup-init`, the Rust toolchain installer. Rust is not pre-installed but can be bootstrapped on demand:
|
||||
|
||||
```bash
|
||||
# One-time setup: install Rust toolchain (~300MB, persists with volumes)
|
||||
rustup-init -y
|
||||
source ~/.cargo/env
|
||||
|
||||
# Now use Rust normally
|
||||
cargo new my-project
|
||||
cargo build
|
||||
cargo run
|
||||
```
|
||||
|
||||
To persist Rust toolchains and cargo data across container restarts, add named volumes to your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- devbox-rustup:/home/developer/.rustup
|
||||
- devbox-cargo:/home/developer/.cargo
|
||||
|
||||
volumes:
|
||||
devbox-rustup:
|
||||
devbox-cargo:
|
||||
```
|
||||
|
||||
### JavaScript and TypeScript
|
||||
|
||||
The base image includes **Node.js 22** and **npm** — sufficient for most JavaScript and TypeScript development:
|
||||
|
||||
```bash
|
||||
# Initialize a new project
|
||||
npm init -y
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run TypeScript (via tsx, ts-node, etc.)
|
||||
npx tsx src/index.ts
|
||||
|
||||
# Use npx for one-off tools
|
||||
npx tsc --init
|
||||
```
|
||||
|
||||
The OMOS image variant also includes **Bun**, a faster JavaScript runtime and package manager:
|
||||
|
||||
```bash
|
||||
bun init
|
||||
bun install
|
||||
bun run src/index.ts
|
||||
```
|
||||
|
||||
Node modules are stored in your project directory under `/workspace` and persist automatically.
|
||||
|
||||
### VS Code integration
|
||||
|
||||
VS Code can connect directly to a running opencode-devbox container for a full IDE experience with IntelliSense, debugging, and extensions running inside the container.
|
||||
|
||||
**Local Docker (Docker running on your workstation):**
|
||||
|
||||
1. Install the [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension
|
||||
2. Start the container: `docker compose up -d`
|
||||
3. In VS Code: `Ctrl+Shift+P` → "Dev Containers: Attach to Running Container" → select `opencode-devbox`
|
||||
|
||||
**Remote Docker (Docker running on a remote server, e.g. via SSH):**
|
||||
|
||||
1. Install the [Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) and [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extensions
|
||||
2. Connect to the remote host: `Ctrl+Shift+P` → "Remote-SSH: Connect to Host"
|
||||
3. On the remote host, start the container: `docker compose up -d`
|
||||
4. In VS Code (now connected to the remote): `Ctrl+Shift+P` → "Dev Containers: Attach to Running Container"
|
||||
|
||||
VS Code extensions installed inside the container persist as long as the container exists (not removed with `docker compose down`). For persistent extension storage across container recreations, add a named volume:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- devbox-vscode:/home/developer/.vscode-server
|
||||
```
|
||||
|
||||
### Multi-user setup
|
||||
|
||||
The shared-machine compose file (`docker-compose.shared.yml`) supports two modes:
|
||||
|
||||
**Own-account mode** (each user has their own OS login — the common case):
|
||||
Leave `SIGNUM` unset in `.env`. The project name defaults to `devbox-$USER`, so each OS user automatically gets isolated container names and named volumes with zero configuration.
|
||||
|
||||
**Shared-account mode** (everyone logs in as the same OS user, e.g. `garage`):
|
||||
Each user sets `SIGNUM=<unique-id>` in `.env` to get isolation.
|
||||
|
||||
Setup per user:
|
||||
|
||||
```bash
|
||||
# Replace <signum> with your username/identifier
|
||||
mkdir -p ~/<signum>/opencode-devbox
|
||||
cd ~/<signum>/opencode-devbox
|
||||
|
||||
# Copy the shared-machine compose and env files
|
||||
cp /path/to/opencode-devbox/docker-compose.shared.yml docker-compose.yml
|
||||
cp /path/to/opencode-devbox/.env.shared.example .env
|
||||
|
||||
# Edit .env — set SIGNUM only if you're in shared-account mode
|
||||
vim .env
|
||||
|
||||
# Start
|
||||
docker compose up -d
|
||||
docker compose exec -u developer devbox opencode
|
||||
```
|
||||
|
||||
Each user's container, config, and named volumes are fully isolated:
|
||||
- Container name: `devbox-<signum>` (or `devbox-$USER` in own-account mode)
|
||||
- Named volumes: prefixed with the project name (`devbox-<signum>_devbox-data`, etc.) — the Docker daemon is system-wide, so directory-name prefixing alone is NOT sufficient for isolation
|
||||
- Opencode config: persisted via per-user named volume (`devbox-<signum>_devbox-opencode-config`)
|
||||
|
||||
See `docker-compose.shared.yml` and `.env.shared.example` for the full configuration.
|
||||
|
||||
### Rebuilding the Image
|
||||
|
||||
`docker compose run` and `docker compose up` use the existing image — they **do not rebuild** when you change the Dockerfile or build args (e.g. updating `OPENCODE_VERSION`). Rebuild explicitly:
|
||||
@@ -142,19 +406,30 @@ docker compose run --rm --build devbox
|
||||
|
||||
### Build Args
|
||||
|
||||
Enable optional language runtimes or pin a specific opencode version:
|
||||
Enable optional language runtimes, pin a specific opencode version, or lock any of the tooling components:
|
||||
|
||||
```bash
|
||||
docker compose build --build-arg INSTALL_PYTHON=true --build-arg INSTALL_GO=true
|
||||
docker compose build --build-arg INSTALL_GO=true
|
||||
docker compose build --build-arg OPENCODE_VERSION=1.5.0
|
||||
docker compose build --build-arg NVIM_VERSION=0.12.1 # pin to a specific version
|
||||
```
|
||||
|
||||
| Arg | Default | Description |
|
||||
|---|---|---|
|
||||
| `INSTALL_PYTHON` | `false` | Python 3 + pip + venv |
|
||||
| `INSTALL_GO` | `false` | Go toolchain |
|
||||
| `INSTALL_OMOS` | `false` | [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration (installs Bun, tmux, and plugin) |
|
||||
| `OMOS_VERSION` | `latest` | Pin a specific oh-my-opencode-slim version |
|
||||
| `INSTALL_GO` | `false` | Go toolchain (resolves latest stable from go.dev when `GO_VERSION=latest`) |
|
||||
| `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_PI` | `false` | **DEPRECATED (removed in v2.0.0)** — install pi alongside opencode. Use [`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox) instead. |
|
||||
| `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). **Note: the pi-only path is deprecated and removed in v2.0.0.** |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
> **Reproducibility note:** With `latest` defaults, two builds of the same `v{opencode}` tag may embed different tool versions if upstream releases have happened in between. This is intentional — it means every rebuild picks up upstream CVE fixes automatically. If you need a bit-for-bit reproducible build, pass explicit `*_VERSION` args. The CI smoke test logs the resolved versions for every release build.
|
||||
|
||||
## oh-my-opencode-slim (Multi-Agent Orchestration)
|
||||
|
||||
@@ -170,7 +445,7 @@ A pre-built OMOS image is available on Docker Hub as `joakimp/opencode-devbox:la
|
||||
docker compose build --build-arg INSTALL_OMOS=true
|
||||
```
|
||||
|
||||
This installs Bun, tmux, and the oh-my-opencode-slim package into the image.
|
||||
This installs Bun and the oh-my-opencode-slim package into the image.
|
||||
|
||||
**2. Enable in `.env`:**
|
||||
|
||||
@@ -191,20 +466,15 @@ On first start, the entrypoint runs the oh-my-opencode-slim installer in non-int
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `ENABLE_OMOS` | `false` | Activate oh-my-opencode-slim on container start |
|
||||
| `OMOS_TMUX` | `false` | Enable tmux pane integration (tmux is included with `INSTALL_OMOS`) |
|
||||
| `OMOS_TMUX` | `false` | Enable tmux pane integration (tmux is included in the base image) |
|
||||
| `OMOS_SKILLS` | `true` | Install recommended skills (simplify, agent-browser, cartography) |
|
||||
| `OMOS_RESET` | `false` | Force regenerate config on next start (backs up existing config) |
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
You can mount your own oh-my-opencode-slim config instead of using the auto-generated one:
|
||||
If you mount the opencode config directory (see Custom opencode config above), the `oh-my-opencode-slim.json` file is included and persists across restarts. Edit it directly to control which models power each agent, fallback chains, council setup, and more.
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./oh-my-opencode-slim.json:/home/developer/.config/opencode/oh-my-opencode-slim.json:ro
|
||||
```
|
||||
|
||||
The config file controls which models power each agent, fallback chains, council setup, and more. See the [oh-my-opencode-slim configuration docs](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/docs/configuration.md) for the full reference.
|
||||
See the [oh-my-opencode-slim configuration docs](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/docs/configuration.md) for the full reference.
|
||||
|
||||
### Verifying Agents
|
||||
|
||||
@@ -216,6 +486,92 @@ ping all agents
|
||||
|
||||
All six agents should respond if your provider authentication is working.
|
||||
|
||||
## pi (alternative/complementary harness)
|
||||
|
||||
> **⚠ DEPRECATED since v1.17.2 — removed in v2.0.0.** pi support in
|
||||
> opencode-devbox (the `INSTALL_PI` build arg, the `*-with-pi` /
|
||||
> `omos-with-pi` / `pi-only` variants, and the `base-pi-only` tag) is
|
||||
> deprecated. pi now ships as its own self-contained image:
|
||||
> **[`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox)** — pull
|
||||
> that directly. The section below documents the legacy path until removal.
|
||||
>
|
||||
> *Migration note for v2.0.0:* the global npm prefix
|
||||
> (`NPM_CONFIG_PREFIX`) will move off the pi-specific `~/.pi/npm-global`
|
||||
> path to a neutral opencode path. Globally `npm install -g`'d tools may
|
||||
> need their volume/PATH refreshed; the v2.0.0 release notes will carry a
|
||||
> one-time migration shim and details.
|
||||
|
||||
[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. If you want pi **without** opencode, use the separate, leaner [`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox) image instead (it's built from the same `Dockerfile.variant` with `INSTALL_OPENCODE=false`, published in its own repo so an opencode-devbox tag never ships without opencode). Alternatively, build from source:
|
||||
|
||||
### Build
|
||||
|
||||
```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:
|
||||
@@ -264,6 +620,179 @@ The `--use-device-code` flag outputs a URL and short code instead of trying to o
|
||||
|
||||
SSO sessions typically last 8–12 hours before requiring re-authentication. Since `~/.aws` is mounted from the host, tokens persist across container restarts.
|
||||
|
||||
## MemPalace — persistent AI memory
|
||||
|
||||
The image includes [MemPalace](https://github.com/MemPalace/mempalace), a local-first AI memory system that stores conversation history verbatim and retrieves it via semantic search. Nothing leaves your machine.
|
||||
|
||||
> MemPalace adds ~300 MB to the image (chromadb, embedding model deps). If you don't use it, rebuild with `--build-arg INSTALL_MEMPALACE=false` to shrink the image.
|
||||
|
||||
### Enabling persistence
|
||||
|
||||
Uncomment the palace volume in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
- devbox-palace:/home/developer/.mempalace
|
||||
```
|
||||
|
||||
Without the volume, palace data lives in the container's writable layer and is lost on `--force-recreate`.
|
||||
|
||||
### MCP integration with opencode
|
||||
|
||||
Add mempalace as an MCP server in your `opencode.jsonc` (inside `~/.config/opencode/`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"mempalace": {
|
||||
"type": "local",
|
||||
"command": ["mempalace-mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> The image installs mempalace into an isolated `uv tool` venv at `/opt/uv-tools/mempalace/`. `uv tool install` places `mempalace-mcp` on `PATH` as a shim whose shebang points at the venv's Python, so MCP clients can invoke it as a normal binary without worrying about the venv. Do **not** use `["python3", "-m", "mempalace.mcp_server"]` — the system Python cannot import from the uv-managed venv and you'll get `ModuleNotFoundError` / `MCP error -32000: connection closed`.
|
||||
|
||||
This gives opencode access to 29 MCP tools for searching memory, querying the knowledge graph, managing wings/rooms/drawers, and agent diaries.
|
||||
|
||||
### Basic usage
|
||||
|
||||
```bash
|
||||
# Mine project files into the palace
|
||||
mempalace mine /workspace
|
||||
|
||||
# Mine conversation transcripts
|
||||
mempalace mine ~/.local/share/opencode/ --mode convos
|
||||
|
||||
# Search memory
|
||||
mempalace search "why did we switch to eno1"
|
||||
|
||||
# Load context for a new session
|
||||
mempalace wake-up
|
||||
```
|
||||
|
||||
Each workspace gets its own isolated "wing" — memories never leak between projects.
|
||||
|
||||
### Scheduled mining (mempalace-toolkit)
|
||||
|
||||
The image bakes in [mempalace-toolkit](https://gitea.jordbo.se/joakimp/mempalace-toolkit), a small set of bash wrappers that pair with mempalace for two common routines:
|
||||
|
||||
```bash
|
||||
# Mine opencode session history (reads ~/.local/share/opencode/opencode.db, stages JSONL, mines into wing_conversations)
|
||||
mempalace-session
|
||||
|
||||
# Mine a project's docs into a dedicated wing
|
||||
mempalace-docs /workspace/my-project
|
||||
```
|
||||
|
||||
Both wrappers are idempotent and dedup-aware — re-running them on unchanged input is a cheap no-op.
|
||||
|
||||
For weekly automated runs, the toolkit ships ready-to-use scheduler templates (systemd user timer, launchd user agent, cron) in its [`contrib/`](https://gitea.jordbo.se/joakimp/mempalace-toolkit/src/branch/main/contrib) directory. The `*-devbox` variants are designed for this container: host-side schedulers that `docker exec` into the running opencode-devbox.
|
||||
|
||||
Disable the toolkit (keeps mempalace itself) with `--build-arg INSTALL_MEMPALACE_TOOLKIT=false`. Pin to a specific ref with `--build-arg MEMPALACE_TOOLKIT_REF=v0.3.0` once tagged releases exist.
|
||||
|
||||
### Storage
|
||||
|
||||
Two separate named volumes keep different data classes apart:
|
||||
|
||||
- **Palace data** (`~/.mempalace/`): ChromaDB vectors, SQLite knowledge graph, drawers. This is your memory — back it up, treat it as precious. Persists via the `devbox-palace` named volume.
|
||||
- **Embedding model cache** (`~/.cache/chroma/`): ONNX model (~79 MB), downloaded automatically on first search. Disposable — blow it away and it re-downloads in ~4 seconds. Persists via the `devbox-chroma-cache` named volume so you don't re-download on every container recreation.
|
||||
- **No API keys required** for core functionality (local embeddings via ONNX).
|
||||
|
||||
Both volumes are commented out by default in `docker-compose.yml` — uncomment to enable:
|
||||
|
||||
```yaml
|
||||
- devbox-palace:/home/developer/.mempalace
|
||||
- devbox-chroma-cache:/home/developer/.cache/chroma
|
||||
```
|
||||
|
||||
**Air-gapped environments:** pre-populate the `devbox-chroma-cache` volume with the `all-MiniLM-L6-v2/` model contents. The palace volume needs no pre-population.
|
||||
|
||||
## Gitea MCP server
|
||||
|
||||
The image includes the [official Gitea MCP server](https://gitea.com/gitea/gitea-mcp) (`gitea-mcp`), providing 50+ MCP tools for interacting with self-hosted Gitea instances — repositories, issues, pull requests, releases, branches, wiki, and Actions.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Create a Personal Access Token on your Gitea instance (Settings → Applications → Generate Token, scopes: `repo`, `read:user`).
|
||||
|
||||
2. Add to your `.env`:
|
||||
```env
|
||||
GITEA_HOST=https://your-gitea-instance.example.com
|
||||
GITEA_ACCESS_TOKEN=your_token_here
|
||||
```
|
||||
|
||||
3. Enable the gitea MCP server in your `opencode.jsonc`:
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"gitea": {
|
||||
"type": "local",
|
||||
"command": ["gitea-mcp", "-t", "stdio", "--host", "{env:GITEA_HOST}"],
|
||||
"environment": {
|
||||
"GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}"
|
||||
},
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The server is installed but disabled by default — it requires authentication to be useful.
|
||||
|
||||
## Context7 MCP server
|
||||
|
||||
The image auto-registers a [Context7](https://context7.com) MCP server, which provides up-to-date library documentation and code examples to LLMs at query time. This is a remote MCP server at `mcp.context7.com/mcp` — no local binary is needed.
|
||||
|
||||
- Auto-registered in the generated `opencode.jsonc` (no manual setup required)
|
||||
- Provides documentation for any programming library/framework on demand
|
||||
- Requires internet access — useless in air-gapped/offline environments
|
||||
|
||||
## Shell defaults
|
||||
|
||||
The image ships a baked `.bash_aliases` and `.inputrc` with quality-of-life defaults. On first container start they are copied from `/etc/skel-devbox/` into `/home/developer/` **only if the target file does not already exist** — so host bind-mounts and any version you've customized inside the container are never overwritten on upgrade.
|
||||
|
||||
Defaults you get out of the box:
|
||||
|
||||
- **Prefix history search** on Up/Down arrows (type `git `, press Up, walk back through prior `git ...` commands only). Ctrl-Up / Ctrl-Down still step through full history.
|
||||
- **Persistent history** — `$HISTFILE` points at `~/.cache/bash/history`, backed by the `devbox-shell-history` named volume so history survives container recreation. Timestamps, 100 000 entries, dedup.
|
||||
- **Case-insensitive tab completion**, coloured completion lists, `show-all-if-ambiguous`.
|
||||
- **Aliases** — `ls`/`ll`/`la` use `eza`, `cat` uses `bat`, `gs`/`gd`/`gl` for git, safe `rm`/`mv`/`cp`.
|
||||
- **Integrations** — `zoxide` (`z <fragment>` to jump), `fzf` Ctrl-R / Ctrl-T key bindings.
|
||||
- **Prompt marker** — `[devbox]` prefix so it's always obvious you're inside the container.
|
||||
|
||||
### Overriding the defaults
|
||||
|
||||
**Option A — bind-mount host files.** Uncomment the bind-mount lines in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
- ~/.bash_aliases:/home/developer/.bash_aliases:ro
|
||||
- ~/.inputrc:/home/developer/.inputrc:ro
|
||||
```
|
||||
|
||||
> **Single-file bind-mount caveat (all platforms):** Docker bind-mounts the file's **inode**, not its path. When editors like vim, nvim, VS Code, or `sed -i` save a file, they write to a temp file and `rename()` it over the original — creating a new inode. The container stays pinned to the old (now unlinked) inode and never sees the update. This is a kernel limitation ([Docker #15793](https://github.com/moby/moby/issues/15793)), not fixable by Docker. Append-only writes (`echo "alias foo=bar" >> file`) are safe because they modify the same inode. **Workaround:** mount the parent directory instead of the single file (e.g. `~/.config/devbox-shell:/home/developer/.config/devbox-shell:ro`) and source files from there.
|
||||
|
||||
**Option B — customize inside the container.** Just edit `~/.bash_aliases` or `~/.inputrc` as normal. Pair this with a bind-mount or named volume on the home dir if you want the edits to survive container recreation.
|
||||
|
||||
### Restoring or diffing defaults
|
||||
|
||||
The skel files remain available inside every container at `/etc/skel-devbox/`. Useful commands:
|
||||
|
||||
```bash
|
||||
# See what the image currently ships
|
||||
cat /etc/skel-devbox/.bash_aliases
|
||||
|
||||
# Diff your current config against the upstream defaults
|
||||
diff ~/.bash_aliases /etc/skel-devbox/.bash_aliases
|
||||
|
||||
# Reset to the baked defaults
|
||||
cp /etc/skel-devbox/.bash_aliases ~/.bash_aliases
|
||||
|
||||
# …or delete the file and recreate the container — the entrypoint
|
||||
# copies from /etc/skel-devbox/ on next start if the target is absent
|
||||
rm ~/.bash_aliases
|
||||
```
|
||||
|
||||
## Secret Scanning
|
||||
|
||||
A [gitleaks](https://github.com/gitleaks/gitleaks) pre-commit hook prevents accidentally committing API keys, passwords, or other secrets.
|
||||
@@ -301,14 +830,14 @@ Host Machine
|
||||
├── ~/.aws ──bind mount──▶ /home/developer/.aws (Bedrock SSO)
|
||||
└── .env ──env vars───▶ provider config + API keys
|
||||
|
||||
Container (Debian bookworm)
|
||||
Container (Debian trixie)
|
||||
├── opencode binary
|
||||
├── oh-my-opencode-slim (optional — multi-agent orchestration plugin, includes Bun + tmux)
|
||||
├── oh-my-opencode-slim (optional — multi-agent orchestration plugin, includes Bun)
|
||||
├── AWS CLI v2 (SSO + Bedrock auth)
|
||||
├── git, ssh, ripgrep, fd, jq, curl, fzf
|
||||
├── neovim 0.12, tmux, htop, bat, eza, zoxide, uv, rustup, make, gcc, g++, rsync
|
||||
├── 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)
|
||||
├── tmux (optional — included with oh-my-opencode-slim, also useful independently)
|
||||
├── entrypoint.sh (UID adjustment, git config, provider setup)
|
||||
└── /workspace ← your code lives here
|
||||
```
|
||||
@@ -321,10 +850,17 @@ Container (Debian bookworm)
|
||||
| `/home/developer/.ssh` | Host bind mount (ro) | ✅ Yes | SSH keys |
|
||||
| `/home/developer/.aws` | Host bind mount (if configured) | ✅ Yes | AWS credentials/SSO cache |
|
||||
| `/home/developer/.local/share/opencode` | Named volume `devbox-data` | ✅ Yes | Session history, memory |
|
||||
| `/home/developer/.config/opencode/opencode.json` | Generated by entrypoint | ❌ No | Provider/model config |
|
||||
| `/home/developer/.config/opencode/oh-my-opencode-slim.json` | Generated by entrypoint (if OMOS enabled) | ❌ No | Agent/model mappings |
|
||||
| `/home/developer/.local/state/opencode` | Named volume `devbox-state` | ✅ Yes | TUI settings (theme, toggles) |
|
||||
| `/home/developer/.cache/bash` | Named volume `devbox-shell-history` | ✅ Yes | Bash history (`$HISTFILE`), survives container recreate |
|
||||
| `/home/developer/.local/share/zoxide` | Named volume `devbox-zoxide` | ✅ Yes | Zoxide directory history (`z <fragment>` jump targets) |
|
||||
| `/home/developer/.local/share/nvim` | Named volume `devbox-nvim-data` | ✅ Yes | Neovim plugins, Mason LSP installs, Lazy plugin cache |
|
||||
| `/home/developer/.local/share/uv` | Named volume `devbox-uv` (if configured) | ✅ Yes | Python installs, uv tool installs |
|
||||
| `/home/developer/.rustup` | Named volume `devbox-rustup` (if configured) | ✅ Yes | Rust toolchains |
|
||||
| `/home/developer/.cargo` | Named volume `devbox-cargo` (if configured) | ✅ Yes | Cargo binaries, registry cache |
|
||||
| `/home/developer/.vscode-server` | Named volume `devbox-vscode` (if configured) | ✅ Yes | VS Code server and extensions |
|
||||
| `/home/developer/.config/opencode` | Named volume `devbox-opencode-config` | ✅ Yes | `opencode.jsonc`, skills, plus `oh-my-opencode-slim.json` on the OMOS variant |
|
||||
|
||||
**opencode config** (`opencode.json`) is auto-generated from `OPENCODE_PROVIDER` on each start. It sets provider and model only — no MCP servers. To use MCP servers or custom settings, mount your own config file (see Custom opencode config above).
|
||||
**opencode config** (`opencode.jsonc`) is auto-generated from `OPENCODE_PROVIDER` on each start. It sets provider and model only — no MCP servers. To persist config changes and use custom settings, use the named volume (default) or bind-mount from host (see Custom opencode config above).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
# check-versions.sh — Compare pinned versions in Dockerfile against latest releases
|
||||
# Run before tagging a release to see what can be bumped.
|
||||
set -euo pipefail
|
||||
|
||||
BOLD="\033[1m"; DIM="\033[2m"; GREEN="\033[32m"; YELLOW="\033[33m"; RESET="\033[0m"
|
||||
|
||||
DOCKERFILE="${1:-Dockerfile}"
|
||||
|
||||
if [[ ! -f "$DOCKERFILE" ]]; then
|
||||
echo "Usage: $0 [Dockerfile]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
get_pinned() {
|
||||
grep "^ARG $1=" "$DOCKERFILE" | head -1 | cut -d= -f2
|
||||
}
|
||||
|
||||
get_latest_github() {
|
||||
local repo="$1"
|
||||
local tag
|
||||
tag=$(curl -s "https://api.github.com/repos/${repo}/releases/latest" | jq -r '.tag_name // empty')
|
||||
# Strip leading 'v' if present
|
||||
echo "${tag#v}"
|
||||
}
|
||||
|
||||
get_latest_go() {
|
||||
curl -s "https://go.dev/dl/?mode=json" | jq -r '.[0].version' | sed 's/^go//'
|
||||
}
|
||||
|
||||
get_latest_npm() {
|
||||
npm view "$1" version 2>/dev/null
|
||||
}
|
||||
|
||||
check() {
|
||||
local name="$1" current="$2" latest="$3"
|
||||
if [[ -z "$latest" ]]; then
|
||||
printf " ${DIM}%-20s %-12s (could not check)${RESET}\n" "$name" "$current"
|
||||
elif [[ "$current" == "$latest" ]]; then
|
||||
printf " ${GREEN}%-20s %-12s ✓ up to date${RESET}\n" "$name" "$current"
|
||||
else
|
||||
printf " ${YELLOW}${BOLD}%-20s %-12s → %s available${RESET}\n" "$name" "$current" "$latest"
|
||||
fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}Version check for $DOCKERFILE${RESET}"
|
||||
echo ""
|
||||
|
||||
# GitHub-sourced binaries
|
||||
check "opencode" "$(get_pinned OPENCODE_VERSION)" "$(get_latest_npm opencode-ai)"
|
||||
check "gosu" "$(get_pinned GOSU_VERSION)" "$(get_latest_github tianon/gosu)"
|
||||
check "fzf" "$(get_pinned FZF_VERSION)" "$(get_latest_github junegunn/fzf)"
|
||||
check "git-lfs" "$(get_pinned GIT_LFS_VERSION)" "$(get_latest_github git-lfs/git-lfs)"
|
||||
check "neovim" "$(get_pinned NVIM_VERSION)" "$(get_latest_github neovim/neovim)"
|
||||
check "bat" "$(get_pinned BAT_VERSION)" "$(get_latest_github sharkdp/bat)"
|
||||
check "eza" "$(get_pinned EZA_VERSION)" "$(get_latest_github eza-community/eza)"
|
||||
check "zoxide" "$(get_pinned ZOXIDE_VERSION)" "$(get_latest_github ajeetdsouza/zoxide)"
|
||||
check "uv" "$(get_pinned UV_VERSION)" "$(get_latest_github astral-sh/uv)"
|
||||
check "Go (opt)" "$(get_pinned GO_VERSION)" "$(get_latest_go)"
|
||||
|
||||
echo ""
|
||||
echo -e "${DIM}Node.js uses major version ($(get_pinned NODE_VERSION)) — auto-updates via nodesource.${RESET}"
|
||||
echo -e "${DIM}rustup-init uses latest from static.rust-lang.org — no pinned version.${RESET}"
|
||||
echo -e "${DIM}Debian apt packages update on each build via apt-get update.${RESET}"
|
||||
echo ""
|
||||
@@ -0,0 +1,318 @@
|
||||
# Deploy — Host VM setup
|
||||
|
||||
Scripts for setting up a fresh Linux VM to host opencode-devbox.
|
||||
|
||||
## Files
|
||||
|
||||
- **`cloud-init.yml`** — cloud-init user-data template for automated VM provisioning on OpenStack, Proxmox, or any cloud with cloud-init support
|
||||
- **`setup-host.sh`** — interactive post-install script for VMs that weren't provisioned with cloud-init
|
||||
- **`setup-openstack-secgroup.sh`** — creates an OpenStack security group with the right rules (SSH, mosh, ICMP)
|
||||
- **`sync-to-vm.sh`** — syncs local config directories (`~/.aws`, `~/.config/opencode`, etc.) to a remote VM based on which bind mounts are active in its `docker-compose.yml`
|
||||
|
||||
## Supported distributions
|
||||
|
||||
- **Debian 13 (Trixie)** — recommended (matches opencode-devbox base image)
|
||||
- **Ubuntu 24.04 LTS** — also works
|
||||
|
||||
Other distributions will need manual adaptation.
|
||||
|
||||
## Quick start
|
||||
|
||||
### Option 1: Cloud-init (automated)
|
||||
|
||||
Customize `cloud-init.yml` — replace the SSH public key and optionally the hostname/timezone. Then use it during VM creation:
|
||||
|
||||
- **Proxmox**: attach as cloud-init user-data
|
||||
- **OpenStack**: pass via `--user-data` flag (see full example below)
|
||||
- **AWS/DigitalOcean/etc**: paste into the "user data" field
|
||||
|
||||
#### Full OpenStack example
|
||||
|
||||
Cloud-init only handles guest configuration — flavor, image, network, and security group must be specified explicitly at creation time.
|
||||
|
||||
> **Note:** Do not use `--key-name` — the SSH key is configured in `cloud-init.yml` under `ssh_authorized_keys` for the `devbox` user. The `--key-name` flag injects into the image's default user (e.g. `debian`), not the `devbox` user created by cloud-init.
|
||||
|
||||
```bash
|
||||
# List available flavors to choose appropriate sizing
|
||||
openstack flavor list
|
||||
|
||||
# Create the security group first (one-time, see below)
|
||||
./setup-openstack-secgroup.sh
|
||||
|
||||
# Basic — boot from default storage
|
||||
openstack server create \
|
||||
--flavor c4m8 \
|
||||
--image Debian-13-Trixie \
|
||||
--network my-network \
|
||||
--security-group opencode-devbox \
|
||||
--user-data cloud-init.yml \
|
||||
devbox-vm
|
||||
```
|
||||
|
||||
If your cloud offers NVMe-backed (performance) volumes, boot from one for faster Docker and build I/O:
|
||||
|
||||
```bash
|
||||
# Performance — boot from NVMe volume (40GB, preserved on instance deletion)
|
||||
openstack server create \
|
||||
--flavor c4m8 \
|
||||
--network my-network \
|
||||
--security-group opencode-devbox \
|
||||
--user-data cloud-init.yml \
|
||||
--block-device source_type=image,uuid=$(openstack image show Debian-13-Trixie -f value -c id),destination_type=volume,volume_size=40,delete_on_termination=false,boot_index=0,volume_type=performance \
|
||||
devbox-vm
|
||||
```
|
||||
|
||||
> **Note:** The inline `volume_type` parameter requires API microversion 2.67+. If the server goes to ERROR state, check your volume quota (`openstack quota show`) and try creating the volume separately:
|
||||
> ```bash
|
||||
> openstack volume create --image Debian-13-Trixie --size 40 --type performance --bootable devbox-boot-volume
|
||||
> openstack server create --flavor c4m8 --volume devbox-boot-volume --network my-network --security-group opencode-devbox --user-data cloud-init.yml devbox-vm
|
||||
> ```
|
||||
|
||||
#### Floating IP
|
||||
|
||||
OpenStack doesn't support assigning a floating IP at instance creation time — it's a separate step after the VM is active:
|
||||
|
||||
```bash
|
||||
# Allocate a new floating IP from the external network
|
||||
openstack floating ip create <external-network>
|
||||
|
||||
# Assign it to the VM
|
||||
openstack server add floating ip devbox-vm <floating-ip>
|
||||
```
|
||||
|
||||
To find your external network name: `openstack network list --external`. If you already have an unassigned floating IP, skip the create step.
|
||||
|
||||
The VM boots with Docker installed, firewall configured (or skipped on OpenStack), and your SSH key authorized. Log in as the `devbox` user.
|
||||
|
||||
### Console password (optional)
|
||||
|
||||
The cloud-init template uses SSH key authentication only — no password is set by default. This is sufficient for normal use since the `devbox` user has passwordless `sudo`.
|
||||
|
||||
A password is only needed for:
|
||||
|
||||
- **Emergency console access** — logging in via OpenStack Horizon console (noVNC) or Proxmox VNC when SSH is unreachable
|
||||
- **`su - devbox`** — switching to the devbox user from another account
|
||||
|
||||
To enable console access, uncomment the `chpasswd` block in `cloud-init.yml` before deploying:
|
||||
|
||||
```yaml
|
||||
chpasswd:
|
||||
expire: false
|
||||
users:
|
||||
- name: devbox
|
||||
password: your-password-here
|
||||
type: text
|
||||
```
|
||||
|
||||
For an already-running VM, set a password via SSH:
|
||||
|
||||
```bash
|
||||
sudo passwd devbox
|
||||
```
|
||||
|
||||
### Option 2: Post-install script (manual)
|
||||
|
||||
On a fresh Debian/Ubuntu VM:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/deploy/setup-host.sh | bash
|
||||
```
|
||||
|
||||
Or clone and run:
|
||||
|
||||
```bash
|
||||
git clone https://gitea.jordbo.se/joakimp/opencode-devbox
|
||||
cd opencode-devbox/deploy
|
||||
./setup-host.sh
|
||||
```
|
||||
|
||||
## What gets installed
|
||||
|
||||
- Docker Engine (from Docker's official apt repo, not distro's `docker.io`)
|
||||
- Docker Compose plugin (v2)
|
||||
- `tmux`, `mosh`, `git`
|
||||
- `ufw` firewall with SSH (22) and mosh (UDP 60000-61000) allowed — **skipped on OpenStack** (detected automatically; use security groups instead)
|
||||
- IPv4 DNS preference (works around Docker Hub IPv6 connectivity issues)
|
||||
|
||||
## OpenStack security groups
|
||||
|
||||
On OpenStack, firewalling is handled by security groups rather than ufw. The `setup-host.sh` script detects OpenStack automatically and skips ufw configuration.
|
||||
|
||||
To create the required security group:
|
||||
|
||||
```bash
|
||||
./setup-openstack-secgroup.sh
|
||||
```
|
||||
|
||||
This creates a security group named `opencode-devbox` with rules for SSH (TCP 22), mosh (UDP 60000-61000), and ICMP. Apply it to your instance:
|
||||
|
||||
```bash
|
||||
# New instance
|
||||
openstack server create --security-group opencode-devbox ...
|
||||
|
||||
# Existing instance
|
||||
openstack server add security group <instance-name> opencode-devbox
|
||||
```
|
||||
|
||||
## VM sizing recommendations
|
||||
|
||||
| Use case | vCPU | RAM | Disk |
|
||||
|---|---|---|---|
|
||||
| Minimum | 2 | 4 GB | 20 GB |
|
||||
| Recommended | 4 | 8 GB | 40 GB |
|
||||
| Heavy use (Rust/Python builds, multi-project) | 8 | 16 GB | 80 GB |
|
||||
|
||||
## After VM setup
|
||||
|
||||
If you uncomment any bind mounts in `docker-compose.yml` (e.g. `~/.aws`, `~/.config/opencode`), create the directories first — Docker creates missing bind mount paths as root-owned, which causes permission issues:
|
||||
|
||||
```bash
|
||||
# Only create directories for mounts you uncomment
|
||||
mkdir -p ~/.aws # AWS Bedrock SSO
|
||||
mkdir -p ~/.config/opencode # persistent opencode config
|
||||
mkdir -p ~/.config/nvim # custom neovim config
|
||||
mkdir -p ~/.agents/skills # opencode agent skills
|
||||
```
|
||||
|
||||
Named volumes (`devbox-data`, `devbox-uv`, etc.) are managed by Docker and need no pre-creation.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/opencode-devbox && cd ~/opencode-devbox
|
||||
curl -sL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/docker-compose.yml -o docker-compose.yml
|
||||
curl -sL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.example -o .env
|
||||
vim .env # configure provider and keys
|
||||
vim docker-compose.yml # uncomment optional volume mounts
|
||||
docker compose up -d
|
||||
docker compose exec -u developer devbox opencode
|
||||
```
|
||||
|
||||
> **AWS Bedrock users:** Uncomment the `~/.aws` volume mount in `docker-compose.yml` before starting. You'll also need to copy your `~/.aws/config` from a machine where SSO is already configured, then authenticate inside the container with `aws sso login`.
|
||||
|
||||
### Syncing local config to the VM
|
||||
|
||||
After editing `docker-compose.yml` on the VM to uncomment the bind mounts you need, run `sync-to-vm.sh` from your local machine to copy the corresponding directories:
|
||||
|
||||
```bash
|
||||
./deploy/sync-to-vm.sh devbox-affection
|
||||
```
|
||||
|
||||
The script reads `docker-compose.yml` on the remote VM, detects which bind mounts are active, and syncs only those directories from your local machine. It also creates the remote directories if they don't exist.
|
||||
|
||||
### Upgrading an existing VM to a new release
|
||||
|
||||
Each tagged release may add new named volumes or bind-mount lines to `docker-compose.yml`. Pulling a new image via `docker compose pull` grabs the new container behaviour, but compose files on the VM are user-owned and never touched by the image — you have to reconcile them yourself when upgrading across versions.
|
||||
|
||||
**Symptom of a missed reconcile:** a new feature quietly doesn't work even though the image is correct. Example from v1.14.19c → v1.14.20: bash history persistence requires the `devbox-shell-history` named volume mounted at `/home/developer/.cache/bash`. The v1.14.20 image writes history to that path either way, but without the volume mount on the VM, writes land in the container's writable layer and vanish on every `--force-recreate`.
|
||||
|
||||
**Upgrade ritual:**
|
||||
|
||||
```bash
|
||||
# On the VM, before recreating the container:
|
||||
cd ~/opencode-devbox
|
||||
cp docker-compose.yml docker-compose.yml.bak-$(date +%Y%m%d-%H%M%S)
|
||||
|
||||
# Compare against the repo version to see what's new:
|
||||
# (from your local checkout)
|
||||
scp devbox-affection:~/opencode-devbox/docker-compose.yml /tmp/vm-compose.yml
|
||||
diff -u /tmp/vm-compose.yml ~/src/src_local/opencode-devbox/docker-compose.yml
|
||||
```
|
||||
|
||||
For each new `volumes:` entry or mount line in the repo version that isn't in your VM's file, add it manually — preserving any local customizations you've made (image variant, read/write flags on bind mounts, etc.). Then:
|
||||
|
||||
```bash
|
||||
docker compose config >/dev/null # verify YAML still parses
|
||||
docker compose up -d --force-recreate
|
||||
```
|
||||
|
||||
If you maintain the VM's compose file with no local changes, `scp` the repo version over wholesale. If you have customizations (the common case), do the diff-and-merge by hand.
|
||||
|
||||
### Shell defaults inside the container
|
||||
|
||||
The image ships baked `.bash_aliases` and `.inputrc` in `/etc/skel-devbox/` — quality-of-life defaults (prefix history search on Up/Down arrows, persistent history across container recreates via the `devbox-shell-history` named volume, `[devbox]` prompt marker, sensible aliases). On first container start the entrypoint copies them to `/home/developer/` **only if the target file does not already exist**.
|
||||
|
||||
This means:
|
||||
|
||||
- Fresh containers get the defaults automatically.
|
||||
- If you bind-mount your host's `~/.bash_aliases` / `~/.inputrc` (see the commented lines in `docker-compose.yml`), your host versions win.
|
||||
- If you edit the files inside a running container and store them via a home-dir bind-mount or equivalent, subsequent upgrades never overwrite them.
|
||||
- To restore the baked defaults any time: `cp /etc/skel-devbox/.bash_aliases ~/` (or delete the file and recreate the container).
|
||||
- To diff your current config against what the image ships: `diff ~/.bash_aliases /etc/skel-devbox/.bash_aliases`.
|
||||
|
||||
### CI runner maintenance: automatic Docker pruning
|
||||
|
||||
Gitea Actions runners accumulate Docker build cache, stale buildkit containers, and unused images over time. Without periodic cleanup, the runner's disk fills up and builds stall during the image-push phase (symptom: `#61 exporting to image` / `pushing layers` hangs indefinitely while buildkit repeatedly re-authenticates with Docker Hub).
|
||||
|
||||
Set up two layers of automatic cleanup on the runner host:
|
||||
|
||||
**1. Daily cron job** — prunes images, containers, and build cache older than 72 hours:
|
||||
|
||||
```bash
|
||||
sudo tee /etc/cron.daily/docker-prune <<'EOF'
|
||||
#!/bin/sh
|
||||
docker system prune -af --filter "until=72h" > /var/log/docker-prune.log 2>&1
|
||||
docker builder prune -af --filter "until=72h" >> /var/log/docker-prune.log 2>&1
|
||||
EOF
|
||||
sudo chmod +x /etc/cron.daily/docker-prune
|
||||
```
|
||||
|
||||
**2. Docker daemon builder GC** — caps buildkit cache at 10 GB (Docker 23.0+):
|
||||
|
||||
Add to `/etc/docker/daemon.json` (create if absent):
|
||||
|
||||
```json
|
||||
{
|
||||
"builder": {
|
||||
"gc": {
|
||||
"enabled": true,
|
||||
"defaultKeepStorage": "10GB"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then `sudo systemctl restart docker`.
|
||||
|
||||
Both are safe to run on a machine that also hosts long-running containers (like opencode-devbox) — `docker system prune` only removes *unused* images and *stopped* containers, never running ones.
|
||||
|
||||
### Troubleshooting: SSH hangs or "banner exchange" timeouts
|
||||
|
||||
If SSH to the VM intermittently fails with `Connection timed out during banner exchange` or pure TCP connect timeouts — especially after the first few successful connects in a short window — the cause is almost certainly your ISP's CGNAT (Carrier-Grade NAT), not the VM.
|
||||
|
||||
**Symptoms**
|
||||
|
||||
- First 3–4 SSH connects succeed, then subsequent ones fail hard for 20–30 minutes
|
||||
- `ping` to the VM works perfectly throughout (ICMP isn't tracked the same way)
|
||||
- `mosh` sessions stay stable once established (UDP, different flow table)
|
||||
- Happens on residential ISPs (Tele2, Comhem, Telia, most European consumer broadband)
|
||||
- VM-side logs show SSH is idle — the SYNs never reach it
|
||||
|
||||
**Cause**
|
||||
|
||||
Residential CGNAT boxes keep a per-subscriber TCP flow table with a small concurrent-flow cap (~4) per destination IP. Once exhausted, new SYNs to that destination are silently dropped until old flows age out (typically 20–30 min after TCP close).
|
||||
|
||||
**Fix**
|
||||
|
||||
Add SSH connection multiplexing on your client so all SSH sessions (interactive, `scp`, `rsync`, scripts) share a single TCP connection to the VM:
|
||||
|
||||
```ssh-config
|
||||
# ~/.ssh/config
|
||||
Host <vm-alias>
|
||||
HostName <vm-ip>
|
||||
User devbox
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
ControlMaster auto
|
||||
ControlPath ~/.ssh/cm/%r@%h:%p
|
||||
ControlPersist 4h
|
||||
ServerAliveInterval 30
|
||||
ServerAliveCountMax 6
|
||||
```
|
||||
|
||||
Then create the socket directory:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.ssh/cm && chmod 700 ~/.ssh/cm
|
||||
```
|
||||
|
||||
All SSH to the VM now multiplexes over a single flow slot, regardless of how many parallel sessions you open. `sync-to-vm.sh` already does this internally for its own rsync/scp calls.
|
||||
|
||||
For a more robust long-term fix (especially if you access the VM from multiple hosts), run a WireGuard tunnel on the VM and route SSH through that — UDP bypasses the TCP flow table entirely.
|
||||
@@ -0,0 +1,110 @@
|
||||
#cloud-config
|
||||
# cloud-init template for opencode-devbox host VM
|
||||
# Tested on Debian 13 (Trixie) and Ubuntu 24.04
|
||||
#
|
||||
# Usage:
|
||||
# - Proxmox: attach this file as cloud-init user-data in VM config
|
||||
# - OpenStack: pass as --user-data when creating the instance
|
||||
# - Cloud providers: paste into "user data" field
|
||||
#
|
||||
# Customize the marked sections before use.
|
||||
|
||||
# ── Hostname ─────────────────────────────────────────────────────────
|
||||
hostname: devbox
|
||||
manage_etc_hosts: true
|
||||
|
||||
# ── User ─────────────────────────────────────────────────────────────
|
||||
users:
|
||||
- name: devbox
|
||||
groups: sudo, docker
|
||||
shell: /bin/bash
|
||||
sudo: ALL=(ALL) NOPASSWD:ALL
|
||||
ssh_authorized_keys:
|
||||
# CUSTOMIZE: replace with your public SSH key.
|
||||
# This is the only SSH key config needed — do NOT use --key-name with
|
||||
# openstack server create, as that injects into the image's default
|
||||
# user (e.g. debian), not the devbox user defined here.
|
||||
- ssh-ed25519 AAAA... your-key-here
|
||||
|
||||
# ── Optional: console password ───────────────────────────────────────
|
||||
# Uncomment to set a password for the devbox user. Only needed for
|
||||
# emergency access via the OpenStack/Proxmox console (VNC/noVNC).
|
||||
# SSH key authentication is used for normal access.
|
||||
#
|
||||
# chpasswd:
|
||||
# expire: false
|
||||
# users:
|
||||
# - name: devbox
|
||||
# password: your-password-here
|
||||
# type: text
|
||||
|
||||
# ── Locale and timezone ──────────────────────────────────────────────
|
||||
# en_US.UTF-8 is pre-generated on Debian/Ubuntu and works out of the box.
|
||||
# To use a different locale (e.g. sv_SE.UTF-8), add it to the runcmd
|
||||
# section before the locale is applied:
|
||||
# - locale-gen sv_SE.UTF-8
|
||||
# Then change the locale line below to match.
|
||||
locale: en_US.UTF-8
|
||||
timezone: Europe/Stockholm
|
||||
|
||||
# ── Package installation ─────────────────────────────────────────────
|
||||
package_update: true
|
||||
package_upgrade: true
|
||||
packages:
|
||||
- ca-certificates
|
||||
- curl
|
||||
- gnupg
|
||||
- git
|
||||
- tmux
|
||||
- mosh
|
||||
- rsync
|
||||
- fzf
|
||||
- ripgrep
|
||||
- ufw
|
||||
|
||||
# ── Commands to run at first boot ────────────────────────────────────
|
||||
runcmd:
|
||||
# Install Docker from official repository
|
||||
- install -m 0755 -d /etc/apt/keyrings
|
||||
- curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
|
||||
- chmod a+r /etc/apt/keyrings/docker.asc
|
||||
- echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo \"$ID\") $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" > /etc/apt/sources.list.d/docker.list
|
||||
- apt-get update
|
||||
- apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
- usermod -aG docker devbox
|
||||
|
||||
# Firewall — skip on OpenStack (use security groups instead)
|
||||
- |
|
||||
if curl -s --connect-timeout 2 http://169.254.169.254/openstack/ >/dev/null 2>&1; then
|
||||
echo "OpenStack detected — skipping ufw (use security groups instead)"
|
||||
else
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw allow ssh
|
||||
ufw allow 60000:61000/udp
|
||||
ufw --force enable
|
||||
fi
|
||||
|
||||
# Disable IPv6 preference for Docker (avoids intermittent Docker Hub connectivity issues)
|
||||
- echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
# Create projects directory for the user
|
||||
- mkdir -p /home/devbox/projects
|
||||
- chown devbox:devbox /home/devbox/projects
|
||||
|
||||
# ── Final message ───────────────────────────────────────────────────
|
||||
final_message: |
|
||||
opencode-devbox host VM ready.
|
||||
|
||||
Next steps:
|
||||
1. SSH in: ssh devbox@<this-host>
|
||||
2. Clone your opencode-devbox compose config, or:
|
||||
mkdir -p ~/opencode-devbox && cd ~/opencode-devbox
|
||||
curl -sL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/docker-compose.yml -o docker-compose.yml
|
||||
curl -sL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.example -o .env
|
||||
3. Edit .env with your provider and keys
|
||||
4. Edit docker-compose.yml to uncomment optional mounts (e.g. ~/.aws for Bedrock)
|
||||
5. docker compose up -d
|
||||
6. docker compose exec -u developer devbox opencode
|
||||
|
||||
Cloud-init run completed in $UPTIME seconds.
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/bin/bash
|
||||
# setup-host.sh — Post-install script for opencode-devbox host VM
|
||||
#
|
||||
# Run this on a fresh Debian 13 or Ubuntu 24.04 VM to set up everything
|
||||
# needed to run opencode-devbox containers.
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/deploy/setup-host.sh | bash
|
||||
#
|
||||
# Or clone and run:
|
||||
# git clone https://gitea.jordbo.se/joakimp/opencode-devbox
|
||||
# cd opencode-devbox/deploy
|
||||
# ./setup-host.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colors ──────────────────────────────────────────────────────────
|
||||
BOLD="\033[1m"; GREEN="\033[32m"; YELLOW="\033[33m"; RED="\033[31m"; RESET="\033[0m"
|
||||
info() { echo -e "${BOLD}==>${RESET} $*"; }
|
||||
ok() { echo -e "${GREEN}${BOLD}✓${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}${BOLD}!${RESET} $*"; }
|
||||
err() { echo -e "${RED}${BOLD}✗${RESET} $*" >&2; }
|
||||
|
||||
# ── Detect distro ──────────────────────────────────────────────────
|
||||
if [[ ! -f /etc/os-release ]]; then
|
||||
err "Cannot detect Linux distribution — /etc/os-release missing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
. /etc/os-release
|
||||
|
||||
case "$ID" in
|
||||
debian|ubuntu)
|
||||
info "Detected $PRETTY_NAME"
|
||||
;;
|
||||
*)
|
||||
err "Unsupported distribution: $ID — this script only supports Debian and Ubuntu"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Require sudo ────────────────────────────────────────────────────
|
||||
if [[ $EUID -eq 0 ]]; then
|
||||
err "Do not run as root — use a regular user with sudo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! sudo -n true 2>/dev/null; then
|
||||
warn "This script needs sudo access. You may be prompted for your password."
|
||||
fi
|
||||
|
||||
# ── Update packages ─────────────────────────────────────────────────
|
||||
info "Updating package index..."
|
||||
sudo apt-get update -qq
|
||||
|
||||
info "Installing base packages..."
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl gnupg git tmux mosh rsync fzf ripgrep ufw
|
||||
|
||||
# ── Docker ──────────────────────────────────────────────────────────
|
||||
if command -v docker &>/dev/null; then
|
||||
ok "Docker already installed ($(docker --version))"
|
||||
else
|
||||
info "Installing Docker from official repository..."
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
sudo curl -fsSL "https://download.docker.com/linux/${ID}/gpg" -o /etc/apt/keyrings/docker.asc
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
|
||||
ok "Docker installed: $(docker --version)"
|
||||
fi
|
||||
|
||||
# ── Add user to docker group ────────────────────────────────────────
|
||||
if groups | grep -q docker; then
|
||||
ok "User already in docker group"
|
||||
else
|
||||
info "Adding $USER to docker group..."
|
||||
sudo usermod -aG docker "$USER"
|
||||
warn "You must log out and back in for docker group to take effect"
|
||||
warn "Or run: newgrp docker"
|
||||
fi
|
||||
|
||||
# ── Firewall ────────────────────────────────────────────────────────
|
||||
# Detect OpenStack — if running on OpenStack, skip ufw (security groups handle firewalling)
|
||||
SKIP_UFW=false
|
||||
if curl -s --connect-timeout 2 http://169.254.169.254/openstack/ &>/dev/null; then
|
||||
SKIP_UFW=true
|
||||
warn "OpenStack detected — skipping ufw (use security groups instead)"
|
||||
warn "Ensure your security group allows: SSH (22/tcp), mosh (60000-61000/udp)"
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_UFW" == "false" ]]; then
|
||||
info "Configuring firewall (ufw)..."
|
||||
sudo ufw default deny incoming >/dev/null
|
||||
sudo ufw default allow outgoing >/dev/null
|
||||
sudo ufw allow ssh >/dev/null
|
||||
sudo ufw allow 60000:61000/udp comment 'mosh' >/dev/null
|
||||
if ! sudo ufw status | grep -q "Status: active"; then
|
||||
sudo ufw --force enable
|
||||
fi
|
||||
ok "Firewall active — SSH and mosh allowed"
|
||||
fi
|
||||
|
||||
# ── IPv4 preference for Docker Hub ──────────────────────────────────
|
||||
if ! grep -q 'precedence ::ffff:0:0/96' /etc/gai.conf 2>/dev/null; then
|
||||
info "Setting IPv4 preference in /etc/gai.conf..."
|
||||
echo 'precedence ::ffff:0:0/96 100' | sudo tee -a /etc/gai.conf > /dev/null
|
||||
ok "IPv4 preferred for DNS resolution"
|
||||
fi
|
||||
|
||||
# ── Create projects directory ───────────────────────────────────────
|
||||
if [[ ! -d "$HOME/projects" ]]; then
|
||||
mkdir -p "$HOME/projects"
|
||||
ok "Created ~/projects"
|
||||
fi
|
||||
|
||||
# ── Done ────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
ok "Host setup complete"
|
||||
echo ""
|
||||
cat <<EOF
|
||||
${BOLD}Next steps:${RESET}
|
||||
|
||||
1. If you weren't already in the docker group, log out and back in:
|
||||
exit
|
||||
ssh <your-user>@<this-host>
|
||||
|
||||
2. Set up opencode-devbox:
|
||||
mkdir -p ~/opencode-devbox && cd ~/opencode-devbox
|
||||
curl -sL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/docker-compose.yml -o docker-compose.yml
|
||||
curl -sL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.example -o .env
|
||||
|
||||
3. Edit .env with your provider and API keys:
|
||||
vim .env
|
||||
|
||||
4. Start and connect:
|
||||
docker compose up -d
|
||||
docker compose exec -u developer devbox opencode
|
||||
|
||||
EOF
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
# setup-openstack-secgroup.sh — Create an OpenStack security group for opencode-devbox
|
||||
#
|
||||
# Prerequisites:
|
||||
# - OpenStack CLI installed (pip install python-openstackclient)
|
||||
# - Authenticated (source your openrc.sh or clouds.yaml configured)
|
||||
#
|
||||
# Usage:
|
||||
# ./setup-openstack-secgroup.sh [group-name]
|
||||
#
|
||||
# Default group name: opencode-devbox
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
GROUP_NAME="${1:-opencode-devbox}"
|
||||
|
||||
BOLD="\033[1m"; GREEN="\033[32m"; YELLOW="\033[33m"; RESET="\033[0m"
|
||||
info() { echo -e "${BOLD}==>${RESET} $*"; }
|
||||
ok() { echo -e "${GREEN}${BOLD}✓${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}${BOLD}!${RESET} $*"; }
|
||||
|
||||
if ! command -v openstack &>/dev/null; then
|
||||
echo "Error: openstack CLI not found. Install with: pip install python-openstackclient"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if group already exists
|
||||
if openstack security group show "$GROUP_NAME" &>/dev/null; then
|
||||
warn "Security group '$GROUP_NAME' already exists — updating rules"
|
||||
else
|
||||
info "Creating security group '$GROUP_NAME'..."
|
||||
openstack security group create "$GROUP_NAME" \
|
||||
--description "opencode-devbox: SSH, mosh, HTTPS"
|
||||
ok "Security group created"
|
||||
fi
|
||||
|
||||
# Add rules (idempotent — OpenStack ignores duplicates)
|
||||
info "Adding rules..."
|
||||
|
||||
# SSH (TCP 22)
|
||||
openstack security group rule create "$GROUP_NAME" \
|
||||
--protocol tcp --dst-port 22 --remote-ip 0.0.0.0/0 \
|
||||
--description "SSH" 2>/dev/null && ok "SSH (TCP 22)" || warn "SSH rule already exists"
|
||||
|
||||
# Mosh (UDP 60000-61000)
|
||||
openstack security group rule create "$GROUP_NAME" \
|
||||
--protocol udp --dst-port 60000:61000 --remote-ip 0.0.0.0/0 \
|
||||
--description "mosh" 2>/dev/null && ok "mosh (UDP 60000-61000)" || warn "mosh rule already exists"
|
||||
|
||||
# ICMP (ping — useful for diagnostics)
|
||||
openstack security group rule create "$GROUP_NAME" \
|
||||
--protocol icmp --remote-ip 0.0.0.0/0 \
|
||||
--description "ICMP ping" 2>/dev/null && ok "ICMP ping" || warn "ICMP rule already exists"
|
||||
|
||||
echo ""
|
||||
ok "Security group '$GROUP_NAME' ready"
|
||||
echo ""
|
||||
echo -e "${BOLD}Apply to a new instance:${RESET}"
|
||||
echo " openstack server create --security-group $GROUP_NAME ..."
|
||||
echo ""
|
||||
echo -e "${BOLD}Apply to an existing instance:${RESET}"
|
||||
echo " openstack server add security group <instance-name> $GROUP_NAME"
|
||||
echo ""
|
||||
Executable
+146
@@ -0,0 +1,146 @@
|
||||
#!/bin/bash
|
||||
# sync-to-vm.sh — Copy local config to an opencode-devbox VM
|
||||
#
|
||||
# Reads docker-compose.yml on the remote VM to detect which bind mounts
|
||||
# are active, then syncs the corresponding directories from this machine.
|
||||
#
|
||||
# Usage:
|
||||
# ./sync-to-vm.sh <ssh-host>
|
||||
#
|
||||
# Examples:
|
||||
# ./sync-to-vm.sh devbox-affection
|
||||
# ./sync-to-vm.sh devbox@129.192.68.184
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colors ──────────────────────────────────────────────────────────
|
||||
BOLD="\033[1m"; GREEN="\033[32m"; YELLOW="\033[33m"; RED="\033[31m"; RESET="\033[0m"
|
||||
info() { echo -e "${BOLD}==>${RESET} $*"; }
|
||||
ok() { echo -e "${GREEN}${BOLD}✓${RESET} $*"; }
|
||||
warn() { echo -e "${YELLOW}${BOLD}!${RESET} $*"; }
|
||||
err() { echo -e "${RED}${BOLD}✗${RESET} $*" >&2; }
|
||||
|
||||
# ── Args ────────────────────────────────────────────────────────────
|
||||
if [[ $# -lt 1 ]]; then
|
||||
err "Usage: $0 <ssh-host>"
|
||||
echo " Example: $0 devbox-affection"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SSH_HOST="$1"
|
||||
REMOTE_COMPOSE="~/opencode-devbox/docker-compose.yml"
|
||||
|
||||
# ── SSH multiplexing (reuse one connection for all operations) ──────
|
||||
CTRL_SOCKET=$(mktemp -u /tmp/sync-to-vm-XXXXXX)
|
||||
SSH_OPTS="-o ControlMaster=auto -o ControlPath=${CTRL_SOCKET} -o ControlPersist=120 -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3"
|
||||
|
||||
cleanup() {
|
||||
ssh ${SSH_OPTS} -O exit "$SSH_HOST" 2>/dev/null || true
|
||||
rm -f "$CTRL_SOCKET"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
ssh_cmd() {
|
||||
ssh ${SSH_OPTS} "$SSH_HOST" "$@"
|
||||
}
|
||||
|
||||
# ── Bind mount patterns to detect ──────────────────────────────────
|
||||
# Maps: grep pattern → local source → remote destination
|
||||
declare -a MOUNT_PATTERNS=(
|
||||
"~/.aws:/home/developer/.aws|$HOME/.aws|~/.aws"
|
||||
"~/.config/opencode:/home/developer/.config/opencode|$HOME/.config/opencode|~/.config/opencode"
|
||||
"~/.config/nvim:/home/developer/.config/nvim|$HOME/.config/nvim|~/.config/nvim"
|
||||
"~/.agents/skills:/home/developer/.agents/skills|$HOME/.agents/skills|~/.agents/skills"
|
||||
)
|
||||
|
||||
# ── Establish persistent SSH connection ─────────────────────────────
|
||||
info "Connecting to ${SSH_HOST}..."
|
||||
if ! ssh_cmd true 2>/dev/null; then
|
||||
err "Cannot connect to ${SSH_HOST}"
|
||||
exit 1
|
||||
fi
|
||||
ok "Connected to ${SSH_HOST}"
|
||||
|
||||
# ── Fetch remote docker-compose.yml ─────────────────────────────────
|
||||
info "Reading docker-compose.yml from ${SSH_HOST}..."
|
||||
REMOTE_COMPOSE_CONTENT=$(ssh_cmd "cat $REMOTE_COMPOSE 2>/dev/null") || {
|
||||
err "Could not read ${REMOTE_COMPOSE} on ${SSH_HOST}"
|
||||
err "Has the VM been set up? Run the post-setup steps first."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Ensure workspace directory exists on remote ─────────────────────
|
||||
REMOTE_ENV="~/opencode-devbox/.env"
|
||||
WORKSPACE_PATH=$(ssh_cmd "grep -E '^\s*WORKSPACE_PATH=' $REMOTE_ENV 2>/dev/null | cut -d= -f2- | tr -d '\"'" 2>/dev/null || true)
|
||||
if [[ -n "$WORKSPACE_PATH" ]]; then
|
||||
info "Ensuring WORKSPACE_PATH (${WORKSPACE_PATH}) exists on ${SSH_HOST}..."
|
||||
ssh_cmd "mkdir -p ${WORKSPACE_PATH}"
|
||||
ok "Workspace directory ready"
|
||||
else
|
||||
# Default from docker-compose.yml is ~/projects or current dir
|
||||
WORKSPACE_PATH=$(echo "$REMOTE_COMPOSE_CONTENT" | grep -oP 'WORKSPACE_PATH:-[^}]+' | sed 's/WORKSPACE_PATH:-//' || true)
|
||||
if [[ -n "$WORKSPACE_PATH" && "$WORKSPACE_PATH" != "." ]]; then
|
||||
info "Ensuring default WORKSPACE_PATH (${WORKSPACE_PATH}) exists on ${SSH_HOST}..."
|
||||
ssh_cmd "mkdir -p ${WORKSPACE_PATH}"
|
||||
ok "Workspace directory ready"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Detect active bind mounts ──────────────────────────────────────
|
||||
SYNCED=0
|
||||
|
||||
for entry in "${MOUNT_PATTERNS[@]}"; do
|
||||
IFS='|' read -r pattern local_path remote_path <<< "$entry"
|
||||
|
||||
# Check if the mount is uncommented (active) in docker-compose.yml
|
||||
# Match lines that start with optional whitespace and a dash, NOT preceded by #
|
||||
if echo "$REMOTE_COMPOSE_CONTENT" | grep -qE "^\s*-\s+['\"]?${pattern}" 2>/dev/null; then
|
||||
# Mount is active — check if local source exists
|
||||
if [[ ! -d "$local_path" ]]; then
|
||||
warn "Mount active for ${pattern} but ${local_path} does not exist locally — skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if directory has content
|
||||
if [[ -z "$(ls -A "$local_path" 2>/dev/null)" ]]; then
|
||||
warn "${local_path} is empty — skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
info "Syncing ${local_path} → ${SSH_HOST}:${remote_path}"
|
||||
|
||||
# Ensure remote directory exists
|
||||
ssh_cmd "mkdir -p ${remote_path}"
|
||||
|
||||
# Sync with rsync (fall back to scp if rsync unavailable)
|
||||
# Exclude generated/cached content that gets recreated on the remote.
|
||||
# Use -rlptD (archive minus -o -g) so ownership on the remote is set
|
||||
# by the receiving user (devbox). Preserving host UID/GID with -a
|
||||
# tagged files with the pusher's numeric GID, which leaked through
|
||||
# whenever the VM happened to have a matching group (see #group-1001).
|
||||
if command -v rsync &>/dev/null; then
|
||||
rsync -rlptDz --progress \
|
||||
--exclude='node_modules' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='.venv' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='cli/cache' \
|
||||
--exclude='sso/cache' \
|
||||
-e "ssh ${SSH_OPTS}" "${local_path}/" "${SSH_HOST}:${remote_path}/"
|
||||
else
|
||||
scp -o "ControlPath=${CTRL_SOCKET}" -r "${local_path}/." "${SSH_HOST}:${remote_path}/"
|
||||
fi
|
||||
|
||||
ok "Synced ${local_path}"
|
||||
SYNCED=$((SYNCED + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Summary ─────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
if [[ $SYNCED -eq 0 ]]; then
|
||||
warn "No active bind mounts detected in remote docker-compose.yml"
|
||||
warn "Uncomment the mounts you need in ${REMOTE_COMPOSE} on the VM, then re-run this script"
|
||||
else
|
||||
ok "Synced ${SYNCED} director$([ $SYNCED -eq 1 ] && echo 'y' || echo 'ies') to ${SSH_HOST}"
|
||||
fi
|
||||
@@ -0,0 +1,93 @@
|
||||
# opencode-devbox docker-compose for shared machines
|
||||
#
|
||||
# For machines where multiple users share one OS account (e.g. 'garage').
|
||||
# Each user gets isolated config, data, and named volumes by setting
|
||||
# SIGNUM in their .env file.
|
||||
#
|
||||
# Setup per user:
|
||||
# 1. mkdir -p ~/<signum>/opencode-devbox && cd ~/<signum>/opencode-devbox
|
||||
# 2. cp docker-compose.shared.yml docker-compose.yml
|
||||
# 3. cp .env.shared.example .env
|
||||
# 4. Edit .env with your signum, provider, keys, etc.
|
||||
# 5. mkdir -p ~/<signum>/.config/opencode
|
||||
# 6. docker compose up -d
|
||||
#
|
||||
# Volume isolation: the top-level 'name:' field derives a unique project
|
||||
# name per user, which Docker Compose uses as the prefix for all named
|
||||
# volumes. Without this, two users whose compose file lives in a directory
|
||||
# with the same basename would share volumes — the Docker daemon is
|
||||
# system-wide and doesn't scope by OS user.
|
||||
#
|
||||
# Two modes:
|
||||
# Own-account mode (each user has their own OS login):
|
||||
# Leave SIGNUM unset in .env — it defaults to $USER automatically.
|
||||
# Shared-account mode (everyone logs in as the same OS user):
|
||||
# Set SIGNUM=<unique-id> in .env so each person gets isolated volumes.
|
||||
|
||||
name: devbox-${SIGNUM:-${USER}}
|
||||
|
||||
services:
|
||||
devbox:
|
||||
image: joakimp/opencode-devbox:latest
|
||||
container_name: devbox-${SIGNUM:-${USER}}
|
||||
stdin_open: true
|
||||
tty: true
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TERM=xterm-256color
|
||||
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
|
||||
- GITEA_HOST=${GITEA_HOST:-}
|
||||
volumes:
|
||||
# Host workspace — user's project directory
|
||||
- ${WORKSPACE_PATH:-~/src}:/workspace
|
||||
|
||||
# SSH keys — user-specific if available, else shared
|
||||
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
|
||||
|
||||
# Optional: mount skillset repo for automatic skill/instruction deployment.
|
||||
# The entrypoint runs deploy-skills.sh --bootstrap on start, creating
|
||||
# relative symlinks that resolve inside the container regardless of
|
||||
# where the repo lives on the host. Set SKILLSET_PATH in .env.
|
||||
# - ${SKILLSET_PATH}:/home/developer/skillset
|
||||
|
||||
# Persist opencode config (opencode.jsonc, oh-my-opencode-slim.json,
|
||||
# instructions, etc.) across container recreations. Auto-generated on
|
||||
# first start from env vars by generate-config.py and the skillset
|
||||
# deploy script. Using a named volume keeps the container's symlinks
|
||||
# independent from the host.
|
||||
- devbox-opencode-config:/home/developer/.config/opencode
|
||||
|
||||
# Persist opencode data (auth, memory, session history)
|
||||
- devbox-data:/home/developer/.local/share/opencode
|
||||
|
||||
# Persist bash history across container recreations
|
||||
- devbox-shell-history:/home/developer/.cache/bash
|
||||
|
||||
# Persist zoxide directory history ('z <fragment>' to jump)
|
||||
- devbox-zoxide:/home/developer/.local/share/zoxide
|
||||
|
||||
# Persist neovim plugin/Mason data (avoids re-downloading on every recreate)
|
||||
- devbox-nvim-data:/home/developer/.local/share/nvim
|
||||
|
||||
# Persist uv data (Python installs)
|
||||
- devbox-uv:/home/developer/.local/share/uv
|
||||
|
||||
# Optional: persist MemPalace data (conversation memory, knowledge graph)
|
||||
# - devbox-palace:/home/developer/.mempalace
|
||||
|
||||
# Optional: persist ChromaDB embedding model cache (~79 MB)
|
||||
# - devbox-chroma-cache:/home/developer/.cache/chroma
|
||||
|
||||
# Optional: AWS credentials (per-user if available)
|
||||
# - ${HOME}/${SIGNUM}/.aws:/home/developer/.aws
|
||||
|
||||
volumes:
|
||||
devbox-opencode-config:
|
||||
devbox-data:
|
||||
devbox-shell-history:
|
||||
devbox-zoxide:
|
||||
devbox-nvim-data:
|
||||
devbox-uv:
|
||||
# devbox-palace:
|
||||
# devbox-chroma-cache:
|
||||
+116
-14
@@ -8,15 +8,26 @@
|
||||
# Or for interactive one-shot:
|
||||
# docker compose run --rm devbox
|
||||
|
||||
# Pin the project name so named volumes survive directory renames.
|
||||
# Without this, Docker Compose derives the project name from the
|
||||
# directory basename — renaming the dir orphans all existing volumes.
|
||||
name: opencode-devbox
|
||||
|
||||
services:
|
||||
devbox:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
INSTALL_PYTHON: "false"
|
||||
INSTALL_GO: "false"
|
||||
INSTALL_OMOS: "false"
|
||||
image: opencode-devbox:latest
|
||||
image: joakimp/opencode-devbox:latest
|
||||
# For multi-agent orchestration, use the omos variant instead:
|
||||
# image: joakimp/opencode-devbox:latest-omos
|
||||
#
|
||||
# To build from source instead of pulling from Docker Hub, uncomment:
|
||||
# build:
|
||||
# context: .
|
||||
# args:
|
||||
# INSTALL_GO: "false"
|
||||
# INSTALL_OMOS: "false"
|
||||
# INSTALL_PI: "false"
|
||||
# # PI_VERSION: "latest"
|
||||
# # INSTALL_OPENCODE: "true"
|
||||
container_name: opencode-devbox
|
||||
stdin_open: true
|
||||
tty: true
|
||||
@@ -24,6 +35,9 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
- TERM=xterm-256color
|
||||
- GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN:-}
|
||||
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
|
||||
- GITEA_HOST=${GITEA_HOST:-}
|
||||
volumes:
|
||||
# Host workspace — mount your project here
|
||||
- ${WORKSPACE_PATH:-.}:/workspace
|
||||
@@ -31,21 +45,109 @@ services:
|
||||
# SSH keys (read-only) — for git push/pull
|
||||
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
|
||||
|
||||
# Optional: mount your own opencode config (MCP servers, custom models, etc.)
|
||||
# - ./opencode.json:/home/developer/.config/opencode/opencode.json:ro
|
||||
# Optional: mount skillset repo for automatic skill/instruction deployment.
|
||||
# The entrypoint runs deploy-skills.sh --bootstrap on start, creating
|
||||
# relative symlinks that resolve inside the container regardless of
|
||||
# where the repo lives on the host. Set SKILLSET_PATH in .env.
|
||||
# - ${SKILLSET_PATH}:/home/developer/skillset
|
||||
|
||||
# Optional: mount opencode skills from host
|
||||
# - ~/.config/opencode/skills:/home/developer/.config/opencode/skills:ro
|
||||
# - ~/.agents/skills:/home/developer/.agents/skills:ro
|
||||
# Persist opencode config (opencode.jsonc, oh-my-opencode-slim.json,
|
||||
# instructions, etc.) across container recreations. Auto-generated on
|
||||
# first start from env vars by generate-config.py and the skillset
|
||||
# deploy script. Using a named volume (not a host bind mount) keeps
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# Optional: mount your own oh-my-opencode-slim config
|
||||
# - ./oh-my-opencode-slim.json:/home/developer/.config/opencode/oh-my-opencode-slim.json:ro
|
||||
# NOTE: Do NOT bind-mount ~/.agents/skills/ from the host. The
|
||||
# container manages its own skills directory independently — the
|
||||
# entrypoint deploys skills from the skillset repo on each start.
|
||||
# Sharing it with the host causes symlink conflicts (relative paths
|
||||
# differ between host and container filesystem namespaces).
|
||||
|
||||
# Optional: mount neovim config from host (plugins auto-install on first start)
|
||||
# - ~/.config/nvim:/home/developer/.config/nvim:ro
|
||||
|
||||
# Optional: persist opencode data (auth, memory, etc.)
|
||||
- devbox-data:/home/developer/.local/share/opencode
|
||||
|
||||
# Optional: persist opencode TUI settings (theme, toggles, etc.)
|
||||
- devbox-state:/home/developer/.local/state/opencode
|
||||
|
||||
# Persist bash history across container recreations.
|
||||
# Without this, ~/.bash_history is lost on 'docker compose up --force-recreate'.
|
||||
- devbox-shell-history:/home/developer/.cache/bash
|
||||
|
||||
# Persist zoxide directory history ('z <fragment>' to jump).
|
||||
- devbox-zoxide:/home/developer/.local/share/zoxide
|
||||
|
||||
# Optional: override baked shell defaults with your host's rc files.
|
||||
# The image ships sensible defaults (history tuning, prefix-search on
|
||||
# Up/Down arrows, fzf/zoxide integration). Uncomment to use your own:
|
||||
#
|
||||
# NOTE: Single-file bind-mounts break when editors use atomic save
|
||||
# (vim, VS Code, sed -i write a temp file then rename() over the
|
||||
# original, creating a new inode the container never sees). This is a
|
||||
# kernel limitation, not Docker-specific. If host edits stop appearing
|
||||
# in the container, mount the parent directory instead — see the
|
||||
# "Shell defaults" section in README.md.
|
||||
# - ~/.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
|
||||
|
||||
# Optional: persist Rust toolchains and cargo data
|
||||
# Without this, 'rustup-init' must be re-run after container removal.
|
||||
# - devbox-rustup:/home/developer/.rustup
|
||||
# - devbox-cargo:/home/developer/.cargo
|
||||
|
||||
# Optional: persist VS Code server and extensions across container recreations
|
||||
# - devbox-vscode:/home/developer/.vscode-server
|
||||
|
||||
# Persist neovim plugin/Mason data (avoids re-downloading on every recreate)
|
||||
- devbox-nvim-data:/home/developer/.local/share/nvim
|
||||
|
||||
# Optional: persist MemPalace data (conversation memory, knowledge graph,
|
||||
# embeddings). Without this, palace data is lost on container recreation.
|
||||
# - devbox-palace:/home/developer/.mempalace
|
||||
|
||||
# Optional: persist ChromaDB embedding model cache (~79 MB, downloaded on
|
||||
# first mempalace search). Without this, the model re-downloads on every
|
||||
# container recreation. Separate from palace data — model cache is
|
||||
# disposable, palace data is precious.
|
||||
# - devbox-chroma-cache:/home/developer/.cache/chroma
|
||||
|
||||
# Optional: AWS credentials/SSO config (not read-only — SSO writes token cache)
|
||||
# - ~/.aws:/home/developer/.aws
|
||||
|
||||
volumes:
|
||||
devbox-opencode-config:
|
||||
devbox-pi-config:
|
||||
devbox-ssh-local:
|
||||
devbox-data:
|
||||
devbox-state:
|
||||
devbox-shell-history:
|
||||
devbox-zoxide:
|
||||
devbox-nvim-data:
|
||||
devbox-uv:
|
||||
# devbox-palace:
|
||||
# devbox-chroma-cache:
|
||||
# devbox-rustup:
|
||||
# devbox-cargo:
|
||||
# devbox-vscode:
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# 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.
|
||||
|
||||
## 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.
|
||||
- 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,127 @@
|
||||
# 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>"
|
||||
# PI_VERSION="<from npm registry, see step 2 below>"
|
||||
# OMOS_VERSION="<from npm registry, see step 2 below>"
|
||||
bash /tmp/manual-publish-vX.Y.Z.sh
|
||||
```
|
||||
|
||||
Keep the historical script in `docs/` as-is — it's an archive of the v1.15.12 publish, useful as a reference if a future debug needs to compare exact arg sets across releases. Don't edit it in place.
|
||||
|
||||
The sections below explain what the script does and what you need to know to edit those four 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 PI_VERSION and OMOS_VERSION
|
||||
|
||||
Gitea CI's `resolve-versions` job queries the npm registry at workflow time and threads concrete versions through every 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/@earendil-works%2Fpi-coding-agent/latest | jq -r .version
|
||||
curl -sf https://registry.npmjs.org/oh-my-opencode-slim/latest | jq -r .version
|
||||
```
|
||||
|
||||
Paste the two version strings into the script's `PI_VERSION` / `OMOS_VERSION` constants. 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 × 5** — sequential (not parallel; one host's egress can't saturate five multi-arch pushes safely). Each variant is `Dockerfile.variant` `FROM ${IMAGE}:base-${BASE_HASH}` plus the appropriate `INSTALL_OPENCODE` / `INSTALL_OMOS` / `INSTALL_PI` build-args, tagged `${RELEASE_TAG}${suffix}` and `latest${suffix}`.
|
||||
5. **Verify** — prints the digest of all 12 expected tags (10 variant + base-hash + base-latest). Spot-check that each `vX.Y.Z*` and its `latest*` alias share a digest.
|
||||
|
||||
Expected wall time on a recent Mac: ~25-40 min (base ~3 min if rebuilt, each variant ~3-7 min mostly QEMU arm64 emulation).
|
||||
|
||||
## 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
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# Manual publish of opencode-devbox v1.15.12 — bypasses 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 5 variants on top of base-<hash>:
|
||||
# :v1.15.12 :latest (INSTALL_OPENCODE only)
|
||||
# :v1.15.12-omos :latest-omos (+ OMOS)
|
||||
# :v1.15.12-with-pi :latest-with-pi (+ pi)
|
||||
# :v1.15.12-omos-with-pi :latest-omos-with-pi (+ both)
|
||||
# :v1.15.12-pi-only :latest-pi-only (pi, no opencode)
|
||||
#
|
||||
# Usage on your host:
|
||||
# 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 v1.15.12
|
||||
# 4. bash /path/to/this/script.sh
|
||||
#
|
||||
# Total expected time: ~25-40 min on a recent Mac (4 multi-arch builds, base
|
||||
# layers cache after the first variant).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="joakimp/opencode-devbox"
|
||||
RELEASE_TAG="v1.15.12"
|
||||
BASE_HASH="8d72a9e44796" # sha256 of Dockerfile.base + rootfs/* + entrypoints (computed by CI logic)
|
||||
BASE_TAG="base-${BASE_HASH}"
|
||||
PI_VERSION="0.76.0" # resolved from npm @earendil-works/pi-coding-agent latest (2026-05-28)
|
||||
OMOS_VERSION="1.1.1" # resolved from npm oh-my-opencode-slim latest (2026-05-28)
|
||||
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-5. variants --------
|
||||
build_variant() {
|
||||
local suffix="$1" # "" | "-omos" | "-with-pi" | "-omos-with-pi" | "-pi-only"
|
||||
local install_omos="$2"
|
||||
local install_pi="$3"
|
||||
local install_opencode="${4:-true}"
|
||||
local extra_args=()
|
||||
[[ "$install_pi" == "true" ]] && extra_args+=(--build-arg "PI_VERSION=${PI_VERSION}")
|
||||
[[ "$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}" \
|
||||
--build-arg "INSTALL_PI=${install_pi}" \
|
||||
${extra_args[@]+"${extra_args[@]}"} \
|
||||
-t "${versioned}" \
|
||||
-t "${floating}" \
|
||||
--push \
|
||||
.
|
||||
}
|
||||
|
||||
echo "==> [3/7] Variant: base (opencode only)"
|
||||
build_variant "" false false
|
||||
|
||||
echo "==> [4/7] Variant: omos"
|
||||
build_variant "-omos" true false
|
||||
|
||||
echo "==> [5/7] Variant: with-pi"
|
||||
build_variant "-with-pi" false true
|
||||
|
||||
echo "==> [6/7] Variant: omos-with-pi"
|
||||
build_variant "-omos-with-pi" true true
|
||||
|
||||
echo "==> [7/7] Variant: pi-only (pi without opencode)"
|
||||
build_variant "-pi-only" false true false
|
||||
|
||||
echo
|
||||
echo "==> Done. Verifying tags on Hub:"
|
||||
for t in \
|
||||
"${RELEASE_TAG}" "latest" \
|
||||
"${RELEASE_TAG}-omos" "latest-omos" \
|
||||
"${RELEASE_TAG}-with-pi" "latest-with-pi" \
|
||||
"${RELEASE_TAG}-omos-with-pi" "latest-omos-with-pi" \
|
||||
"${RELEASE_TAG}-pi-only" "latest-pi-only" \
|
||||
"${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.
|
||||
+139
-56
@@ -1,6 +1,60 @@
|
||||
#!/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
|
||||
# .inputrc) and recreate the container, or cp from /etc/skel-devbox/
|
||||
# directly.
|
||||
SKEL_DIR="/etc/skel-devbox"
|
||||
if [ -d "$SKEL_DIR" ]; then
|
||||
for f in .bash_aliases .inputrc; do
|
||||
if [ -f "$SKEL_DIR/$f" ] && [ ! -e "$HOME/$f" ]; then
|
||||
cp "$SKEL_DIR/$f" "$HOME/$f"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── MemPalace: initialize palace for the workspace if mempalace is installed
|
||||
# Creates the palace directory structure on first run. Idempotent — skips
|
||||
# if palace already exists, so upgrades from older versions preserve
|
||||
# existing data. `--yes` auto-accepts detected entities so the init is
|
||||
# non-interactive — the container entrypoint has no usable stdin for
|
||||
# prompts anyway.
|
||||
if command -v mempalace &>/dev/null && [ -d /workspace ]; then
|
||||
PALACE_DIR="${HOME}/.mempalace"
|
||||
if [ ! -d "$PALACE_DIR/palace" ]; then
|
||||
echo "Initializing MemPalace for workspace (non-interactive)..."
|
||||
# </dev/null: mempalace init has an interactive "Mine this directory
|
||||
# now? [Y/n]" prompt that --yes does not auto-answer in all paths.
|
||||
# Without redirected stdin, the process blocks here forever when run
|
||||
# from `docker run -it` (the TTY keeps stdin open). EOF on stdin
|
||||
# makes the prompt fall through to its default (skip).
|
||||
mempalace init --yes /workspace </dev/null >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Git config defaults ──────────────────────────────────────────────
|
||||
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
|
||||
git config --global user.name "$GIT_USER_NAME"
|
||||
@@ -10,71 +64,100 @@ if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null;
|
||||
fi
|
||||
|
||||
# ── Generate opencode config from env vars if no config mounted ──────
|
||||
CONFIG_DIR="$HOME/.config/opencode"
|
||||
CONFIG_FILE="$CONFIG_DIR/opencode.json"
|
||||
# Delegated to a standalone Python script for clarity and testability.
|
||||
# The script is idempotent: it never overwrites an existing opencode.json
|
||||
# (bind-mounted from host, persisted in named volume, or previously
|
||||
# generated) and no-ops if OPENCODE_PROVIDER is unset.
|
||||
python3 /usr/local/lib/opencode-devbox/generate-config.py
|
||||
|
||||
if [ ! -f "$CONFIG_FILE" ] && [ -n "${OPENCODE_PROVIDER:-}" ]; then
|
||||
echo "Generating opencode config for provider: $OPENCODE_PROVIDER"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
|
||||
case "$OPENCODE_PROVIDER" in
|
||||
anthropic)
|
||||
cat > "$CONFIG_FILE" <<EOF
|
||||
{
|
||||
"\$schema": "https://opencode.ai/config.json",
|
||||
"model": "${OPENCODE_MODEL:-anthropic/claude-sonnet-4-5}",
|
||||
"share": "disabled",
|
||||
"autoupdate": false
|
||||
}
|
||||
EOF
|
||||
;;
|
||||
openai)
|
||||
cat > "$CONFIG_FILE" <<EOF
|
||||
{
|
||||
"\$schema": "https://opencode.ai/config.json",
|
||||
"model": "${OPENCODE_MODEL:-openai/gpt-4o}",
|
||||
"share": "disabled",
|
||||
"autoupdate": false
|
||||
}
|
||||
EOF
|
||||
;;
|
||||
amazon-bedrock)
|
||||
cat > "$CONFIG_FILE" <<EOF
|
||||
{
|
||||
"\$schema": "https://opencode.ai/config.json",
|
||||
"model": "${OPENCODE_MODEL:-amazon-bedrock/anthropic.claude-sonnet-4-5-v1}",
|
||||
"share": "disabled",
|
||||
"autoupdate": false,
|
||||
"provider": {
|
||||
"amazon-bedrock": {
|
||||
"options": {
|
||||
"region": "${AWS_REGION:-us-east-1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
;;
|
||||
*)
|
||||
cat > "$CONFIG_FILE" <<EOF
|
||||
{
|
||||
"\$schema": "https://opencode.ai/config.json",
|
||||
"model": "${OPENCODE_MODEL:-anthropic/claude-sonnet-4-5}",
|
||||
"share": "disabled",
|
||||
"autoupdate": false
|
||||
}
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
# ── 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
|
||||
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool).
|
||||
# These are pi packages (not symlink-style extensions): they're cloned to
|
||||
# /opt with node_modules baked at BUILD time, then registered here via
|
||||
# `pi install <local-path>`. Verified 2026-06-03: a local-path install is
|
||||
# instant + in-place (pi loads the extension directly from /opt) + idempotent
|
||||
# (no duplicate package entry on re-run), and stores a relative path that
|
||||
# resolves into the image-layer /opt so it survives volume recreate. The
|
||||
# fork/recall tools register on the NEXT pi start (extensions bind at
|
||||
# startup). Guard on settings.json so we only install once per volume.
|
||||
for _pkg in /opt/pi-fork /opt/pi-observational-memory; do
|
||||
[ -d "$_pkg" ] || continue
|
||||
_name=$(basename "$_pkg")
|
||||
if ! grep -q "$_name" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
|
||||
pi install "$_pkg" >/dev/null 2>&1 || \
|
||||
echo "WARN: pi install $_name failed (continuing)"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
||||
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
|
||||
# run the deploy script to create relative symlinks for skills and instructions.
|
||||
# This ensures skills resolve correctly inside the container regardless of
|
||||
# where the repo lives on the host. Idempotent — second run is a no-op.
|
||||
#
|
||||
# Detection order:
|
||||
# 1. SKILLSET_CONTAINER_PATH env var (explicit, for non-standard layouts)
|
||||
# 2. $HOME/skillset (dedicated volume mount via SKILLSET_PATH in compose)
|
||||
# 3. /workspace/skillset (skillset is directly inside workspace root)
|
||||
SKILLSET_DEPLOY=""
|
||||
if [ -n "${SKILLSET_CONTAINER_PATH:-}" ] && [ -x "${SKILLSET_CONTAINER_PATH}/deploy-skills.sh" ]; then
|
||||
SKILLSET_DEPLOY="${SKILLSET_CONTAINER_PATH}/deploy-skills.sh"
|
||||
elif [ -x "$HOME/skillset/deploy-skills.sh" ]; then
|
||||
SKILLSET_DEPLOY="$HOME/skillset/deploy-skills.sh"
|
||||
elif [ -x /workspace/skillset/deploy-skills.sh ]; then
|
||||
SKILLSET_DEPLOY="/workspace/skillset/deploy-skills.sh"
|
||||
fi
|
||||
if [ -n "$SKILLSET_DEPLOY" ]; then
|
||||
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
CONFIG_DIR="$HOME/.config/opencode"
|
||||
OMOS_CONFIG="$CONFIG_DIR/oh-my-opencode-slim.json"
|
||||
|
||||
# ── oh-my-opencode-slim setup (multi-agent orchestration) ────────────
|
||||
# Activated by ENABLE_OMOS=true. Requires the image to be built with
|
||||
# INSTALL_OMOS=true (which installs bun + the oh-my-opencode-slim package).
|
||||
OMOS_CONFIG="$CONFIG_DIR/oh-my-opencode-slim.json"
|
||||
|
||||
if [ "${ENABLE_OMOS:-false}" = "true" ]; then
|
||||
if ! command -v bunx &>/dev/null; then
|
||||
if ! command -v bun &>/dev/null; then
|
||||
echo "WARNING: ENABLE_OMOS=true but bun is not installed."
|
||||
echo "Rebuild with: docker compose build --build-arg INSTALL_OMOS=true"
|
||||
elif [ ! -f "$OMOS_CONFIG" ]; then
|
||||
|
||||
+81
-9
@@ -6,18 +6,22 @@ CURRENT_UID=$(id -u "$USER_NAME")
|
||||
CURRENT_GID=$(id -g "$USER_NAME")
|
||||
|
||||
# ── UID/GID adjustment ───────────────────────────────────────────────
|
||||
# Priority: env vars > auto-detect from /workspace > default (1000)
|
||||
# Priority per dimension: env var > auto-detect from /workspace > no-op
|
||||
# UID and GID are detected independently so a GID-only mismatch (e.g. host
|
||||
# user has UID 1000 but primary group at GID 1001) is still corrected.
|
||||
TARGET_UID="${USER_UID:-}"
|
||||
TARGET_GID="${USER_GID:-}"
|
||||
|
||||
# Auto-detect from /workspace owner if env vars not set
|
||||
if [ -z "$TARGET_UID" ] && [ -d /workspace ]; then
|
||||
WORKSPACE_UID=$(stat -c '%u' /workspace 2>/dev/null || stat -f '%u' /workspace 2>/dev/null)
|
||||
WORKSPACE_GID=$(stat -c '%g' /workspace 2>/dev/null || stat -f '%g' /workspace 2>/dev/null)
|
||||
# Only adjust if workspace is owned by a non-root user
|
||||
if [ "$WORKSPACE_UID" != "0" ] && [ "$WORKSPACE_UID" != "$CURRENT_UID" ]; then
|
||||
if [ -d /workspace ]; then
|
||||
WORKSPACE_UID=$(stat -c '%u' /workspace 2>/dev/null || stat -f '%u' /workspace 2>/dev/null || echo "")
|
||||
WORKSPACE_GID=$(stat -c '%g' /workspace 2>/dev/null || stat -f '%g' /workspace 2>/dev/null || echo "")
|
||||
# Adopt workspace UID if env var not set and workspace is non-root-owned
|
||||
if [ -z "$TARGET_UID" ] && [ -n "$WORKSPACE_UID" ] && [ "$WORKSPACE_UID" != "0" ] && [ "$WORKSPACE_UID" != "$CURRENT_UID" ]; then
|
||||
TARGET_UID="$WORKSPACE_UID"
|
||||
TARGET_GID="${TARGET_GID:-$WORKSPACE_GID}"
|
||||
fi
|
||||
# Adopt workspace GID if env var not set and workspace group differs
|
||||
if [ -z "$TARGET_GID" ] && [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "0" ] && [ "$WORKSPACE_GID" != "$CURRENT_GID" ]; then
|
||||
TARGET_GID="$WORKSPACE_GID"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -25,12 +29,13 @@ fi
|
||||
if [ -n "$TARGET_GID" ] && [ "$TARGET_GID" != "$CURRENT_GID" ]; then
|
||||
groupmod -g "$TARGET_GID" "$USER_NAME" 2>/dev/null || true
|
||||
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -group "$CURRENT_GID" -exec chgrp "$TARGET_GID" {} + 2>/dev/null || true
|
||||
echo "Adjusted developer GID to $TARGET_GID"
|
||||
fi
|
||||
|
||||
if [ -n "$TARGET_UID" ] && [ "$TARGET_UID" != "$CURRENT_UID" ]; then
|
||||
usermod -u "$TARGET_UID" "$USER_NAME" 2>/dev/null || true
|
||||
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -user "$CURRENT_UID" -exec chown "$TARGET_UID" {} + 2>/dev/null || true
|
||||
echo "Adjusted developer UID:GID to $TARGET_UID:${TARGET_GID:-$CURRENT_GID}"
|
||||
echo "Adjusted developer UID to $TARGET_UID"
|
||||
fi
|
||||
|
||||
# ── SSH key permissions ──────────────────────────────────────────────
|
||||
@@ -46,5 +51,72 @@ if [ -d "/home/$USER_NAME/.ssh" ] && [ "$(ls -A "/home/$USER_NAME/.ssh" 2>/dev/n
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Fix ownership of named volume mount points ──────────────────────
|
||||
# Named volumes are created as root on first use. Fix ownership so the
|
||||
# developer user can write to them.
|
||||
FINAL_UID="${TARGET_UID:-$CURRENT_UID}"
|
||||
FINAL_GID="${TARGET_GID:-$CURRENT_GID}"
|
||||
|
||||
# First, fix parent dirs that Docker auto-creates as root:root when it
|
||||
# materializes nested mount points (e.g. mounting a volume at
|
||||
# .local/state/opencode creates .local/state as root). Non-recursive —
|
||||
# we only need the dir node itself; children are handled below or were
|
||||
# created by the user.
|
||||
for parent in \
|
||||
/home/"$USER_NAME"/.local \
|
||||
/home/"$USER_NAME"/.local/share \
|
||||
/home/"$USER_NAME"/.local/state \
|
||||
/home/"$USER_NAME"/.cache \
|
||||
/home/"$USER_NAME"/.config; do
|
||||
if [ -d "$parent" ] && [ "$(stat -c '%u' "$parent" 2>/dev/null)" != "$FINAL_UID" ]; then
|
||||
chown "$FINAL_UID":"$FINAL_GID" "$parent" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
for dir in \
|
||||
/home/"$USER_NAME"/.local/share/opencode \
|
||||
/home/"$USER_NAME"/.local/state/opencode \
|
||||
/home/"$USER_NAME"/.local/share/uv \
|
||||
/home/"$USER_NAME"/.local/share/zoxide \
|
||||
/home/"$USER_NAME"/.local/share/nvim \
|
||||
/home/"$USER_NAME"/.mempalace \
|
||||
/home/"$USER_NAME"/.cache/bash \
|
||||
/home/"$USER_NAME"/.cache/chroma \
|
||||
/home/"$USER_NAME"/.rustup \
|
||||
/home/"$USER_NAME"/.cargo \
|
||||
/home/"$USER_NAME"/.vscode-server \
|
||||
/home/"$USER_NAME"/.config/opencode \
|
||||
/home/"$USER_NAME"/.config/nvim \
|
||||
/home/"$USER_NAME"/.pi \
|
||||
/home/"$USER_NAME"/.ssh-local \
|
||||
/home/"$USER_NAME"/.agents/skills; do
|
||||
[ -d "$dir" ] || continue
|
||||
|
||||
# Sentinel-file fast path: on volumes with thousands of files (nvim
|
||||
# plugins, palace data) the recursive chown used to cost multiple
|
||||
# seconds on every container start even when ownership was already
|
||||
# correct. Now we write a sentinel after a successful chown and skip
|
||||
# the walk when the sentinel matches the target UID:GID.
|
||||
#
|
||||
# If USER_UID changes between runs (user switches hosts, different
|
||||
# workspace owner), the sentinel won't match and the full chown runs.
|
||||
sentinel="$dir/.devbox-owner"
|
||||
expected="$FINAL_UID:$FINAL_GID"
|
||||
if [ -f "$sentinel" ] && [ "$(cat "$sentinel" 2>/dev/null)" = "$expected" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Recursive chown needed. Only do it when the top-level differs too
|
||||
# (covers the common case of fresh root-owned named volumes).
|
||||
if [ "$(stat -c '%u' "$dir" 2>/dev/null)" != "$FINAL_UID" ]; then
|
||||
chown -R "$FINAL_UID":"$FINAL_GID" "$dir" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Write sentinel so subsequent starts skip the recursive walk.
|
||||
# Suppress errors — a read-only mount would fail here, but that would
|
||||
# already have failed above on the chown itself.
|
||||
echo "$expected" > "$sentinel" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# ── Drop to developer user for remaining setup ──────────────────────
|
||||
exec gosu "$USER_NAME" /usr/local/bin/entrypoint-user.sh "$@"
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# opencode-devbox bash aliases and customizations
|
||||
# Sourced by the Debian-default ~/.bashrc on shell startup.
|
||||
# To override, bind-mount your host's ~/.bash_aliases over this file
|
||||
# via docker-compose.yml.
|
||||
|
||||
# ── Host-shared shell customizations (devbox-shell bridge) ───────────
|
||||
# If the host bind-mounts a directory at ~/.config/devbox-shell/ (the
|
||||
# recommended pattern for sharing aliases/PATH/utilities between host
|
||||
# and container), source the bash_aliases file from it. This survives
|
||||
# --force-recreate because it's baked into the image's skel, not the
|
||||
# container's writable layer. Hosts that don't use this pattern are
|
||||
# unaffected — the test silently skips if the file doesn't exist.
|
||||
[ -r "$HOME/.config/devbox-shell/bash_aliases" ] && . "$HOME/.config/devbox-shell/bash_aliases"
|
||||
|
||||
# ── History persistence and quality ──────────────────────────────────
|
||||
# The named volume devbox-shell-history is mounted at ~/.cache/bash
|
||||
# so history survives container recreation.
|
||||
export HISTFILE="${HOME}/.cache/bash/history"
|
||||
mkdir -p "$(dirname "$HISTFILE")" 2>/dev/null || true
|
||||
|
||||
# Large, time-stamped, deduplicated history. Append rather than overwrite.
|
||||
export HISTSIZE=100000
|
||||
export HISTFILESIZE=200000
|
||||
export HISTCONTROL=ignoreboth:erasedups
|
||||
export HISTTIMEFORMAT='%F %T '
|
||||
shopt -s histappend 2>/dev/null
|
||||
shopt -s cmdhist 2>/dev/null
|
||||
# Note: PROMPT_COMMAND="history -a" is installed LATER in this file,
|
||||
# after zoxide's init runs. Installing it here would create a
|
||||
# "history -a;;__zoxide_hook" chain because zoxide's init uses ';'
|
||||
# as its separator and prepends itself; two adjacent ';' breaks the
|
||||
# parser. See https://github.com/ajeetdsouza/zoxide/issues/722.
|
||||
|
||||
# ── Common aliases ───────────────────────────────────────────────────
|
||||
# Prefer eza (modern ls) when available
|
||||
if command -v eza >/dev/null 2>&1; then
|
||||
alias ls='eza --group-directories-first'
|
||||
alias ll='eza -lh --group-directories-first --git'
|
||||
alias la='eza -lha --group-directories-first --git'
|
||||
alias tree='eza --tree'
|
||||
else
|
||||
alias ll='ls -lh'
|
||||
alias la='ls -lha'
|
||||
fi
|
||||
|
||||
# Prefer bat (syntax-highlighted cat) when available
|
||||
if command -v bat >/dev/null 2>&1; then
|
||||
alias cat='bat --style=plain --paging=never'
|
||||
alias less='bat --paging=always'
|
||||
fi
|
||||
|
||||
# Git shortcuts
|
||||
alias gs='git status'
|
||||
alias gd='git diff'
|
||||
alias gl='git log --oneline --graph --decorate -20'
|
||||
|
||||
# ── LAN access via the host (dssh) ───────────────────────────────────
|
||||
# When running on a VM-backed host (macOS OrbStack / Docker Desktop), the
|
||||
# entrypoint's setup-lan-access.sh generates ~/.ssh-local/config so the host
|
||||
# can be used as an SSH jump to reach LAN peers. These aliases wrap `ssh -F`
|
||||
# / `scp -F` against that config. Guarded so they only appear when the config
|
||||
# was actually generated (no-op / absent on native Linux hosts).
|
||||
if [ -r "$HOME/.ssh-local/config" ]; then
|
||||
alias dssh='ssh -F "$HOME/.ssh-local/config"'
|
||||
alias dscp='scp -F "$HOME/.ssh-local/config"'
|
||||
fi
|
||||
|
||||
# Safety: confirm before destructive ops
|
||||
alias rm='rm -i'
|
||||
alias mv='mv -i'
|
||||
alias cp='cp -i'
|
||||
|
||||
# ── Shell integrations ───────────────────────────────────────────────
|
||||
# zoxide — smarter cd. Use 'z <fragment>' to jump to previously-visited dirs.
|
||||
if command -v zoxide >/dev/null 2>&1; then
|
||||
eval "$(zoxide init bash)"
|
||||
fi
|
||||
|
||||
# fzf — fuzzy finder key bindings (Ctrl-R for history, Ctrl-T for files).
|
||||
# We install fzf from GitHub releases (not apt), so sourcing from the
|
||||
# apt-path /usr/share/doc/fzf/examples/* would find nothing. Use the
|
||||
# binary's own --bash flag (available since fzf 0.48) for setup.
|
||||
if command -v fzf >/dev/null 2>&1; then
|
||||
eval "$(fzf --bash)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── PROMPT_COMMAND: flush history every prompt ───────────────────────
|
||||
# Installed AFTER zoxide init so zoxide's hook is already in place;
|
||||
# we append with a newline separator to avoid the ';;' parse error
|
||||
# described at the top of this file. Guarded so repeated sourcing
|
||||
# (e.g. `exec bash`) doesn't stack duplicates.
|
||||
if [ -z "${DEVBOX_HIST_SET:-}" ]; then
|
||||
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a"
|
||||
export DEVBOX_HIST_SET=1
|
||||
fi
|
||||
|
||||
# ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container
|
||||
# Preserves the default Debian PS1 logic but prefixes with a container marker.
|
||||
# We check for the literal '[devbox]' substring in PS1 rather than relying on
|
||||
# an exported guard variable — otherwise `exec bash` inherits the guard but
|
||||
# gets a fresh (prefix-less) PS1 from .bashrc, and the prefix would never be
|
||||
# re-added in the new shell.
|
||||
if [ -n "${PS1:-}" ] && [[ "$PS1" != *"[devbox]"* ]]; then
|
||||
PS1='\[\e[38;5;39m\][devbox]\[\e[0m\] '"${PS1}"
|
||||
fi
|
||||
@@ -0,0 +1,27 @@
|
||||
# opencode-devbox readline defaults
|
||||
# To override, bind-mount your host's ~/.inputrc over this file
|
||||
# via docker-compose.yml.
|
||||
|
||||
# Inherit system-wide defaults (colour, 8-bit input, …) if present
|
||||
$include /etc/inputrc
|
||||
|
||||
# ── History search on Up/Down ────────────────────────────────────────
|
||||
# Type a prefix, press Up, and walk through previous commands starting
|
||||
# with that prefix. Ctrl-Up / Ctrl-Down keep the unconditional stepper.
|
||||
"\e[A": history-search-backward
|
||||
"\e[B": history-search-forward
|
||||
"\e[1;5A": previous-history
|
||||
"\e[1;5B": next-history
|
||||
|
||||
# ── Completion quality ───────────────────────────────────────────────
|
||||
set show-all-if-ambiguous on # single Tab shows matches on ambiguity
|
||||
set completion-ignore-case on # case-insensitive file/dir completion
|
||||
set colored-stats on # colour ls-style completion list entries
|
||||
set colored-completion-prefix on # highlight the matched prefix
|
||||
set visible-stats on # append /*@ type indicators in completion
|
||||
set mark-symlinked-directories on # add trailing / to symlinks to dirs
|
||||
set skip-completed-text on # don't re-insert already-typed text
|
||||
|
||||
# Treat hyphens and underscores as equivalent when completing (e.g.
|
||||
# typing `foo-` matches both `foo-bar` and `foo_bar`).
|
||||
set completion-map-case on
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate opencode.json from environment variables on first container start.
|
||||
|
||||
Safety guarantees:
|
||||
- NEVER overwrites an existing opencode.json. If the file is present
|
||||
(whether bind-mounted from the host, persisted in a named volume, or
|
||||
previously generated), this script exits immediately without writing.
|
||||
- Requires OPENCODE_PROVIDER to be set. Without it, no file is written.
|
||||
|
||||
Environment variables:
|
||||
OPENCODE_PROVIDER Required. One of: anthropic, openai, amazon-bedrock.
|
||||
OPENCODE_MODEL Optional. Overrides the provider default model.
|
||||
AWS_REGION Bedrock only. Default: us-east-1.
|
||||
AWS_PROFILE Bedrock only. Default: default.
|
||||
|
||||
MCP servers are auto-registered for tools detected on PATH:
|
||||
- mempalace (if installed) — enabled
|
||||
- gitea-mcp (if installed) — registered but disabled by default
|
||||
|
||||
Output path: $HOME/.config/opencode/opencode.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Default model per provider. Update here when upstream changes.
|
||||
DEFAULT_MODELS: dict[str, str] = {
|
||||
"anthropic": "anthropic/claude-sonnet-4-6",
|
||||
"openai": "openai/gpt-5.4",
|
||||
"amazon-bedrock": (
|
||||
"amazon-bedrock/global.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
),
|
||||
}
|
||||
|
||||
# Fallback when OPENCODE_PROVIDER is set but not recognized.
|
||||
FALLBACK_MODEL = DEFAULT_MODELS["anthropic"]
|
||||
|
||||
SCHEMA_URL = "https://opencode.ai/config.json"
|
||||
|
||||
|
||||
def build_config(provider: str, model: str) -> dict:
|
||||
"""Build the base opencode.json structure for a provider."""
|
||||
config: dict = {
|
||||
"$schema": SCHEMA_URL,
|
||||
"model": model,
|
||||
"share": "disabled",
|
||||
"autoupdate": False,
|
||||
}
|
||||
|
||||
if provider == "amazon-bedrock":
|
||||
config["provider"] = {
|
||||
"amazon-bedrock": {
|
||||
"options": {
|
||||
"region": os.environ.get("AWS_REGION", "us-east-1"),
|
||||
"profile": os.environ.get("AWS_PROFILE", "default"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def register_mcp_servers(config: dict) -> list[str]:
|
||||
"""Auto-register MCP servers for tools detected on PATH.
|
||||
|
||||
Returns the list of server names that were added. The "mcp" key
|
||||
is only added to the config when at least one server is registered.
|
||||
"""
|
||||
servers: dict[str, dict] = {}
|
||||
|
||||
# MemPalace — local-first AI memory (if installed).
|
||||
# `mempalace-mcp` is the entry-point binary shipped by the mempalace
|
||||
# Python package. `uv tool install mempalace` places it on PATH as a
|
||||
# shim whose shebang points at the isolated venv's Python, so system
|
||||
# `python3 -m mempalace.mcp_server` (which would fail — system
|
||||
# python3 can't import from the uv venv) is unnecessary here.
|
||||
if shutil.which("mempalace-mcp"):
|
||||
servers["mempalace"] = {
|
||||
"type": "local",
|
||||
"command": ["mempalace-mcp"],
|
||||
}
|
||||
|
||||
# Gitea — self-hosted Git forge API (if installed).
|
||||
# Disabled by default; user must set GITEA_ACCESS_TOKEN + GITEA_HOST
|
||||
# and flip enabled=true in their config.
|
||||
if shutil.which("gitea-mcp"):
|
||||
servers["gitea"] = {
|
||||
"type": "local",
|
||||
"command": ["gitea-mcp", "-t", "stdio"],
|
||||
"enabled": False,
|
||||
}
|
||||
|
||||
# Context7 — up-to-date library documentation for LLMs (remote).
|
||||
# Free tier works without an API key; set CONTEXT7_API_KEY for higher
|
||||
# rate limits. No local binary needed — purely a remote MCP endpoint.
|
||||
servers["context7"] = {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
}
|
||||
|
||||
if servers:
|
||||
config["mcp"] = servers
|
||||
|
||||
return list(servers.keys())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
provider = os.environ.get("OPENCODE_PROVIDER", "").strip()
|
||||
if not provider:
|
||||
# No provider set — nothing to do. Not an error.
|
||||
return 0
|
||||
|
||||
home = Path(os.environ.get("HOME", "/home/developer"))
|
||||
config_dir = home / ".config" / "opencode"
|
||||
config_file = config_dir / "opencode.jsonc"
|
||||
config_file_legacy = config_dir / "opencode.json"
|
||||
|
||||
# CRITICAL: never overwrite an existing config. Users may have
|
||||
# bind-mounted their host config directory, or their config may be
|
||||
# persisted in a named volume from a previous run.
|
||||
# Check both .json and .jsonc variants.
|
||||
if config_file.exists() or config_file_legacy.exists():
|
||||
existing = config_file if config_file.exists() else config_file_legacy
|
||||
print(
|
||||
f"Existing config found at {existing} — "
|
||||
"skipping generation.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
if provider not in DEFAULT_MODELS:
|
||||
print(
|
||||
f"WARNING: unknown OPENCODE_PROVIDER={provider!r}, "
|
||||
f"falling back to default model {FALLBACK_MODEL!r}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
model = os.environ.get("OPENCODE_MODEL", "").strip() or DEFAULT_MODELS.get(
|
||||
provider, FALLBACK_MODEL
|
||||
)
|
||||
|
||||
print(f"Generating opencode config for provider: {provider}", file=sys.stderr)
|
||||
|
||||
config = build_config(provider, model)
|
||||
added = register_mcp_servers(config)
|
||||
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write as JSONC so we can include helpful comments.
|
||||
content = json.dumps(config, indent=2)
|
||||
|
||||
# Insert a comment about Context7 API key after the context7 url line.
|
||||
context7_comment = (
|
||||
' "url": "https://mcp.context7.com/mcp"\n'
|
||||
" // For higher rate limits, sign up at https://context7.com/dashboard\n"
|
||||
' // and add: "headers": { "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" }'
|
||||
)
|
||||
content = content.replace(
|
||||
' "url": "https://mcp.context7.com/mcp"',
|
||||
context7_comment,
|
||||
)
|
||||
|
||||
with config_file.open("w") as f:
|
||||
f.write(content)
|
||||
f.write("\n")
|
||||
|
||||
if added:
|
||||
print(
|
||||
f"MCP servers registered in opencode config: {', '.join(added)}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+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
|
||||
Executable
+220
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate DOCKER_HUB.md.
|
||||
|
||||
Rationale
|
||||
---------
|
||||
DOCKER_HUB.md is the public-facing description shown on Docker Hub. It
|
||||
has two hard constraints the README does not:
|
||||
|
||||
1. A 25 kB byte limit on the full_description field.
|
||||
2. A different audience: Hub readers want a 30-second evaluation —
|
||||
"what is this, how do I run it, does it have what I need" — and
|
||||
reference material is better consulted in context on gitea.
|
||||
|
||||
For a long time this script tried to derive DOCKER_HUB.md from README.md
|
||||
by section selection + targeted replacement. As the README grew that
|
||||
approach pushed against the 25 kB ceiling on every change, costing a
|
||||
trim-something-else exercise per edit (final state: 3 byte headroom).
|
||||
|
||||
The new approach is much simpler: a hand-written HUB_TEMPLATE below.
|
||||
The template intentionally stays slim and links out to the gitea README
|
||||
for everything that benefits from depth. README.md grows freely.
|
||||
|
||||
Trade-off: when image-variants table or quick-start flow changes,
|
||||
update HUB_TEMPLATE here too. That coupling is now explicit and
|
||||
local rather than spread across SECTION_RULES + REPLACEMENTS + TRIM
|
||||
machinery.
|
||||
|
||||
Usage
|
||||
-----
|
||||
Regenerate in place:
|
||||
python3 scripts/generate-dockerhub-md.py
|
||||
|
||||
Fail if DOCKER_HUB.md is out of sync with what this script would emit
|
||||
(run this in CI):
|
||||
python3 scripts/generate-dockerhub-md.py --check
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DOCKER_HUB = REPO_ROOT / "DOCKER_HUB.md"
|
||||
|
||||
# Max size for Docker Hub full_description (bytes, UTF-8).
|
||||
MAX_SIZE_BYTES = 25_000
|
||||
|
||||
# Where readers go for the full reference.
|
||||
GITEA = "https://gitea.jordbo.se/joakimp/opencode-devbox"
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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-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`.
|
||||
|
||||
> A fifth, pi-without-opencode build is produced from the same `Dockerfile.variant`
|
||||
> (`INSTALL_OPENCODE=false`) but is **not** published under this repo — it ships as
|
||||
> the separate [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)
|
||||
> image so an "opencode-devbox" tag never lacks opencode.
|
||||
|
||||
## Quick Start
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/opencode-devbox && cd ~/opencode-devbox
|
||||
curl -O https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/docker-compose.yml
|
||||
curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.example -o .env
|
||||
# Edit .env — set OPENCODE_PROVIDER, the matching API key,
|
||||
# WORKSPACE_PATH, GIT_USER_NAME, GIT_USER_EMAIL.
|
||||
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.
|
||||
|
||||
**One-shot run, no persistence:**
|
||||
|
||||
```bash
|
||||
docker run -it --rm \\
|
||||
-e ANTHROPIC_API_KEY=your-key \\
|
||||
-e OPENCODE_PROVIDER=anthropic \\
|
||||
-e GIT_USER_NAME="Your Name" \\
|
||||
-e GIT_USER_EMAIL="you@example.com" \\
|
||||
-v ~/projects:/workspace \\
|
||||
-v ~/.ssh:/home/developer/.ssh:ro \\
|
||||
joakimp/opencode-devbox:latest
|
||||
```
|
||||
|
||||
Full setup guide — authentication for each provider (Anthropic, OpenAI, Bedrock SSO + static), persistence model, build args, troubleshooting: <{GITEA}#readme>
|
||||
|
||||
## 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.
|
||||
- **[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.
|
||||
|
||||
## Authentication
|
||||
|
||||
The container reads provider credentials from environment variables and host-mounted config:
|
||||
|
||||
- **Anthropic / OpenAI / Groq / others:** set `OPENCODE_PROVIDER` and the corresponding `*_API_KEY` via `-e` or `.env`.
|
||||
- **AWS Bedrock (SSO):** mount `~/.aws` from the host, `OPENCODE_PROVIDER=amazon-bedrock`, then `aws sso login` inside the container. Tokens persist across container restarts via the host bind-mount.
|
||||
- **OAuth / device-code providers:** auth state lives in opencode's config, which is persisted via the `devbox-opencode-config` named volume.
|
||||
|
||||
Full Bedrock walkthrough (IAM roles, permissions, multi-account setups): see the [AWS Bedrock Authentication](
|
||||
{GITEA}#aws-bedrock-authentication
|
||||
) section on gitea.
|
||||
|
||||
## Persistence
|
||||
|
||||
| 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-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) |
|
||||
|
||||
Workspace bind-mount (`/workspace`) is your project directory on the host, so source code is never inside the container.
|
||||
|
||||
Full persistence reference, including multi-user (`SIGNUM`) isolation and host bind-mount alternatives: see the [README on gitea]({GITEA}#persistence).
|
||||
|
||||
## Where to Go Next
|
||||
|
||||
- **Full README** with build args, every feature in detail, troubleshooting: <{GITEA}>
|
||||
- **CHANGELOG** for version history: <{GITEA}/src/branch/main/CHANGELOG.md>
|
||||
- **Issues / source / docker-compose templates:** <{GITEA}>
|
||||
- **Agent-facing internals** (for future maintainers / coding agents working in the repo): <{GITEA}/src/branch/main/AGENTS.md>
|
||||
|
||||
## 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>
|
||||
|
||||
## License
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
|
||||
def generate() -> str:
|
||||
return HUB_TEMPLATE
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Fail if DOCKER_HUB.md differs from generated content.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
content = generate()
|
||||
size = len(content.encode("utf-8"))
|
||||
if size > MAX_SIZE_BYTES:
|
||||
print(
|
||||
f"ERROR: generated DOCKER_HUB.md is {size} bytes, exceeding the "
|
||||
f"Docker Hub limit of {MAX_SIZE_BYTES} bytes.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if args.check:
|
||||
existing = DOCKER_HUB.read_text(encoding="utf-8") if DOCKER_HUB.exists() else ""
|
||||
if existing != content:
|
||||
print(
|
||||
"ERROR: DOCKER_HUB.md is out of sync with the template.\n"
|
||||
"Run: python3 scripts/generate-dockerhub-md.py",
|
||||
file=sys.stderr,
|
||||
)
|
||||
import difflib
|
||||
|
||||
diff = difflib.unified_diff(
|
||||
existing.splitlines(keepends=True),
|
||||
content.splitlines(keepends=True),
|
||||
fromfile="DOCKER_HUB.md (committed)",
|
||||
tofile="DOCKER_HUB.md (generated)",
|
||||
n=2,
|
||||
)
|
||||
sys.stderr.writelines(list(diff)[:80])
|
||||
return 1
|
||||
print(
|
||||
f"OK: DOCKER_HUB.md is in sync with HUB_TEMPLATE "
|
||||
f"({size} bytes, {MAX_SIZE_BYTES} limit, "
|
||||
f"{MAX_SIZE_BYTES - size} bytes headroom).",
|
||||
)
|
||||
return 0
|
||||
|
||||
DOCKER_HUB.write_text(content, encoding="utf-8")
|
||||
print(
|
||||
f"Wrote {DOCKER_HUB} ({size} bytes, {MAX_SIZE_BYTES} limit, "
|
||||
f"{MAX_SIZE_BYTES - size} bytes headroom).",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+431
@@ -0,0 +1,431 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke-test a freshly-built opencode-devbox image.
|
||||
#
|
||||
# Verifies:
|
||||
# - Core binaries are on PATH and runnable
|
||||
# - opencode itself starts and prints a version
|
||||
# - Entrypoint runs cleanly as non-root after UID adjustment
|
||||
# - 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|pi-only]
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 all checks passed
|
||||
# 1 one or more checks failed
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${1:-}"
|
||||
VARIANT="base"
|
||||
if [ "${2:-}" = "--variant" ]; then
|
||||
VARIANT="${3:-base}"
|
||||
fi
|
||||
|
||||
if [ -z "$IMAGE" ]; then
|
||||
echo "usage: $0 <image> [--variant base|omos|with-pi|omos-with-pi|pi-only]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
FAILED=0
|
||||
pass() { echo " ✓ $1"; }
|
||||
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
||||
warn() { echo " ⚠ $1" >&2; }
|
||||
|
||||
# Registration assertions (fork/recall installed by the BASE image's
|
||||
# entrypoint-user.sh via `pi install /opt/<pkg>`) depend on the base, not the
|
||||
# variant layer built here. validate.yml builds variants FROM the published
|
||||
# base-latest, which can lag the entrypoint in the current commit (the base
|
||||
# only rebuilds on a release tag), so a stale base-latest would red the
|
||||
# push-to-main run with a false negative. These checks are therefore warn-only
|
||||
# by default; the release pipeline (docker-publish-split.yml) builds the base
|
||||
# fresh in the same run and sets STRICT_REGISTRATION=1 to enforce them hard.
|
||||
# The build-time /opt + node_modules checks below stay hard in every path —
|
||||
# those are produced by the variant layer and must always be correct.
|
||||
STRICT_REGISTRATION="${STRICT_REGISTRATION:-0}"
|
||||
|
||||
run() {
|
||||
# Run a command inside the image and capture its output.
|
||||
# First arg is a label, rest is the shell command.
|
||||
local label="$1"; shift
|
||||
local out
|
||||
if out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$*" 2>&1); then
|
||||
pass "$label ($(echo "$out" | head -1))"
|
||||
else
|
||||
fail "$label: $out"
|
||||
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 --"
|
||||
# Prints the actual version of every floating component so CI logs
|
||||
# always record what got baked into this image, even when Dockerfile
|
||||
# ARGs default to "latest".
|
||||
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)"
|
||||
printf " %-15s %s\n" "bat" "$(bat --version)"
|
||||
printf " %-15s %s\n" "eza" "$(eza --version | head -2 | tail -1)"
|
||||
printf " %-15s %s\n" "zoxide" "$(zoxide --version)"
|
||||
printf " %-15s %s\n" "uv" "$(uv --version)"
|
||||
printf " %-15s %s\n" "fzf" "$(fzf --version)"
|
||||
printf " %-15s %s\n" "fd" "$(fd --version)"
|
||||
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
|
||||
printf " %-15s %s\n" "bun" "$(bun --version)"
|
||||
fi
|
||||
if command -v mempalace >/dev/null 2>&1; then
|
||||
printf " %-15s %s\n" "mempalace" "$(mempalace --version 2>&1 | head -1 || echo installed)"
|
||||
fi
|
||||
if command -v mempalace-session >/dev/null 2>&1 && [ -d /opt/mempalace-toolkit ]; then
|
||||
printf " %-15s %s\n" "toolkit" "$(git -C /opt/mempalace-toolkit rev-parse --short HEAD 2>/dev/null || echo installed)"
|
||||
fi
|
||||
'
|
||||
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).
|
||||
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v opencode" >/dev/null 2>&1; then
|
||||
run "opencode" "opencode --version"
|
||||
else
|
||||
echo " - opencode not installed (INSTALL_OPENCODE=false)"
|
||||
fi
|
||||
run "node" "node --version"
|
||||
run "npm" "npm --version"
|
||||
run "git" "git --version"
|
||||
run "nvim" "nvim --version | head -1"
|
||||
run "bat" "bat --version"
|
||||
run "eza" "eza --version | head -1"
|
||||
run "zoxide" "zoxide --version"
|
||||
run "uv" "uv --version"
|
||||
run "uvx" "uvx --version"
|
||||
run "rustup-init" "rustup-init --version"
|
||||
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
|
||||
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev/null 2>&1; then
|
||||
run "mempalace" "mempalace --help | head -1"
|
||||
run "mempalace-mcp" "test -x /usr/local/bin/mempalace-mcp && readlink /usr/local/bin/mempalace-mcp"
|
||||
else
|
||||
echo " - mempalace not installed (INSTALL_MEMPALACE=false)"
|
||||
fi
|
||||
|
||||
# mempalace-toolkit wrappers: present unless built with INSTALL_MEMPALACE_TOOLKIT=false
|
||||
# Gated on mempalace presence — wrappers are useless without the CLI.
|
||||
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace && command -v mempalace-session" >/dev/null 2>&1; then
|
||||
run "mempalace-session (toolkit)" "mempalace-session --help | head -1"
|
||||
run "mempalace-docs (toolkit)" "mempalace-docs --help | head -1"
|
||||
run "toolkit symlink target" "test -L /usr/local/bin/mempalace-session && readlink /usr/local/bin/mempalace-session"
|
||||
elif docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev/null 2>&1; then
|
||||
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
|
||||
if [ -n "${EXPECTED_PI_VERSION:-}" ]; then
|
||||
run_expect "pi version matches build-arg" "pi --version" "$EXPECTED_PI_VERSION"
|
||||
else
|
||||
run "pi" "pi --version"
|
||||
fi
|
||||
run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD"
|
||||
run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD"
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool): cloned to
|
||||
# /opt with node_modules baked at build time (a local-path `pi install` does
|
||||
# NOT npm-install, so deps MUST already be present for the extension to load).
|
||||
run "pi-fork clone + node_modules" \
|
||||
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules && echo ok"
|
||||
run "pi-observational-memory clone + node_modules" \
|
||||
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules && echo ok"
|
||||
|
||||
# Run the full entrypoint as developer to verify install.sh deployment.
|
||||
# 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.
|
||||
# The deploy order is: pi-toolkit (writes keybindings.json) -> pi-extensions
|
||||
# (symlinks its *.ts) -> settings.json -> mempalace.ts bridge (LAST). Gating
|
||||
# only on keybindings.json races: it lands when pi-toolkit finishes, before
|
||||
# pi-extensions has symlinked its *.ts, so the "*.ts >= 4" check below could
|
||||
# sample mid-deploy under parallel build load (observed v1.16.2 run 370:
|
||||
# smoke-with-pi saw <4 .ts while omos-with-pi/pi-only saw 8). Wait for the
|
||||
# LAST-deployed artifact (the mempalace.ts bridge symlink) AND a settled
|
||||
# extension count so the deploy is fully complete before any assertion runs.
|
||||
# Up to 45s — pi-bearing variants have more setup work under load.
|
||||
for _ in $(seq 1 45); do
|
||||
if docker exec "$CID" sh -c \
|
||||
'test -L $HOME/.pi/agent/keybindings.json && \
|
||||
test -L $HOME/.pi/agent/extensions/mempalace.ts && \
|
||||
[ "$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l)" -ge 4 ]' \
|
||||
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
|
||||
}
|
||||
|
||||
# Like exec_test but warn-only unless STRICT_REGISTRATION=1 (see note at top).
|
||||
exec_test_reg() {
|
||||
local label="$1"; shift
|
||||
local out
|
||||
if out=$(docker exec -u developer "$CID" sh -c "$*" 2>&1); then
|
||||
pass "$label ($(echo "$out" | head -1))"
|
||||
elif [ "$STRICT_REGISTRATION" = "1" ]; then
|
||||
fail "$label: $out"
|
||||
else
|
||||
warn "$label (warn-only — stale base-latest? set STRICT_REGISTRATION=1 to enforce): $out"
|
||||
fi
|
||||
}
|
||||
|
||||
exec_test "~/.pi/agent/keybindings.json (pi-toolkit)" \
|
||||
'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'
|
||||
|
||||
# pi-fork + pi-observational-memory are registered by entrypoint-user.sh via
|
||||
# `pi install /opt/<pkg>` (records a relative path into settings.json
|
||||
# packages). That runs slightly after the keybindings marker, so wait for it.
|
||||
for _ in $(seq 1 15); do
|
||||
if docker exec "$CID" grep -q pi-observational-memory \
|
||||
/home/developer/.pi/agent/settings.json 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
exec_test_reg "pi-fork registered in settings.json (fork tool)" \
|
||||
'grep -q pi-fork $HOME/.pi/agent/settings.json && echo ok'
|
||||
exec_test_reg "pi-observational-memory registered in settings.json (recall tool)" \
|
||||
'grep -q pi-observational-memory $HOME/.pi/agent/settings.json && echo ok'
|
||||
|
||||
docker rm -f "$CID" >/dev/null 2>&1 || true
|
||||
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
|
||||
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
|
||||
# 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"
|
||||
else
|
||||
pass "bun correctly absent from base image"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Entrypoint behaviour --"
|
||||
|
||||
# Generate-config script exists and has valid syntax.
|
||||
run "generate-config.py exists" \
|
||||
"test -x /usr/local/lib/opencode-devbox/generate-config.py && python3 -m py_compile /usr/local/lib/opencode-devbox/generate-config.py && echo ok"
|
||||
|
||||
# Entrypoint drops to developer user and runs a trivial command.
|
||||
# Writes the result to a file inside the container so we don't have to
|
||||
# disentangle entrypoint log output from command stdout on the host.
|
||||
label="entrypoint drops to developer"
|
||||
tmpout=$(mktemp)
|
||||
if docker run --rm -e OPENCODE_PROVIDER= "$IMAGE" \
|
||||
sh -c 'whoami > /tmp/who && cat /tmp/who' > "$tmpout" 2>/dev/null; then
|
||||
# The last line of stdout is the whoami output. Entrypoint log lines
|
||||
# (MemPalace init, "Adjusted developer UID", etc.) go to stderr or
|
||||
# get printed before our sh command runs.
|
||||
actual=$(tail -1 "$tmpout" | tr -d '[:space:]')
|
||||
if [ "$actual" = "developer" ]; then
|
||||
pass "$label"
|
||||
else
|
||||
fail "$label: expected 'developer', got '$actual' (full output: $(cat "$tmpout"))"
|
||||
fi
|
||||
else
|
||||
fail "$label: container failed"
|
||||
fi
|
||||
rm -f "$tmpout"
|
||||
|
||||
# Config generation with anthropic provider writes valid JSONC with the
|
||||
# expected shape. The script's log message goes to stderr (line 1 of
|
||||
# generate-config.py uses file=sys.stderr) so capturing only stdout
|
||||
# gives us clean JSONC. We strip // comments before validating JSON.
|
||||
label="generate-config produces valid opencode.jsonc"
|
||||
tmp=$(mktemp -d)
|
||||
if docker run --rm \
|
||||
-e OPENCODE_PROVIDER=anthropic \
|
||||
-e HOME=/tmp/home \
|
||||
--entrypoint="" \
|
||||
"$IMAGE" sh -c '
|
||||
mkdir -p /tmp/home
|
||||
python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null
|
||||
cat /tmp/home/.config/opencode/opencode.jsonc
|
||||
' > "$tmp/out.jsonc" 2>/dev/null; then
|
||||
# Strip single-line // comments for JSON validation (respecting strings)
|
||||
if python3 -c "
|
||||
import re, json, sys
|
||||
text = open('$tmp/out.jsonc').read()
|
||||
# Match either a string literal or a // comment; keep strings, drop comments
|
||||
pattern = r'\"(?:\\\\.|[^\"\\\\])*\"|//[^\n]*'
|
||||
stripped = re.sub(pattern, lambda m: m.group(0) if m.group(0).startswith('\"') else '', text)
|
||||
c = json.loads(stripped)
|
||||
assert c['model'].startswith('anthropic/'), c
|
||||
assert c['autoupdate'] is False
|
||||
assert c['share'] == 'disabled'
|
||||
assert 'context7' in c.get('mcp', {}), 'context7 MCP not registered'
|
||||
" 2>&1; then
|
||||
pass "$label"
|
||||
else
|
||||
fail "$label: output doesn't match expected shape: $(cat "$tmp/out.jsonc")"
|
||||
fi
|
||||
else
|
||||
fail "$label: container failed: $(cat "$tmp/out.jsonc")"
|
||||
fi
|
||||
|
||||
# Config generation is idempotent — running twice must not overwrite.
|
||||
# Tests both legacy .json and new .jsonc detection.
|
||||
label="generate-config never overwrites existing config"
|
||||
if docker run --rm \
|
||||
-e OPENCODE_PROVIDER=anthropic \
|
||||
-e HOME=/tmp/home \
|
||||
--entrypoint="" \
|
||||
"$IMAGE" sh -c '
|
||||
mkdir -p /tmp/home/.config/opencode
|
||||
echo "{\"sentinel\": \"user-config\"}" > /tmp/home/.config/opencode/opencode.json
|
||||
python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null
|
||||
cat /tmp/home/.config/opencode/opencode.json
|
||||
' 2>/dev/null | grep -q '"sentinel": "user-config"'; then
|
||||
pass "$label"
|
||||
else
|
||||
fail "$label: existing config was modified!"
|
||||
fi
|
||||
rm -rf "$tmp"
|
||||
|
||||
echo
|
||||
echo "-- Image size --"
|
||||
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.
|
||||
# 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.
|
||||
# with-pi 2700→2900 and omos-with-pi 3700→3900: baking pi-fork +
|
||||
# pi-observational-memory node_modules into /opt (fork pulls its
|
||||
# @earendil-works peer deps, ~150 MB) adds to both pi-bearing variants.
|
||||
# base 2500→2600 on v1.15.13c — base crept to 2506 MB (LAN-access script +
|
||||
# updated entrypoint + routine apt-get upgrade drift), tripping the
|
||||
# deliberately zero-headroom 2500 ceiling and skipping promote-base-latest.
|
||||
# omos variant to ~3.1 GB. Functional smoke checks all pass; this is a
|
||||
# guardrail, not a performance limit.
|
||||
# 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, v1.15.4
|
||||
# omos-with-pi). 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
|
||||
[ "$VARIANT" = "with-pi" ] && THRESHOLD=3050
|
||||
[ "$VARIANT" = "omos-with-pi" ] && THRESHOLD=4050
|
||||
# pi-only = with-pi minus opencode (its platform binary is ~145 MB), so it
|
||||
# lands a bit under base. Threshold 2750 leaves the same headroom pattern.
|
||||
[ "$VARIANT" = "pi-only" ] && THRESHOLD=2850
|
||||
if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then
|
||||
fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT"
|
||||
else
|
||||
pass "image size ${SIZE_MB} MB within threshold ${THRESHOLD} MB"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo "=== FAILED: $FAILED check(s) ===" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "=== PASSED ==="
|
||||
@@ -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