Not tagged yet; this is the v2.9.0 changeset landing on main. Added - agent-browser + a Playwright-managed headless Chromium in the base (~625 MB after deleting the redundant chromium_headless_shell build), so an agent can drive a real browser and VERIFY front-end work instead of assuming it renders. Ported from pi-devbox. AGENT_BROWSER_EXECUTABLE_PATH points at the stable symlink /usr/local/bin/agent-chrome, which the Dockerfile resolves with `find` rather than hardcoding: Playwright's browser dir is per-version AND per-arch (chrome-linux on arm64, chrome-linux64 on amd64), and the headless shell binary is named chrome-headless-shell so `-name chrome` skips it. - opencode-devbox-version: a reader for the build manifest. The image has baked ground truth to /etc/opencode-devbox/build-manifest.json for several releases, but nothing read it and nothing printed it — so "which image am I running?" meant knowing the path by heart. Three modes (--json/--quiet/human), plus a live-vs-baked drift check, because NPM_CONFIG_PREFIX points at the persistent config volume and a user `npm install -g opencode` can shadow the baked binary. entrypoint-user.sh prints it as its first output. - ENV COLORTERM=truecolor, completing a true-colour story the image already half-shipped (terminfo entries + Neovim termguicolors, but no capability advertisement, so bat/delta fell back to 256 colours). - Smoke assertions for agent-browser, that agent-chrome resolves to an executable (catches a Playwright layout change, not just a dangling symlink), COLORTERM, the manifest's release_tag, and all three version-command modes. Changed - opencode 1.17.20 -> 1.18.13. Verified by diffing upstream source, not release notes: core config.ts, config/provider.ts and schema.json are byte-identical, so generate-config.py needs no change. 1.18.13 (published mid-audit) was re-verified separately — 249 files in the compare payload, under GitHub's 300-file cap, so the list is complete rather than truncated; content is the Electron app plus localisation; the five contract-surface files hash identical at both tags. The bg-subagents removal trigger has NOT fired: runtime-flags.ts still gates the flag behind OPENCODE_EXPERIMENTAL at all three tags. - yq: dropped Debian's apt package (the unrelated Python kislyuk/yq — jq syntax, 3.x line) for mikefarah's Go yq v4 from GitHub. The cloud-init repo's provision.sh/deploy.sh need v4 syntax, and THIRD_PARTY.md already credited "yq (mikefarah)" while the image shipped the Python one, so this also closes a documented-vs-shipped mismatch. Smoke pins the contract to mikefarah v4. BEHAVIOUR CHANGE for any in-image script calling yq with jq-style syntax. - mempalace pin 3.5.0 -> 3.6.0, in lockstep with pi-devbox (5724302). Reviewed for MCP tool-schema changes before bumping — none, and nothing touches diary_write. - Default models -> claude-opus-5 (anthropic, and bedrock's global.anthropic.claude-opus-5) and openai/gpt-5.6. gpt-5.4 had gone stale: gpt-5.6 shipped four days before the v2.8.0 cut. Affects only new containers with no OPENCODE_MODEL and no existing config. - Smoke size thresholds +650 MB (base 2950->3600, omos 3650->4300), sized to keep the same ~250 MB headroom so the guardrail still catches runaway growth rather than routine apt drift. Do NOT copy pi-devbox's number: it sums `docker history`, this repo uses `docker image inspect .Size`. Documentation - New README section "Choosing a provider and model", making explicit that the baked defaults are only defaults and nobody is locked to Anthropic/Bedrock, including the three real gotchas: defaults seed only a NEW config, an existing opencode.jsonc on the persistent volume is never rewritten, and switching model needs no rebuild. - New README section "Browser automation (agent-browser)"; opencode-devbox-version documented under Build provenance; COLORTERM under Terminal compatibility. - README Build Args table drift fixed — FOUR missing args added (AGENT_BROWSER_VERSION, PLAYWRIGHT_VERSION, YQ_VERSION and GITLEAKS_VERSION, the last of which had existed as an ARG but was never listed), plus rows for the two pinned args absent entirely (MEMPALACE_VERSION, DEBIAN_VERSION), plus a refreshed stale OPENCODE_VERSION example. Third consecutive release to find drift in this table. - AGENTS.md: the stale MemPalace anyOf convention rewritten. It described a perl RUN block already DELETED at the 3.5.0 bump and asserted "PyPI latest is 3.4.0 (== our pin), no release contains the fix yet, the workaround must stay" — all three false. Replaced with a pin-review rule. Two new conventions added: the agent-browser/Chromium size coupling, and the yq identity trap. - THIRD_PARTY.md: agent-browser, Playwright, Chromium. Verified locally with the CI-pinned hadolint 2.14.0 and actionlint 1.7.7, the shell guard, DOCKER_HUB.md sync, bash -n, py_compile, and by generating the config for all three providers.
37 KiB
AGENTS.md
Project overview
Docker image packaging opencode into a production-ready dev container. Image variants are published to Docker Hub via Gitea Actions CI. Not a library or application — this is infrastructure (Dockerfiles, entrypoint scripts, docker-compose, documentation).
pi was removed in v2.0.0 (deprecated since v1.17.2). The
INSTALL_PIbuild arg, thewith-pi/omos-with-pi/pi-onlyvariants, thebase-pi-onlypublished tag, and all~/.pi-related wiring are gone. pi now ships from its own repo (joakimp/pi-devbox). Do not add pi functionality here. The removal history + theNPM_CONFIG_PREFIXrelocation (~/.pi/npm-global→~/.config/opencode/npm-global, with a one-time migration shim inentrypoint-user.sh) are recorded indocs/CLEANUP-v2.0.0.mdand the v2.0.0 CHANGELOG entry.
File roles
Dockerfile.base— variant-independent layers (apt, locales, AWS CLI, Node.js, mempalace, gitea-mcp, user setup, chromadb prewarm, ENVs, entrypoints). Published asjoakimp/opencode-devbox:base-<sha12>. Rebuilt only when its content hash changes.Dockerfile.variant—FROMs the base and adds only opencode/omos installs gated by build args:INSTALL_OPENCODE(default true),INSTALL_OMOS, andINSTALL_MEMPALACE. All GitHub-sourced binaries are pinned with version ARGs. Two variants:base(INSTALL_OPENCODE=true) andomos(+INSTALL_OMOS=true).entrypoint.sh— runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via.devbox-ownersentinel when ownership is already correct). Then drops to developer via gosu.entrypoint-user.sh— runs as developer: git config, opencode.jsonc generation (delegated togenerate-config.py), LAN-access setup (delegated tosetup-lan-access.sh), a one-time npm-global prefix migration shim (legacy~/.pi/npm-global→~/.config/opencode/npm-global), skillset auto-deploy from mounted skillset repo, OMOS bundled-skills reconcile (symlinks the image's bundled skills into~/.agents/skills/), image-baked fallback-skills reconcile (symlinks/usr/local/share/opencode-devbox/skills/*into~/.agents/skills/only-when-absent) + harness-instruction reconcile (symlinks/usr/local/share/opencode-devbox/instructions/*.mdinto~/.config/opencode/instructions/), OMOS config setup.rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh— host-OS-agnostic LAN reachability helper. Always writes the writable~/.ssh-local/configsidecar on every host OS: aHost *block that redirectsControlPathinto~/.ssh-local/cm/(first-value-wins over any read-only~/.ssh-bound per-host setting) plusInclude ~/.ssh/config. On VM-backed hosts (macOS OrbStack / Docker Desktop, detected viahost.docker.internalresolution) it additionally inserts the host-jump block; on native Linux that block is omitted (LAN is reachable directly) but the sidecar is still rendered. Previously the script exited early on native Linux, leavingdssh/dscpbroken when~/.sshwas read-only there. Controlled byDEVBOX_LAN_ACCESS/HOST_SSH_USER/DEVBOX_HOST_ALIAS/DEVBOX_LAN_AUTOJUMP_PRIVATE. Ships the mechanism only (generichostjump alias); user targets stay host-side — named-peerProxyJump hostoverrides go in a bind-mounted~/.config/devbox-shell/ssh-lan.conf(Included before~/.ssh/config), never baked into the image. Scoping invariant: everyIncludein the generated config MUST be preceded by a bareHost *reset — anIncludeis scoped to the enclosingHost/Matchblock, so without the reset the included config only applies when targetinghost/macand named peers fall back to SSH defaults. Non-fatal. Counted in the base hash, so editing it advancesbase-latest.rootfs/usr/local/lib/opencode-devbox/generate-config.py— generates~/.config/opencode/opencode.jsoncfrom env vars. Never overwrites an existing config (checks both.jsonand.jsonc). Auto-registers MCP servers for detected tools (mempalace viamempalace-mcp, gitea-mcp, context7 remote endpoint).rootfs/usr/local/bin/opencode-devbox-version— reader for the build manifest thatDockerfile.variantbakes at/etc/opencode-devbox/build-manifest.json. Three modes (--json,--quiet, default human) plus a live-vs-bakedopencode --versiondrift check (a usernpm install -g opencodelands on the persistent config volume and can shadow the baked/usrbinary). Printed as the first line ofentrypoint-user.shso "which image am I in?" is answered at start. Added v2.9.0 — before that the manifest was baked but nothing read it. Lives underrootfs/, so editing it advances the base content hash.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/recreate-sanity-check.sh— runtime post-recreate verification (counterpart to the build-timesmoke-test.sh). Run inside the container afterdocker compose up -d --force-recreateto confirm the new image is live (opencode version matchesDockerfile.variant'sOPENCODE_VERSION), persisted named volumes survived (mempalace palace, opencode.db, bash-history), omos runtime skill symlinks resolve, shell defaults re-seeded, and/opttoolkits intact. Not run by CI or the entrypoint — it needs the running container + volumes that smoke-test.sh (which uses--entrypoint="") cannot see.scripts/generate-dockerhub-md.py— generatesDOCKER_HUB.mdfrom a hand-maintainedHUB_TEMPLATEconstant.--checkfails if the committed file is out of sync (enforced by thevalidateworkflow).DOCKER_HUB.md— auto-generated fromHUB_TEMPLATEinscripts/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 ofDOCKER_HUB.md: the Hub doc is hand-maintained in the generator'sHUB_TEMPLATEand 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— amd64 build + smoke test for both variants. No push trigger at all:pull_requestandworkflow_dispatchonly, so it never runs on a push to main. See the trigger-model convention below..gitea/workflows/docker-publish-split.yml— production CI pipeline on tag push (v*). Two-phase split-base: computes base hash, conditionally builds base, runs 2 parallel smoke tests, then 2 parallel multi-arch variant builds, promotesbase-latestalias, updates Docker Hub description..gitea/workflows/lint.yml— the repo's cheap-checks workflow (~30s) and the only workflow that runs on a push to main. Three jobs: (1)actionlint— a Gitea-accurate shell guard (scripts/check-workflow-shell.sh) plus pinned actionlint + shellcheck, asserting everyrun:step resolves tobashunder Gitea'sshdefault and closing the actionlint blind spot (actionlint models GitHub, whose defaultrunshell is bash); (2)hadolint— pinned 2.14.0 against both Dockerfiles, config in.hadolint.yamlso a local run reproduces CI; (3)docs-check—DOCKER_HUB.mdvsHUB_TEMPLATEsync, moved here fromvalidate.ymlso it survives that workflow becoming PR/dispatch-only. Ported from pi-devbox.
Versioning scheme
Image tags follow independent semver — they version this image, not the
bundled opencode release. v2.0.0 is the decoupling point (the pi-removal
breaking release); from there the opencode npm version is tracked in
CHANGELOG.md and the OPENCODE_VERSION ARG but no longer drives the tag. This
mirrors the sibling joakimp/pi-devbox
repo, which decoupled from the pi tool version at its own v1.0.0.
- MAJOR — breaking changes to how users run/configure the container (volume
layout, removed variants/build-args, an entrypoint contract change that
requires user action).
v2.0.0(pi removal + npm-prefix relocation) is the reference example. - MINOR — backward-compatible features: new variants/tags, new opt-in
behavior, new env vars, or changed-but-compatible semantics. Example:
v2.1.0added the OMOS bundled-skills image-symlink mechanism;v2.3.0added the image-baked fallback skills + harness-instruction mechanism. - PATCH — opencode/tool version bumps and small fixes that don't change the contract. When a release pairs a tool bump with a feature, the feature wins and it's a minor.
- Pre-flight check — before triggering any release CI build, check whether opencode is behind and offer to bump it. Compare the pinned
OPENCODE_VERSIONinDockerfile.variantagainst the latest npm release (./check-versions.sh Dockerfile.variantflags→ X availablefor opencode — note the base-only pins likeGOSU_VERSIONlive inDockerfile.base, so run it against that too for a full sweep). If the latest stableopencode-aiis newer than the pin, ASK THE USER whether to bump before the build — never bump silently; a version bump is a functional change they may want to hold or schedule. When a bump is part of the release, re-check theOPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTSremoval-trigger against the new version's release notes (see the bg-subagents note under Critical conventions), then verify the pin is real before claiming it in the CHANGELOG:Historical note: under the oldnpm view opencode-ai version # must equal the X.Y.Z you pin in Dockerfile.variantv{opencode_version}[letter]scheme a mismatched tag was a namespace hazard — e.g.v1.15.12was cut while opencode was still1.15.11, then re-cut asv1.15.11c(2026-05-28), costing a CI cycle. Semver tags no longer encode the opencode version, so that specific collision class is gone — but a CHANGELOG that names the wrong upstream version is still wrong.
CI produces four Docker Hub tags under opencode-devbox per release: vX.Y.Z, latest, vX.Y.Z-omos, latest-omos — one tag pair (versioned + floating alias) per variant (two variants: base, omos).
When bumping the opencode version, bump OPENCODE_VERSION in Dockerfile.variant and update the comment in .env.example if it names a specific model/version for context.
Upstream sources — where to look up release notes
When drafting a release CHANGELOG entry, pull notes from the canonical upstream repo for each tracked package. Getting this wrong leads to thin or wrong release notes; the image bytes are unaffected but the documentation suffers.
| Package | Canonical upstream | What you'll find there |
|---|---|---|
opencode-ai (npm) |
https://github.com/anomalyco/opencode/releases | Per-version release notes with Core / TUI / Desktop / SDK sections, contributor attributions. Some versions have empty bodies (internal/no-user-visible); most do not. |
| Other floated tools (gosu, fzf, bat, eza, zoxide, uv, nvim, gitea-mcp, Go, oh-my-opencode-slim) | Each project's own GitHub releases page | Usually less material per release; quote selectively. |
Where opencode actually lives (read this before you go looking): the canonical opencode repo is github.com/anomalyco/opencode. It used to be github.com/sst/opencode and was renamed/moved to anomalyco/opencode months ago — sst/opencode is the same repo and now issues a 301 → anomalyco/opencode (verified 2026-06-16). It is not a separate fork. Old sst/opencode links still resolve via the redirect, but always treat anomalyco/opencode as the source of truth for releases, PRs, and issues so search results pointing at the old name don't surprise you. Quick check: curl -sI https://github.com/sst/opencode | grep -i location → anomalyco/opencode.
Fetch pattern (saved here for muscle memory):
# Latest stable opencode-ai versions on npm
npm view opencode-ai time --json | python3 -c 'import sys,json,re; d=json.load(sys.stdin); print(*sorted([(v,t) for v,t in d.items() if re.fullmatch(r"\d+\.\d+\.\d+",v)], key=lambda x:x[1], reverse=True)[:6], sep="\n")'
# Release notes for a specific version
curl -s https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.15.10 | python3 -c 'import sys,json; print(json.load(sys.stdin).get("body","(empty)"))'
Critical conventions
-
entrypoint.sh volume ownership loop — when adding a new named volume mount point, add it to the
for dir in ...loop inentrypoint.shso root-owned volumes get chowned on startup. The loop writes a.devbox-ownersentinel 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.mdis the source of truth for user-facing build/run/config detail.DOCKER_HUB.mdis auto-generated fromHUB_TEMPLATEinscripts/generate-dockerhub-md.py. CI's--checkrun 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.mdrecords every release. When cutting a tag, promote## Unreleasedto## vX.Y.Z[n] — YYYY-MM-DDBEFORE pushing the tag so the tag points at a CHANGELOG that names itself. Keep entries reverse-chronological (newest at top, after theUnreleasedblock). Doc-only updates that happen post-tag (Hub description live-patches, README clarifications) get a fresh## Unreleasedblock 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/*.ymlor the variant matrix, grep this file for stale numbers (grep -nE "four|eight|all [0-9]")..env.examplemust 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.pyrefactor 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/latestLocation redirect (or the go.dev JSON feed for Go) and derives the concrete version. Use the sameARCHcase-switch pattern for multi-arch support (amd64/arm64) — mind project-specific arch-name deviations (gitleaks usesx64, bat/eza/zoxide usex86_64/aarch64, gosu usesamd64/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 — and add its*_VERSIONARG to the README Build Args table (the floated-latestrow alongsideGOSU_VERSION, FZF_VERSION, …). That table is a separate exhaustive list from the env-var table and has silently drifted before:MICRO_VERSION,TEALDEER_VERSION, andTYPST_VERSIONwere missing from it for three releases (v2.4.0–v2.7.0), caught only by the 2026-07-13 doc-drift audit. -
Resolved versions are logged by the smoke test —
scripts/smoke-test.shprints a "Resolved component versions" table as its first step. CI logs always capture what got baked into a given image even when ARGs default tolatest. -
OMOS_VERSIONMUST be passed by CI as a concrete version, not left at thelatestdefault. The npm install step inDockerfile.variant(oh-my-opencode-slim@${OMOS_VERSION}) produces an identical layer-hash when the ARG value is byte-identical across builds; combined with the registry buildcache (base-buildcache) the layer gets reused even whenlatestwould 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 byOPENCODE_VERSIONbeing a hard-coded ARG that bumps every release — that bump invalidates the parent-chain cache key for the downstream omos layer — but the masking would fail the moment avN.N.Nbopencode-version-unchanged release ships that only bumps omos. Preventative fix:.gitea/workflows/docker-publish-split.ymlhas aresolve-versionsjob that runsnpm view oh-my-opencode-slim version, exposing the concrete value as an output that the omos smoke + build jobs consume via build-args. Smoke tests assert via theEXPECTED_OMOS_VERSIONenv var — would catch the regression on the next release rather than several releases later. If you change the variant build-args list, the resolve-versions job, or the smoke EXPECTED_*_VERSION wiring, audit all affected jobs in lockstep. -
resolve-versionsalso pinsmempalace-toolkitto a SHA —resolve-versionsresolves themempalace-toolkitmainHEAD to a commit SHA (mempalace_toolkit_refoutput) via the gitea commits API (/api/v1/repos/joakimp/mempalace-toolkit/commits?limit=1&sha=main; gitea allows unauthenticated public-repo listing, so no secret is required). Unlike every other dependency,mempalace-toolkitis cloned inDockerfile.base, not the variant — so the resolve→build-arg→variant plumbing bypasses it. To make a moved toolkit actually land, the resolved SHA is folded into thebase-decidehash (sobase_tagchanges → base rebuilds) AND passed tobuild-baseas--build-arg MEMPALACE_TOOLKIT_REF. Consequentlybase-decidenow depends onresolve-versions(they no longer run in parallel), and the base clone uses a SHA-capablegit fetch <ref> + checkout FETCH_HEAD(agit clone --branch <40-char-SHA>would fail). Trade-off:base_tagnow reflects a live gitea lookup — on an API blip it falls back tomain, triggering one extra base rebuild, never a missed one. If you touchresolve-versions,base-decide's hash inputs, or thebuild-basebuild-args, audit.gitea/README.mdStep 1 in lockstep. -
Registry buildkit cache-export is currently disabled — do NOT re-add
cache-from/cache-toto thebuild-basestep in.gitea/workflows/docker-publish-split.ymlwithout first verifying that buildkit'smode=maxcache-export toregistry-1.docker.iono 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-towhile image push worked fine. Failure shape is stable (Offset:0in the_statetoken, HTML response body = CDN-tier rejection, not registry backend), repo-specific (we're the only repo writing:base-buildcachemode=max), and explains why pinningsetup-buildx-action@v4.0.0didn'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 inbase-decideand 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.12Unreleasedblock for the full diagnostic chain. Manual escape-hatch publish procedure:docs/manual-host-publish.md. -
Push steps wrap
docker buildx build --pushin a 3-attempt retry loop (15s, 30s backoff) for transientregistry-1.docker.ioblips — rate limits, brief 5xx, CDN flap. Implemented as inlineshell: bashsteps withdocker buildx buildraw rather thandocker/build-push-action@v7so the loop is visible and tweakable. Affects the 1 base + 2 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 theci-release-watcherskill'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. -
Background subagents flag baked ON — experimental, watch for promotion —
Dockerfile.basesetsENV OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true. opencode gates native background subagents behind this flag (packages/opencode/src/tool/task.tsfails withBackground subagents require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=truewhen unset);oh-my-opencode-slimV2+ makes background orchestration its default workflow, so the omos variant is effectively degraded without it. It's a base ENV (applies to both variants; harmless on plain opencode — only enables a capability) and stays runtime-overridable (-e …=false). It's counted in the base hash, so editing that line advancesbase-latest. REMOVAL TRIGGER: when opencode promotes background subagents out ofEXPERIMENTAL_(flag renamed or made default), drop the ENV. No upstream roadmap date as of opencode 1.18.13 / omos 2.2.9 (2026-08) — re-verified at all three of the 1.17.20, 1.18.12 and 1.18.13 tags thatpackages/opencode/src/effect/runtime-flags.tsis unchanged and still gates the flag behindOPENCODE_EXPERIMENTAL. Documented in lockstep in README env table,.env.example, and asserted byscripts/smoke-test.sh(bg-subagents env baked). -
agent-browser + Chromium is the base's size driver — thresholds are coupled —
Dockerfile.baseinstalls theagent-browserCLI and a Playwright-managed Chromium (~625 MB after dropping the redundantchromium_headless_shell-*build). It is by far the largest single thing in the image and ships in both variants. The Chrome binary is reached via the stable symlink/usr/local/bin/agent-chrome(exposed asAGENT_BROWSER_EXECUTABLE_PATH) and the Dockerfilefinds it rather than hardcoding a path, because Playwright's browser dir is per-version and per-arch (chrome-linuxon arm64,chrome-linux64on amd64). If you add anything else large, or remove this layer in a fork, update the size thresholds inscripts/smoke-test.shin the same commit — a threshold trip mid-release causes a partial publish and a letter-suffix recovery cycle. Do not copy pi-devbox's threshold number: it sumsdocker historywhile this repo usesdocker image inspect .Size. -
yqhere means mikefarah's Go v4, not Debian'syq— Debian/Ubuntu'syqapt package is the unrelated Python kislyuk/yq (a jq-syntax wrapper on a 3.x line). Since v2.9.0 the apt package is removed and the mikefarah binary is installed from GitHub, because thecloud-initrepo'sprovision.sh/deploy.shrequire v4 syntax — and becauseTHIRD_PARTY.mdhad credited "yq (mikefarah)" while the image actually shipped the Python one.scripts/smoke-test.shpins the contract withyq --version | grep -qE 'mikefarah.*version v4', so both a regression to the apt package and a future yq v5 fail CI. Do not "simplify" this back into the apt list, and do not install both — with twoyqbinaries on PATH the meaning ofyqsilently depends on PATH order. -
MemPalace pin — schema-regression watch target (workaround already removed) —
MEMPALACE_VERSIONis deliberately pinned (currently 3.6.0) rather than floated, because an unpinneduv tool install mempalaceis what once silently swept in a brokendiary_writeschema. History: mempalace 3.3.x/3.4.0 advertised a root-levelanyOfonmempalace_diary_write'sinputSchema, which Anthropic's tools API (and Codex) reject at session start (input_schema does not support oneOf, allOf, or anyOf at the top level), making the whole MCP server fail to load.Dockerfile.baseused to carry a perl RUN block that stripped it. That workaround is gone — upstream fixed it in 3.5.0 (issue #1728 / PR #1717, merged 2026-06-14;diary_writenow advertises"required": ["agent_name"]and enforces entry/content at dispatch), so the block was deleted when the pin moved to 3.5.0. 3.6.0 (2026-07-17) was reviewed for schema changes before bumping: it is purely additive/reliability (secureserveremote mode, optional Milvus, atomic KGsupersede(), mining exclusions) and touches no MCP tool schema. Ongoing rule: before bumping this pin, diff the release notes for anything touching MCP tool schemas — that is the regression class this pin exists to catch — and bump in lockstep with pi-devbox'sMEMPALACE_VERSION. NOTE:MEMPALACE_VERSION(the PyPI pin) andMEMPALACE_TOOLKIT_REF(the git ref for themempalace-toolkitclone) are unrelated despite the shared prefix; do not conflate them. -
MemPalace install path — installed via
uv tool installinto/opt/uv-tools/mempalace/. Both themempalaceCLI and themempalace-mcpMCP 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 usepip install --break-system-packages— that was the previous approach and has been removed. Do not use["python3", "-m", "mempalace.mcp_server"]inopencode.jsonc— system Python can't import from the uv venv. -
generate-config.py idempotency — the script MUST never overwrite an existing
opencode.jsoncor legacyopencode.json. Config persists in thedevbox-opencode-confignamed volume; accidentally clobbering that file would destroy hand-edits. The smoke test asserts this. -
Skillset auto-deploy — on every container start,
entrypoint-user.shlooks for a skillset repo (detection order:$SKILLSET_CONTAINER_PATH→$HOME/skillset→/workspace/skillset) and runsdeploy-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 volumedevbox-opencode-configpersists the deployed config across restarts. -
OMOS bundled-skills reconcile — on the omos variant,
entrypoint-user.shsymlinks the five skills bundled withoh-my-opencode-slim(clonedeps,codemap,deepwork,oh-my-opencode-slim,simplify) from the image path/usr/lib/node_modules/oh-my-opencode-slim/src/skills/<name>into~/.agents/skills/, on every start, after the skillset deploy (so OMOS wins name collisions vialn -sfn— the only overlap issimplify, which was removed from the skillset repo). These are absolute symlinks (target is image-internal at a fixed/usrpath) — do NOT "fix" them to relative like skillset's. Because the target lives in the image, pulling a newer image updates the skills with no installer run and no config reset. The block is non-fatal ({ … } || true), gated byOMOS_SKILLS(default true, independent ofENABLE_OMOS) and the presence of the source dir (no-op on the base variant). The twooh-my-opencode-slim installcalls now pass--skills=nounconditionally — the installer manages onlyoh-my-opencode-slim.json, never skills; do not reintroduce installer-managed skills. A one-time migration (marker:~/.config/opencode/.omos-skills-migrated) backs up — never deletes — any frozen real copies the old installer left in~/.config/opencode/skills/to<name>.bak.<epoch>, because those would otherwise shadow the fresh image-sourced symlinks. The build-time smoke test asserts the bundled-skills source path exists (catches an upstream package restructure loudly). Full rationale:docs/omos-skills.md. -
Config persistence via named volume —
devbox-opencode-configis 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. BecauseNPM_CONFIG_PREFIXis set to~/.config/opencode/npm-global(relocated from the legacy~/.pi/npm-globalin v2.0.0), anything installed vianpm install -gas the developer user also lands on this volume and survives container recreate AND image rebuild. -
npm-global prefix relocation (v2.0.0 breaking change) — the user-writable global npm prefix moved from
~/.pi/npm-globalto~/.config/opencode/npm-global. The old path lived on thedevbox-pi-configvolume (only mounted indocker-compose.yml); the new path is ondevbox-opencode-config, which is a persistent named volume in BOTHdocker-compose.ymlanddocker-compose.shared.yml.entrypoint-user.shcarries a one-time migration shim: if~/.pi/npm-globalexists and the marker~/.config/opencode/npm-global/.migrated-from-dot-piis absent, itcp -anthe oldlib//bin//share/into the new prefix (never overwriting fresh installs) and writes the marker. Baked binaries stay on/usr(the variant Dockerfile runs eachnpm install -gwithNPM_CONFIG_PREFIX=/usr) so the volume mount doesn't shadow them. TheENV NPM_CONFIG_PREFIX/PATHlines inDockerfile.baseare declared after all build-time installs. -
Default CMD is
bash -l— not a harness.docker compose run --rm devboxdrops the user into a login shell to choose:aws sso login, thenopencode(or any tool). Pass the harness explicitly to launch directly:docker compose run --rm devbox opencode.docker compose execbypasses entrypoint+CMD entirely (existing user workflow unchanged). -
Docker Hub description update — uses
/v2/auth/tokenendpoint (not the deprecated/v2/users/login). Auth usesidentifier/secretfields, returnsaccess_token, sent asBearer. Short description must be ≤100 bytes.
CI quirks
- Both build jobs include an IPv4 preference step (
gai.conf+driver-opts: network=hostfor buildx) to work around intermittent IPv6 failures on the Gitea runners. update-descriptionjob runs when the base variant published (needs: [build-variant-base, build-variant-omos], gated withalways()+ an explicitbuild-variant-base.result == 'success'check so a partial-publish run still refreshes the Hub description).- No image build happens on a push — builds are tag-triggered or explicitly requested. The three workflows divide cleanly:
lint.ymlis cheap checks on every push/PR (actionlint + shell guard, hadolint,docs-check) and is the only workflow a push to main triggers;validate.ymlis the amd64 build + smoke test and has no push trigger (pull_request / workflow_dispatch only);docker-publish-split.ymlis the release path (tag-only). So pushing work-in-progress to main is free. It is safe because the release path already fails closed:docker-publish-split.ymlpushes variant tags only aftersmoke-base/smoke-omospass and promotesbase-latestlast, so an aborted release leaves at worst an unreferencedbase-<hash>blob on Hub — never a half-published version tag.validate-base/validate-omosadditionally keep a now-redundantgithub.event_name != 'push'clause as belt-and-braces, so re-adding a push trigger can't silently re-enable builds. To validate before tagging, open a PR,workflow_dispatchthe Validate workflow, orworkflow_dispatchdocker-publish-split.ymlagainst a throwaway tag withpromote_latest=false(the only option that also exercises a changed base, whichvalidate.ymlstructurally cannot — it builds variants on top of Hub'sbase-latest). Keep cheap checks inlint.yml, notvalidate.yml— anything that needs no image belongs in the workflow that actually runs on push; that is whydocs-checklives there. If you change this model, update this bullet, both workflow header comments, and the two File roles entries together. - Tags must be pushed to trigger the publish workflow. A push to main triggers
lint.ymlonly. - 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
load: truejobs (validate-base,validate-omos,smoke-base,smoke-omos) include aReclaim runner diskstep that strips catthehacker-resident toolchains and prunes stale docker state beforesetup-buildx-action. Build jobs use a lighter version (push-by-digest doesn't needdocker system prune). Don't remove these steps without testing on a fresh runner. docker/build-push-action@v7withplatforms: linux/amd64,linux/arm64handles 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 requiresactions/{upload,download}-artifact@v4+which Gitea Actions doesn't support (see below).actions/upload-artifactandactions/download-artifactmust stay at @v3 on Gitea. v4+ uses a GitHub-Enterprise-specific Artifact API; runs fail withGHESNotSupportedError. 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 declareshell: bashon the step. BUILDKIT_PROGRESS=plainis set at workflow level ondocker-publish-split.ymlso 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.
Gitea API access (env token)
GITEA_ACCESS_TOKEN + GITEA_HOST are passed into the container from the
host .env via docker-compose.yml / docker-compose.shared.yml
(${GITEA_ACCESS_TOKEN:-} / ${GITEA_HOST:-}), primarily to enable the
gitea-mcp server (see generate-config.py). They are not baked into
the image. When configured, they are also available for any direct
Gitea API interaction from inside the container — inspecting CI runs,
checking published tags, listing commits — e.g.
curl -H "Authorization: token $GITEA_ACCESS_TOKEN" "$GITEA_HOST/api/v1/repos/joakimp/opencode-devbox/actions/runs?limit=5".
Prefer this over a short-lived PAT file when the env token is present (the
ci-release-watcher skill auto-detects it). Public-repo GET listings work
unauthenticated (see the resolve-versions mempalace-toolkit note above), so
the token matters mainly for private repos or rate-limit headroom; its
lifecycle is host-managed, so there is nothing to revoke after use. Never
echo the token value (including into logs).
Testing changes
The smoke test (scripts/smoke-test.sh) is the canonical check and runs automatically in CI. To run locally:
# 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:
docker compose run --rm devbox bash- Check specific tools inside:
nvim --version,bat --version,uv --version,mempalace --help, etc. - For entrypoint changes: test with a non-1000 UID workspace to verify UID adjustment, volume ownership fixes, and the
.devbox-ownersentinel behavior. - For
generate-config.pychanges: run standalone withHOME=/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 entrypointAdd uv package manager to base image for on-demand Python supportUpgrade base image from Debian bookworm to trixie (current stable)