Files
opencode-devbox/AGENTS.md
T
pi 13219d1d55
Publish Docker Image / resolve-versions (push) Successful in 5s
Lint / docs-check (push) Successful in 7s
Lint / hadolint (push) Successful in 14s
Publish Docker Image / base-decide (push) Successful in 8s
Lint / actionlint (push) Successful in 6m19s
Publish Docker Image / build-base (push) Successful in 41m18s
Publish Docker Image / smoke-base (push) Successful in 4m57s
Publish Docker Image / smoke-omos (push) Successful in 9m45s
Publish Docker Image / build-variant-base (push) Successful in 14m53s
Publish Docker Image / build-variant-omos (push) Successful in 24m46s
Publish Docker Image / update-description (push) Successful in 12s
Publish Docker Image / promote-base-latest (push) Successful in 16s
docs(agents): Gitea API jobs endpoint takes id, not run_number
GET /api/v1/repos/{owner}/{repo}/actions/runs/{n}/jobs expects the internal run
`id`, not the `run_number` the UI shows as `#239`. The two diverge widely
(id=534 was run_number=238) and the wrong key does NOT error — it silently
returns another run's jobs.

This cost a wrong conclusion while verifying that a push to main no longer
triggers image builds: querying .../runs/238/jobs returned
docs-check/validate-base/validate-omos, which made a lint.yml run look like it
had built images. Corrected by reading `id` from the run listing.

Records the two reliable patterns in the existing "Gitea API access" section:
filter the runs listing on head_sha to learn authoritatively which runs a commit
triggered, then key the jobs endpoint by that id. Also notes that counting runs
per commit is the cheapest assertion of the trigger model (2 runs before the
split, 1 after), and that lint.yml's actionlint job can take 6–15 min because it
apt-installs shellcheck in-container — so a still-running lint is not a problem
signal.
2026-08-04 16:49:02 +02:00

39 KiB
Raw Blame History

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_PI build arg, the with-pi / omos-with-pi / pi-only variants, the base-pi-only published tag, and all ~/.pi-related wiring are gone. pi now ships from its own repo (joakimp/pi-devbox). Do not add pi functionality here. The removal history + the NPM_CONFIG_PREFIX relocation (~/.pi/npm-global~/.config/opencode/npm-global, with a one-time migration shim in entrypoint-user.sh) are recorded in docs/CLEANUP-v2.0.0.md and the v2.0.0 CHANGELOG entry.

File roles

  • Dockerfile.base — variant-independent layers (apt, locales, AWS CLI, Node.js, mempalace, gitea-mcp, user setup, chromadb prewarm, ENVs, entrypoints). Published as joakimp/opencode-devbox:base-<sha12>. Rebuilt only when its content hash changes.
  • Dockerfile.variantFROMs the base and adds only opencode/omos installs gated by build args: INSTALL_OPENCODE (default true), INSTALL_OMOS, and INSTALL_MEMPALACE. All GitHub-sourced binaries are pinned with version ARGs. Two variants: base (INSTALL_OPENCODE=true) and omos (+INSTALL_OMOS=true).
  • entrypoint.sh — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via .devbox-owner sentinel when ownership is already correct). Then drops to developer via gosu.
  • entrypoint-user.sh — runs as developer: git config, opencode.jsonc generation (delegated to generate-config.py), LAN-access setup (delegated to setup-lan-access.sh), a one-time npm-global prefix migration shim (legacy ~/.pi/npm-global~/.config/opencode/npm-global), skillset auto-deploy from mounted skillset repo, OMOS 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/*.md into ~/.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/config sidecar on every host OS: a Host * block that redirects ControlPath into ~/.ssh-local/cm/ (first-value-wins over any read-only ~/.ssh-bound per-host setting) plus Include ~/.ssh/config. On VM-backed hosts (macOS OrbStack / Docker Desktop, detected via host.docker.internal resolution) 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, leaving dssh/dscp broken when ~/.ssh was read-only there. 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. Non-fatal. Counted in the base hash, so editing it advances base-latest.
  • rootfs/usr/local/lib/opencode-devbox/generate-config.py — generates ~/.config/opencode/opencode.jsonc from env vars. Never overwrites an existing config (checks both .json and .jsonc). Auto-registers MCP servers for detected tools (mempalace via mempalace-mcp, gitea-mcp, context7 remote endpoint).
  • rootfs/usr/local/bin/opencode-devbox-version — reader for the build manifest that Dockerfile.variant bakes at /etc/opencode-devbox/build-manifest.json. Three modes (--json, --quiet, default human) plus a live-vs-baked opencode --version drift check (a user npm install -g opencode lands on the persistent config volume and can shadow the baked /usr binary). Printed as the first line of entrypoint-user.sh so "which image am I in?" is answered at start. Added v2.9.0 — before that the manifest was baked but nothing read it. Lives under rootfs/, 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.shruntime post-recreate verification (counterpart to the build-time smoke-test.sh). Run inside the container after docker compose up -d --force-recreate to confirm the new image is live (opencode version matches Dockerfile.variant's OPENCODE_VERSION), persisted named volumes survived (mempalace palace, opencode.db, bash-history), omos runtime skill symlinks resolve, shell defaults re-seeded, and /opt toolkits 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 — 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.mdauto-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.mdread 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_request and workflow_dispatch only, 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, promotes base-latest alias, 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 every run: step resolves to bash under Gitea's sh default and closing the actionlint blind spot (actionlint models GitHub, whose default run shell is bash); (2) hadolint — pinned 2.14.0 against both Dockerfiles, config in .hadolint.yaml so a local run reproduces CI; (3) docs-checkDOCKER_HUB.md vs HUB_TEMPLATE sync, moved here from validate.yml so 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.0 added the OMOS bundled-skills image-symlink mechanism; v2.3.0 added 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 checkbefore triggering any release CI build, check whether opencode is behind and offer to bump it. Compare the pinned OPENCODE_VERSION in Dockerfile.variant against the latest npm release (./check-versions.sh Dockerfile.variant flags → X available for opencode — note the base-only pins like GOSU_VERSION live in Dockerfile.base, so run it against that too for a full sweep). If the latest stable opencode-ai is 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 the OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS removal-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:
    npm view opencode-ai version           # must equal the X.Y.Z you pin in Dockerfile.variant
    
    Historical note: under the old v{opencode_version}[letter] scheme a mismatched tag was a namespace hazard — e.g. v1.15.12 was cut while opencode was still 1.15.11, then re-cut as v1.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 agosst/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 locationanomalyco/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 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 — and add its *_VERSION ARG to the README Build Args table (the floated-latest row alongside GOSU_VERSION, FZF_VERSION, …). That table is a separate exhaustive list from the env-var table and has silently drifted before: MICRO_VERSION, TEALDEER_VERSION, and TYPST_VERSION were missing from it for three releases (v2.4.0v2.7.0), caught only by the 2026-07-13 doc-drift audit.

  • Resolved versions are logged by the smoke testscripts/smoke-test.sh prints a "Resolved component versions" table as its first step. CI logs always capture what got baked into a given image even when ARGs default to latest.

  • OMOS_VERSION MUST be passed by CI as a concrete version, not left at the latest default. The npm install step in Dockerfile.variant (oh-my-opencode-slim@${OMOS_VERSION}) produces an identical layer-hash when the ARG value is byte-identical across builds; combined with the registry buildcache (base-buildcache) the layer gets reused even when latest would have resolved to a newer upstream. This is the same class of bug that bit pi-devbox v0.74.0 → v0.75.5 (silent same-bytes-across-releases regression discovered 2026-05-23, fixed in pi-devbox v0.75.5b). It is currently masked in opencode-devbox by OPENCODE_VERSION being a hard-coded ARG that bumps every release — that bump invalidates the parent-chain cache key for the downstream omos layer — but the masking would fail the moment a vN.N.Nb opencode-version-unchanged release ships that only bumps omos. Preventative fix: .gitea/workflows/docker-publish-split.yml has a resolve-versions job that runs npm view oh-my-opencode-slim version, exposing the concrete value as an output that the omos smoke + build jobs consume via build-args. Smoke tests assert via the EXPECTED_OMOS_VERSION env var — would catch the regression on the next release rather than several releases later. If you change the variant build-args list, the resolve-versions job, or the smoke EXPECTED_*_VERSION wiring, audit all affected jobs in lockstep.

  • resolve-versions also pins mempalace-toolkit to a SHAresolve-versions resolves the mempalace-toolkit main HEAD to a commit SHA (mempalace_toolkit_ref output) 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-toolkit is cloned in Dockerfile.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 the base-decide hash (so base_tag changes → base rebuilds) AND passed to build-base as --build-arg MEMPALACE_TOOLKIT_REF. Consequently base-decide now depends on resolve-versions (they no longer run in parallel), and the base clone uses a SHA-capable git fetch <ref> + checkout FETCH_HEAD (a git clone --branch <40-char-SHA> would fail). Trade-off: base_tag now reflects a live gitea lookup — on an API blip it falls back to main, triggering one extra base rebuild, never a missed one. If you touch resolve-versions, base-decide's hash inputs, or the build-base build-args, audit .gitea/README.md Step 1 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 + 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 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.

  • Background subagents flag baked ON — experimental, watch for promotionDockerfile.base sets ENV OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true. opencode gates native background subagents behind this flag (packages/opencode/src/tool/task.ts fails with Background subagents require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true when unset); oh-my-opencode-slim V2+ 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 advances base-latest. REMOVAL TRIGGER: when opencode promotes background subagents out of EXPERIMENTAL_ (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 that packages/opencode/src/effect/runtime-flags.ts is unchanged and still gates the flag behind OPENCODE_EXPERIMENTAL. Documented in lockstep in README env table, .env.example, and asserted by scripts/smoke-test.sh (bg-subagents env baked).

  • agent-browser + Chromium is the base's size driver — thresholds are coupledDockerfile.base installs the agent-browser CLI and a Playwright-managed Chromium (~625 MB after dropping the redundant chromium_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 as AGENT_BROWSER_EXECUTABLE_PATH) and the Dockerfile finds it rather than hardcoding a path, because Playwright's browser dir is per-version and per-arch (chrome-linux on arm64, chrome-linux64 on amd64). If you add anything else large, or remove this layer in a fork, update the size thresholds in scripts/smoke-test.sh in 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 sums docker history while this repo uses docker image inspect .Size.

  • yq here means mikefarah's Go v4, not Debian's yq — Debian/Ubuntu's yq apt 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 the cloud-init repo's provision.sh/deploy.sh require v4 syntax — and because THIRD_PARTY.md had credited "yq (mikefarah)" while the image actually shipped the Python one. scripts/smoke-test.sh pins the contract with yq --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 two yq binaries on PATH the meaning of yq silently depends on PATH order.

  • MemPalace pin — schema-regression watch target (workaround already removed)MEMPALACE_VERSION is deliberately pinned (currently 3.6.0) rather than floated, because an unpinned uv tool install mempalace is what once silently swept in a broken diary_write schema. History: mempalace 3.3.x/3.4.0 advertised a root-level anyOf on mempalace_diary_write's inputSchema, 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.base used 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_write now 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 (secure serve remote mode, optional Milvus, atomic KG supersede(), 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's MEMPALACE_VERSION. NOTE: MEMPALACE_VERSION (the PyPI pin) and MEMPALACE_TOOLKIT_REF (the git ref for the mempalace-toolkit clone) are unrelated despite the shared prefix; do not conflate them.

  • 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.

  • OMOS bundled-skills reconcile — on the omos variant, entrypoint-user.sh symlinks the five skills bundled with oh-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 via ln -sfn — the only overlap is simplify, which was removed from the skillset repo). These are absolute symlinks (target is image-internal at a fixed /usr path) — 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 by OMOS_SKILLS (default true, independent of ENABLE_OMOS) and the presence of the source dir (no-op on the base variant). The two oh-my-opencode-slim install calls now pass --skills=no unconditionally — the installer manages only oh-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 volumedevbox-opencode-config is a Docker named volume mounted at ~/.config/opencode/. It is NOT a host bind mount by default. This separation allows both native and containerized opencode to coexist on the same machine without symlink conflicts. Users who need to override can replace the named volume with a host bind mount in their compose file. Because NPM_CONFIG_PREFIX is set to ~/.config/opencode/npm-global (relocated from the legacy ~/.pi/npm-global in v2.0.0), anything installed via npm install -g as the developer user also lands on this volume and survives container recreate AND image rebuild.

  • npm-global prefix relocation (v2.0.0 breaking change) — the user-writable global npm prefix moved from ~/.pi/npm-global to ~/.config/opencode/npm-global. The old path lived on the devbox-pi-config volume (only mounted in docker-compose.yml); the new path is on devbox-opencode-config, which is a persistent named volume in BOTH docker-compose.yml and docker-compose.shared.yml. entrypoint-user.sh carries a one-time migration shim: if ~/.pi/npm-global exists and the marker ~/.config/opencode/npm-global/.migrated-from-dot-pi is absent, it cp -an the old lib//bin//share/ into the new prefix (never overwriting fresh installs) and writes the marker. Baked binaries stay on /usr (the variant Dockerfile runs each npm install -g with NPM_CONFIG_PREFIX=/usr) so the volume mount doesn't shadow them. The ENV NPM_CONFIG_PREFIX/PATH lines in Dockerfile.base are declared after all build-time installs.

  • Default CMD is bash -l — not a harness. docker compose run --rm devbox drops the user into a login shell to choose: aws sso login, then opencode (or any tool). Pass the harness explicitly to launch directly: docker compose run --rm devbox opencode. docker compose exec bypasses entrypoint+CMD entirely (existing user workflow unchanged).

  • Docker Hub description update — uses /v2/auth/token endpoint (not the deprecated /v2/users/login). Auth uses identifier/secret fields, returns access_token, sent as Bearer. Short description must be ≤100 bytes.

CI quirks

  • Both build jobs include an IPv4 preference step (gai.conf + driver-opts: network=host for buildx) to work around intermittent IPv6 failures on the Gitea runners.
  • update-description job runs when the base variant published (needs: [build-variant-base, build-variant-omos], gated with always() + an explicit build-variant-base.result == 'success' check so a partial-publish run still refreshes the Hub description).
  • No image build happens on a push — builds are tag-triggered or explicitly requested. The three workflows divide cleanly: lint.yml is cheap checks on every push/PR (actionlint + shell guard, hadolint, docs-check) and is the only workflow a push to main triggers; validate.yml is the amd64 build + smoke test and has no push trigger (pull_request / workflow_dispatch only); docker-publish-split.yml is 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.yml pushes variant tags only after smoke-base/smoke-omos pass and promotes base-latest last, so an aborted release leaves at worst an unreferenced base-<hash> blob on Hub — never a half-published version tag. validate-base/validate-omos additionally keep a now-redundant github.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_dispatch the Validate workflow, or workflow_dispatch docker-publish-split.yml against a throwaway tag with promote_latest=false (the only option that also exercises a changed base, which validate.yml structurally cannot — it builds variants on top of Hub's base-latest). Keep cheap checks in lint.yml, not validate.yml — anything that needs no image belongs in the workflow that actually runs on push; that is why docs-check lives 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.yml only.
  • 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: true jobs (validate-base, validate-omos, smoke-base, smoke-omos) include a Reclaim runner disk step that strips catthehacker-resident toolchains and prunes stale docker state before setup-buildx-action. Build jobs use a lighter version (push-by-digest doesn't need docker system prune). Don't remove these steps without testing on a fresh runner.
  • docker/build-push-action@v7 with platforms: linux/amd64,linux/arm64 handles multi-arch push natively in a single job — produces a proper manifest list, no matrix or merge step needed. An earlier revision split into per-arch matrix jobs with digest artifacts, but that pattern requires actions/{upload,download}-artifact@v4+ which Gitea Actions doesn't support (see below).
  • actions/upload-artifact and actions/download-artifact must stay at @v3 on Gitea. v4+ uses a GitHub-Enterprise-specific Artifact API; runs fail with GHESNotSupportedError. If you need artifacts for a new reason (build logs, SBOMs, etc.), pin @v3 explicitly.
  • Step scripts run under /bin/sh (dash), not bash. Avoid bash-isms like ${VAR//a/b} parameter-pattern substitution; use POSIX alternatives (tr, sed) or declare shell: bash on the step.
  • 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.

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).

Gotcha — the jobs endpoint takes the internal id, NOT the run_number the UI shows as #239. The two diverge widely (id=534 was run_number=238), and GET .../actions/runs/<run_number>/jobs does not error — it silently returns the jobs of a different run. This cost a wrong conclusion once: while verifying that a push no longer triggers builds, querying .../runs/238/jobs returned docs-check/validate-base/validate-omos, making a lint.yml run look like it had built images. Always read id from the run listing and use that. Two reliable patterns:

# Authoritative: which runs did MY commit trigger? Filter on head_sha — do not
# trust ordering or run numbering.
curl -sS -H "Authorization: token $GITEA_ACCESS_TOKEN" \
  "$GITEA_HOST/api/v1/repos/joakimp/opencode-devbox/actions/runs?limit=20" \
  | jq --arg sha "$(git rev-parse HEAD)" \
      '.workflow_runs[] | select(.head_sha==$sha) | {id, run_number, path, event, status, conclusion}'

# Then the per-job breakdown, keyed by the id from above (NOT run_number)
curl -sS -H "Authorization: token $GITEA_ACCESS_TOKEN" \
  "$GITEA_HOST/api/v1/repos/joakimp/opencode-devbox/actions/runs/<id>/jobs" \
  | jq '.jobs[] | {name, status, conclusion}'

Counting runs per commit is also the cheapest way to assert the trigger model from the previous section: commits before the split show 2 runs (lint.yml + validate.yml), commits after show 1 (lint.yml). Note lint.yml is cheap in CPU but not always in wall-clock — its actionlint job apt-get installs shellcheck inside the container and has taken 615 min on a busy runner, so a still-in_progress lint run is not evidence of a problem.

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:

  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)