Compare commits

...

75 Commits

Author SHA1 Message Date
pi 13219d1d55 docs(agents): Gitea API jobs endpoint takes id, not run_number
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
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
pi 37960186c6 release: v2.9.0 — agent-browser, manifest reader, opencode 1.18.13
Lint / docs-check (push) Successful in 5s
Lint / hadolint (push) Successful in 12s
Lint / actionlint (push) Has been cancelled
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.
2026-08-04 16:31:13 +02:00
pi 5fb07e0a39 ci: no image build on push — split cheap checks from builds
Lint / hadolint (push) Successful in 8s
Lint / docs-check (push) Successful in 14s
Lint / actionlint (push) Successful in 6m41s
A push to main used to trigger validate.yml, which builds an amd64 variant and
runs the smoke test. Pushing work-in-progress to main therefore cost a build.
Restructure so the three workflows divide by cost, matching pi-devbox:

  lint.yml                 cheap checks, every push/PR — the ONLY workflow a
                           push to main triggers
  validate.yml             amd64 build + smoke test — pull_request and
                           workflow_dispatch only, no push trigger at all
  docker-publish-split.yml the release path, tag-only (unchanged)

docs-check (DOCKER_HUB.md vs HUB_TEMPLATE) moves from validate.yml to lint.yml.
It needs no image, and anything that needs no image belongs in the workflow that
actually runs on push — otherwise the doc-drift guard would have been silently
lost when validate.yml stopped running on pushes. That guard earns its keep: it
has caught real drift.

validate-base/validate-omos additionally keep a `github.event_name != 'push'`
clause. It is redundant now that the trigger is gone, and deliberately so:
re-adding a push trigger later cannot silently re-enable builds on every push.

Renamed lint.yml `Lint workflows` -> `Lint`, since it now covers Dockerfiles and
docs as well as workflows. No references to the old name existed.

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. Pre-tag
validation remains available three ways: open a PR, dispatch Validate, or
dispatch docker-publish-split.yml against a throwaway tag with
promote_latest=false — the only route that also exercises a CHANGED base, which
validate.yml structurally cannot (it builds variants on Hub's base-latest).

Coverage lost is narrower than it looks: validate-base/validate-omos were
already skipped whenever a commit touched Dockerfile.base, rootfs/, or
entrypoint*.sh, so they only ever ran for variant-only changes — most usefully a
bare OPENCODE_VERSION bump, for which an explicit dispatch is now the
equivalent.

Verified with the CI-pinned actionlint 1.7.7 and scripts/check-workflow-shell.sh.
AGENTS.md updated in the same commit (file roles for both workflows + the
trigger-model convention). The CHANGELOG entry lands with the v2.9.0 changeset
in the following commit, which is one unreleased block covering all of v2.9.0.
2026-08-04 16:23:03 +02:00
joakimp d2f2396c4a docs(rootfs): re-sync mempalace snapshot from skillset 63f3bf5
Lint workflows / hadolint (push) Successful in 8s
Validate / docs-check (push) Successful in 8s
Lint workflows / actionlint (push) Successful in 23s
Validate / base-change-warning (push) Successful in 9s
Validate / validate-omos (push) Has been skipped
Validate / validate-base (push) Has been skipped
This snapshot was byte-identical to skillset and therefore MISSING the
Temporal grounding guidance, which had been authored only into pi-devbox's
vendored copy (pi-devbox 904fe85) and never back-ported to the owner repo. So
opencode-devbox containers — and any host consumer reading skillset directly —
never saw it, while pi-devbox containers did.

skillset 63f3bf5 is now canonical again and carries the section with wording
generalized to cover both devboxes ("a devbox container (pi-devbox or
opencode-devbox)"). Snapshot + VENDORED.md provenance updated to match.

Substance for the agent: establish "now" and compute now - entry.timestamp
before saying "yesterday"/"last week" — a container recreate or fresh session
is not a day boundary, and two diary entries 90 minutes apart can straddle one.
2026-07-29 19:44:36 +02:00
joakimp bade89ff35 docs(skill): note macOS NFD-filename gotcha for dscp/scp
Lint workflows / hadolint (push) Failing after 12s
Lint workflows / actionlint (push) Successful in 27s
Validate / docs-check (push) Failing after 20s
Validate / base-change-warning (push) Successful in 16s
Validate / validate-base (push) Has been skipped
Validate / validate-omos (push) Has been skipped
Mirror of the pi-devbox note: accented filenames on a macOS host are stored
decomposed (NFD), so a precomposed (NFC) remote path in scp/dscp silently fails
with 'No such file or directory'. Document the wildcard / list-first workaround
next to the dssh/dscp alias table in opencode-devbox-environment.
2026-07-17 13:08:56 +02:00
pi 03b8fa431e docs(agents): fix stale push-step count (5 -> 2 variant)
Validate / docs-check (push) Successful in 7s
Validate / base-change-warning (push) Successful in 6s
Lint workflows / actionlint (push) Successful in 22s
Lint workflows / hadolint (push) Successful in 1m5s
Validate / validate-base (push) Successful in 4m5s
Validate / validate-omos (push) Successful in 5m9s
AGENTS.md said the retry-wrapped 'docker buildx build --push' steps were
'1 base + 5 variant'. Ground truth from docker-publish-split.yml: the retry
loop wraps exactly 3 push steps — build-base + build-variant-base +
build-variant-omos (1 base + 2 variant). promote-base-latest uses crane copy
(not a buildx push) and the smoke jobs use load:true. Stale count was a
leftover from the old with-pi/omos-with-pi/pi-only variant lineup.

Docs-only; no image change, no release.
2026-07-14 08:46:27 +02:00
pi 046a5aeb3b release: v2.8.0 — sonnet-5 defaults, opencode 1.17.20, docs backfill
Publish Docker Image / resolve-versions (push) Successful in 13s
Lint workflows / actionlint (push) Successful in 14s
Lint workflows / hadolint (push) Successful in 8s
Publish Docker Image / base-decide (push) Successful in 16s
Publish Docker Image / build-base (push) Successful in 46m27s
Publish Docker Image / smoke-omos (push) Successful in 5m16s
Publish Docker Image / smoke-base (push) Successful in 12m28s
Publish Docker Image / build-variant-omos (push) Successful in 19m47s
Publish Docker Image / build-variant-base (push) Successful in 14m30s
Publish Docker Image / update-description (push) Successful in 8s
Publish Docker Image / promote-base-latest (push) Successful in 11s
Promote CHANGELOG Unreleased -> v2.8.0. Contents:
- Default models -> claude-sonnet-5 (changed-but-compatible; new containers only)
- opencode 1.17.15 -> 1.17.20
- bg-subagents removal-trigger markers refreshed 1.17.20/omos 2.2.0 (base-affecting
  -> base image rebuilds this release)
- README backfill for v2.4.0-v2.7.0 features + README/AGENTS polish

Minor per the repo versioning policy (tool bump paired with changed-compatible
default behaviour -> feature wins -> minor).
2026-07-14 00:17:38 +02:00
pi fac14e2c56 docs+chore: refresh bg-subagents markers to 1.17.20/omos 2.2.0, link THIRD_PARTY, guard build-args table
Lint workflows / hadolint (push) Successful in 17s
Lint workflows / actionlint (push) Successful in 22s
Validate / base-change-warning (push) Successful in 8s
Validate / docs-check (push) Successful in 11s
Validate / validate-base (push) Has been skipped
Validate / validate-omos (push) Has been skipped
- Dockerfile.base + AGENTS.md: bg-subagents removal-trigger last-checked markers
  1.17.15/omos 2.1.0 -> 1.17.20/omos 2.2.0 (re-verified; trigger still not fired).
  The Dockerfile.base comment edit changes the base-content hash, so base-<hash>
  advances and the base image rebuilds on the next release.
- README: refresh stale OPENCODE_VERSION build-arg examples (1.5.0, 1.17.8 -> 1.17.20);
  link THIRD_PARTY.md from the License section.
- AGENTS.md: adding a new floated *_VERSION ARG must also update the README Build Args
  table (closes the coupling gap that hid MICRO/TEALDEER/TYPST for 3 releases).
- CHANGELOG Unreleased: record both under Changed + Documentation.
2026-07-14 00:09:53 +02:00
pi cc890a90e7 docs(readme): backfill user docs for v2.4.0-v2.7.0 features
Lint workflows / hadolint (push) Successful in 9s
Lint workflows / actionlint (push) Successful in 20s
Features that shipped without README prose are now documented:
- PDF export (pandoc --pdf-engine=typst) — new Document conversion section
- Terminal TERM/terminfo support (ncurses-term, kitty-terminfo, xterm-ghostty)
- Neovim 24-bit colour default (termguicolors via system sysinit.vim)
- first-shell host SSH reachability check (Shell defaults list)
- baked global gitignore (patterns + core.excludesFile wiring)
- Build Args table: add MICRO_VERSION, TEALDEER_VERSION, TYPST_VERSION

Docs-only; noted under CHANGELOG Unreleased. Hub template and .env.example
were already accurate. Drift found via a v2.4.0+ CHANGELOG-vs-docs audit.
2026-07-13 23:57:57 +02:00
pi 1295c56930 chore: bump opencode 1.17.15 -> 1.17.20 + pre-flight version-check guidance
Lint workflows / hadolint (push) Successful in 8s
Lint workflows / actionlint (push) Successful in 15s
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 18s
Validate / validate-base (push) Successful in 3m46s
Validate / validate-omos (push) Successful in 19m9s
- Dockerfile.variant: OPENCODE_VERSION 1.17.15 -> 1.17.20 (latest stable on npm,
  verified via npm view). Variant-only layer; base image untouched.
- AGENTS.md: Pre-flight check now requires checking whether opencode is behind
  and ASKING THE USER before bumping ahead of a release CI build; links the
  bg-subagents removal-trigger re-check.
- CHANGELOG Unreleased: record the bump; 1.17.16-1.17.20 are TUI/cosmetic +
  internal fixes; bg-subagents removal-trigger re-checked, not fired.
2026-07-13 23:43:19 +02:00
pi 0cbcea83dc chore(config): bump default amazon-bedrock model to claude-sonnet-5
Lint workflows / hadolint (push) Successful in 8s
Validate / docs-check (push) Successful in 9s
Lint workflows / actionlint (push) Successful in 15s
Validate / base-change-warning (push) Successful in 8s
Validate / validate-base (push) Has been skipped
Validate / validate-omos (push) Has been skipped
DEFAULT_MODELS[amazon-bedrock] -> amazon-bedrock/global.anthropic.claude-sonnet-5
(was global.anthropic.claude-sonnet-4-5-20250929-v1:0). Verified invokable via
pi --list-models. Folded into the existing Unreleased changelog entry; openai
(gpt-5.4) default unchanged.
2026-07-13 23:16:15 +02:00
pi 4024c4d87b chore(config): bump default anthropic model to claude-sonnet-5
Validate / docs-check (push) Successful in 7s
Lint workflows / hadolint (push) Successful in 13s
Lint workflows / actionlint (push) Successful in 18s
Validate / base-change-warning (push) Successful in 6s
Validate / validate-base (push) Has been skipped
Validate / validate-omos (push) Has been skipped
DEFAULT_MODELS[anthropic] (and thus FALLBACK_MODEL) -> anthropic/claude-sonnet-5,
matching the .env.example bump. openai/amazon-bedrock defaults unchanged.
Documented under CHANGELOG Unreleased (no release cut yet).
2026-07-13 23:13:24 +02:00
pi 4499bcf6c3 docs(env): bump example OPENCODE_MODEL to claude-sonnet-5
Lint workflows / hadolint (push) Successful in 8s
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 14s
Lint workflows / actionlint (push) Successful in 22s
Validate / validate-base (push) Successful in 3m43s
Validate / validate-omos (push) Successful in 19m31s
2026-07-13 23:03:01 +02:00
pi 281ccbaa70 feat: pi-devbox parity — typst PDF, terminal terminfo, nvim colour, host-ssh check, hygiene
Validate / docs-check (push) Successful in 14s
Validate / base-change-warning (push) Successful in 9s
Publish Docker Image / resolve-versions (push) Successful in 14s
Lint workflows / actionlint (push) Successful in 21s
Validate / validate-base (push) Has been skipped
Validate / validate-omos (push) Has been skipped
Lint workflows / hadolint (push) Successful in 16s
Publish Docker Image / base-decide (push) Successful in 12s
Publish Docker Image / build-base (push) Successful in 42m20s
Publish Docker Image / smoke-base (push) Successful in 4m25s
Publish Docker Image / smoke-omos (push) Successful in 5m33s
Publish Docker Image / build-variant-base (push) Successful in 14m26s
Publish Docker Image / build-variant-omos (push) Successful in 19m41s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Publish Docker Image / update-description (push) Successful in 14s
Ports the base additions from pi-devbox v1.4.0 + v1.5.0 that opencode-devbox
lacked (opencode-devbox already tracks pi-devbox for CLI-toolset parity, v2.6.0):

- typst PDF engine for pandoc (v1.4.0) + the pandoc typst-template default-font
  patch (v1.5.0) so 'pandoc --pdf-engine=typst' works without -V mainfont.
  pandoc shipped since v2.6.0 as a front-end only (no PDF back-end). +xz-utils.
  Tracks latest; --build-arg TYPST_VERSION escape hatch.
- Terminal support (v1.5.0): ncurses-term + kitty-terminfo + a compiled
  xterm-ghostty alias (tic -x, use=ghostty), so wezterm/alacritty/foot/ghostty/
  kitty resolve TERM over SSH instead of degrading to a dumb fallback.
- Readable Neovim colours (v1.5.0): system-wide /etc/xdg/nvim/sysinit.vim with
  termguicolors.
- Host SSH reachability check at shell startup (v1.4.0): one-time probe in
  .bash_aliases warning (with fix steps + inline pubkey) when the Mac host is
  unreachable. The rest of the LAN stack was already present.
- .claude/settings.local.json added to the gitignore_global seed (v1.5.0).
- Repo hygiene (v1.5.0): LICENSE (MIT), THIRD_PARTY.md, hadolint CI job (pinned
  v2.14.0) + .hadolint.yaml, IDEAS.md backlog.

Base-affecting (Dockerfile.base + rootfs) → base-<hash> advances, base rebuilds.
smoke-test gains typst/PDF, terminfo, and nvim-tgc assertions. Validated:
hadolint clean on both Dockerfiles, bash -n OK, base-hash guard OK, workflow
guard OK. CHANGELOG v2.7.0.
2026-07-13 19:55:50 +02:00
Joakim Persson bf53a8eaa8 ci(smoke): +200 MB size headroom (base 2950 / omos 3650)
Validate / docs-check (push) Successful in 6s
Lint workflows / actionlint (push) Successful in 22s
Validate / base-change-warning (push) Successful in 55s
Validate / validate-omos (push) Successful in 4m40s
Validate / validate-base (push) Successful in 10m56s
The v2.6.0 pi-devbox CLI-parity additions (pandoc + graphviz + tealdeer,
~200 MB on the base layer) consumed nearly all the ~250 MB headroom that
v1.16.2 restored. v2.6.0 passed 2750/3450 but with a slim margin. Restore
~250 MB headroom so the guardrail catches runaway growth, not routine apt
drift or minor upstream bumps. smoke-test.sh only — no base rebuild; affects
the next tagged release's gate.
2026-07-08 09:11:40 +02:00
Joakim Persson a31ef52b00 release: v2.6.0 — pi-devbox CLI parity + bg-subagents default + opencode 1.17.13 → 1.17.15
Publish Docker Image / resolve-versions (push) Successful in 7s
Validate / docs-check (push) Successful in 18s
Lint workflows / actionlint (push) Successful in 15s
Publish Docker Image / base-decide (push) Successful in 11s
Validate / base-change-warning (push) Successful in 1m1s
Validate / validate-omos (push) Failing after 4m33s
Validate / validate-base (push) Failing after 13m48s
Publish Docker Image / build-base (push) Successful in 41m18s
Publish Docker Image / smoke-base (push) Successful in 5m42s
Publish Docker Image / smoke-omos (push) Successful in 15m3s
Publish Docker Image / build-variant-base (push) Successful in 16m24s
Publish Docker Image / build-variant-omos (push) Successful in 34m35s
Publish Docker Image / update-description (push) Successful in 8s
Publish Docker Image / promote-base-latest (push) Successful in 10s
- opencode 1.17.13 → 1.17.15 (OPENCODE_VERSION in Dockerfile.variant)
- promote CHANGELOG Unreleased → v2.6.0 (2026-07-08) with opencode release notes
- (base-hash-advancing changes already staged in prior two commits: CLI tools + bg-subagents ENV)

AGENTS.md tag/variant/skill counts verified unchanged; DOCKER_HUB.md in sync.
2026-07-08 00:20:29 +02:00
Joakim Persson bab78044a1 feat: bake OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true (base ENV)
opencode gates native background subagents behind this experimental flag;
oh-my-opencode-slim V2+ makes background orchestration its default workflow,
so the omos variant was effectively degraded without it. Set as a base-level
ENV (both variants, harmless for plain opencode) and overridable at runtime.

Documented in lockstep: README env table, .env.example, CHANGELOG Unreleased,
and AGENTS.md (with a removal trigger for when opencode promotes it out of
experimental). smoke-test asserts the var is baked into the image env.

Also folds the prior CLI-tools additions into the CHANGELOG Unreleased block.
2026-07-08 00:16:38 +02:00
Joakim Persson 61ec340e40 feat: add yq, pandoc, graphviz, tldr (tealdeer) + dot-watch — CLI parity with pi-devbox
- apt: yq (YAML query), pandoc (doc converter), graphviz (dot rendering)
- tealdeer: tldr command as ~5MB static musl binary (TEALDEER_VERSION arg)
- dot-watch: auto-render .dot to PNG on save (graphviz-only, no imagemagick)
- README: document new tools
- smoke-test: presence assertions for yq, pandoc, dot, tldr, dot-watch

Excludes socat, imagemagick, studio-expose (pi-studio-specific).
2026-07-07 23:43:14 +02:00
pi ee7cfae1ff release: v2.5.0 — external/shared MemPalace + validate.yml base-change fixes
Publish Docker Image / resolve-versions (push) Successful in 4s
Publish Docker Image / base-decide (push) Successful in 8s
Lint workflows / actionlint (push) Successful in 32s
Publish Docker Image / build-base (push) Successful in 30m54s
Publish Docker Image / smoke-omos (push) Successful in 4m49s
Publish Docker Image / smoke-base (push) Successful in 10m37s
Publish Docker Image / build-variant-omos (push) Successful in 19m4s
Publish Docker Image / build-variant-base (push) Successful in 18m42s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Promotes the Unreleased block to v2.5.0. Bundled contents:
- feat: optional shared/external MemPalace — generate-config.py registers a remote
  MCP endpoint when MEMPALACE_REMOTE_URL is set; adds docker-compose.mempalace.yml.
- fix: validate.yml false-red on base-changing commits (skip validate-base/omos via
  base_changed) + rootfs/ base-input detector regex.
opencode stays 1.17.13 (== npm latest). Base rebuilds (rootfs/generate-config.py
changed); mempalace-toolkit clone advances to main HEAD (external-transport bridge).
2026-07-02 14:50:06 +02:00
pi fb6588ab1f fix(validate.yml): rootfs/ base-input detection (anchored-group regex bug)
Validate / docs-check (push) Successful in 7s
Validate / base-change-warning (push) Successful in 9s
Lint workflows / actionlint (push) Successful in 14s
Validate / validate-omos (push) Successful in 6m48s
Validate / validate-base (push) Successful in 15m5s
The detect step's regex '^(Dockerfile\.base|rootfs/|entrypoint.*\.sh)$' anchored
the whole alternation with a trailing $, so the rootfs/ branch only matched a
file literally named 'rootfs/' — never real paths like
rootfs/usr/local/lib/opencode-devbox/generate-config.py (which Dockerfile.base
COPYs into the base). Result: rootfs-only base changes set base_changed=false and
validate-base/validate-omos ran against the stale base-latest instead of skipping
(observed live: run 429, my own generate-config.py commit, did not skip).

Fix: compute the match once into $changed with rootfs/ as a PREFIX
('^(Dockerfile\.base$|rootfs/|entrypoint.*\.sh$)') and gate on -n; reuse it for
the file listing so the two greps can't drift. Bug predates the skip feature
(introduced in dba05da); the skip in 703edbe made it load-bearing.

actionlint clean; detect logic simulated across rootfs/Dockerfile.base/entrypoint/
docs-only change-sets.
2026-07-02 13:23:46 +02:00
pi 703edbe4a1 feat: optional shared/external MemPalace + fix validate.yml false-red on base changes
Validate / docs-check (push) Successful in 6s
Validate / base-change-warning (push) Successful in 6s
Lint workflows / actionlint (push) Successful in 32s
Validate / validate-base (push) Successful in 3m35s
Validate / validate-omos (push) Successful in 4m35s
Added:
- generate-config.py registers mempalace as a remote MCP endpoint when
  MEMPALACE_REMOTE_URL is set (MEMPALACE_REMOTE_TOKEN -> Bearer), else the local
  stdio command as before. Same env contract as pi-devbox's bridge.
- docker-compose.mempalace.yml: optional shared server (mempalace-mcp --transport http),
  loopback-bound by default.
- compose (both) + .env.example(.shared): MEMPALACE_REMOTE_URL/TOKEN + local-vs-external docs.

Fixed:
- validate.yml: skip validate-base/validate-omos on base-changing commits
  (base-change-warning now exports base_changed). Previously a commit that changed
  the base AND tightened smoke-test.sh in lockstep (v2.4.0 nano/micro) hard-failed
  against the stale base-latest until the release rebuilt it. actionlint clean.

README + CHANGELOG (Unreleased).
2026-07-02 13:09:29 +02:00
pi f7e23d236c release: v2.4.0 — nano + micro editors, CI hardening, opencode 1.17.10 → 1.17.13
Validate / docs-check (push) Successful in 8s
Validate / base-change-warning (push) Successful in 7s
Validate / validate-omos (push) Failing after 4m28s
Validate / validate-base (push) Failing after 5m8s
Publish Docker Image / smoke-base (push) Successful in 3m39s
Publish Docker Image / smoke-omos (push) Successful in 7m6s
Publish Docker Image / resolve-versions (push) Successful in 4s
Lint workflows / actionlint (push) Successful in 13s
Publish Docker Image / base-decide (push) Successful in 21s
Publish Docker Image / build-base (push) Successful in 31m2s
Publish Docker Image / build-variant-base (push) Successful in 13m58s
Publish Docker Image / build-variant-omos (push) Successful in 23m13s
Publish Docker Image / update-description (push) Successful in 7s
Publish Docker Image / promote-base-latest (push) Successful in 10s
Promotes the Unreleased block to v2.4.0. Bundled contents:
- feat: nano + micro non-modal editors (d9ad634) — Dockerfile.base change → base rebuild
- ci: sh-vs-bash guard + base-latest digest promote, ported from pi-devbox (acb2096)
- feat: global gitignore baked into the image (6639ba5)
- opencode 1.17.10 → 1.17.13 (Dockerfile.variant)

Also registers .gitea/workflows/lint.yml in AGENTS.md's workflow list.
DOCKER_HUB.md unchanged (HUB_TEMPLATE untouched; --check passes).
Pre-flight: opencode-ai@1.17.13 confirmed on npm (== latest).
2026-07-01 23:35:48 +02:00
pi d9ad634d5a feat: ship nano + micro (non-modal editors) alongside nvim
The image shipped only nvim (EDITOR=nvim), a modal vi-style editor. Add
both a classic and a modern non-modal option so users who aren't
comfortable with vi keybindings have a choice:

- nano (apt): ~2.8 MB installed; deps (libc6, libncursesw6, libtinfo6)
  already present via nvim/less/htop/tmux, so no extra packages pulled in.
- micro: ~12 MB single static Go binary from GitHub releases (same pattern
  as bat/eza/zoxide). Desktop-style keys (Ctrl+S/Ctrl+Q), mouse, syntax
  highlighting. ARG MICRO_VERSION pins; defaults to latest.

Combined ~15 MB (well within the smoke size threshold's ~250 MB headroom,
so no threshold bump). EDITOR stays nvim; both editors are opt-in
(export EDITOR=micro | nano). Uses the canonical micro-editor/micro URL
because the old zyedidia/micro org rename makes /releases/latest redirect
to another /latest, defeating the tag-parsing latest-resolution idiom
(independently verified: old org 302s to micro-editor/micro; both arch
tarballs HTTP 200; latest resolves to v2.0.15).

Base-image change, so it lands on the next base-<hash> rebuild. Updates
README (what's-inside tree + EDITOR note), CHANGELOG (Unreleased/Added),
and smoke-test.sh (nano + micro presence checks). Ported from pi-devbox
3a59e15.
2026-07-01 23:20:49 +02:00
pi acb2096406 ci: port pi-devbox CI hardening — bash-default footgun guard + base-latest digest promote
Two CI-only changes ported from pi-devbox (no runtime/image impact),
adapted to opencode-devbox's split-base 2-variant pipeline. Rides the
next release.

C — eliminate the sh-vs-bash footgun class:
- Add `defaults: run: shell: bash` workflow-wide to docker-publish-split.yml
  and validate.yml. Gitea's default step shell is sh/dash, so bash-only
  syntax in a step that omits `shell: bash` fails silently. All pre-existing
  steps are POSIX, so bash runs them unchanged (no behavioural change).
- New .gitea/workflows/lint.yml (push/PR/dispatch): a Gitea-accurate shell
  guard (scripts/check-workflow-shell.sh) + pinned actionlint + shellcheck.
  The guard closes the actionlint blind spot: actionlint models GitHub
  (default shell bash) so it does NOT flag bash syntax in a shell-less step.
  Guard scans ALL .gitea/workflows/*.yml (hence the validate.yml default too).
  Ported from pi-devbox 26384fe/d1db595.

B — promote-base-latest re-points base-latest by digest, not need_build:
  The gate keyed off need_build=='true', assuming need_build==false meant
  base-latest was current. A dry-run dispatch that pre-builds base-<hash>
  falsifies that, leaving base-latest one base behind. Gate now runs on every
  tag release / promote dispatch; the no-op optimization moved into the step
  as a crane digest compare (re-tags only when base-latest != released
  base-<hash>). Ported from pi-devbox b7197e8.

Validated locally: all 3 workflows YAML-parse; shell guard passes real
workflows and correctly fails a synthetic omit-shell+pipefail workflow;
actionlint (pinned 1.7.7) passes with explicit .gitea/workflows/*.yml glob.
2026-07-01 23:00:20 +02:00
pi 6639ba5820 feat: bake global gitignore (core.excludesFile) into image
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 11s
Validate / validate-omos (push) Successful in 4m20s
Validate / validate-base (push) Successful in 14m0s
Seed ~/.gitignore_global from /etc/skel-devbox (seed-if-absent, like
.bash_aliases/.inputrc, so user edits survive recreate) and wire it via
git config --global core.excludesFile, guarded so a user-set excludesFile
is never overridden. Ignores *.bak, *.bak.*, *~, *.orig, *.swp, *.tmp
across all repos without per-repo .gitignore entries.
2026-06-28 11:52:02 +02:00
pi b9039f577e release: v2.3.0 — image-baked fallback skills + opencode 1.17.10 + mempalace 3.5.0
Validate / base-change-warning (push) Successful in 14s
Publish Docker Image / resolve-versions (push) Successful in 9s
Publish Docker Image / base-decide (push) Successful in 13s
Validate / validate-base (push) Failing after 3m26s
Validate / validate-omos (push) Failing after 4m28s
Publish Docker Image / build-base (push) Successful in 37m26s
Publish Docker Image / smoke-omos (push) Successful in 4m36s
Publish Docker Image / smoke-base (push) Successful in 7m41s
Publish Docker Image / build-variant-base (push) Successful in 13m47s
Publish Docker Image / build-variant-omos (push) Successful in 19m24s
Publish Docker Image / promote-base-latest (push) Successful in 8s
Validate / docs-check (push) Successful in 6s
Publish Docker Image / update-description (push) Successful in 9s
- Add image-baked fallback skills (opencode-devbox-environment, mempalace) +
  harness instruction (instructions/opencode-devbox.md) under
  /usr/local/share/opencode-devbox/, symlinked in by entrypoint-user.sh
  (skills only-when-absent; instruction symlink to image, never copied into the
  devbox-opencode-config volume). Ported from pi-devbox v1.2.0/v1.2.1, adapted
  to opencode's ~/.config/opencode/instructions/ auto-load model. No
  pi-extensions skill (opencode has no fork/recall).
- Bump opencode 1.17.8 -> 1.17.10.
- Bump mempalace 3.4.0 -> 3.5.0 (lockstep with pi-devbox v1.2.2); remove the
  obsolete diary_write anyOf perl workaround (fixed upstream, issue #1728).
- Fix stale ssh-lan.conf ProxyJump guidance comment in setup-lan-access.sh
  (mirrors pi-devbox 8de0fad); comment-only.
- smoke-test.sh + recreate-sanity-check.sh assert baked source + resolved links.
- Docs: README Custom skills, AGENTS.md duties + MINOR example, CHANGELOG.
2026-06-25 09:58:13 +02:00
pi 992cb6702f release: v2.2.0
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Publish Docker Image / resolve-versions (push) Successful in 7s
Publish Docker Image / base-decide (push) Successful in 9s
Publish Docker Image / build-base (push) Successful in 30m33s
Publish Docker Image / smoke-base (push) Successful in 3m23s
Publish Docker Image / smoke-omos (push) Successful in 13m49s
Publish Docker Image / build-variant-base (push) Successful in 14m1s
Publish Docker Image / build-variant-omos (push) Successful in 22m56s
2026-06-19 20:18:40 +02:00
pi 9b1e8c0b30 docs: keep v2.1.1 CHANGELOG entry historically accurate (revert over-eager fork edit)
The doc-drift pass rewrote the v2.1.1 release entry to describe v2.2.0
behaviour; at v2.1.1 the sidecar genuinely was a no-op on native Linux, so
the edit falsified release history. The new behaviour is already documented
in the Unreleased section. Other doc-drift fixes retained.
2026-06-19 20:15:45 +02:00
pi 1f0d06444b docs: fix drift against main batch (SSH sidecar, proposed config, provenance, forked build)
Validate / docs-check (push) Successful in 7s
Validate / base-change-warning (push) Successful in 14s
Validate / validate-omos (push) Failing after 4m23s
Validate / validate-base (push) Failing after 5m8s
DRIFT-STALE fixes (stale/misleading text corrected):
- README.md:160 — "on native Linux it does nothing" → accurate always-render
  description (sidecar written on every OS; jump block still omitted on Linux)
- AGENTS.md:22 — setup-lan-access.sh "no-op on native Linux" → corrected to
  always-render + removed now-redundant inline ControlPath/UserKnownHostsFile
  sentence (that's what the sidecar does, not what the script detects)
- CHANGELOG.md:594 (v2.1.1 intro) — "no-op" → "(see v2.2.0)" historical note
- docs/plan-lan-access-and-pi-extensions.md:222 — annotated the completed
  checkbox with the v2.2.0 refinement

GAP additions:
- README.md:212 — custom config section now describes opencode.jsonc.proposed
  sidecar: written-on-diff, removed-on-match, never overwrites, one-line hint
- README.md build-args table — INSTALL_MEMPALACE_TOOLKIT row now mentions
  MEMPALACE_TOOLKIT_REPO alongside MEMPALACE_TOOLKIT_REF
- README.md (after build-args table) — two new sections:
    "Building a fork / relocated build" — MEMPALACE_TOOLKIT_REPO build-arg
    table + two-step docker build example + credentials note
    "Build provenance (labels + manifest)" — docker inspect + run examples,
    OCI label set, ground-truth manifest path

Version string drift: no hard-coded 1.17.7 found outside CHANGELOG (all
occurrences are historical release entries — correct by definition).
CI-internal items (resolve-versions, check-base-hash.sh): no existing doc
described these mechanics, so no update needed.
2026-06-19 20:14:12 +02:00
pi af11c32f4f feat(config): non-destructive opencode.jsonc.proposed sidecar (closes #8 batch item)
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 14s
Validate / validate-omos (push) Failing after 4m22s
Validate / validate-base (push) Failing after 5m5s
Completes the pi-devbox v1.1.4 "merge new defaults into preserved config"
idea, adapted to opencode-devbox's env-generated, JSONC-with-comments config
where an in-place merge would be destructive.

generate-config.py keeps its "never touch an existing config" guarantee and
adds a side-channel: when a live config exists, render the config it WOULD
generate for the current env + image defaults and write it to a NON-loaded
opencode.jsonc.proposed — but only when it differs from the live config;
remove it once they match. opencode never loads .proposed files, so it is a
pure manual-merge reference (e.g. surfacing a default MCP server added in a
newer image). An unparseable live config surfaces the proposal rather than
guessing equivalence. A one-line hint is logged on write.

- render_config(): shared renderer so first-gen and proposed paths can't drift
- _loads_jsonc(): string-aware //-comment strip (same approach as smoke-test;
  preserves https:// inside strings), raises on invalid JSON
- write_proposed(): write-on-diff + stale removal + live untouched
- smoke-test.sh: asserts write-on-diff, removal-on-match, live not clobbered
- entrypoint-user.sh + module docstring: document the sidecar
- CHANGELOG: moved from "Deferred" to "Added"

Caveat (documented in the file header): the proposal reflects env + image
defaults, so a diff may include the user's own past edits, not only new
image defaults.
2026-06-19 20:07:54 +02:00
pi 1c4239e9b0 port pi-devbox v1.1.4–v1.1.6 hardening; bump opencode 1.17.7→1.17.8
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 9s
Validate / validate-base (push) Successful in 3m9s
Validate / validate-omos (push) Successful in 17m47s
Functional (not verbatim) port of the build-provenance, CI-hardening, SSH
and shell fixes from the sibling pi-devbox repo, adapted to opencode-devbox's
companions and two-variant (base/omos) shape. Defaults unchanged → canonical
CI build stays byte-identical apart from the opencode bump and the
(cache-free) provenance layer.

Fixed:
- SSH read-only ~/.ssh ControlPath: setup-lan-access.sh now renders the
  writable ~/.ssh-local/config sidecar (ControlPath redirect + Include) on
  EVERY host OS instead of exit 0-ing on native Linux; jump-specific blocks
  gated behind new NEED_JUMP flag. dssh/dscp + ControlMaster now survive a
  read-only ~/.ssh on native-Linux hosts. (pi-devbox v1.1.5)
- bash history loss in nested/tmux shells: DEVBOX_HIST_SET no longer exported
  so each shell re-installs its own history -a flush. (pi-devbox v1.1.4)

Added:
- build provenance: OCI labels + /etc/opencode-devbox/build-manifest.json
  written from ground truth (opencode --version, installed omos version,
  /opt/mempalace-toolkit HEAD); wired into build-variant-* and smoke-* jobs;
  smoke-test.sh asserts manifest + label. (pi-devbox v1.1.6)
- scripts/check-base-hash.sh CI guard: fails if a Dockerfile.base ARG *_REF
  is not folded into the base_tag hash. (pi-devbox v1.1.6)
- overridable MEMPALACE_TOOLKIT_REPO build-arg in Dockerfile.base. (v1.1.6)

Changed:
- resolve-versions: fail-loud validation (SHA / semver) that aborts the
  release instead of silently falling back to floating main; adds shell: bash
  (set -o pipefail is illegal under the runner default dash). (pi-devbox v1.1.6)

Bumped:
- opencode-ai 1.17.7 → 1.17.8 (current npm latest stable).

Deferred (needs a decision): opencode.json merge-on-recreate — see CHANGELOG.
2026-06-19 19:45:11 +02:00
Joakim Persson 717c69ee17 v2.1.2: bump opencode 1.17.6->1.17.7
Validate / docs-check (push) Successful in 7s
Validate / base-change-warning (push) Successful in 10s
Publish Docker Image / resolve-versions (push) Successful in 5s
Publish Docker Image / base-decide (push) Successful in 16s
Publish Docker Image / build-base (push) Has been skipped
Validate / validate-base (push) Successful in 3m16s
Publish Docker Image / smoke-omos (push) Successful in 4m22s
Publish Docker Image / smoke-base (push) Successful in 5m9s
Validate / validate-omos (push) Successful in 13m55s
Publish Docker Image / build-variant-base (push) Successful in 15m11s
Publish Docker Image / build-variant-omos (push) Successful in 19m14s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Successful in 7s
Image-semver patch: opencode-only version bump (variant-layer rebuild).
Upstream 1.17.7 is bugfixes + minor improvements, no breaking/runtime/Bun/AVX
changes. Also fixes a stale CHANGELOG preamble that still described the
pre-v2.0.0 'v{opencode_version}[letter]' scheme instead of independent semver.
2026-06-16 08:41:14 +02:00
Joakim Persson 2ac84fa4fb docs(AGENTS): clarify opencode repo moved sst->anomalyco (not a fork)
Old note framed sst/opencode as a separate fork with a divergent release
timeline. It is the same repo, renamed/moved months ago; sst/opencode now
301-redirects to anomalyco/opencode (verified). Reframe as the canonical
source of truth with a quick verification command to stop the recurring
surprise.
2026-06-16 08:41:14 +02:00
pi 66527aeec9 docs(AGENTS): document GITEA_ACCESS_TOKEN env for general Gitea API access
Validate / base-change-warning (push) Successful in 28s
Validate / docs-check (push) Successful in 56s
Validate / validate-base (push) Successful in 3m17s
Validate / validate-omos (push) Successful in 4m23s
GITEA_ACCESS_TOKEN + GITEA_HOST (passed from host .env via compose,
primarily for gitea-mcp) are also usable for any direct Gitea API work —
run inspection, tag checks — not just ci-release-watcher. Prefer over a
PAT file when present; host-managed lifecycle, nothing to revoke. Mirrors
the same note added to pi-devbox AGENTS.md.
2026-06-15 22:30:43 +02:00
Joakim Persson 063cc6b6e6 test: add runtime recreate-sanity-check script
Validate / docs-check (push) Successful in 6s
Validate / base-change-warning (push) Successful in 10s
Validate / validate-omos (push) Successful in 4m11s
Validate / validate-base (push) Successful in 12m45s
Runtime peer 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), persisted named volumes
survived, omos skill symlinks resolve, shell defaults re-seeded, and /opt
toolkits intact. smoke-test.sh runs with --entrypoint="" and cannot see the
running container's volumes/symlinks, hence a separate runtime check.

Not run by CI or the entrypoint (it needs the release-time expected version
and a running container). Maintainer tooling, not baked into the image.
Registered in AGENTS.md File roles. Doc/script-only — no image rebuild.
2026-06-14 22:45:24 +02:00
Joakim Persson 52e8affa86 v2.1.1: bump opencode 1.17.5->1.17.6
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 15s
Publish Docker Image / resolve-versions (push) Successful in 8s
Publish Docker Image / base-decide (push) Successful in 12s
Validate / validate-base (push) Successful in 3m16s
Validate / validate-omos (push) Successful in 4m12s
Publish Docker Image / build-base (push) Successful in 37m15s
Publish Docker Image / smoke-base (push) Successful in 5m0s
Publish Docker Image / smoke-omos (push) Successful in 18m20s
Publish Docker Image / build-variant-base (push) Successful in 15m7s
Publish Docker Image / build-variant-omos (push) Successful in 18m29s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 7s
Patch release. Upstream 1.17.6 is a single MCP-compatibility bugfix (no breaking/runtime/AVX changes). Also lands the unreleased mempalace-toolkit SHA-resolution CI fix and two doc corrections; the toolkit change advances the base hash so this carries a base rebuild + base-latest re-promote.
2026-06-14 18:38:35 +02:00
pi e963f83e70 ci: CI-resolve mempalace-toolkit to a pinned SHA
Validate / docs-check (push) Successful in 7s
Validate / base-change-warning (push) Successful in 58s
Validate / validate-base (push) Successful in 3m19s
Validate / validate-omos (push) Successful in 4m19s
mempalace-toolkit is the only dependency cloned in Dockerfile.base (all
others live in the variant), so it bypassed the resolve-versions ->
build-arg plumbing and its ref stayed a literal `main`. Because the base
only rebuilds on a content hash, a toolkit-only fix would silently fail to
land unless Dockerfile.base itself changed.

Mirrors pi-devbox commit 4744f05, adapted to this repo:
- resolve-versions: new mempalace_toolkit_ref output via the gitea commits
  API (first gitea call in this repo's CI; works unauthenticated, no secret).
- base-decide: needs resolve-versions; fold the SHA into the base-tag hash
  so a moved toolkit forces a base rebuild (they no longer run in parallel).
- build-base: needs resolve-versions; pass --build-arg MEMPALACE_TOOLKIT_REF.
- Dockerfile.base: clone switched to SHA-capable git fetch + checkout
  FETCH_HEAD (git clone --branch <SHA> would fail).
- docs lockstep: .gitea/README.md Step 1 (no longer "in parallel"), AGENTS.md
  Critical conventions, CHANGELOG Unreleased.

base_tag now reflects a live gitea lookup; on API blip it falls back to
`main`, triggering one extra rebuild, never a missed one. No new tag —
lands on the next release or workflow_dispatch.
2026-06-14 15:51:55 +02:00
pi 4409bd0719 docs: correct mempalace anyOf workaround watch-target (PR #1735 is dead)
PR #1735 (the diary_write root-anyOf fix) was closed UNMERGED on 2026-06-11,
so the old "remove once PR #1735 ships" TODO points at a dead PR. Issue #1728
is still open; PR #1717 is the current live fix candidate; mempalace PyPI
latest is still 3.4.0 (== our pin), so the workaround must stay.

- Dockerfile.base: rewrite the upstream-tracking comment + TODO to reflect
  #1735 dead / watch #1717 / removal trigger = a PyPI release > 3.4.0 that
  actually strips the root anyOf.
- AGENTS.md: add a durable "anyOf workaround — upstream watch target" note
  under Critical conventions (persists context across machines/sessions),
  incl. the MEMPALACE_VERSION-vs-MEMPALACE_TOOLKIT_REF non-conflation warning.

Docs-only; no behavior change. Workaround remains live and correct for 3.4.0.
2026-06-14 15:33:46 +02:00
Joakim Persson c0d2516456 docs: fix quick-start — bare 'run devbox' lands in a shell, not opencode
Validate / base-change-warning (push) Successful in 7s
Validate / docs-check (push) Successful in 10s
Validate / validate-base (push) Successful in 3m2s
Validate / validate-omos (push) Successful in 6m38s
The image's default CMD is bash -l, so 'docker compose run --rm devbox'
with no command drops into a login shell; you pass 'opencode' explicitly to
start the harness. The README quick-start claimed the opposite and carried a
garbled 'Use bash instead of (no command)' half-sentence; the same error was
mirrored in the Hub HUB_TEMPLATE. Fix both and regenerate DOCKER_HUB.md.
Doc-only — no image bytes change.
2026-06-13 23:24:49 +02:00
Joakim Persson ba8000732d v2.1.0: symlink OMOS bundled skills from image, bump opencode 1.17.4->1.17.5
Validate / base-change-warning (push) Successful in 7s
Validate / docs-check (push) Successful in 13s
Publish Docker Image / base-decide (push) Successful in 8s
Publish Docker Image / resolve-versions (push) Successful in 10s
Validate / validate-omos (push) Successful in 4m15s
Validate / validate-base (push) Successful in 5m2s
Publish Docker Image / build-base (push) Successful in 30m33s
Publish Docker Image / smoke-base (push) Successful in 3m20s
Publish Docker Image / smoke-omos (push) Successful in 4m24s
Publish Docker Image / build-variant-base (push) Successful in 13m37s
Publish Docker Image / build-variant-omos (push) Successful in 30m18s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 14s
Deploy the five oh-my-opencode-slim bundled skills (clonedeps, codemap,
deepwork, oh-my-opencode-slim, simplify) by symlinking them from the image
path into ~/.agents/skills/ on every container start, instead of the
installer copying them into the persistent config volume on first run only.
Image-sourced links mean 'docker compose pull' + recreate refreshes the
skills with no installer run and no config reset; the old copy-on-first-run
froze them in the volume forever.

- entrypoint-user.sh: new non-fatal OMOS bundled-skills reconcile block
  (runs after skillset deploy so OMOS wins the simplify collision; absolute
  symlinks; gated by OMOS_SKILLS, now independent of ENABLE_OMOS). Both
  installer calls now pass --skills=no. One-time migration backs up (never
  deletes) frozen real copies in ~/.config/opencode/skills/ to .bak.<epoch>.
- scripts/smoke-test.sh: assert the bundled-skills source path on omos.
- Bump OPENCODE_VERSION 1.17.4 -> 1.17.5.
- Versioning: document the move to independent image semver (v2.0.0 was the
  decouple point), mirroring pi-devbox. README/AGENTS/.env.example/CHANGELOG
  updated; new docs/omos-skills.md.
2026-06-13 22:32:09 +02:00
pi 72298ae77e v2.0.0: remove pi, relocate npm-global prefix, bump opencode 1.17.2->1.17.4
Validate / base-change-warning (push) Successful in 14s
Validate / docs-check (push) Successful in 13s
Publish Docker Image / resolve-versions (push) Successful in 8s
Publish Docker Image / base-decide (push) Successful in 13s
Validate / validate-omos (push) Successful in 12m42s
Validate / validate-base (push) Successful in 13m39s
Publish Docker Image / build-base (push) Successful in 44m17s
Publish Docker Image / smoke-base (push) Successful in 3m46s
Publish Docker Image / smoke-omos (push) Successful in 5m54s
Publish Docker Image / build-variant-base (push) Successful in 18m11s
Publish Docker Image / build-variant-omos (push) Successful in 19m34s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Publish Docker Image / update-description (push) Successful in 15s
PR-5 (per docs/CLEANUP-v2.0.0.md). Major release with two breaking changes:

1. pi fully removed (deprecated in v1.17.2). Gone: INSTALL_PI + all PI_*
   build args; with-pi/omos-with-pi/pi-only variants; base-pi-only publish
   job; all ~/.pi entrypoint wiring; the 3 pi smoke/validate/build-variant
   CI jobs. Only base + omos variants remain (4 tags/release).

2. NPM_CONFIG_PREFIX relocated ~/.pi/npm-global -> ~/.config/opencode/npm-global
   (persistent in both compose files). entrypoint-user.sh gains a one-time
   migration shim that copies old global npm packages forward.

Also: opencode 1.17.2->1.17.4; DOCKER_HUB.md gains {{OPENCODE_VERSION}}
placeholder filled by CI at publish time (mirrors pi-devbox); full docs
drift sweep across README/AGENTS/.gitea-README/.env.example/manual-host-publish;
DOCKER_HUB.md regenerated + --check passes; both workflows YAML-valid;
all shell scripts pass bash -n.
2026-06-13 17:10:45 +02:00
pi c8217814c8 docs(v2.0.0): add explicit base-pi-only* Hub-tag purge step
Validate / docs-check (push) Failing after 7s
Validate / base-change-warning (push) Successful in 9s
Validate / validate-omos (push) Successful in 4m21s
Validate / validate-with-pi (push) Successful in 7m27s
Validate / validate-omos-with-pi (push) Successful in 5m52s
Validate / validate-base (push) Successful in 11m15s
Validate / validate-pi-only (push) Successful in 6m32s
Document that base-pi-only / base-pi-only-vX.Y.Z on joakimp/pi-devbox are
orphaned legacy artifacts (no pi-devbox build input references them) that
the build-variant-pi-only job re-publishes every release. Purge them from
the pi-devbox Hub repo after PR-5 removes the publisher; before that, the
floating tag just reappears. Added to removal steps + verification.
2026-06-10 22:34:03 +02:00
pi ff6e17b732 v1.17.2: bump opencode 1.16.2->1.17.2, deprecate pi, pin+patch mempalace
Validate / base-change-warning (push) Successful in 7s
Validate / docs-check (push) Failing after 9s
Validate / validate-omos (push) Successful in 4m4s
Validate / validate-with-pi (push) Successful in 7m14s
Validate / validate-omos-with-pi (push) Successful in 5m46s
Publish Docker Image / base-decide (push) Successful in 9s
Publish Docker Image / resolve-versions (push) Successful in 4s
Validate / validate-pi-only (push) Successful in 6m27s
Validate / validate-base (push) Successful in 14m39s
Publish Docker Image / build-base (push) Successful in 31m9s
Publish Docker Image / smoke-base (push) Successful in 5m3s
Publish Docker Image / smoke-with-pi (push) Successful in 5m2s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 5m59s
Publish Docker Image / smoke-pi-only (push) Successful in 6m48s
Publish Docker Image / smoke-omos (push) Successful in 12m8s
Publish Docker Image / build-variant-base (push) Successful in 13m37s
Publish Docker Image / build-variant-with-pi (push) Successful in 17m8s
Publish Docker Image / build-variant-pi-only (push) Successful in 22m57s
Publish Docker Image / build-variant-omos (push) Successful in 19m4s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 28m5s
Publish Docker Image / promote-base-latest (push) Successful in 10s
Publish Docker Image / update-description (push) Successful in 12s
opencode-ai 1.16.2 -> 1.17.2 (OPENCODE_VERSION).

Deprecate all pi support ahead of v2.0.0 removal (pi now ships from the
standalone joakimp/pi-devbox image, v1.0.0+, which no longer FROMs
base-pi-only):
- build-time stderr deprecation warning when INSTALL_PI=true
- README / DOCKER_HUB.md / AGENTS.md mark the with-pi/omos-with-pi/pi-only
  variants + base-pi-only tag deprecated, point to pi-devbox
- docs/CLEANUP-v2.0.0.md committed as the removal plan
- CHANGELOG pre-announces the v2.0.0 NPM_CONFIG_PREFIX relocation

Harden mempalace install (mirrors pi-devbox):
- pin via MEMPALACE_VERSION ARG (default 3.4.0); unpinned install is what
  swept in the broken schema
- idempotent, self-deactivating patch stripping the top-level anyOf from
  mempalace_diary_write input_schema (Anthropic tools API rejects it).
  Upstream: MemPalace/mempalace#1728, PR #1735

Fold prior Unreleased smoke-test pi-extensions readiness fix into v1.17.2.
2026-06-10 19:31:49 +02:00
pi c6f9d1148b smoke: wait for pi-extensions deploy completion, not just keybindings
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 11s
Validate / validate-base (push) Successful in 3m24s
Validate / validate-with-pi (push) Successful in 4m59s
Validate / validate-omos (push) Successful in 6m59s
Validate / validate-pi-only (push) Successful in 4m20s
Validate / validate-omos-with-pi (push) Successful in 14m33s
The entrypoint-deploy wait loop gated only on keybindings.json (written by
pi-toolkit, before pi-extensions), so the *.ts >= 4 assertion could sample
mid-deploy under parallel build load. v1.16.2 run 370: smoke-with-pi saw <4
while omos-with-pi/pi-only (same pi-extensions 357fcc6) saw 8, skipping
build-variant-with-pi. Now wait for the last-deployed artifact (mempalace.ts
bridge) AND a settled extension count (>=4), up to 45s. Test-only; no image
change, so no re-tag needed.
2026-06-08 22:49:09 +02:00
pi 56e6a782e3 Bump opencode 1.15.13 -> 1.16.2, pick up pi 0.79.0
Validate / base-change-warning (push) Successful in 10s
Validate / docs-check (push) Successful in 52s
Validate / validate-base (push) Successful in 3m7s
Validate / validate-omos (push) Successful in 6m40s
Validate / validate-omos-with-pi (push) Successful in 4m54s
Publish Docker Image / base-decide (push) Successful in 12s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / resolve-versions (push) Successful in 5s
Validate / validate-with-pi (push) Successful in 10m11s
Publish Docker Image / smoke-base (push) Successful in 3m5s
Publish Docker Image / smoke-omos (push) Successful in 4m24s
Validate / validate-pi-only (push) Successful in 6m6s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 5m0s
Publish Docker Image / smoke-pi-only (push) Successful in 3m39s
Publish Docker Image / build-variant-base (push) Successful in 15m24s
Publish Docker Image / build-variant-omos (push) Successful in 18m44s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 22m14s
Publish Docker Image / build-variant-pi-only (push) Successful in 21m13s
Publish Docker Image / smoke-with-pi (push) Successful in 4m0s
Publish Docker Image / build-variant-with-pi (push) Successful in 16m49s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Successful in 11s
Bump OPENCODE_VERSION in Dockerfile.variant to 1.16.2 (rolls up the
1.16.0/1.16.1/1.16.2 upstream releases of 2026-06-05). The pi-bearing
variants pick up pi 0.78.1 -> 0.79.0 via CI's resolve-versions job.

Preemptively raise smoke size thresholds +150 MB on opencode-bearing
variants (base/omos/with-pi/omos-with-pi) and +100 MB on pi-only ahead
of the combined minor opencode + pi bump. base (2506) and omos (3206)
were on ~94 MB headroom and minor bumps have tripped these before
(v1.15.0, v1.15.4); restores ~250 MB headroom to avoid a partial publish.

Promote CHANGELOG Unreleased -> v1.16.2.
2026-06-08 21:58:46 +02:00
pi 49d3e113ee docs: complete CHANGELOG/AGENTS + promote Unreleased -> v1.15.13e
Validate / docs-check (push) Successful in 7s
Validate / base-change-warning (push) Successful in 11s
Validate / validate-base (push) Successful in 3m33s
Validate / validate-with-pi (push) Successful in 4m35s
Validate / validate-omos (push) Successful in 6m59s
Validate / validate-pi-only (push) Successful in 3m30s
Validate / validate-omos-with-pi (push) Successful in 17m14s
Publish Docker Image / base-decide (push) Successful in 15s
Publish Docker Image / resolve-versions (push) Successful in 9s
Publish Docker Image / build-base (push) Successful in 31m45s
Publish Docker Image / smoke-base (push) Successful in 3m44s
Publish Docker Image / smoke-omos (push) Successful in 4m44s
Publish Docker Image / smoke-pi-only (push) Successful in 3m38s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 8m54s
Publish Docker Image / smoke-with-pi (push) Successful in 10m44s
Publish Docker Image / build-variant-base (push) Successful in 14m24s
Publish Docker Image / build-variant-omos (push) Successful in 19m43s
Publish Docker Image / build-variant-pi-only (push) Successful in 18m42s
Publish Docker Image / build-variant-with-pi (push) Successful in 17m45s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 32m54s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Publish Docker Image / update-description (push) Successful in 12s
- CHANGELOG: add the missing entry for the ~/.config/devbox-shell compose-doc
  commit (440218f); promote Unreleased -> v1.15.13e (2026-06-04) with a release
  summary (letter-suffix rebuild on opencode 1.15.13, picks up pi 0.78.1 + LAN
  key persistence + devbox-ssh-local chown fix + validate.yml false-neg fix).
- AGENTS.md: document the STRICT_REGISTRATION smoke-gate knob under CI quirks
  (kept in lockstep with the validate.yml/docker-publish-split.yml change).

Docs only; no image/behavior change. Tagging v1.15.13e after this lands.
2026-06-04 22:41:30 +02:00
pi f1e879ca6c docs: per-host ControlPath under ~/.ssh breaks pi --ssh (read-only mount)
The bind-mounted ~/.ssh/config is read before the baked Host * default and
SSH uses the first ControlPath it sees. A per-host block pointing ControlPath
under ~/.ssh/ (CGNAT-multiplexing pattern) wins but fails in-container because
~/.ssh is read-only, silently breaking pi --ssh <host> (falls back to local
tools). Documented the host-side fix: drop the override or repoint at the
writable /tmp/sshcm/. README + CHANGELOG only, no image change.
2026-06-04 22:31:54 +02:00
pi 9c31c641d6 smoke: gate fork/recall registration checks behind STRICT_REGISTRATION (#12)
Validate / base-change-warning (push) Successful in 7s
Validate / docs-check (push) Successful in 8s
Validate / validate-omos (push) Successful in 4m31s
Validate / validate-with-pi (push) Successful in 4m29s
Validate / validate-pi-only (push) Successful in 3m38s
Validate / validate-base (push) Successful in 9m41s
Validate / validate-omos-with-pi (push) Successful in 5m14s
validate.yml builds variants FROM the published base-latest, which lags
the entrypoint in the current commit until a release tag rebuilds the
base. The fork/recall registration smoke checks depend on the base
entrypoint running 'pi install /opt/<pkg>', so a stale base-latest reded
push-to-main runs with a false negative even when the variant layer was
correct.

smoke-test.sh now gates the two registration assertions behind
STRICT_REGISTRATION (warn-only when unset). validate.yml leaves it unset;
docker-publish-split.yml, which builds the base fresh in the same run,
sets STRICT_REGISTRATION=1 on the pi-bearing smoke jobs. Build-time /opt
+ node_modules checks stay hard in both paths.
2026-06-04 21:59:39 +02:00
pi d9dc85d825 entrypoint: chown devbox-ssh-local volume so jump key generates
Validate / docs-check (push) Successful in 6s
Validate / base-change-warning (push) Successful in 13s
Validate / validate-omos (push) Successful in 4m28s
Validate / validate-base (push) Successful in 5m31s
Validate / validate-omos-with-pi (push) Successful in 5m17s
Validate / validate-with-pi (push) Successful in 10m30s
Validate / validate-pi-only (push) Successful in 5m43s
The named-volume persistence change for ~/.ssh-local did not update the
entrypoint's volume-ownership loop. Docker creates named volumes as
root:root, so setup-lan-access.sh (running as developer) silently failed
to mkdir/ssh-keygen, leaving no jump key and breaking LAN access on the
first --force-recreate. Add ~/.ssh-local to the chown list.
2026-06-04 14:59:46 +02:00
pi 0b78ab4a94 LAN jump key: persist via named volume + one-line authorize hint
Validate / docs-check (push) Successful in 7s
Validate / base-change-warning (push) Successful in 9s
Validate / validate-omos (push) Successful in 4m26s
Validate / validate-with-pi (push) Successful in 4m30s
Validate / validate-pi-only (push) Successful in 3m33s
Validate / validate-omos-with-pi (push) Successful in 8m44s
Validate / validate-base (push) Successful in 9m8s
Persist ~/.ssh-local (devbox-ssh-local named volume) so the generated
LAN-jump key survives 'docker compose up --force-recreate'. Authorize
it on the host once per machine instead of after every container update.

setup-lan-access.sh now prints a copy-paste
'echo <pubkey> >> ~/.ssh/authorized_keys' line whenever it generates a
new key (not only when HOST_SSH_USER is unset), and stays silent once
the key is persisted. README + CHANGELOG updated.
2026-06-04 14:33:58 +02:00
pi 440218fc4c compose: document optional ~/.config/devbox-shell mount (LAN ssh-lan.conf + bash_aliases bridge)
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 14s
Validate / validate-base (push) Successful in 3m32s
Validate / validate-with-pi (push) Successful in 4m32s
Validate / validate-omos (push) Successful in 6m58s
Validate / validate-pi-only (push) Successful in 3m37s
Validate / validate-omos-with-pi (push) Successful in 17m51s
2026-06-04 13:34:10 +02:00
pi a56a5846a5 LAN-access: fix Include scope + read-only ControlPath, add ssh-lan.conf & RFC1918 autojump
Validate / docs-check (push) Successful in 6s
Validate / base-change-warning (push) Successful in 11s
Validate / validate-omos (push) Successful in 4m25s
Validate / validate-base (push) Successful in 5m21s
Validate / validate-omos-with-pi (push) Successful in 5m24s
Publish Docker Image / base-decide (push) Successful in 9s
Publish Docker Image / resolve-versions (push) Successful in 4s
Validate / validate-with-pi (push) Successful in 10m42s
Validate / validate-pi-only (push) Successful in 5m51s
Publish Docker Image / build-base (push) Successful in 30m30s
Publish Docker Image / smoke-base (push) Successful in 3m31s
Publish Docker Image / smoke-with-pi (push) Successful in 7m7s
Publish Docker Image / smoke-pi-only (push) Successful in 3m50s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 5m20s
Publish Docker Image / smoke-omos (push) Successful in 12m4s
Publish Docker Image / build-variant-base (push) Successful in 15m56s
Publish Docker Image / build-variant-pi-only (push) Successful in 16m6s
Publish Docker Image / build-variant-with-pi (push) Successful in 17m56s
Publish Docker Image / build-variant-omos (push) Successful in 22m32s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 33m41s
Publish Docker Image / update-description (push) Successful in 9s
Publish Docker Image / promote-base-latest (push) Successful in 13s
- Fix: Include ~/.ssh/config was scoped to the Host host/mac block, so
  dssh <peer> by name fell back to SSH defaults. Emit Host * scope reset
  before every Include.
- Fix: redirect ControlPath to writable ~/.ssh-local sidecar (Mac config's
  ~/.ssh/cm path is read-only in the container, broke multiplexed hosts).
- Add: Include host-owned ~/.config/devbox-shell/ssh-lan.conf for named-peer
  ProxyJump overrides (keeps image generic; peer names stay host-side).
- Add: opt-in DEVBOX_LAN_AUTOJUMP_PRIVATE=1 RFC1918 catch-all for roaming.
- Docs: README/.env.example/AGENTS/CHANGELOG + new ssh-lan.conf.example.
2026-06-04 00:52:42 +02:00
pi 053dac5308 docs: promote CHANGELOG Unreleased -> v1.15.13c
Validate / base-change-warning (push) Successful in 7s
Validate / docs-check (push) Successful in 14s
Validate / validate-omos (push) Successful in 4m23s
Validate / validate-with-pi (push) Failing after 4m38s
Publish Docker Image / base-decide (push) Successful in 11s
Publish Docker Image / resolve-versions (push) Successful in 5s
Publish Docker Image / build-base (push) Has been skipped
Validate / validate-base (push) Successful in 5m27s
Validate / validate-pi-only (push) Failing after 3m59s
Publish Docker Image / smoke-base (push) Successful in 3m33s
Publish Docker Image / smoke-omos (push) Successful in 7m4s
Publish Docker Image / smoke-with-pi (push) Successful in 4m35s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 5m31s
Validate / validate-omos-with-pi (push) Failing after 15m20s
Publish Docker Image / smoke-pi-only (push) Successful in 6m16s
Publish Docker Image / build-variant-base (push) Successful in 14m24s
Publish Docker Image / build-variant-omos (push) Successful in 19m13s
Publish Docker Image / build-variant-with-pi (push) Successful in 25m46s
Publish Docker Image / build-variant-pi-only (push) Successful in 15m39s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 27m57s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Successful in 7s
2026-06-03 21:46:37 +02:00
pi c71c03f0f1 fix: bump base smoke size threshold 2500->2600 MB
v1.15.13b's base crept to 2506 MB (LAN-access script + updated entrypoint
+ apt drift), tripping the zero-headroom 2500 ceiling. smoke-base failed,
which cascaded into skipping build-variant-base AND promote-base-latest,
so base-latest never advanced. All functional checks passed — this is a
guardrail bump, not a real regression. base-<hash> and the omos/with-pi/
omos-with-pi/pi-only variants did publish on the fresh base in run 354.
2026-06-03 21:46:15 +02:00
pi 1e98b53113 feat: publish pi-only build into the pi-devbox repo, not opencode-devbox (Option B)
The pi-only variant was published as opencode-devbox:latest-pi-only —
an 'opencode-devbox' tag containing no opencode, which confused users.

- build-variant-pi-only now pushes joakimp/pi-devbox:base-pi-only[-vX.Y.Z]
  instead of opencode-devbox:*-pi-only. New PI_IMAGE workflow env.
- Still built from the same Dockerfile.variant (single source of truth),
  still smoke-tested by smoke-pi-only / validate-pi-only before publish.
- De-advertised pi-only from README, DOCKER_HUB (HUB_TEMPLATE), AGENTS,
  .gitea/README. opencode-devbox now publishes 8 tags + base-latest.
- Documented in CHANGELOG (Unreleased) and the plan doc.

Note: old opencode-devbox:{latest,vX.Y.Z}-pi-only tags from v1.15.13b are
superseded and should be deleted from Docker Hub.
2026-06-03 17:04:21 +02:00
pi 30380abdef Cut v1.15.13b — LAN access + fork/recall + pi-only variant
Publish Docker Image / base-decide (push) Successful in 9s
Publish Docker Image / resolve-versions (push) Successful in 4s
Publish Docker Image / build-base (push) Successful in 30m44s
Publish Docker Image / smoke-omos (push) Successful in 4m40s
Publish Docker Image / smoke-with-pi (push) Successful in 4m40s
Publish Docker Image / smoke-pi-only (push) Successful in 3m37s
Publish Docker Image / smoke-base (push) Failing after 8m45s
Publish Docker Image / build-variant-base (push) Has been skipped
Publish Docker Image / smoke-omos-with-pi (push) Successful in 8m49s
Publish Docker Image / build-variant-omos (push) Successful in 19m18s
Publish Docker Image / build-variant-with-pi (push) Successful in 17m58s
Publish Docker Image / build-variant-pi-only (push) Successful in 21m43s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 28m19s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Container-level rebuild on opencode 1.15.13 / pi 0.78.0 (both unchanged):
host-OS-agnostic LAN access (base), pi-fork (fork) + pi-observational-memory
(recall) in pi variants, and the new pi-only variant (basis for pi-devbox).
2026-06-03 16:40:22 +02:00
pi 237588253f docs: fix stale variant/job counts missed in pi-only sweep
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 17s
Validate / validate-base (push) Successful in 3m40s
Validate / validate-with-pi (push) Failing after 4m43s
Validate / validate-omos (push) Successful in 7m7s
Validate / validate-pi-only (push) Failing after 3m44s
Validate / validate-omos-with-pi (push) Failing after 18m16s
- AGENTS.md: 'eight load:true jobs' -> ten (add validate-pi-only, smoke-pi-only)
- .gitea/README.md: 'four variants / eight tags' -> five / ten
- docs/manual-host-publish.md: 'Variants x4 / 10 tags' -> x5 / 12 tags

These are living operational facts; the remaining 'four/eight' hits are
illustrative meta-instructions or dated historical CHANGELOG entries (correct
as-is).
2026-06-03 16:34:36 +02:00
pi fc034ceade feat: add pi-only variant (pi without opencode) as basis for pi-devbox
Validate / docs-check (push) Successful in 10s
Validate / base-change-warning (push) Successful in 23s
Validate / validate-omos (push) Successful in 4m36s
Validate / validate-omos-with-pi (push) Failing after 5m40s
Validate / validate-with-pi (push) Failing after 7m35s
Validate / validate-pi-only (push) Failing after 3m45s
Validate / validate-base (push) Failing after 16m12s
All opencode-devbox variants set INSTALL_OPENCODE=true, so pointing pi-devbox
at with-pi dragged opencode along and made it ~a re-tag of latest-with-pi.
Add a 5th variant pi-only (INSTALL_OPENCODE=false, INSTALL_PI=true): pi +
companions (toolkit, extensions, fork, recall) + base tooling, no opencode
(~145 MB lighter than with-pi).

- Dockerfile.variant: document pi-only in the variant table.
- CI docker-publish-split.yml: new smoke-pi-only + build-variant-pi-only jobs
  (tags :VERSION-pi-only / :latest-pi-only, multi-arch); wired into
  promote-base-latest and update-description needs.
- validate.yml: new validate-pi-only main-branch gate job.
- smoke-test.sh: accept --variant pi-only; threshold 2750 MB; opencode-absent
  path already handled.
- Docs: HUB_TEMPLATE (regenerated DOCKER_HUB.md), README, AGENTS (variant/tag
  counts 4->5, 8->10 tags), .gitea/README, manual-host-publish.sh (5 variants),
  plan doc implementation note.

This is the single source of truth for joakimp/pi-devbox, which now FROMs
latest-pi-only. Versions unchanged (opencode 1.15.13, pi 0.78.0).
2026-06-03 16:13:44 +02:00
pi f09a4f382a feat: host-agnostic LAN access (base) + fork/recall in pi variants
Validate / base-change-warning (push) Successful in 22s
Validate / docs-check (push) Successful in 44s
Validate / validate-base (push) Successful in 3m27s
Validate / validate-omos (push) Successful in 7m3s
Validate / validate-with-pi (push) Failing after 4m33s
Validate / validate-omos-with-pi (push) Failing after 8m29s
Item A — LAN access (base image):
- New rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh, invoked
  non-fatally from entrypoint-user.sh. On VM-backed hosts (macOS OrbStack /
  Docker Desktop, detected via host.docker.internal) it generates a writable
  ~/.ssh-local/config that uses the host as an SSH jump to reach LAN peers;
  no-op on native Linux. Ships the mechanism (generic 'host' jump alias),
  not policy (targets stay in the user's bind-mounted ~/.ssh/config).
- New env knobs: DEVBOX_LAN_ACCESS (auto|jump|off), HOST_SSH_USER,
  DEVBOX_HOST_ALIAS. dssh/dscp aliases in .bash_aliases (guarded).

Item B — pi-fork (fork) + pi-observational-memory (recall) in pi variants:
- Dockerfile.variant clones both elpapi42 repos to /opt and runs npm install
  there at build time (local-path 'pi install' does not npm-install, so deps
  must be present to load). New args PI_FORK_REPO/REF, PI_OBSMEM_REPO/REF.
- entrypoint-user.sh registers them at runtime via 'pi install /opt/<pkg>'
  (instant, in-place, idempotent; tools bind on next pi start).
- CI resolve-versions resolves each repo's master HEAD to a commit SHA and
  passes PI_FORK_REF/PI_OBSMEM_REF — same cache-hit guard as PI_VERSION.
- smoke-test asserts /opt clones + node_modules + settings.json registration;
  size thresholds bumped (with-pi 2700->2900, omos-with-pi 3700->3900).

Versions unchanged (opencode 1.15.13, pi 0.78.0 — both still latest).
Docs: README LAN section + env table, .env.example, AGENTS.md, CHANGELOG.
Plan recorded in docs/plan-lan-access-and-pi-extensions.md.
2026-06-03 15:45:45 +02:00
pi f61b5a4977 Cut v1.15.13 — opencode 1.15.12→1.15.13 upstream, pi 0.77.0→0.78.0
Validate / base-change-warning (push) Successful in 23s
Validate / docs-check (push) Successful in 51s
Validate / validate-base (push) Successful in 3m50s
Publish Docker Image / base-decide (push) Successful in 13s
Publish Docker Image / resolve-versions (push) Successful in 4s
Validate / validate-with-pi (push) Successful in 4m3s
Publish Docker Image / build-base (push) Has been skipped
Validate / validate-omos (push) Successful in 7m34s
Publish Docker Image / smoke-base (push) Successful in 3m42s
Publish Docker Image / smoke-omos (push) Successful in 4m31s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 4m57s
Publish Docker Image / smoke-with-pi (push) Successful in 6m22s
Validate / validate-omos-with-pi (push) Successful in 15m13s
Publish Docker Image / build-variant-base (push) Successful in 14m10s
Publish Docker Image / build-variant-omos (push) Successful in 23m24s
Publish Docker Image / build-variant-with-pi (push) Successful in 16m3s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 31m50s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Successful in 6s
2026-05-31 22:26:21 +02:00
pi 870da12c92 Cut v1.15.12 — opencode 1.15.11→1.15.12 upstream, pi 0.76.0→0.77.0
Validate / docs-check (push) Successful in 12s
Validate / base-change-warning (push) Successful in 52s
Validate / validate-base (push) Successful in 3m34s
Publish Docker Image / base-decide (push) Successful in 12s
Publish Docker Image / resolve-versions (push) Successful in 5s
Validate / validate-omos (push) Successful in 4m33s
Publish Docker Image / build-base (push) Has been skipped
Validate / validate-with-pi (push) Successful in 6m29s
Publish Docker Image / smoke-base (push) Successful in 3m45s
Publish Docker Image / smoke-omos (push) Successful in 4m37s
Publish Docker Image / smoke-with-pi (push) Successful in 6m29s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 4m59s
Validate / validate-omos-with-pi (push) Successful in 12m42s
Publish Docker Image / build-variant-base (push) Successful in 16m17s
Publish Docker Image / build-variant-omos (push) Successful in 19m12s
Publish Docker Image / build-variant-with-pi (push) Successful in 20m22s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 21m20s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Successful in 10s
opencode-ai actually released 1.15.12 upstream (2026-05-28); this is
the genuine first container build on it, plus the pi 0.77.0 bump
(Claude Opus 4.8, --exclude-tools, headless Codex subscription login,
streaming-aware extension input, plus a long fix list).

Re-uses the v1.15.12 git tag, force-overwriting the historical
artifact tag at be2a168 from the 2026-05-28 versioning slip (caught
same day and re-cut as v1.15.11c; corresponding Hub images already
manually deleted). Commit be2a168 and the v1.15.11c CHANGELOG block
referencing the slip remain in history.

No base-image change — unchanged Dockerfile.base/rootfs/entrypoint
will hit base-decide cache-hit short-circuit; only the four variant
builds + manifest tagging will run.

See CHANGELOG v1.15.12 for the full upstream notes.
2026-05-29 09:06:54 +02:00
pi cb50e6ea60 Cut v1.15.11c — re-tag of v1.15.12 to fix versioning-scheme violation
Validate / base-change-warning (push) Successful in 5s
Validate / docs-check (push) Successful in 13s
Validate / validate-with-pi (push) Successful in 4m8s
Publish Docker Image / base-decide (push) Successful in 13s
Validate / validate-omos (push) Successful in 4m34s
Publish Docker Image / resolve-versions (push) Successful in 5s
Publish Docker Image / build-base (push) Has been skipped
Validate / validate-base (push) Successful in 5m19s
Publish Docker Image / smoke-base (push) Successful in 3m43s
Publish Docker Image / smoke-omos (push) Successful in 4m41s
Publish Docker Image / smoke-with-pi (push) Successful in 6m38s
Validate / validate-omos-with-pi (push) Successful in 12m30s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 4m53s
Publish Docker Image / build-variant-base (push) Successful in 14m29s
Publish Docker Image / build-variant-with-pi (push) Successful in 21m5s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 21m6s
Publish Docker Image / build-variant-omos (push) Successful in 23m14s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Has been skipped
The 2026-05-28 morning v1.15.12 release violated the project's
v{opencode_version}[letter] tagging scheme: opencode-ai stayed at
1.15.11 upstream (no 1.15.12 exists on npm), so the third container
build on opencode 1.15.11 should have been v1.15.11c.

The commit message of the slipped tag (be2a168) itself said
'OPENCODE_VERSION stays at 1.15.11 (no upstream change)' — the slip
was caught the same afternoon during a versioning audit.

This release re-cuts at HEAD and supersedes v1.15.12. The slipped
git tag and the eight v1.15.12* / latest* Docker Hub images remain
as historical artifacts. Future builds on opencode 1.15.11 continue
the letter sequence as v1.15.11d, v1.15.11e, etc; v1.15.12 will only
be reused if and when opencode upstream actually releases 1.15.12.

Includes everything in v1.15.12 plus the afternoon followup work:
- CI: registry cache-export disabled (Hub 400 root-cause fix)
- Docs: manual host-publish runbook + script archive
- CI: workflow-level 3-attempt retry around buildx build --push

AGENTS.md: new pre-flight check requirement under Versioning scheme
documenting the slip as a cautionary example. Mandatory
'npm view opencode-ai version' check before any non-letter-suffix tag.

CHANGELOG: new v1.15.11c block with full content list; v1.15.12 block
gets a note documenting the supersession.
2026-05-28 16:54:23 +02:00
pi 1fe5b5df91 ci: workflow-level 3-attempt retry around buildx build --push
Validate / docs-check (push) Successful in 7s
Validate / base-change-warning (push) Successful in 6s
Validate / validate-with-pi (push) Successful in 4m11s
Validate / validate-omos (push) Successful in 4m31s
Validate / validate-base (push) Successful in 5m19s
Validate / validate-omos-with-pi (push) Successful in 11m38s
Belt-and-braces against transient registry-1.docker.io blips (rate
limits, brief 5xx, CDN flap). Replaces all five push docker/build-push-
action@v7 invocations (1 base + 4 variants) with shell: bash steps that
run docker buildx build --push in a for-loop with backoff (15s, 30s).
Smoke build steps (load: true, no push) are untouched.

Does NOT mask deterministic failures: a true regression (e.g. the
cache-export 400 we hit 2026-05-23..28) fails all 3 attempts
identically and the job still fails by design. Orthogonal layer to
both cache-export disablement and the ci-release-watcher skill's
transient-rerun heuristic.

- AGENTS.md: new Critical conventions bullet documenting the retry
  pattern, the consistency rule across push steps, and why it's
  duplicated rather than factored (Gitea Actions doesn't support
  reusable composite shell steps cleanly).
- CHANGELOG.md: Unreleased section addendum, no image-side change.

No image-side change.
2026-05-28 16:32:41 +02:00
pi 6cc2670a93 docs: manual host-publish runbook + cache-export gotcha in AGENTS.md
Validate / docs-check (push) Successful in 6s
Validate / base-change-warning (push) Successful in 12s
Validate / validate-with-pi (push) Successful in 4m5s
Validate / validate-omos (push) Successful in 4m27s
Validate / validate-base (push) Successful in 5m33s
Validate / validate-omos-with-pi (push) Successful in 12m18s
Captures the escape-hatch procedure used to ship v1.15.12 on 2026-05-28
when buildkit cache-export mode=max started returning HTTP 400 from the
Hub CDN, breaking five consecutive CI publishes (runs #332/333/334/336
+ a rerun).

- docs/manual-host-publish.sh: the literal script that shipped v1.15.12
  from a developer Mac via Orbstack, preserved as-is for future reference.
- docs/manual-host-publish.md: runbook explaining when to reach for it,
  the four constants to edit, three ways to source BASE_HASH (CI log /
  Hub probe / local recompute matching base-decide's exact recipe
  including __pycache__/.DS_Store junk filters), and adaptations for
  pi-devbox / letter-suffix rebuilds / partial-failure recovery.
- AGENTS.md: new Critical conventions bullet documenting the cache-from
  /cache-to disablement, failure shape, repo-specificity, why action
  pinning didn't help, the trade-off, and the re-enable condition.
  Cross-references CHANGELOG v1.15.12 Unreleased + the new runbook.
2026-05-28 16:21:40 +02:00
joakimp 51ec4a88cf CI: drop registry cache-export from build-base (Hub 400 root cause)
Validate / base-change-warning (push) Successful in 6s
Validate / docs-check (push) Successful in 13s
Validate / validate-with-pi (push) Successful in 4m9s
Validate / validate-omos (push) Successful in 4m31s
Validate / validate-base (push) Successful in 5m40s
Validate / validate-omos-with-pi (push) Successful in 12m49s
Diagnosed during manual v1.15.12 publish: buildkit's mode=max cache export
to registry-1.docker.io reproducibly returns HTTP 400 with HTML body on the
resumable-upload PUT. Image push (layers + manifest) works fine in parallel;
only --cache-to fails. Removing cache-from/cache-to lets the publish complete.

This explains all four prior CI failures (runs 332/333/334/336) which shared
the exact same failure shape. Action-pin hypothesis (setup-buildx-action
v4.1.0) was correctly disproven by run 336 with v4.0.0 pinned.

Trade-off: every Dockerfile.base change now pays the full ~3 min multi-arch
build. Unchanged bases short-circuit at the content-addressed probe step in
base-decide and never re-build, so day-to-day cost is zero.

Re-enable when moby/buildkit upstream resolves the cache-export protocol
mismatch with Hub CDN, or when we can switch to a non-registry cache backend.

CHANGELOG.md: full root-cause writeup in Unreleased section, including
status update on every prior suspect (all ruled out).
2026-05-28 10:40:08 +00:00
joakimp be2a16834c Cut v1.15.12 — revert v4.0.0 pin (busted), bump pi to 0.76.0
Validate / docs-check (push) Successful in 8s
Validate / base-change-warning (push) Successful in 52s
Validate / validate-base (push) Failing after 3m34s
Publish Docker Image / base-decide (push) Successful in 10s
Publish Docker Image / resolve-versions (push) Successful in 4s
Validate / validate-with-pi (push) Failing after 4m0s
Validate / validate-omos (push) Failing after 6m50s
Validate / validate-omos-with-pi (push) Failing after 12m15s
Publish Docker Image / build-base (push) Failing after 30m40s
Publish Docker Image / smoke-base (push) Has been skipped
Publish Docker Image / smoke-with-pi (push) Has been skipped
Publish Docker Image / build-variant-base (push) Has been skipped
Publish Docker Image / build-variant-with-pi (push) Has been skipped
Publish Docker Image / smoke-omos (push) Has been skipped
Publish Docker Image / build-variant-omos-with-pi (push) Has been skipped
Publish Docker Image / build-variant-omos (push) Has been skipped
Publish Docker Image / smoke-omos-with-pi (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
The v1.15.11b experiment confirmed setup-buildx-action@v4.1.0 is NOT
the regressor: pinning all 9 references to @v4.0.0 reproduced the
exact same '400 Bad request' from registry-1.docker.io on the first
layer-blob PUT. CI run #336 failed twice (original + Gitea auto-rerun),
both with HTML 400 bodies (CDN-tier rejection) at Offset:0. UUIDs and
_state signatures differ across attempts; only the failure pattern is
stable.

Reverting all 9 pins back to @v4 — keeping a wrong pin holds us off
action improvements with no benefit. Real suspects now narrow to:
runner-image (catthehacker:act-latest, floating), runner-2 host
network egress, buildx 0.34.x signed _state token format, or per-repo
Hub-side state. Investigation deferred; this release ships via manual
docker buildx build --push from a developer Orbstack to bypass the
broken runner-network → Hub-CDN combo (we know that path works in
~25s for the same multi-arch build to the same Hub account).

PI_VERSION=latest resolves to pi-coding-agent 0.76.0 (published
2026-05-27 20:03 UTC). OPENCODE_VERSION stays at 1.15.11 (no upstream
bump since 1.15.11 was published 2026-05-27 03:59 UTC).

Files:
- .gitea/workflows/docker-publish-split.yml: 9 setup-buildx-action
  references reverted from @v4.0.0 to @v4
- CHANGELOG.md: v1.15.12 entry with regression triage status
  (ruled-out vs still-suspect)
2026-05-28 08:11:00 +00:00
joakimp a16da2f041 Cut v1.15.11b — pin setup-buildx-action@v4.0.0
Validate / docs-check (push) Successful in 6s
Validate / base-change-warning (push) Successful in 6s
Validate / validate-with-pi (push) Failing after 4m1s
Publish Docker Image / base-decide (push) Successful in 8s
Publish Docker Image / resolve-versions (push) Successful in 5s
Validate / validate-omos-with-pi (push) Failing after 4m52s
Validate / validate-omos (push) Failing after 6m41s
Validate / validate-base (push) Failing after 8m55s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Publish Docker Image / build-base (push) Failing after 37m43s
Publish Docker Image / smoke-base (push) Has been skipped
Publish Docker Image / smoke-omos (push) Has been skipped
Publish Docker Image / smoke-with-pi (push) Has been skipped
Publish Docker Image / build-variant-omos (push) Has been skipped
Publish Docker Image / build-variant-with-pi (push) Has been skipped
Publish Docker Image / smoke-omos-with-pi (push) Has been skipped
Publish Docker Image / build-variant-base (push) Has been skipped
Publish Docker Image / build-variant-omos-with-pi (push) Has been skipped
The v1.15.11 publish failed three times in a row (runs #332/333/334)
with identical '400 Bad request' from registry-1.docker.io on the
multi-arch buildx layer-blob PUT. Triage on 2026-05-27 confirmed:

  - Multi-arch buildx push from a developer host: succeeds in 25s
    (same Hub account, same multi-arch path)
  - Account / repo / Hub-CDN: all healthy
  - Last known-good Gitea-runner Hub push: 2026-05-23 ~20:26 UTC
    (pi-devbox v0.75.5b) — predates docker/setup-buildx-action v4.1.0
    by <24h
  - docker/setup-buildx-action@v4 floats to v4.1.0 (published
    2026-05-22 16:00 UTC), bundling a newer buildx/buildkit whose
    push protocol may trip Hub's CDN URI-length cap on the ~1.4 KB
    _state query string in resumable-upload PUT URLs.

Pinning all nine setup-buildx-action references to @v4.0.0 to
test the hypothesis. setup-qemu-action@v3 left floating since
QEMU wasn't in the suspected blast radius. If v4.0.0 publishes
cleanly we keep the pin and file an upstream buildkit/buildx
issue.

No source changes — same OPENCODE_VERSION=1.15.11, same Dockerfile.base
and Dockerfile.variant. v1.15.11 (original tag) is preserved as a
historical marker of the first publish attempt; v1.15.11b becomes
the canonical release.
2026-05-27 21:05:17 +00:00
joakimp 608304c3de Bump opencode 1.15.10 -> 1.15.11 + cut v1.15.11
Publish Docker Image / base-decide (push) Successful in 9s
Publish Docker Image / resolve-versions (push) Successful in 5s
Validate / base-change-warning (push) Successful in 5s
Validate / docs-check (push) Successful in 49s
Validate / validate-with-pi (push) Failing after 4m8s
Validate / validate-omos (push) Failing after 4m53s
Validate / validate-base (push) Failing after 5m22s
Validate / validate-omos-with-pi (push) Failing after 14m49s
Publish Docker Image / build-base (push) Failing after 30m39s
Publish Docker Image / smoke-base (push) Has been skipped
Publish Docker Image / smoke-omos (push) Has been skipped
Publish Docker Image / build-variant-omos (push) Has been skipped
Publish Docker Image / build-variant-base (push) Has been skipped
Publish Docker Image / smoke-with-pi (push) Has been skipped
Publish Docker Image / build-variant-with-pi (push) Has been skipped
Publish Docker Image / smoke-omos-with-pi (push) Has been skipped
Publish Docker Image / build-variant-omos-with-pi (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
First release on opencode 1.15.11. Also ships the four devbox-side fixes
accumulated since v1.15.10:

  - 668592d Base: SSH ControlMaster default on a writable socket path
  - 73a7f96 Base: gitleaks added; git-crypt confirmed installed
  - 3cbcb44 CI: fix resolve-versions to use curl+jq instead of npm view
  - f7c3409 CI: preventative fix for PI_VERSION/OMOS_VERSION cache-hit regression

Downstream pi-devbox inherits all of these on its next build against
base-latest.

Upstream release notes:
  https://github.com/anomalyco/opencode/releases/tag/v1.15.11
2026-05-27 15:02:24 +00:00
joakimp 668592da0d Base: SSH ControlMaster default on a writable socket path
Validate / docs-check (push) Successful in 9s
Validate / base-change-warning (push) Successful in 11s
Validate / validate-with-pi (push) Failing after 4m6s
Validate / validate-omos (push) Failing after 4m31s
Validate / validate-omos-with-pi (push) Failing after 4m52s
Validate / validate-base (push) Failing after 13m20s
Devboxes typically mount ~/.ssh from the host read-only (security: keys
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 multiplex fails with:

  unix_listener: cannot bind to path .../cm/...: Read-only file system
  kex_exchange_identification: Connection closed by remote host

The second line is downstream — when ControlMaster fails, ssh falls
back to fresh TCP connections, and on residential CGNAT (most European
ISPs) the per-(src,dst) concurrent-flow cap (~4) silently drops further
SYNs once exceeded, manifesting as banner-exchange timeouts that look
like a remote problem.

Fix: bake /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
base image with Host * defaults — ControlPath rooted at /tmp/sshcm/
(per-container, always writable), ControlMaster auto, ControlPersist
10m, ServerAlive{Interval=30,CountMax=6}. Companion entrypoint-user.sh
creates /tmp/sshcm mode 700 on each container start (/tmp is
per-container so the dir can't be baked into a layer; mode 700 is
required by OpenSSH for ControlPath dirs). Debian's stock ssh_config
sources ssh_config.d/*.conf before its own Host * block, so user
~/.ssh/config overrides still win.

Two smoke assertions catch regressions: (a) the conf file exists, (b)
ssh -G reports a controlpath rooted at /tmp/sshcm/ — second one catches
the silent case where something later in the config chain shadows the
bake-in.

Discovered while running a recon shell from inside pi-devbox to a
Proxmox node — fresh ssh hit banner-exchange timeout, debug output
pointed at the read-only socket dir as the actual root cause.

Cascades to all variants and to pi-devbox automatically on next build
against base-latest. No size/threshold impact (~250-byte conf file).
2026-05-24 19:51:38 +00:00
joakimp 3cbcb44cf5 CI: fix resolve-versions to use curl+jq instead of npm view
catthehacker/ubuntu:act-latest ships Node/npm under /opt/acttoolcache/
with PATH updated only in /etc/environment. act_runner (nektos/act) does
not source /etc/environment — it reads the Docker image's ENV instructions
(inspectResult.Config.Env) which only contain DEBIAN_FRONTEND=noninteractive.
So npm is NOT on PATH and 'npm view ...' would have CI-failed on first run.

Fix: query the npm registry HTTP API directly with curl+jq, both of which
are already used extensively by this workflow (curl for Hub auth/manifest
inspect, jq for token parsing). The endpoint
  https://registry.npmjs.org/<pkg>/latest
returns JSON with a 'version' field — equivalent to 'npm view <pkg> version'
but with no toolchain dependency.

Verified locally: both URLs resolve correctly to 0.75.5 (pi) and 1.1.1 (omos).

Evidence: nektos/act pkg/container/docker_run.go reads imageEnv from
inspectResult.Config.Env, not /etc/environment. DefaultPathVariable() in
linux_container_environment_extensions.go returns a hardcoded path with no
/opt/acttoolcache in it.
2026-05-24 15:59:53 +00:00
joakimp 73a7f96056 Base: add gitleaks; surface git-crypt in smoke + docs
Both tools are used as part of the secret-management setup in several
of the repos this devbox operates on (gitleaks pre-commit hook +
git-crypt for selectively-encrypted canonical config). Having them in
the container means hooks fire correctly inside instead of warning
'gitleaks not installed' on every commit.

git-crypt was already installed via apt in Dockerfile.base (line 58),
just unasserted by smoke and unmentioned in user-facing docs.

gitleaks is new: Go-compiled binary fetched from GitHub releases via
the same /releases/latest redirect-resolution pattern as gosu, fzf,
git-lfs, etc. Arch suffix is 'x64' (not 'x86_64' / 'amd64') on this
project — flagged in the Dockerfile comment and in AGENTS.md's
floated-binaries gotcha list.

Adds ~21 MB to the base layer (gitleaks 8.30.1 binary). No variant
threshold bumps needed (2500–3700 MB envelope, 21 MB is noise).

CHANGES

Dockerfile.base — new GITLEAKS_VERSION=latest ARG + install RUN
right after the git-lfs block. Multi-arch (linux/amd64=x64,
linux/arm64=arm64). Echoes resolved version + runs 'gitleaks version'
to fail the build on any install error.

scripts/smoke-test.sh — git-crypt and gitleaks added to the
'Resolved component versions' table (printed first thing in CI logs)
and to the 'Core binaries' assertion list (run helper). Smoke now
fails fast if either binary regresses.

README.md — 'What's in the image' tree line names gitleaks alongside
the existing git-crypt.

AGENTS.md — gitleaks added to the 'GitHub-sourced binaries float by
default' list with a new clause flagging project-specific arch-name
deviations (gitleaks=x64, bat/eza/zoxide=x86_64/aarch64, gosu=
amd64/arm64). Saves the next person from the 'why does this not
download' debugging session.

CHANGELOG.md — sub-entry under existing Unreleased, before the
PI_VERSION/OMOS_VERSION cache-hit fix entry.

DOWNSTREAM IMPACT

This is a base-layer change — base-decide will compute a fresh
base-<hash>, build-base will run (no cache hit), all four variants
will rebuild. First real base rebuild since v1.14.50b. Pi-devbox's
next FROM base-latest pull picks up gitleaks automatically with no
Dockerfile change there.

Verified end-to-end on host: gitleaks 8.30.1 21 MB binary extracts
cleanly from the URL the Dockerfile constructs and 'gitleaks version'
prints '8.30.1'.

Holding off on tagging — opencode + pi upstreams unchanged at 1.15.10
and 0.75.5 respectively. Will ride along with the next upstream-bump
release rather than burning a base rebuild on a no-upstream-change
container-only roll.
2026-05-24 15:49:38 +00:00
joakimp f7c34091b1 CI: preventative fix for PI_VERSION/OMOS_VERSION cache-hit silent regression
Mirrors the pi-devbox v0.75.5b fix (2026-05-23) onto the four-variant
pipeline here. The with-pi, omos, and omos-with-pi variants install
upstream npm packages whose *_VERSION build-args defaulted to 'latest'.
When the build-arg string is byte-identical across builds, the layer
hash is identical and the registry buildcache silently reuses the layer
from whatever upstream version was current when the cache was first
populated — same mechanism that shipped pi-devbox v0.74.0..v0.75.5 with
identical image bytes.

Currently masked here because OPENCODE_VERSION is a hard-coded ARG that
bumps every release; parent-chain cache invalidation flushes the
downstream pi/omos layers. Masking would fail on any vN.N.Nb opencode-
version-unchanged release that only bumps pi or omos. Filed last night
as parked followup; fixing preventatively now that #5 (AWS SSO inside
tor-ms22 container) cleared.

CHANGES

.gitea/workflows/docker-publish-split.yml — new resolve-versions job
running 'npm view @earendil-works/pi-coding-agent version' and
'npm view oh-my-opencode-slim version', exposing concrete strings as
job outputs. All six affected jobs (smoke-omos, smoke-with-pi,
smoke-omos-with-pi, build-variant-omos, build-variant-with-pi,
build-variant-omos-with-pi) now consume them as PI_VERSION /
OMOS_VERSION build-args. smoke-base / build-variant-base unaffected.

scripts/smoke-test.sh — new run_expect helper asserting an expected
substring in command output. The pi check uses EXPECTED_PI_VERSION;
the omos check uses EXPECTED_OMOS_VERSION against npm ls -g. Both env
vars are wired from resolve-versions outputs in the smoke jobs. Catches
this regression class on the next release, not four releases later.

Dockerfile.variant — comment blocks above OPENCODE_VERSION (source-
pinned, not subject to the bug), PI_VERSION (CI-resolved), and
OMOS_VERSION (CI-resolved) explaining the cache-hit footgun.

AGENTS.md — new convention bullet under 'Critical conventions' naming
the resolve-versions job + EXPECTED_*_VERSION wiring as the contract
to keep in lockstep when modifying variant build-args.

.gitea/README.md — Step 1 expanded to cover the parallel resolve-
versions job alongside base-decide; pipeline diagram updated.

CHANGELOG.md — Unreleased entry describing the fix, masking mechanism,
and audit footprint.

No image-content change expected on the next release vs what 'latest'
would have resolved to anyway. Purely makes the cache invalidate
correctly going forward.
2026-05-24 15:38:36 +00:00
joakimp 4cce39d167 AGENTS: add 'Upstream sources' section pointing at anomalyco/opencode
Validate / base-change-warning (push) Successful in 14s
Validate / docs-check (push) Successful in 16s
Validate / validate-base (push) Successful in 3m39s
Validate / validate-with-pi (push) Successful in 4m8s
Validate / validate-omos (push) Successful in 6m46s
Validate / validate-omos-with-pi (push) Successful in 13m35s
Publish Docker Image / build-base (push) Has been cancelled
Publish Docker Image / smoke-base (push) Has been cancelled
Publish Docker Image / smoke-omos (push) Has been cancelled
Publish Docker Image / smoke-with-pi (push) Has been cancelled
Publish Docker Image / smoke-omos-with-pi (push) Has been cancelled
Publish Docker Image / build-variant-base (push) Has been cancelled
Publish Docker Image / build-variant-omos (push) Has been cancelled
Publish Docker Image / build-variant-with-pi (push) Has been cancelled
Publish Docker Image / build-variant-omos-with-pi (push) Has been cancelled
Publish Docker Image / promote-base-latest (push) Has been cancelled
Publish Docker Image / update-description (push) Has been cancelled
Publish Docker Image / base-decide (push) Failing after 14m23s
Tonight's v1.15.10 release surfaced a documentation drift footgun: I
checked github.com/sst/opencode (a fork) for release notes instead of
the canonical github.com/anomalyco/opencode. Empty bodies on sst led
me to write 'upstream releases ship empty bodies and no CHANGELOG'
in the v1.15.10 CHANGELOG, which was wrong — anomalyco's release
pages have rich Core/TUI/Desktop/SDK sections.

Added a new 'Upstream sources — where to look up release notes'
section between 'Versioning scheme' and 'Critical conventions',
documenting:
  - Canonical upstream for opencode-ai (anomalyco/opencode), pi
    (npm tarball CHANGELOG.md), other floated tools.
  - The sst/opencode trap explicitly named so future-pi doesn't
    repeat the mistake.
  - Working fetch commands as muscle memory: 'npm view ... time'
    for latest stable filtering, 'curl /releases/tags/' for body,
    'npm pack' for pi's changelog.

No CI implications, doc-only.
2026-05-23 19:26:46 +02:00
joakimp 72d2c99885 docs: enrich v1.15.10 CHANGELOG with actual upstream release notes
The v1.15.10 release commit (80e57d7) said upstream releases ship
empty bodies — that was incorrect. I was checking sst/opencode which
is a fork; the canonical upstream this devbox tracks is
github.com/anomalyco/opencode (per user correction). Three of the
four versions (1.15.7, 1.15.9, 1.15.10) have rich release notes;
only 1.15.8 is empty.

Expanded the v1.15.10 CHANGELOG entry with per-version summaries:

- v1.15.7: Grok OAuth + device-code login, v2 session API safe-error
  responses with reference IDs, Codex OAuth refresh dedup, restored
  OpenAI OAuth + reasoning streams, friendly tool-schema errors,
  Grok PDF attachments, several TUI and desktop improvements.

- v1.15.8: empty release body (assumed internal/no user-visible).

- v1.15.9: redesigned diff viewer with file tree + enabled by default,
  MCP OAuth callbackPort and scope-in-clientMetadata, Vertex Anthropic
  multi-region endpoint fix, many 'show clearer error' improvements,
  native reasoning metadata preservation across turns, TUI worktree-
  copy shortcut, desktop titlebar tab navigation.

- v1.15.10: single fix — restored legacy production desktop flows
  for opening projects and starting sessions.

Doc-only commit on main. The v1.15.10 tag snapshot is unchanged
because CI is mid-flight against it (5 jobs running at the time of
this correction); cancelling and re-tagging would cost ~50 min of
CI re-run for changelog wording with no image-content effect.
The Hub description for v1.15.10 will reflect the thin tag-snapshot
CHANGELOG; main has the correct content for the next release and
for anyone reading current docs.
2026-05-23 19:19:19 +02:00
joakimp 80e57d732b Bump opencode 1.15.6 -> 1.15.10 + cut v1.15.10
Validate / validate-base (push) Waiting to run
Validate / validate-with-pi (push) Waiting to run
Validate / docs-check (push) Successful in 15s
Validate / base-change-warning (push) Successful in 59s
Validate / validate-omos (push) Successful in 4m33s
Validate / validate-omos-with-pi (push) Successful in 7m50s
Publish Docker Image / base-decide (push) Failing after 10m22s
Publish Docker Image / build-base (push) Has been cancelled
Publish Docker Image / smoke-base (push) Has been cancelled
Publish Docker Image / smoke-omos (push) Has been cancelled
Publish Docker Image / smoke-with-pi (push) Has been cancelled
Publish Docker Image / smoke-omos-with-pi (push) Has been cancelled
Publish Docker Image / build-variant-base (push) Has been cancelled
Publish Docker Image / build-variant-omos (push) Has been cancelled
Publish Docker Image / build-variant-with-pi (push) Has been cancelled
Publish Docker Image / build-variant-omos-with-pi (push) Has been cancelled
Publish Docker Image / promote-base-latest (push) Has been cancelled
Publish Docker Image / update-description (push) Has been cancelled
Four upstream patch releases over two days. Upstream releases ship
empty bodies and no CHANGELOG; the patch sequence (1.15.7-1.15.10)
is fixes only per typical sst/opencode cadence.

The with-pi and omos-with-pi variants will also implicitly bump
pi 0.75.4 -> 0.75.5 since PI_VERSION=latest resolves at build time.

omos-with-pi smoke threshold remains 3700 MB (set v1.15.4b 2026-05-18).
Four opencode patches plus one pi patch typically add only a few MB
across both, not expected to trip. If it does, recovery is the
well-worn letter-suffix pattern (v1.15.10b with threshold bump).

Cache hit expected on base-35ee5fe7861a since neither Dockerfile.base
nor rootfs/ have changed since v1.14.50b. Built on the same CI path
as v1.15.6 — pinned-crane install (T14), skip-promote-on-cache-hit
(T15), and update-description-always-on-base-success (v1.15.4b)
all expected to remain quiet on this cache-hit run.
2026-05-23 19:14:58 +02:00
44 changed files with 5909 additions and 851 deletions
+63 -32
View File
@@ -6,8 +6,12 @@
# Which provider to auto-configure (anthropic, openai, amazon-bedrock)
OPENCODE_PROVIDER=anthropic
# Model override (optional, defaults per provider)
# OPENCODE_MODEL=anthropic/claude-sonnet-4-6
# Model override (optional). Unset = the per-provider default baked into
# generate-config.py: anthropic/claude-opus-5, amazon-bedrock/
# global.anthropic.claude-opus-5, or openai/gpt-5.6. Set this to use any other
# model — the value is written verbatim as the `model` field, so it works for
# providers with no baked default too. Format: <provider>/<model>.
# OPENCODE_MODEL=anthropic/claude-opus-5
# ── API Keys (set the one matching your provider) ────────────────────
# ANTHROPIC_API_KEY=
@@ -31,6 +35,52 @@ WORKSPACE_PATH=~/projects
# Path to SSH keys on host
SSH_KEY_PATH=~/.ssh
# ── MemPalace memory (local by default) ───────────────────────────
# By default each container runs its OWN MemPalace (a local stdio server;
# palace stored at ~/.mempalace). Uncomment the devbox-palace volume in
# docker-compose.yml to persist it across container recreation.
#
# To instead share ONE MemPalace across several containers / harnesses
# (pi + opencode + native), point every container at an external HTTP
# endpoint. When MEMPALACE_REMOTE_URL is set, no local mempalace-mcp is
# spawned and the devbox-palace volume is irrelevant.
# Serve it with: mempalace-mcp --transport http --host 0.0.0.0 --port 8765
# MEMPALACE_REMOTE_URL=http://mempalace.lan:8765/mcp
# MEMPALACE_REMOTE_TOKEN= # optional — sent as: Authorization: Bearer <token>
# ── 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
@@ -65,34 +115,15 @@ SSH_KEY_PATH=~/.ssh
# 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
# OMOS_SKILLS=true # Symlink bundled OMOS skills (clonedeps, codemap,
# # deepwork, oh-my-opencode-slim, simplify) from the
# # image into ~/.agents/skills/ each start; updates
# # on image pull. Independent of ENABLE_OMOS.
# # See docs/omos-skills.md
# OMOS_RESET=false # Force regenerate oh-my-opencode-slim config on next start (does not affect skills)
# ── 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)
# ── Background subagents (opencode experimental flag) ────────────────
# Baked ON in the image (ENV in Dockerfile.base) because OMOS V2+ default
# orchestration depends on opencode's native background subagents. Uncomment
# to opt out (e.g. to force blocking/foreground orchestration).
# OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=false
+7
View File
@@ -27,6 +27,13 @@ GIT_USER_EMAIL=your.name@example.com
# If you have per-user keys: SSH_KEY_PATH=~/<signum>/.ssh
# SSH_KEY_PATH=~/.ssh
# ── MemPalace memory ─────────────────────────────────────────────────
# Default: each container runs its own LOCAL palace. To share ONE external
# MemPalace across containers/harnesses, set the URL (no local server is
# spawned when set). MEMPALACE_REMOTE_TOKEN is an optional bearer token.
# MEMPALACE_REMOTE_URL=http://mempalace.lan:8765/mcp
# MEMPALACE_REMOTE_TOKEN=
# ── Locale (defaults to en_US.UTF-8) ────────────────────────────────
# LANG=sv_SE.UTF-8
# LANGUAGE=sv_SE:sv
+77 -45
View File
@@ -8,14 +8,16 @@ the build pipeline is shaped the way it is, you're in the right place.
| File | Trigger | Role |
|---|---|---|
| [`workflows/docker-publish-split.yml`](workflows/docker-publish-split.yml) | `push: tags: v*` | **Production release pipeline.** Two-phase split-base build: shared `base-<hash>` published once (skipped on cache hit), then four parallel variant deltas. ~4080 min wall clock depending on runner count and whether base needs rebuilding. |
| [`workflows/validate.yml`](workflows/validate.yml) | `push: branches: main` + PR | **Lightweight gate.** amd64-only smoke test of all four variants + `DOCKER_HUB.md` sync check. ~30 min. Fires on every push to `main`. |
| [`workflows/docker-publish-split.yml`](workflows/docker-publish-split.yml) | `push: tags: v*` | **Production release pipeline.** Two-phase split-base build: shared `base-<hash>` published once (skipped on cache hit), then two parallel variant deltas. ~4080 min wall clock depending on runner count and whether base needs rebuilding. |
| [`workflows/validate.yml`](workflows/validate.yml) | `push: branches: main` + PR | **Lightweight gate.** amd64-only smoke test of both variants + `DOCKER_HUB.md` sync check. ~30 min. Fires on every push to `main`. |
## Why the split-base pipeline exists
opencode-devbox publishes **four image variants** (`base`, `omos`, `with-pi`, `omos-with-pi`) × **two architectures** (amd64, arm64) = **eight image tags per release**. Today's runners are 2 self-hosted gitea Actions runners. arm64 builds are emulated under QEMU, which is the dominant cost (~35x slower than native).
opencode-devbox builds **two image variants** (`base`, `omos`) × **two architectures** (amd64, arm64), publishing **four tags per release** + the floating `base-latest`. Today's runners are 2 self-hosted gitea Actions runners. arm64 builds are emulated under QEMU, which is the dominant cost (~35x slower than native).
The four variants share ~95% of their layers (Debian + apt + Node + AWS CLI + mempalace + dev tools + entrypoints). The original `Dockerfile` was a single multi-stage build with `INSTALL_*` build-args gating variant-specific RUNs. BuildKit's per-layer cache key is content-addressed, but as soon as a build-arg-gated `RUN` produces a different layer hash for variant A vs variant B, every subsequent layer also has a different parent → identical commands re-execute per variant. Result: minimal cross-variant cache reuse on a fresh build.
> pi was removed in v2.0.0; it now builds in its own `joakimp/pi-devbox` repo. Before v2.0.0 a fifth `pi-only` build was produced here and pushed into that repo as `base-pi-only` — that coupling is gone.
The two variants share ~95% of their layers (Debian + apt + Node + AWS CLI + mempalace + dev tools + entrypoints). The original `Dockerfile` was a single multi-stage build with `INSTALL_*` build-args gating variant-specific RUNs. BuildKit's per-layer cache key is content-addressed, but as soon as a build-arg-gated `RUN` produces a different layer hash for variant A vs variant B, every subsequent layer also has a different parent → identical commands re-execute per variant. Result: minimal cross-variant cache reuse on a fresh build.
Two improvements were considered:
@@ -30,10 +32,10 @@ The split-base architecture is what the `docker-publish-split.yml` workflow exer
┌──────────────────┐
│ base-decide │ compute base-<hash>;
│ │ probe Docker Hub.
│ hash inputs: │
│ Dockerfile.base│
│ rootfs/ │
│ entrypoint*.sh │
│ hash inputs: │ (resolve-versions
│ Dockerfile.base│ runs in parallel:
│ rootfs/ │ npm view omos
│ entrypoint*.sh │ → concrete version)
└────────┬─────────┘
┌─────────────┴─────────────┐
@@ -47,18 +49,18 @@ The split-base architecture is what the `docker-publish-split.yml` workflow exer
└────────┬─────────┘ to Docker Hub.
┌───────────────────────┼───────────────────────┐
▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────────┐
│smoke-base│ │smoke-omos│ ... │smoke-omos-pi │ amd64 only,
└────┬─────┘ └────┬─────┘ └──────┬───────┘ parallel.
│ │
▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────────┐
│build- │ │build- │ │build- │ multi-arch,
│variant- │ │variant- │ ... │variant- parallel,
│base │ │omos │ │omos-with-pi │ tag push.
└────┬─────┘ └────┬─────┘ └──────┬───────┘
└───────────────────────┴──────────────────────┘
▼ ▼
┌──────────┐ ┌──────────┐
│smoke-base│ │smoke-omos│ amd64 only,
└────┬─────┘ └────┬─────┘ parallel.
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│build- │ │build- │ multi-arch,
│variant- │ │variant- │ parallel,
│base │ │omos │ tag push.
└────┬─────┘ └────┬─────┘
└──────────────────────┘
┌──────────────────────────┐
@@ -73,10 +75,16 @@ The split-base architecture is what the `docker-publish-split.yml` workflow exer
└──────────────────────────┘
```
### Step 1: `base-decide`
### Step 1: `resolve-versions`, then `base-decide`
Compute a SHA-256 hash over the inputs that determine the base image's
content:
**`resolve-versions`** resolves floating refs to concrete values: `omos_version`
(npm `latest`) and `mempalace_toolkit_ref` (the `mempalace-toolkit` `main` HEAD
resolved to a commit SHA via the gitea commits API). **`base-decide`** now
**depends on `resolve-versions`** (they no longer run in parallel) because it
folds `mempalace_toolkit_ref` into the base hash — see below.
**`base-decide`** computes a SHA-256 hash over the inputs that determine
the base image's content:
```sh
{
@@ -88,6 +96,9 @@ content:
! -name '._*' \
-print0 | sort -z | xargs -0 cat
cat entrypoint.sh entrypoint-user.sh
echo "$mempalace_toolkit_ref" # CI-resolved SHA; mempalace-toolkit is
# cloned in Dockerfile.base, so a moved
# toolkit must force a base rebuild
} | sha256sum | cut -c1-12
```
@@ -106,6 +117,28 @@ This is the core cache-reuse mechanism. Version-bump-only releases
that change anything in the base — apt packages, AWS CLI, Node version,
locale list, entrypoint scripts — pay the full base-build cost once.
**`resolve-versions`** runs alongside `base-decide` (no `needs:`
dependency between them) and resolves the floating npm packages whose
`*_VERSION` build-args default to `latest`:
```sh
OMOS_VERSION=$(npm view oh-my-opencode-slim version)
```
The output (`omos_version`) is consumed by the omos variant smoke and
build jobs. **Why this exists:** without it, the `npm install -g` RUN
layer in `Dockerfile.variant` hashes
identically across builds (same ARG default, same command string), so
the registry buildcache silently reuses the layer from whatever upstream
version was current when the cache was first populated. This is the
cache-hit silent-regression class of bug that shipped pi-devbox v0.74.0
through v0.75.5 with identical image bytes (fixed in pi-devbox v0.75.5b
2026-05-23). Currently masked here by `OPENCODE_VERSION` bumping every
release (parent-chain cache-key invalidation), but masking would fail on
a `vN.N.Nb` opencode-version-unchanged release that only bumps omos.
Smoke jobs additionally assert `EXPECTED_OMOS_VERSION` against the
resolved value.
### Step 2: `build-base` (conditional)
Only runs when `need_build=true`. Multi-arch (amd64 + arm64) build of
@@ -116,23 +149,21 @@ when only one or two layers changed.
The base image is **not** tagged `base-latest` here — that promotion
happens at the very end after all variants succeed (see step 5).
### Step 3: `smoke-*` (×4, parallel)
### Step 3: `smoke-*` (×2, parallel)
For each variant: build amd64-only against the base tag, load into
local docker, run [`scripts/smoke-test.sh`](../scripts/smoke-test.sh).
Variant build-args:
| variant | INSTALL_OPENCODE | INSTALL_OMOS | INSTALL_PI |
|---|---|---|---|
| `base` | true | false | false |
| `omos` | true | true | false |
| `with-pi` | true | false | true |
| `omos-with-pi` | true | true | true |
| variant | INSTALL_OPENCODE | INSTALL_OMOS |
|---|---|---|
| `base` | true | false |
| `omos` | true | true |
Smoke runs `--variant <name>` to enable variant-specific assertions.
Gate the publish: a smoke failure for variant X blocks `build-variant-X`.
### Step 4: `build-variant-*` (×4, parallel)
### Step 4: `build-variant-*` (×2, parallel)
For each variant that passed smoke: multi-arch (amd64 + arm64) build of
`Dockerfile.variant`, pushed to Docker Hub with the user-facing release
@@ -142,8 +173,6 @@ tags:
|---|---|
| `build-variant-base` | `vX.Y.Z`, `latest` |
| `build-variant-omos` | `vX.Y.Z-omos`, `latest-omos` |
| `build-variant-with-pi` | `vX.Y.Z-with-pi`, `latest-with-pi` |
| `build-variant-omos-with-pi` | `vX.Y.Z-omos-with-pi`, `latest-omos-with-pi` |
The `latest*` aliases are only updated when `promote_latest=true` (the
manual dispatch input) — for test runs, `promote_latest=false` keeps the
@@ -151,7 +180,7 @@ production aliases pointing at the previous good release.
### Step 5: `promote-base-latest`
Once all four variants successfully publish, re-tag `base-<hash>` as
Once both variants successfully publish, re-tag `base-<hash>` as
`base-latest` using `crane copy`. This is a **manifest-level re-tag, not
a rebuild** — it touches only Docker Hub's image index, takes seconds,
and is atomic.
@@ -159,7 +188,7 @@ and is atomic.
The reason this happens *after* variants succeed (rather than alongside
`build-base`) is so a partial failure leaves `base-latest` pointing at
the previous known-good base. External consumers who pin to
`base-latest` (e.g. the planned pi-devbox repo) never see a broken base.
`base-latest` never see a broken base.
### Step 6: `update-description`
@@ -171,18 +200,21 @@ field via the Hub REST API. Same step as the production pipeline.
The base sets
```
ENV NPM_CONFIG_PREFIX=/home/developer/.pi/npm-global
ENV NPM_CONFIG_PREFIX=/home/developer/.config/opencode/npm-global
```
This is intentional — it makes `pi install npm:<pkg>` and `npm install -g`
land on the `devbox-pi-config` named volume at runtime, so user-installed
packages survive container recreate AND image rebuild.
This is intentional — it makes `npm install -g` land on the
`devbox-opencode-config` named volume at runtime, so user-installed
packages survive container recreate AND image rebuild. (Before v2.0.0
this prefix lived at `~/.pi/npm-global` on the now-removed
`devbox-pi-config` volume; `entrypoint-user.sh` migrates the old path
once.)
But the *variant build* inherits this prefix at build time. If left as-is,
`npm install -g opencode-ai@$VERSION` in `Dockerfile.variant` would
install opencode into `/home/developer/.pi/npm-global/...`, which is then
**shadowed by the volume mount at runtime** → opencode disappears from
PATH on first start.
install opencode into `/home/developer/.config/opencode/npm-global/...`,
which is then **shadowed by the volume mount at runtime** → opencode
disappears from PATH on first start.
Fix: each `npm install -g` in `Dockerfile.variant` overrides the prefix
per-RUN:
@@ -193,7 +225,7 @@ RUN NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION}
Baked binaries land on `/usr/bin/...` (system prefix), survive the volume
mount. Runtime-installed user packages still land on
`~/.pi/npm-global/...`. Both visible on PATH.
`~/.config/opencode/npm-global/...`. Both visible on PATH.
## Cache strategy
@@ -215,7 +247,7 @@ matters more.
| Scenario | Production pipeline | Split-base pipeline |
|---|---|---|
| Version-bump-only release (only opencode/pi/omos version changed) | ~165180 min | **~3040 min** (base cache hit) |
| Version-bump-only release (only opencode/omos version changed) | ~165180 min | **~3040 min** (base cache hit) |
| Base-touching release (apt/Node/Debian/entrypoint change) | ~165180 min | **~7090 min** (base rebuilds) |
The split-base pipeline pays its dues on base-touching releases (which are
@@ -229,7 +261,7 @@ on every push to `main` and on PRs. It:
1. Runs `scripts/generate-dockerhub-md.py --check` to enforce
`DOCKER_HUB.md` is in sync with `HUB_TEMPLATE`.
2. Builds each of the four variants amd64-only (no multi-arch, no push)
2. Builds each of the two variants amd64-only (no multi-arch, no push)
and runs `scripts/smoke-test.sh`.
This catches regressions before they reach a tag push. Wall clock ~30 min.
+272 -237
View File
@@ -7,10 +7,10 @@ name: Publish Docker Image
# 1. base-decide compute base hash from Dockerfile.base + rootfs/
# + entrypoints; probe Docker Hub for existing tag.
# 2. build-base only if probe missed; multi-arch push of base-<hash>.
# 3. smoke-* (×4) amd64-only build of each variant FROMing the base
# 3. smoke-* (×2) amd64-only build of each variant FROMing the base
# tag; runs scripts/smoke-test.sh.
# 4. build-variant-* multi-arch push of each variant tag (the user-
# (×4) facing release tags, unchanged in shape).
# (×2) facing release tags, unchanged in shape).
# 5. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`
# (manifest copy, no rebuild).
# 6. update-description patch Docker Hub description (unchanged).
@@ -34,6 +34,17 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
# Gitea Actions' default step shell is `sh -e {0}` (dash), which rejects
# bash-only syntax like `set -o pipefail`, `[[ ]]`, and arrays. Setting the
# default to bash workflow-wide eliminates the whole class of "forgot
# `shell: bash` on this step" bugs. (Ported from pi-devbox, where this class
# bit twice: ed49b8d resolve-versions, b7197e8/b33e9dc promote-base-latest,
# run 418.) All existing dash steps use only POSIX syntax, so bash (a
# superset) runs them unchanged. Enforced by lint.yml's shell guard.
defaults:
run:
shell: bash
env:
BUILDKIT_PROGRESS: plain
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/opencode-devbox
@@ -48,6 +59,7 @@ env:
jobs:
# ── Phase 1: decide whether base needs rebuilding ──────────────────
base-decide:
needs: [resolve-versions]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
@@ -58,6 +70,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Guard — base *_REF args must be folded into the base hash
run: bash scripts/check-base-hash.sh
- name: Compute base tag from Dockerfile.base + dependencies
id: compute
run: |
@@ -77,6 +92,10 @@ jobs:
! -name '._*' \
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
cat entrypoint.sh entrypoint-user.sh
# mempalace-toolkit is cloned in Dockerfile.base at a ref CI
# resolves to a SHA; fold it in so base_tag changes when the
# toolkit moves (otherwise a toolkit-only fix never lands).
echo "${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}"
} | sha256sum | cut -c1-12
)
BASE_TAG="base-${HASH}"
@@ -102,9 +121,75 @@ jobs:
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} missing — will build."
fi
# ── Phase 1b: resolve floating npm versions (omos) to concrete
# versions so the variant build-args carry a different value when an
# upstream package bumps. Without this, when OMOS_VERSION defaults to
# 'latest', the docker/build-push-action build-arg string is byte-
# identical across builds, so the resulting layer-hash is identical,
# so the registry buildcache silently reuses the layer from whatever
# omos version was current when the cache was first populated. Same
# class of bug as pi-devbox v0.74.0..v0.75.5 (fixed in v0.75.5b
# 2026-05-23). Currently masked because OPENCODE_VERSION is hard-coded
# in Dockerfile.variant and bumps every release — invalidating the
# parent-chain cache key for the omos layer — but that masking would
# fail the moment we cut a vN.N.Nb opencode-version-unchanged release
# that only bumps omos. Fix is preventative.
resolve-versions:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
outputs:
omos_version: ${{ steps.resolve.outputs.omos_version }}
mempalace_toolkit_ref: ${{ steps.resolve.outputs.mempalace_toolkit_ref }}
steps:
- name: Resolve omos version from npm registry
id: resolve
shell: bash
run: |
set -euo pipefail
# Fail loud rather than silently shipping a floating ref or a bad
# version. A transient network/API failure must ABORT the release,
# not bake an unpinned ref that defeats both cache-busting AND
# after-the-fact reproducibility. (Previously the gitea lookup fell
# back to `main` via `|| echo`, and the npm lookup had no guard.)
# NOTE: shell: bash is REQUIRED — `set -o pipefail` is illegal in
# the runner's default dash/sh and aborts the step immediately.
require_sha() { # $1=label $2=value
if ! printf '%s' "${2:-}" | grep -qiE '^[0-9a-f]{40}$'; then
echo "::error::Could not resolve $1 to a commit SHA (got '${2:-<empty>}'). Refusing to fall back to a floating ref — published images must stay reproducible. Check connectivity and GITEA_BUILD_TOKEN/GITHUB_TOKEN."
exit 1
fi
}
# Query the npm registry directly via curl+jq rather than `npm view`.
# catthehacker/ubuntu:act-latest ships Node/npm under /opt/acttoolcache/
# and adds it to PATH only via /etc/environment — which act_runner never
# sources (it reads the Docker image's ENV instructions, not /etc/environment).
# curl and jq are both guaranteed present in every job in this workflow.
OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.version' 2>/dev/null || true)
if ! printf '%s' "${OMOS_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::Could not resolve oh-my-opencode-slim version from npm (got '${OMOS_VERSION:-<empty>}'). Refusing to build with an unresolved version."
exit 1
fi
echo "omos_version=${OMOS_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved OMOS_VERSION=${OMOS_VERSION}"
# Resolve mempalace-toolkit main HEAD to a commit SHA. Unlike omos
# (an npm pkg baked into the VARIANT), mempalace-toolkit is cloned
# in Dockerfile.base, so this SHA is ALSO folded into the
# base-decide hash to force a base rebuild when the toolkit moves
# (without it, a toolkit-only fix silently fails to land unless
# Dockerfile.base itself changes). gitea allows unauthenticated
# public-repo commit listing; the token header is harmless if the
# env vars are unset (degrades to anon, still HTTP 200).
MEMPALACE_TOOLKIT_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \
"https://gitea.jordbo.se/api/v1/repos/joakimp/mempalace-toolkit/commits?limit=1&sha=main" \
| jq -r '.[0].sha // empty' 2>/dev/null || true)
require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF"
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
echo "Resolved MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}"
# ── Phase 2: build & push base (multi-arch), only when needed ──────
build-base:
needs: [base-decide]
needs: [base-decide, resolve-versions]
if: needs.base-decide.outputs.need_build == 'true'
runs-on: ubuntu-latest
container:
@@ -148,17 +233,46 @@ jobs:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base (multi-arch)
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.base
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
# Registry cache for faster repeat base rebuilds (e.g. Node bump).
cache-from: type=registry,ref=${{ env.IMAGE }}:base-buildcache
cache-to: type=registry,ref=${{ env.IMAGE }}:base-buildcache,mode=max
- name: Build and push base (multi-arch) — with retry
shell: bash
env:
BASE_TAG_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
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 \
--build-arg MEMPALACE_TOOLKIT_REF="${MEMPALACE_TOOLKIT_REF}" \
--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
@@ -206,15 +320,17 @@ jobs:
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=false
RELEASE_TAG=smoke
SOURCE_REVISION=${{ github.sha }}
- name: Smoke test (amd64)
run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base
smoke-omos:
needs: [base-decide, build-base]
needs: [base-decide, build-base, resolve-versions]
if: |
always() &&
needs.base-decide.result == 'success' &&
needs.resolve-versions.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
container:
@@ -248,95 +364,17 @@ jobs:
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=false
- run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos
smoke-with-pi:
needs: [base-decide, build-base]
if: |
always() &&
needs.base-decide.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-with-pi
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=true
- run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi
smoke-omos-with-pi:
needs: [base-decide, build-base]
if: |
always() &&
needs.base-decide.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-omos-with-pi
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=true
- run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
RELEASE_TAG=smoke
SOURCE_REVISION=${{ github.sha }}
- env:
EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos
# ── Phase 4: multi-arch publish per variant ────────────────────────
build-variant-base:
needs: [base-decide, smoke-base]
needs: [base-decide, smoke-base, resolve-versions]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
@@ -370,21 +408,48 @@ jobs:
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=false
tags: ${{ steps.tags.outputs.tags }}
- name: Build and push variant (with retry)
shell: bash
env:
TAGS: ${{ steps.tags.outputs.tags }}
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
run: |
set -euo pipefail
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
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 "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
--build-arg "BUILD_DATE=${BUILD_DATE}" \
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
"${TAG_FLAGS[@]}" \
.; then
echo "==> Attempt ${attempt} succeeded"
exit 0
fi
if [[ "${attempt}" -lt 3 ]]; then
backoff=$(( attempt * 15 ))
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
sleep "${backoff}"
fi
done
echo "==> All 3 build+push attempts failed"
exit 1
build-variant-omos:
needs: [base-decide, smoke-omos]
needs: [base-decide, smoke-omos, resolve-versions]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
@@ -418,114 +483,46 @@ jobs:
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=false
tags: ${{ steps.tags.outputs.tags }}
build-variant-with-pi:
needs: [base-decide, smoke-with-pi]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
id: tags
- name: Build and push variant (with retry)
shell: bash
env:
TAGS: ${{ steps.tags.outputs.tags }}
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
run: |
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-with-pi"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-with-pi"
set -euo pipefail
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
TAG_FLAGS=()
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
# 3-attempt retry (see build-base step for rationale). Variant: omos.
for attempt in 1 2 3; do
echo "==> Build+push attempt ${attempt}/3"
if docker buildx build \
--platform linux/amd64,linux/arm64 \
--file Dockerfile.variant \
--push \
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
--build-arg "INSTALL_OPENCODE=true" \
--build-arg "INSTALL_OMOS=true" \
--build-arg "OMOS_VERSION=${OMOS_VERSION}" \
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
--build-arg "BUILD_DATE=${BUILD_DATE}" \
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
"${TAG_FLAGS[@]}" \
.; then
echo "==> Attempt ${attempt} succeeded"
exit 0
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=true
tags: ${{ steps.tags.outputs.tags }}
build-variant-omos-with-pi:
needs: [base-decide, smoke-omos-with-pi]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-omos-with-pi"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-omos-with-pi"
if [[ "${attempt}" -lt 3 ]]; then
backoff=$(( attempt * 15 ))
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
sleep "${backoff}"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64,linux/arm64
push: true
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=true
tags: ${{ steps.tags.outputs.tags }}
done
echo "==> All 3 build+push attempts failed"
exit 1
# ── Phase 5: promote base-<hash> → base-latest (manifest copy only) ─
promote-base-latest:
@@ -533,23 +530,25 @@ jobs:
- base-decide
- build-variant-base
- build-variant-omos
- build-variant-with-pi
- build-variant-omos-with-pi
# Skip on cache-hit base builds: when need_build=false, base-latest
# already points at the same digest as base-<hash>, so the retag is
# a tautology and any transient failure of it is purely cosmetic.
# Manual workflow_dispatch with promote_latest=true overrides this
# gate as an escape hatch (e.g., if base-latest got hand-deleted).
# Run on every tag release (and promote_latest=true dispatch). The gate
# deliberately does NOT key off need_build anymore: the no-op optimization
# for genuine cache-hit releases moved INTO the step as a crane digest
# compare (see below). Keying the gate on need_build was wrong because a
# prior dry-run dispatch (promote_latest=false) can pre-build+push
# base-<hash>, making need_build=false on the subsequent tag run even
# though base-latest is still stale — the old gate then skipped promotion
# and left base-latest pointing at the PREVIOUS base. (Ported from
# pi-devbox b7197e8, which hit exactly this on its v1.2.3 dry-run-first
# release, 2026-06-27.)
#
# `always()` wrapper + explicit base-variant success check protects
# against the gitea-Actions default of "skipped need => skip dependent":
# a partial-publish run (e.g., omos-with-pi smoke fails) shouldn't
# a partial-publish run (e.g., omos smoke fails) shouldn't
# prevent the base-latest alias from advancing on a real base rebuild.
if: |
always() &&
needs.build-variant-base.result == 'success' &&
(inputs.promote_latest == 'true' ||
(github.ref_type == 'tag' && needs.base-decide.outputs.need_build == 'true'))
(inputs.promote_latest == 'true' || github.ref_type == 'tag')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
@@ -574,21 +573,39 @@ jobs:
crane auth login docker.io \
-u ${{ vars.DOCKERHUB_USERNAME }} \
-p "${{ secrets.DOCKERHUB_TOKEN }}"
- name: Re-tag base-<hash> as base-latest
- name: Re-tag base-<hash> as base-latest (only if stale)
env:
BASE_HASH_REF: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
BASE_LATEST_REF: ${{ env.IMAGE }}:base-latest
run: |
crane copy \
${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} \
${{ env.IMAGE }}:base-latest
set -euo pipefail
# Correctness invariant: after a release, base-latest must resolve to
# the SAME digest as the base-<hash> the just-built variants were
# FROM. Compare digests rather than trusting need_build — a prior
# dry-run dispatch can pre-build base-<hash>, so need_build=false on
# the tag run does NOT imply base-latest is already current. When the
# digests already match (genuine cache-hit release) this is a no-op,
# so we skip the crane copy entirely — preserving the original
# "don't do a tautological retag" intent and avoiding any cosmetic
# transient-failure exposure on releases that change nothing.
want=$(crane digest "${BASE_HASH_REF}")
have=$(crane digest "${BASE_LATEST_REF}" 2>/dev/null || echo "")
echo "base-<hash> digest: ${want}"
echo "base-latest digest: ${have:-<absent>}"
if [ "${want}" = "${have}" ]; then
echo "base-latest already current; nothing to promote."
else
echo "Promoting base-latest -> ${BASE_HASH_REF}"
crane copy "${BASE_HASH_REF}" "${BASE_LATEST_REF}"
fi
# ── 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
# Run when at least the base variant published — don't let a single
# variant failure (e.g., omos-with-pi smoke threshold) prevent Hub
# variant failure (e.g., omos smoke threshold) prevent Hub
# description refresh for the other variants that did publish.
# Without this `always()` wrapper, gitea Actions' default behavior
# of "skipped need => skip dependent" cascades from any failed/
@@ -605,6 +622,24 @@ jobs:
- uses: actions/checkout@v4
- name: Update Docker Hub description
run: |
# Substitute {{OPENCODE_VERSION}} placeholders in DOCKER_HUB.md so
# the Hub page always shows which opencode version is baked into
# :latest. The placeholder lives in DOCKER_HUB.md (committed); CI
# fills it at publish time from the pinned ARG in
# Dockerfile.variant — the same value that was baked into the
# image — so the page and the image never drift. (Mirrors the
# {{PI_VERSION}} pattern in pi-devbox's docker-publish.yml.)
OPENCODE_VERSION=$(sed -n 's/^ARG OPENCODE_VERSION=//p' Dockerfile.variant | head -1)
if [ -z "${OPENCODE_VERSION}" ]; then
echo "::error::Could not extract OPENCODE_VERSION from Dockerfile.variant"
exit 1
fi
cp DOCKER_HUB.md /tmp/hub-full.md
sed -i "s/{{OPENCODE_VERSION}}/${OPENCODE_VERSION}/g" /tmp/hub-full.md
if grep -q '{{OPENCODE_VERSION}}' /tmp/hub-full.md; then
echo "::error::DOCKER_HUB.md still contains unsubstituted {{OPENCODE_VERSION}} markers"
exit 1
fi
TOKEN=$(curl -s -X POST https://hub.docker.com/v2/auth/token \
-H "Content-Type: application/json" \
-d '{"identifier":"${{ vars.DOCKERHUB_USERNAME }}","secret":"${{ secrets.DOCKERHUB_TOKEN }}"}' \
@@ -614,7 +649,7 @@ jobs:
exit 1
fi
HTTP_CODE=$(jq -n \
--rawfile full DOCKER_HUB.md \
--rawfile full /tmp/hub-full.md \
--arg short "Portable AI dev environment for opencode. Debian-based with git, Node.js, AWS CLI, and SSH support." \
'{"full_description": $full, "description": $short}' | \
curl -s -o /tmp/hub-response.txt -w "%{http_code}" -X PATCH \
+116
View File
@@ -0,0 +1,116 @@
name: Lint
# The repo's cheap-checks workflow: everything that can be verified WITHOUT
# building an image. Runs on every push and PR, in ~30s, independent of the
# build pipeline. Three jobs: workflow lint (actionlint + the Gitea shell
# guard), Dockerfile lint (hadolint), and the DOCKER_HUB.md docs-sync check.
#
# Deliberately the ONLY workflow that runs on a push to main. validate.yml
# (amd64 build + smoke) is PR/dispatch-only and docker-publish-split.yml is
# tag-only, so pushing work-in-progress to main never starts an image build.
#
# Its original purpose, still the most important one: the "bash-only syntax
# under the default `sh`/dash shell" footgun. Ported from pi-devbox, where this
# class broke resolve-versions (ed49b8d) and promote-base-latest (b7197e8 → run
# 418). actionlint runs shellcheck against each `run:` step using its
# *effective* shell, so `set -o pipefail` under dash is flagged as SC3040
# before any expensive build runs.
on:
push:
pull_request:
workflow_dispatch:
concurrency:
group: lint-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
shell: bash
jobs:
actionlint:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Install shellcheck
run: |
apt-get update
apt-get install -y --no-install-recommends shellcheck python3-yaml
- name: Gitea shell guard (catches the actionlint blind spot)
# actionlint models GitHub Actions, where the default run shell is
# bash, so it does NOT flag bash syntax in a step that merely OMITS
# `shell:` — which is exactly how ed49b8d and b7197e8 manifested on
# Gitea (default sh/dash). This guard enforces that every run: step
# resolves to bash under Gitea's real defaults. Run it BEFORE
# actionlint so the more precise diagnostic surfaces first.
run: bash scripts/check-workflow-shell.sh .gitea/workflows
- name: Install actionlint (pinned)
env:
ACTIONLINT_VERSION: 1.7.7
run: |
curl -fsSL \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C /usr/local/bin actionlint
actionlint --version
- name: Run actionlint
# SHELLCHECK_OPTS excludes pure-style codes (quoting/style opinions)
# so the guard stays focused on correctness bugs — crucially the
# SC3xxx "not POSIX / wrong shell" family that catches the pipefail
# footgun. Do NOT exclude SC3040 (set -o pipefail under sh) or any
# other SC3xxx code.
env:
SHELLCHECK_OPTS: "-e SC2086 -e SC2016 -e SC2129 -e SC2001 -e SC2312"
# Pass explicit paths: actionlint's no-arg mode auto-detects a
# project by looking for `.github/workflows`, which doesn't exist in
# this `.gitea/workflows` repo and hard-fails with exit 3
# ("no project was found"). Globbing the workflow files is the
# supported way to lint a non-GitHub layout.
run: actionlint -color .gitea/workflows/*.yml
hadolint:
# Lint the two Dockerfiles that ARE the project (the shell/actions linting
# above never looked at them). Config — ignored rules + failure threshold
# — lives in .hadolint.yaml, which hadolint reads automatically, so a local
# `hadolint Dockerfile.base` reproduces CI exactly.
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Install hadolint (pinned)
env:
HADOLINT_VERSION: 2.14.0
run: |
curl -fsSL \
"https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-Linux-x86_64" \
-o /usr/local/bin/hadolint
chmod +x /usr/local/bin/hadolint
hadolint --version
- name: Run hadolint
run: hadolint Dockerfile.base Dockerfile.variant
docs-check:
# Fails if DOCKER_HUB.md is out of sync with what generate-dockerhub-md.py
# would produce from HUB_TEMPLATE. Keeps the two docs from drifting.
#
# Lives here rather than in validate.yml because it needs no image: keeping
# it in the cheap workflow means it still runs on every push to main now
# that validate.yml is PR/dispatch-only. Reproduce locally with
# `python3 scripts/generate-dockerhub-md.py --check`.
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- name: Check DOCKER_HUB.md is in sync with HUB_TEMPLATE
run: python3 scripts/generate-dockerhub-md.py --check
+68 -155
View File
@@ -1,9 +1,24 @@
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.
# Image build + smoke test. This workflow has NO push trigger at all — it fires
# only on pull_request and on explicit workflow_dispatch.
#
# Division of labour across this repo's three workflows:
# lint.yml — cheap checks, every push/PR: workflow lint,
# Dockerfile lint, DOCKER_HUB.md sync. No builds.
# validate.yml (this file) — amd64 build + smoke test. PR / manual only.
# docker-publish-split.yml — the release path, tag-only: multi-arch build,
# smoke gates, then push + promote.
#
# Net effect: no image build can start from a push to main. A tag — or an
# explicit dispatch — is required. That is safe because the release path fails
# closed: variant tags are pushed only after smoke-base/smoke-omos pass, and
# base-latest is promoted last, so an aborted release leaves at worst an
# unreferenced base-<hash> blob on Hub, never a half-published version tag.
#
# To validate before tagging: open a PR, dispatch this workflow, or dispatch
# docker-publish-split.yml against a throwaway tag with promote_latest=false
# (the only route that also exercises a CHANGED BASE — see the trade-off below).
#
# Trade-off: variant builds here use the published `base-latest` image
# from Docker Hub as their parent, NOT a locally-built base. This is
@@ -22,43 +37,35 @@ name: Validate
# blind-spot applies.
on:
push:
branches:
- main
paths-ignore:
- 'CHANGELOG.md'
- 'README.md'
- 'DOCKER_HUB.md'
- 'deploy/**'
- '.gitleaks.toml'
pull_request:
branches:
- main
workflow_dispatch:
# Gitea Actions' default step shell is `sh` (dash); force bash workflow-wide so
# no run: step silently falls through to dash. Enforced by lint.yml's
# scripts/check-workflow-shell.sh guard, which scans ALL .gitea/workflows/*.yml
# (so this file must resolve to bash too, not just docker-publish-split.yml).
defaults:
run:
shell: bash
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.
# (Dockerfile.base, rootfs/, entrypoint*.sh) AND exports `base_changed` so
# validate-base/validate-omos can skip. validate.yml uses Hub's base-latest
# as the parent for variant builds, so a changed base is NOT exercised here
# — worse, if the same commit tightens smoke-test.sh in lockstep with the
# base change (as the nano/micro commit did), those jobs HARD-FAIL against
# the stale base-latest: a false red that self-heals only once the release
# rebuilds base-latest. So we skip them here and defer full base validation
# to the release path (docker-publish-split.yml on tag push).
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
outputs:
base_changed: ${{ steps.detect.outputs.base_changed }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -66,18 +73,38 @@ jobs:
fetch-depth: 2
- name: Detect base-input changes
id: detect
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
# Base inputs baked into Dockerfile.base: Dockerfile.base itself, any
# file under rootfs/ (COPYed into the base), and entrypoint*.sh. NB:
# rootfs/ is a PREFIX match — the earlier '^(...|rootfs/|...)$' anchored
# the whole group, so the rootfs/ branch only matched a file literally
# named 'rootfs/' and never real paths like rootfs/usr/.../x.py.
base_re='^(Dockerfile\.base$|rootfs/|entrypoint.*\.sh$)'
changed="$(git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -E "$base_re" || true)"
if [ -n "$changed" ]; then
echo "base_changed=true" >> "$GITHUB_OUTPUT"
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 — validate-base/validate-omos are SKIPPED to avoid a false failure against the stale base-latest. 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:"
printf '%s\n' "$changed"
else
echo "base_changed=false" >> "$GITHUB_OUTPUT"
echo "No base-image inputs changed in this commit — validate-base/validate-omos will build against the published base-latest."
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:
needs: [base-change-warning]
# Two gates, both must hold for this job to run:
# 1. base_changed != true — the documented blind spot: this workflow builds
# variants on top of Hub's base-latest, so a commit changing base inputs
# would either not exercise the change or hard-fail against a stale
# base-latest (see base-change-warning).
# 2. event_name != push — belt-and-braces. Redundant today (this workflow
# has no push trigger), kept deliberately so that re-adding a push
# trigger later cannot silently re-enable image builds on every push to
# main. If you intend that, remove this clause explicitly.
if: ${{ needs.base-change-warning.outputs.base_changed != 'true' && github.event_name != 'push' }}
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
@@ -139,6 +166,9 @@ jobs:
bash scripts/smoke-test.sh opencode-devbox:ci-base --variant base
validate-omos:
needs: [base-change-warning]
# Same two gates as validate-base — see the comment there.
if: ${{ needs.base-change-warning.outputs.base_changed != 'true' && github.event_name != 'push' }}
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
@@ -195,120 +225,3 @@ jobs:
- name: Smoke test
run: |
bash scripts/smoke-test.sh opencode-devbox:ci-omos --variant omos
validate-with-pi:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: |
echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Reclaim runner disk
run: |
set -x
df -h / || true
rm -rf \
/opt/hostedtoolcache \
/opt/microsoft \
/opt/az \
/opt/ghc \
/usr/local/.ghcup \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/powershell \
/usr/local/share/chromium \
/usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
docker system df || true
docker system prune -af --volumes || true
docker builder prune -af || true
df -h / || true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Build with-pi image (amd64, load to local daemon)
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
build-args: |
BASE_IMAGE=joakimp/opencode-devbox:base-latest
INSTALL_PI=true
tags: opencode-devbox:ci-with-pi
- name: Smoke test
run: |
bash scripts/smoke-test.sh opencode-devbox:ci-with-pi --variant with-pi
validate-omos-with-pi:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: |
echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Reclaim runner disk
run: |
set -x
df -h / || true
rm -rf \
/opt/hostedtoolcache \
/opt/microsoft \
/opt/az \
/opt/ghc \
/usr/local/.ghcup \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/usr/local/share/powershell \
/usr/local/share/chromium \
/usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
apt-get clean || true
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
docker system df || true
docker system prune -af --volumes || true
docker builder prune -af || true
df -h / || true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Build omos+with-pi image (amd64, load to local daemon)
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
build-args: |
BASE_IMAGE=joakimp/opencode-devbox:base-latest
INSTALL_OMOS=true
INSTALL_PI=true
tags: opencode-devbox:ci-omos-with-pi
- name: Smoke test
run: |
bash scripts/smoke-test.sh opencode-devbox:ci-omos-with-pi --variant omos-with-pi
+27
View File
@@ -0,0 +1,27 @@
# hadolint configuration for opencode-devbox.
#
# Both Dockerfiles are linted in CI (.gitea/workflows/lint.yml → `hadolint`
# job). hadolint reads this file automatically, so a local
# `hadolint Dockerfile.base` reproduces CI exactly.
#
# The ignores below are DELIBERATE project choices — they mirror the
# philosophy of the shellcheck excludes already applied to `run:` steps
# (SHELLCHECK_OPTS in lint.yml). Anything NOT listed here still fails the
# build at `warning` and above, so new Dockerfile smells are caught going
# forward.
ignored:
- DL3008 # "pin apt versions" — intentionally unpinned: the base tracks
# Debian stable and runs `apt-get upgrade`, so pinning point
# versions would rot and fight security updates.
- DL3016 # "pin npm versions" — opencode / oh-my-opencode-slim ARE pinned,
# but via build-args (CI-resolved from npm), not the npm CLI.
- DL4006 # "set -o pipefail before a pipe" — the piped RUNs are
# download|extract steps with their own retries / `set -e`.
# Switching the global SHELL to bash is a larger, base-affecting
# change — tracked in IDEAS.md.
- DL3003 # "use WORKDIR, not cd" — cosmetic in the few `cd` RUNs here.
- SC2086 # "double-quote to prevent word-splitting" — the same code is
# excluded for shell `run:` steps in lint.yml; splitting is
# intentional in these contexts.
failure-threshold: warning
+134 -19
View File
@@ -4,34 +4,90 @@
Docker image packaging [opencode](https://opencode.ai) into a production-ready dev container. Image variants are published to Docker Hub via Gitea Actions CI. Not a library or application — this is infrastructure (Dockerfiles, entrypoint scripts, docker-compose, documentation).
> **pi was removed in v2.0.0** (deprecated since v1.17.2). The `INSTALL_PI`
> build arg, the `with-pi` / `omos-with-pi` / `pi-only` variants, the
> `base-pi-only` published tag, and all `~/.pi`-related wiring are gone. pi
> now ships from its own repo (`joakimp/pi-devbox`). Do not add pi
> functionality here. The removal history + the `NPM_CONFIG_PREFIX`
> relocation (`~/.pi/npm-global` → `~/.config/opencode/npm-global`, with a
> one-time migration shim in `entrypoint-user.sh`) are recorded in
> `docs/CLEANUP-v2.0.0.md` and the v2.0.0 CHANGELOG entry.
## File roles
- `Dockerfile.base` — variant-independent layers (apt, locales, AWS CLI, Node.js, mempalace, gitea-mcp, user setup, chromadb prewarm, ENVs, entrypoints). Published as `joakimp/opencode-devbox:base-<sha12>`. Rebuilt only when its content hash changes.
- `Dockerfile.variant``FROM`s the base and adds only opencode/omos/pi installs gated by build args: `INSTALL_OPENCODE` (default true), `INSTALL_OMOS`, `INSTALL_PI`, and `INSTALL_MEMPALACE`. All GitHub-sourced binaries are pinned with version ARGs.
- `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu. Volume ownership loop covers `~/.pi/` when `INSTALL_PI=true`.
- `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), pi-toolkit + pi-extensions deploy (when pi installed), pi settings.json bootstrap, mempalace pi-bridge symlink, skillset auto-deploy from mounted skillset repo, OMOS setup.
- `Dockerfile.variant``FROM`s the base and adds only opencode/omos installs gated by build args: `INSTALL_OPENCODE` (default true), `INSTALL_OMOS`, and `INSTALL_MEMPALACE`. All GitHub-sourced binaries are pinned with version ARGs. Two variants: `base` (`INSTALL_OPENCODE=true`) and `omos` (`+INSTALL_OMOS=true`).
- `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu.
- `entrypoint-user.sh` — runs as developer: git config, opencode.jsonc generation (delegated to `generate-config.py`), LAN-access setup (delegated to `setup-lan-access.sh`), a one-time npm-global prefix migration shim (legacy `~/.pi/npm-global``~/.config/opencode/npm-global`), skillset auto-deploy from mounted skillset repo, OMOS 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.sh`**runtime** 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.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 4 parallel smoke tests, then 4 parallel multi-arch variant builds, promotes `base-latest` alias, updates Docker Hub description.
- `.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-check``DOCKER_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
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.
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`](https://gitea.jordbo.se/joakimp/pi-devbox)
repo, which decoupled from the pi tool version at its own `v1.0.0`.
- 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.
- **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 check** — **before 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:
```bash
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 eight Docker Hub tags per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos`, `vX.Y.Z[n]-with-pi`, `latest-with-pi`, `vX.Y.Z[n]-omos-with-pi`, `latest-omos-with-pi` — one tag pair (versioned + floating alias) per build variant.
CI produces 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):
```bash
# Latest stable opencode-ai versions on npm
npm view opencode-ai time --json | python3 -c 'import sys,json,re; d=json.load(sys.stdin); print(*sorted([(v,t) for v,t in d.items() if re.fullmatch(r"\d+\.\d+\.\d+",v)], key=lambda x:x[1], reverse=True)[:6], sep="\n")'
# Release notes for a specific version
curl -s https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.15.10 | python3 -c 'import sys,json; print(json.load(sys.stdin).get("body","(empty)"))'
```
## Critical conventions
- **entrypoint.sh volume ownership loop** — when adding a new named volume mount point, add it to the `for dir in ...` loop in `entrypoint.sh` so root-owned volumes get chowned on startup. The loop writes a `.devbox-owner` sentinel after a successful chown so subsequent starts skip the recursive walk. Users should not touch these files.
@@ -45,30 +101,89 @@ When bumping the opencode version, bump `OPENCODE_VERSION` in `Dockerfile.varian
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, nvim, bat, eza, zoxide, uv, gitea-mcp, Go, oh-my-opencode-slim all default to `latest`. Each build-time install step reads the `/releases/latest` Location redirect (or the go.dev JSON feed for Go) and derives the concrete version. Use the same `ARCH` case-switch pattern for multi-arch support (amd64/arm64). Intentional pins: `OPENCODE_VERSION` (drives the image tag), `NODE_VERSION=22` (major pin), `DEBIAN_VERSION=trixie-slim` (OS base). Adding a new upstream tool: follow the existing floated-version pattern, don't hardcode a specific tag.
- **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 test** — `scripts/smoke-test.sh` prints a "Resolved component versions" table as its first step. CI logs always capture what got baked into a given image even when ARGs default to `latest`.
- **`OMOS_VERSION` MUST be passed by CI as a concrete version**, not left at the `latest` default. The npm install step in `Dockerfile.variant` (`oh-my-opencode-slim@${OMOS_VERSION}`) produces an identical layer-hash when the ARG value is byte-identical across builds; combined with the registry buildcache (`base-buildcache`) the layer gets reused even when `latest` would have resolved to a newer upstream. This is the same class of bug that bit pi-devbox v0.74.0 → v0.75.5 (silent same-bytes-across-releases regression discovered 2026-05-23, fixed in pi-devbox v0.75.5b). It is currently *masked* in opencode-devbox by `OPENCODE_VERSION` being a hard-coded ARG that bumps every release — that bump invalidates the parent-chain cache key for the downstream omos layer — but the masking would fail the moment a `vN.N.Nb` opencode-version-unchanged release ships that only bumps omos. Preventative fix: `.gitea/workflows/docker-publish-split.yml` has a `resolve-versions` job that runs `npm view oh-my-opencode-slim version`, exposing the concrete value as an output that the omos smoke + build jobs consume via build-args. Smoke tests assert via the `EXPECTED_OMOS_VERSION` env var — would catch the regression on the next release rather than several releases later. **If you change the variant build-args list, the resolve-versions job, or the smoke EXPECTED_*_VERSION wiring, audit all affected jobs in lockstep.**
- **`resolve-versions` also pins `mempalace-toolkit` to a SHA** — `resolve-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 promotion** — `Dockerfile.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 coupled** — `Dockerfile.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 **`find`s** 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.
- **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).
- **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 volume** — `devbox-opencode-config` is a Docker named volume mounted at `~/.config/opencode/`. It is NOT a host bind mount by default. This separation allows both native and containerized opencode to coexist on the same machine without symlink conflicts. Users who need to override can replace the named volume with a host bind mount in their compose file. Because `NPM_CONFIG_PREFIX` is set to `~/.config/opencode/npm-global` (relocated from the legacy `~/.pi/npm-global` in v2.0.0), anything installed via `npm install -g` as the developer user also lands on this volume and survives container recreate AND image rebuild.
- **npm-global prefix relocation (v2.0.0 breaking change)** — the user-writable global npm prefix moved from `~/.pi/npm-global` to `~/.config/opencode/npm-global`. The old path lived on the `devbox-pi-config` volume (only mounted in `docker-compose.yml`); the new path is on `devbox-opencode-config`, which is a persistent named volume in BOTH `docker-compose.yml` and `docker-compose.shared.yml`. `entrypoint-user.sh` carries a one-time migration shim: if `~/.pi/npm-global` exists and the marker `~/.config/opencode/npm-global/.migrated-from-dot-pi` is absent, it `cp -an` the old `lib/`/`bin/`/`share/` into the new prefix (never overwriting fresh installs) and writes the marker. Baked binaries stay on `/usr` (the variant Dockerfile runs each `npm install -g` with `NPM_CONFIG_PREFIX=/usr`) so the volume mount doesn't shadow them. The `ENV NPM_CONFIG_PREFIX`/`PATH` lines in `Dockerfile.base` are declared *after* all build-time installs.
- **Default CMD is `bash -l`** — not a harness. `docker compose run --rm devbox` drops the user into a login shell to choose: `aws sso login`, then `opencode` (or any tool). Pass the harness explicitly to launch directly: `docker compose run --rm devbox opencode`. `docker compose exec` bypasses entrypoint+CMD entirely (existing user workflow unchanged).
- **Docker Hub description update** — uses `/v2/auth/token` endpoint (not the deprecated `/v2/users/login`). Auth uses `identifier`/`secret` fields, returns `access_token`, sent as `Bearer`. Short description must be ≤100 bytes.
## CI quirks
- Both build jobs include an IPv4 preference step (`gai.conf` + `driver-opts: network=host` for buildx) to work around intermittent IPv6 failures on the Gitea runners.
- `update-description` job runs only when both builds succeed (`needs: [build-base, build-omos]`).
- Tags must be pushed to trigger the publish workflow. The validate workflow runs on push to main and PRs.
- `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 eight `load: true` jobs (`validate-base`, `validate-omos`, `validate-with-pi`, `validate-omos-with-pi`, `smoke-base`, `smoke-omos`, `smoke-with-pi`, `smoke-omos-with-pi`) include a `Reclaim runner disk` step that strips catthehacker-resident toolchains and prunes stale docker state before `setup-buildx-action`. Build jobs use a lighter version (push-by-digest doesn't need `docker system prune`). Don't remove these steps without testing on a fresh runner.
- **Gitea Actions runner has ~40 GB disk, often 70%+ used at job start.** All `load: true` jobs (`validate-base`, `validate-omos`, `smoke-base`, `smoke-omos`) include a `Reclaim runner disk` step that strips catthehacker-resident toolchains and prunes stale docker state before `setup-buildx-action`. Build jobs use a lighter version (push-by-digest doesn't need `docker system prune`). Don't remove these steps without testing on a fresh runner.
- **`docker/build-push-action@v7` with `platforms: linux/amd64,linux/arm64` handles multi-arch push natively in a single job** — produces a proper manifest list, no matrix or merge step needed. An earlier revision split into per-arch matrix jobs with digest artifacts, but that pattern requires `actions/{upload,download}-artifact@v4+` which Gitea Actions doesn't support (see below).
- **`actions/upload-artifact` and `actions/download-artifact` must stay at @v3 on Gitea.** v4+ uses a GitHub-Enterprise-specific Artifact API; runs fail with `GHESNotSupportedError`. If you need artifacts for a new reason (build logs, SBOMs, etc.), pin @v3 explicitly.
- **Step scripts run under `/bin/sh` (dash), not bash.** Avoid bash-isms like `${VAR//a/b}` parameter-pattern substitution; use POSIX alternatives (`tr`, `sed`) or declare `shell: bash` on the step.
- **`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:
```bash
# 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 install`s 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:
+1247 -2
View File
File diff suppressed because it is too large Load Diff
+15 -11
View File
@@ -2,19 +2,25 @@
Portable AI developer environment for [opencode](https://opencode.ai). Debian-based, with git, SSH, Node.js, AWS CLI v2, and common dev tools pre-installed.
> **Current `:latest` ships opencode `{{OPENCODE_VERSION}}`** (the baked version is asserted by smoke tests, so this page never drifts from the image).
Designed for teams who want a reproducible coding-agent setup that runs the same on every laptop and CI runner — without forcing each developer to install Bun, Node, AWS CLI, mempalace, or maintain shell config drift across machines.
## Image Variants
| Tag | Description |
|---|---|
| `latest` / `vX.Y.Z` | Base image — opencode, Node.js, AWS CLI, dev tools |
| `latest` / `vX.Y.Z` | Base image — opencode `{{OPENCODE_VERSION}}`, Node.js, AWS CLI, dev tools |
| `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun |
| `latest-with-pi` / `vX.Y.Z-with-pi` | Base + [pi](https://github.com/earendil-works/pi) as alternative/complementary harness (shares the mempalace install with opencode) |
| `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | OMOS + pi together |
All variants support `linux/amd64` and `linux/arm64`.
> **Looking for pi?** As of v2.0.0 the pi coding-agent is no longer bundled in
> opencode-devbox. It ships as the dedicated
> [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox) image, which
> shares the same mempalace memory layer. See
> <https://gitea.jordbo.se/joakimp/pi-devbox>.
## Quick Start
For a fully-configured environment with persistent state (opencode config, mempalace memory, neovim plugins, bash history) surviving container recreation, use docker-compose. **You don't need to clone the repo** — just grab two template files:
@@ -28,7 +34,7 @@ curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.
docker compose run --rm devbox
```
This drops you straight into opencode with your project mounted at `/workspace`. Use `bash` as the command (e.g. `docker compose run --rm devbox bash`) to land in a shell first — useful for `aws sso login`, `pi` (on `*-with-pi` variants), or multi-harness workflows.
This mounts your project at `/workspace`. With no command (as above) the image's default `CMD` (`bash -l`) drops you into a login shell — run `opencode` to start the harness, or `aws sso login` first, etc. To start opencode directly, pass it as the command: `docker compose run --rm devbox opencode`.
**One-shot run, no persistence:**
@@ -48,11 +54,10 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Bedroc
## What's Inside
- **[opencode](https://opencode.ai)** — primary coding-agent harness. Multi-provider (Anthropic, OpenAI, Bedrock, Google, Groq, etc.).
- **[pi](https://github.com/earendil-works/pi)** *(in `*-with-pi` variants)* — lightweight TUI coding-agent that coexists with opencode and shares the same mempalace install. Includes the `mcp-loader` extension so any local-stdio or remote streamable-HTTP MCP server (searxng, gitea, context7, …) can be added by editing `~/.pi/agent/settings.json`.
- **[mempalace](https://github.com/MemPalace/mempalace)** — persistent AI memory layer (ChromaDB + SQLite). Wing/diary/knowledge-graph entries are mutually visible to opencode and pi.
- **[mempalace](https://github.com/MemPalace/mempalace)** — persistent AI memory layer (ChromaDB + SQLite). Wing/diary/knowledge-graph entries are shareable with the sibling [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox) image when both point at the same palace.
- **[oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim)** *(in `*-omos` variants)* — multi-agent orchestration on top of opencode (council, fallback chains, named agents).
- **AWS CLI v2** with SSO support, **Node.js LTS**, **Bun** (OMOS variants), **uv** (Python), **gosu** for clean UID/GID adjustment to match your host workspace.
- **MCP wrappers** for mempalace pre-installed and pre-wired to both harnesses.
- **MCP wrappers** for mempalace pre-installed and pre-wired to opencode.
## Authentication
@@ -70,8 +75,7 @@ https://gitea.jordbo.se/joakimp/opencode-devbox#aws-bedrock-authentication
| Volume | Mount | Survives |
|---|---|---|
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild |
| `devbox-pi-config` | `~/.pi` | container recreate, image rebuild — incl. user-installed pi packages via `pi install` (`NPM_CONFIG_PREFIX` points into the volume) |
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild — incl. user-installed npm globals via `npm install -g` (`NPM_CONFIG_PREFIX` points into the volume) |
| `devbox-palace` (uncomment) | `~/.mempalace` | container recreate, image rebuild — palace data is precious, treat as primary storage |
| `devbox-chroma-cache` | `~/.cache/chroma` | container recreate (model cache, disposable — re-downloads in seconds) |
@@ -88,7 +92,7 @@ Full persistence reference, including multi-user (`SIGNUM`) isolation and host b
## Sibling images
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** — pi-only image built on top of this image's base layer. Smaller (~700 MB) and version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly. Use this if you want pi without opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** — the pi coding-agent in its own self-contained image, built on a shared Debian base. Version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly and can share this image's mempalace palace. Use it if you want pi instead of (or alongside) opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
## License
@@ -96,4 +100,4 @@ MIT. See <https://gitea.jordbo.se/joakimp/opencode-devbox/src/branch/main/LICENS
---
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate.
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate. The `{{OPENCODE_VERSION}}` placeholder is filled by CI at publish time.
+338 -22
View File
@@ -1,22 +1,21 @@
# opencode-devbox — base image (variant-independent layers)
#
# This Dockerfile produces an image tagged base-<hash>, used as the parent
# for all four published variants (base, omos, with-pi, omos-with-pi).
# It contains everything that does not depend on variant-specific
# build-args (INSTALL_OPENCODE, INSTALL_OMOS, INSTALL_PI). The variant
# Dockerfile (Dockerfile.variant) FROMs the base and adds only those
# deltas.
# for all published variants (base, omos). It contains everything that
# does not depend on variant-specific build-args (INSTALL_OPENCODE,
# INSTALL_OMOS). The variant Dockerfile (Dockerfile.variant) FROMs the
# base and adds only those deltas.
#
# The base is rebuilt only when this file or anything it COPYs in
# changes (rootfs/, entrypoint*.sh). Version bumps to OPENCODE_VERSION,
# OMOS_VERSION, PI_VERSION, etc. do NOT trigger a base rebuild.
# OMOS_VERSION, etc. do NOT trigger a base rebuild.
#
# To force a base rebuild for fresh apt packages without other code
# changes, bump the BASE_REBUILD_DATE comment below. The hash is
# 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)
# BASE_REBUILD_DATE: 2026-07-13 (v2.7.0 — typst PDF engine + terminal terminfo (ncurses-term/kitty/xterm-ghostty) + nvim true-colour; pi-devbox parity)
#
# See the project README's "Build pipeline" section for the rationale.
@@ -36,6 +35,11 @@ ENV DEBIAN_FRONTEND=noninteractive
# 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.
# `nano` is included as a small, non-modal terminal editor for users who
# don't want vi-style modal editing — a companion to nvim and the `micro`
# binary installed further down. ~2.8 MB; its deps (libc6, libncursesw6,
# libtinfo6) are already pulled in by nvim/less/htop/tmux, so it adds no
# extra packages. EDITOR stays nvim; opt in via `export EDITOR=nano`.
RUN apt-get update && \
apt-get upgrade -y --no-install-recommends && \
apt-get install -y --no-install-recommends \
@@ -67,10 +71,63 @@ RUN apt-get update && \
rsync \
python3-pip \
python3-venv \
nano \
pandoc \
xz-utils \
graphviz \
kitty-terminfo \
ncurses-term \
&& 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.
#
# CAVEAT (and why dssh/dscp are handled elsewhere): a user per-host override
# that points ControlPath BACK under the read-only ~/.ssh (e.g. the common
# CGNAT idiom `ControlPath ~/.ssh/cm/%r@%h:%p`) re-introduces the
# unwritable-socket failure for a plain `ssh <host>` — a system drop-in here
# can never override a user's per-host value. For `ssh -F ~/.ssh-local/config`
# (the dssh/dscp aliases), setup-lan-access.sh redirects ControlPath into the
# writable ~/.ssh-local sidecar, so those paths are unaffected. See CHANGELOG
# "Unreleased".
RUN mkdir -p /etc/ssh/ssh_config.d && \
printf '%s\n' \
'# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \
'# Override per-host in ~/.ssh/config if the master socket location' \
'# needs to differ.' \
'Host *' \
' ControlMaster auto' \
' ControlPath /tmp/sshcm/%r@%h:%p' \
' ControlPersist 10m' \
' ServerAliveInterval 30' \
' ServerAliveCountMax 6' \
> /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf && \
chmod 644 /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
#
# Version policy for the binaries below:
@@ -126,6 +183,24 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;;
git lfs install --system && \
git-lfs --version
# gitleaks — secret scanner (used as a pre-commit hook in several of the
# repos this devbox is meant to operate on; pairs with git-crypt below).
# Distributed as a Go-compiled tarball; arch suffix is `x64` (not `x86_64`
# or `amd64`) on this project — mind the deviation from the surrounding
# tools' naming.
ARG GITLEAKS_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x64" ;; arm64) echo "arm64" ;; *) echo "x64" ;; esac) && \
V="${GITLEAKS_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing gitleaks ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/download/v${V}/gitleaks_${V}_linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin gitleaks && \
chmod +x /usr/local/bin/gitleaks && \
gitleaks version
# neovim — modern text editor
ARG NVIM_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
@@ -140,6 +215,33 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;
ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \
nvim --version | head -1
# micro — modern, non-modal terminal editor. Ships alongside nvim so users
# who aren't comfortable with vi-style modal editing have a friendly option:
# desktop-style keybindings (Ctrl+S save, Ctrl+Q quit, Ctrl+C/V/X, Ctrl+Z
# undo), mouse support, and syntax highlighting out of the box. A single
# static Go binary (~12 MB) installed from GitHub releases, exactly like
# bat/eza/zoxide below. EDITOR stays nvim (see below); users opt in with
# `export EDITOR=micro` or `git config --global core.editor micro`.
#
# NOTE: upstream moved zyedidia/micro -> micro-editor/micro. The old org URL
# still 302s, but its /releases/latest redirect lands on ANOTHER /latest URL
# (the org rename), so the tag-parsing idiom below would resolve "latest"
# instead of a version. Use the canonical micro-editor/micro URL.
# Arch asset naming differs from the others: amd64 -> linux64, arm64 ->
# linux-arm64. The tarball extracts to micro-<version>/micro.
ARG MICRO_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "linux64" ;; arm64) echo "linux-arm64" ;; *) echo "linux64" ;; esac) && \
V="${MICRO_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/micro-editor/micro/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && [ -n "$V" ] && \
echo "Installing micro ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/micro-editor/micro/releases/download/v${V}/micro-${V}-${ARCH}.tar.gz" | tar -xz -C /tmp && \
install /tmp/micro-${V}/micro /usr/local/bin/micro && \
rm -rf /tmp/micro-${V} && \
micro --version
# 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) && \
@@ -198,25 +300,143 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64"
rm -rf /tmp/uv-* && \
uv --version
# tealdeer — Rust port of tldr (community-maintained command examples)
# Provides the `tldr` command; ~5 MB static binary, ~135 MB smaller than
# the Node tldr global. Same UX as the Node version.
ARG TEALDEER_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
V="${TEALDEER_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing tealdeer ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/download/v${V}/tealdeer-linux-${ARCH}-musl" -o /usr/local/bin/tldr && \
chmod +x /usr/local/bin/tldr && \
tldr --version
# ── typst — lightweight PDF engine for pandoc (Markdown→PDF) ─────────
# pandoc (apt-installed above) is only a front-end; rendering PDF needs a
# back-end engine. Rather than a ~600 MB TeX Live install, we ship typst:
# a single ~30 MB static Rust binary with no LaTeX dependency, used via
# `pandoc --pdf-engine=typst`. A fuller TeX Live remains the higher-fidelity
# fallback for anyone who needs LaTeX-exact output (install on demand).
# Ported from pi-devbox (its v1.4.0 + v1.5.0 font-default fix).
#
# Follows the `latest` GitHub-release convention (like tealdeer/uv/bat).
# typst ships a `.tar.xz` asset (hence xz-utils in the apt layer above)
# that extracts to typst-<arch>-unknown-linux-musl/typst. Pin a specific
# tag with --build-arg TYPST_VERSION=vX.Y.Z.
#
# We also patch pandoc's bundled typst template
# (/usr/share/pandoc/data/templates/template.typst): its conf() defaults the
# document font to an empty tuple (`font: ()`), so a naked
# `pandoc --pdf-engine=typst` fails with "font fallback list must not be empty"
# unless the caller passes `-V mainfont=...`. We default it to Libertinus Serif
# (typst's own bundled default font) so PDF export works out-of-the-box.
ARG TYPST_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
V="${TYPST_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/typst/typst/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && [ -n "$V" ] && \
echo "Installing typst ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/typst/typst/releases/download/v${V}/typst-${ARCH}-unknown-linux-musl.tar.xz" | tar -xJ -C /tmp && \
install /tmp/typst-${ARCH}-unknown-linux-musl/typst /usr/local/bin/typst && \
rm -rf /tmp/typst-${ARCH}-unknown-linux-musl && \
typst --version && \
sed -i 's/^ font: (),$/ font: ("Libertinus Serif",),/' /usr/share/pandoc/data/templates/template.typst && \
grep -q 'font: ("Libertinus Serif",),' /usr/share/pandoc/data/templates/template.typst
# ── yq (mikefarah) — YAML processor, jq's companion for YAML ─────────
# Installed as the mikefarah Go binary — NOT Debian's `yq` apt package, which
# is the unrelated Python kislyuk/yq (a jq wrapper with different syntax and a
# different version line, 3.x). THIRD_PARTY.md already credited "yq
# (mikefarah)" while the image actually shipped the Python one, so this closes
# a documented-vs-shipped mismatch as well as bringing parity with pi-devbox
# (its v1.2.3). The cloud-init repo's deploy.sh/provision.sh require mikefarah
# v4 syntax. Follows the repo's `latest` convention (like tealdeer/uv/typst);
# the smoke test pins the contract to major v4, so both a regression to the
# Python package and a surprise future yq v5 fail CI loudly instead of
# silently breaking those scripts. Pin a tag with --build-arg YQ_VERSION=vX.Y.Z.
ARG YQ_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
V="${YQ_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/mikefarah/yq/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
[ -n "$V" ] && \
echo "Installing mikefarah yq ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/mikefarah/yq/releases/download/${V}/yq_linux_${ARCH}" -o /usr/local/bin/yq && \
chmod +x /usr/local/bin/yq && \
yq --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.
#
# 3.5.0 (2026-06) ships the upstream fix for that top-level-anyOf schema
# (issue #1728 / PR #1717, merged 2026-06-14): diary_write now advertises
# `"required": ["agent_name"]` with entry/content enforced at dispatch, which
# the Anthropic tools API accepts — so the perl mcp_server.py workaround that
# used to live below is gone. (pi-devbox dropped it in its v1.2.2.)
#
# 3.6.0 (2026-07-17) is an additive/reliability release — secure `mempalace
# serve` remote mode, optional Milvus backend, atomic KG supersede(),
# conversation chronology, mining exclusions, plus recovery/locking fixes.
# Reviewed for MCP tool-schema changes before bumping: there are NONE, and
# nothing touches diary_write — so the 3.3.x/3.4.0 regression class does not
# recur. Two fixes are directly relevant to how this image uses mempalace:
# read-only mode now covers checkpoint + delete_by_source in _MUTATING_TOOLS
# (#1930), and agent attribution is preserved in mempalace_checkpoint
# (#2023/#2034).
ARG MEMPALACE_VERSION=3.6.0
ENV UV_TOOL_DIR=/opt/uv-tools
ENV UV_TOOL_BIN_DIR=/usr/local/bin
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
mkdir -p /opt/uv-tools && \
uv tool install --no-cache mempalace && \
uv tool install --no-cache "mempalace==${MEMPALACE_VERSION}" && \
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
fi
# (The mempalace diary_write top-level-anyOf workaround that patched
# mcp_server.py here was removed when MEMPALACE_VERSION moved to 3.5.0 —
# fixed upstream via issue #1728 / PR #1717 (merged 2026-06-14). Mirrors
# pi-devbox v1.2.2. See CHANGELOG.md.)
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
ARG INSTALL_MEMPALACE_TOOLKIT=true
ARG MEMPALACE_TOOLKIT_REF=main
# MEMPALACE_TOOLKIT_REPO is overridable so a relocated/forked build can repoint
# the clone without editing this Dockerfile (matches the *_REPO pattern used by
# other companions). Defaults to the canonical gitea origin; the default CI
# build is byte-identical.
ARG MEMPALACE_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/mempalace-toolkit.git
# MEMPALACE_TOOLKIT_REF accepts EITHER a branch name OR a commit SHA. CI
# resolves it to a SHA (resolve-versions job) and folds that SHA into the
# base-decide hash so the base rebuilds when the toolkit moves. `git clone
# --branch <40-char-SHA>` fails ("Remote branch not found"), so use
# `git fetch <ref> + checkout FETCH_HEAD`, which works for name and SHA.
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 && \
rm -rf /opt/mempalace-toolkit && mkdir -p /opt/mempalace-toolkit && \
git -C /opt/mempalace-toolkit init -q && \
git -C /opt/mempalace-toolkit remote add origin "${MEMPALACE_TOOLKIT_REPO}" && \
ok=0; for i in 1 2 3 4 5; do \
if git -C /opt/mempalace-toolkit fetch --depth 1 origin "${MEMPALACE_TOOLKIT_REF}" && \
git -C /opt/mempalace-toolkit checkout -q FETCH_HEAD; then ok=1; break; fi; \
echo "git fetch mempalace-toolkit@${MEMPALACE_TOOLKIT_REF} failed (attempt $i/5), retrying in $((i*5))s..."; \
sleep $((i*5)); \
done; \
[ "$ok" = "1" ] && \
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 && \
@@ -251,7 +471,26 @@ ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
ENV EDITOR=nvim
# Advertise 24-bit colour so colour-aware tools (Neovim's own auto-detect, bat,
# delta, ...) use true colour instead of a 256-colour fallback. Completes the
# true-colour story the terminfo + sysinit.vim layers below already start.
# Safe for the modern terminals this devbox targets; override by exporting
# `COLORTERM=` (empty) from a terminal that lacks true-colour support.
ENV COLORTERM=truecolor
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
# Enable opencode's native background subagents. opencode gates this behind an
# experimental flag (packages/opencode/src/tool/task.ts errors 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 broken without this. Baked here as
# a base ENV (applies to both variants; harmless for plain opencode — it only
# *enables* a capability). Overridable at runtime: -e OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=false.
# REMOVAL TRIGGER: when opencode promotes background subagents out of experimental
# (flag becomes default / renamed), drop this ENV. No upstream roadmap date as of
# opencode 1.18.13 / omos 2.2.9 (2026-08). Re-verified against upstream source
# at all three tags (1.17.20, 1.18.12, 1.18.13): packages/opencode/src/effect/runtime-flags.ts
# is unchanged and still gates the flag behind OPENCODE_EXPERIMENTAL — trigger has NOT fired.
ENV OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true
# ── Node.js (required for opencode/pi/omos at variant build + MCP servers) ──
ARG NODE_VERSION=22
@@ -259,6 +498,45 @@ RUN curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors https://deb.nodesour
apt-get install -y --no-install-recommends nodejs && \
rm -rf /var/lib/apt/lists/*
# ── agent-browser + Playwright Chromium — real-browser verification ──
# Lets the agent drive an actual browser (open pages, click/fill/eval, snapshot
# the DOM, screenshot) to VERIFY front-end work — live DOM, WebGL, layout,
# popup positioning — instead of guessing. Ported from pi-devbox.
#
# We resolve the Chrome binary through a stable symlink (/usr/local/bin/
# agent-chrome) exposed via AGENT_BROWSER_EXECUTABLE_PATH — the symlink
# insulates the ENV from Playwright's per-version, per-ARCH browser directory
# (`chrome-linux` on arm64, `chrome-linux64` on amd64 — Chrome-for-Testing), so
# we `find` the `chrome` binary rather than hardcode the path; the headless-shell
# binary is named `chrome-headless-shell`, so `-name chrome` skips it.
#
# `playwright install --with-deps chromium` also apt-installs Chromium's runtime
# libs; verified to resolve correctly on Debian trixie (the t64 library renames
# are handled by Playwright's dep list). The build runs as root, so the apt step
# works. NPM_CONFIG_PREFIX=/usr keeps both CLIs on /usr so they survive the
# ~/.config/opencode/npm-global volume mount (the same trick the variant uses
# for opencode). After fetching, we DROP Playwright's `chromium_headless_shell-*`
# build — agent-browser drives the full chrome, so the headless shell is dead
# weight — and clean the apt/npm caches, trimming the layer to ~625 MB from
# ~960 MB. This is the bulk of the base's size and the one real tradeoff of
# shipping it to every variant; the smoke-test size thresholds were lifted in
# lockstep (see scripts/smoke-test.sh).
ARG AGENT_BROWSER_VERSION=latest
ARG PLAYWRIGHT_VERSION=latest
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/ms-playwright
RUN NPM_CONFIG_PREFIX=/usr npm install -g \
"agent-browser@${AGENT_BROWSER_VERSION}" \
"playwright@${PLAYWRIGHT_VERSION}" && \
playwright install --with-deps chromium && \
CHROME="$(find "${PLAYWRIGHT_BROWSERS_PATH}" -type f -name chrome -path '*/chromium-*/*' | head -n1)" && \
[ -n "$CHROME" ] && ln -sf "$CHROME" /usr/local/bin/agent-chrome && \
agent-browser --version && \
test -x "$(readlink -f /usr/local/bin/agent-chrome)" && \
rm -rf "${PLAYWRIGHT_BROWSERS_PATH}"/chromium_headless_shell-* && \
npm cache clean --force && \
rm -rf /var/lib/apt/lists/* /root/.npm /tmp/*
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/local/bin/agent-chrome
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
RUN ARCH=$(case "${TARGETARCH}" in \
amd64) echo "x86_64" ;; \
@@ -283,7 +561,7 @@ RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
# Create standard directories
RUN mkdir -p /workspace \
/home/${USER_NAME}/.config/opencode/skills \
/home/${USER_NAME}/.pi/agent/extensions \
/home/${USER_NAME}/.config/opencode/npm-global \
/home/${USER_NAME}/.agents/skills \
/home/${USER_NAME}/.local/share/opencode \
/home/${USER_NAME}/.cache/bash \
@@ -303,31 +581,69 @@ print('chromadb embedding model warmed: all-MiniLM-L6-v2')" && \
ls -lh /home/${USER_NAME}/.cache/chroma/onnx_models/all-MiniLM-L6-v2/ ; \
fi
# ── User-writable npm global prefix on the devbox-pi-config volume ──
# ── User-writable npm global prefix on the devbox-opencode-config volume ──
# By default npm's global prefix is /usr (writable only by root) so any
# `pi install npm:<pkg>` or `npm install -g <pkg>` invoked by the
# developer user would EACCES. Pointing the prefix into ~/.pi places
# user-installed packages on the named volume, which means they survive
# container recreation AND image rebuilds.
# `npm install -g <pkg>` invoked by the developer user would EACCES.
# Pointing the prefix into ~/.config/opencode places user-installed
# packages on the devbox-opencode-config named volume, which means they
# survive container recreation AND image rebuilds.
#
# NOTE (v2.0.0): this prefix previously lived at ~/.pi/npm-global — a
# pi-specific path. With pi removed (see docs/CLEANUP-v2.0.0.md) it now
# lives under ~/.config/opencode, which is a persistent named volume in
# BOTH docker-compose.yml and docker-compose.shared.yml (the old ~/.pi
# volume was only in the former). A one-time migration shim in
# entrypoint-user.sh copies any existing ~/.pi/npm-global contents to the
# new prefix on first start so user-installed globals are not lost.
#
# IMPORTANT: in this split-build layout the variant Dockerfile inherits
# this prefix at build time. To keep the baked binaries on /usr (so the
# ~/.pi volume mount doesn't shadow them), the variant Dockerfile MUST
# run each `npm install -g` with NPM_CONFIG_PREFIX=/usr in the per-RUN
# volume mount doesn't shadow them), the variant Dockerfile MUST run each
# `npm install -g` with NPM_CONFIG_PREFIX=/usr in the per-RUN
# environment. See Dockerfile.variant.
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global
ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.config/opencode/npm-global
ENV PATH="/home/${USER_NAME}/.config/opencode/npm-global/bin:${PATH}"
# ── Shell defaults (bash history, aliases, readline) ─────────────────
RUN mkdir -p /etc/skel-devbox
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
COPY rootfs/home/developer/.gitignore_global /etc/skel-devbox/.gitignore_global
# ── Editor defaults: system-wide Neovim true-colour ──────────────────
# /etc/xdg/nvim/sysinit.vim is Neovim's system vimrc: it loads for every user
# (before any personal ~/.config/nvim) and can still be overridden per-user.
# Enables termguicolors so the default theme renders in 24-bit colour instead
# of a muddy 256-colour fallback. Pairs with kitty-terminfo (installed above).
COPY rootfs/etc/xdg/nvim/sysinit.vim /etc/xdg/nvim/sysinit.vim
# ── Terminal support: xterm-ghostty terminfo alias ──────────────────
# ncurses-term (installed above) covers wezterm/alacritty/foot/st and the base
# `ghostty` entry, but Ghostty connects with TERM=xterm-ghostty, for which no
# distro packages an entry. Ship a thin alias (use=ghostty) and compile it into
# the system terminfo db with `tic -x`, so it inherits the maintained ghostty
# capability set. The `infocmp` check fails the build if the entry didn't land.
COPY rootfs/usr/local/share/terminfo-src/ghostty.terminfo /usr/local/share/terminfo-src/ghostty.terminfo
RUN tic -x -o /usr/share/terminfo /usr/local/share/terminfo-src/ghostty.terminfo && \
infocmp -x xterm-ghostty >/dev/null
# ── Entrypoint ────────────────────────────────────────────────────────
COPY rootfs/usr/local/lib/opencode-devbox/ /usr/local/lib/opencode-devbox/
COPY rootfs/usr/local/bin/dot-watch /usr/local/bin/dot-watch
# Reader for the build manifest baked in Dockerfile.variant. Printed at
# container start by entrypoint-user.sh; also available on demand.
COPY rootfs/usr/local/bin/opencode-devbox-version /usr/local/bin/opencode-devbox-version
# Image-baked skills + harness instruction. Under /usr/local so a named volume
# over a home dir (e.g. devbox-opencode-config on ~/.config/opencode) can't
# shadow them; entrypoint-user.sh links them into ~/.agents/skills/ and
# ~/.config/opencode/instructions/ on every start. See
# rootfs/usr/local/share/opencode-devbox/skills/VENDORED.md.
COPY rootfs/usr/local/share/opencode-devbox/ /usr/local/share/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/bin/dot-watch \
/usr/local/bin/opencode-devbox-version \
/usr/local/lib/opencode-devbox/*.py
# Start as root — entrypoint adjusts UID/GID then drops to developer
+81 -45
View File
@@ -3,26 +3,29 @@
# FROMs a base-<hash> image produced by Dockerfile.base and adds only
# the variant-specific tools (opencode, pi, oh-my-opencode-slim, Go).
#
# The four published variants are produced from THIS Dockerfile by
# The two published variants are produced from THIS Dockerfile by
# varying build args:
#
# variant INSTALL_OPENCODE INSTALL_OMOS INSTALL_PI
# ───────────────── ──────────────── ──────────── ──────────
# base true false false
# omos true true false
# with-pi true false true
# omos-with-pi true true true
# variant INSTALL_OPENCODE INSTALL_OMOS
# ──────── ──────────────── ────────────
# base true false
# omos true true
#
# pi was removed in v2.0.0 (it had been deprecated since v1.17.2). It now
# ships from its own self-contained image: joakimp/pi-devbox:latest
# (https://gitea.jordbo.se/joakimp/pi-devbox). See docs/CLEANUP-v2.0.0.md
# for the removal history.
#
# Pass `--build-arg BASE_IMAGE=<repo>:base-<hash>` to select the base.
# The CI workflow computes the base hash from Dockerfile.base + rootfs/
# + entrypoint*.sh and feeds it in.
#
# IMPORTANT: the base image sets NPM_CONFIG_PREFIX to
# /home/developer/.pi/npm-global so runtime `pi install npm:...` and
# `npm install -g` by the developer user lands on the named volume.
# At BUILD time we want the baked binaries on /usr so they survive the
# volume mount. Each `npm install -g` below therefore prefixes the
# command with `NPM_CONFIG_PREFIX=/usr`.
# /home/developer/.config/opencode/npm-global so runtime `npm install -g`
# by the developer user lands on the named volume. At BUILD time we want
# the baked binaries on /usr so they survive the volume mount. Each
# `npm install -g` below therefore prefixes the command with
# `NPM_CONFIG_PREFIX=/usr`.
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
@@ -31,45 +34,17 @@ ARG TARGETARCH
ARG USER_NAME=developer
# ── Install opencode via npm ─────────────────────────────────────────
# OPENCODE_VERSION is intentionally pinned in this Dockerfile (not
# 'latest'). It drives the release tag and gets bumped via a source
# edit, so the cache-hit class of bug that bit pi-devbox v0.74.0..
# v0.75.5 cannot apply here.
ARG INSTALL_OPENCODE=true
ARG OPENCODE_VERSION=1.15.6
ARG OPENCODE_VERSION=1.18.13
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \
opencode --version ; \
fi
# ── Optional: pi coding-agent ────────────────────────────────────────
# pi-toolkit and pi-extensions are cloned into /opt/. entrypoint-user.sh
# runs each repo's install.sh on container start so symlinks land under
# ~/.pi/agent/ on the named volume.
ARG INSTALL_PI=false
ARG PI_VERSION=latest
ARG PI_TOOLKIT_REF=main
ARG PI_EXTENSIONS_REF=main
RUN if [ "${INSTALL_PI}" = "true" ]; then \
set -e && \
git_clone_retry() { \
url="$1"; ref="$2"; dest="$3"; \
for i in 1 2 3 4 5; do \
if git clone --depth 1 --branch "$ref" "$url" "$dest"; then return 0; fi; \
rm -rf "$dest"; \
echo "git clone $url failed (attempt $i/5), retrying in $((i*5))s..."; \
sleep $((i*5)); \
done; \
return 1; \
} && \
if [ "${PI_VERSION}" = "latest" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
else \
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
fi && \
pi --version && \
git_clone_retry https://gitea.jordbo.se/joakimp/pi-toolkit.git "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
git_clone_retry https://gitea.jordbo.se/joakimp/pi-extensions.git "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" ; \
fi
# ── Optional: Go ─────────────────────────────────────────────────────
ARG INSTALL_GO=false
ARG GO_VERSION=latest
@@ -89,6 +64,13 @@ RUN if [ "${INSTALL_GO}" = "true" ]; then \
# ── Optional: oh-my-opencode-slim (multi-agent orchestration) ────────
# Installs Bun runtime and the oh-my-opencode-slim npm package.
# OMOS_VERSION has a cache-hit footgun when left at the `latest` default
# in registry-cached CI builds: the resulting build-arg string is byte-
# identical across builds, so the layer-hash is identical, so the
# registry buildcache silently reuses the layer from whatever omos
# version was current when the cache was first populated. CI resolves it
# via `npm view oh-my-opencode-slim version` and passes the concrete
# value as a build-arg (see resolve-versions in docker-publish-split.yml).
ARG INSTALL_OMOS=false
ARG OMOS_VERSION=latest
RUN if [ "${INSTALL_OMOS}" = "true" ]; then \
@@ -109,4 +91,58 @@ RUN if [ "${INSTALL_OMOS}" = "true" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g oh-my-opencode-slim@${OMOS_VERSION}; \
fi
# ── Build provenance: OCI labels + on-disk manifest ──────────────────
# These ARGs are declared LAST, immediately before the layer that uses
# them, so a changing BUILD_DATE / RELEASE_TAG / SOURCE_REVISION never
# invalidates the expensive npm-install layers above. OPENCODE_VERSION,
# OMOS_VERSION and INSTALL_OMOS are already in scope from earlier in this
# stage and need no re-declaration; MEMPALACE_TOOLKIT_REF is consumed in
# Dockerfile.base, so it is re-declared here only to land in the labels.
ARG RELEASE_TAG=dev
ARG BUILD_DATE=
ARG SOURCE_REVISION=
ARG MEMPALACE_TOOLKIT_REF=main
LABEL org.opencontainers.image.version="${RELEASE_TAG}" \
org.opencontainers.image.revision="${SOURCE_REVISION}" \
org.opencontainers.image.created="${BUILD_DATE}" \
se.jordbo.opencode-devbox.opencode-version="${OPENCODE_VERSION}" \
se.jordbo.opencode-devbox.install-omos="${INSTALL_OMOS}" \
se.jordbo.opencode-devbox.omos-version="${OMOS_VERSION}" \
se.jordbo.opencode-devbox.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}"
# The manifest is written from GROUND TRUTH — the live `opencode --version`,
# the omos package's installed version (when present), and the actual
# checked-out HEAD of /opt/mempalace-toolkit (cloned in the base) — not
# merely the intended build-args. That way it also exposes a dependency
# that silently resolved to something other than the requested value.
# oh-my-opencode-slim is present only in the omos variant (JSON null
# otherwise). NOTE: omos is installed under prefix /usr at build time, so
# we resolve its dir via `npm root -g` with that prefix rather than the
# runtime NPM_CONFIG_PREFIX the base sets for the developer volume.
RUN set -e; \
mkdir -p /etc/opencode-devbox; \
rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \
OPENCODE_V="$(opencode --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \
OMOS_REV='null'; \
if [ "${INSTALL_OMOS}" = "true" ]; then \
OMOS_DIR="$(NPM_CONFIG_PREFIX=/usr npm root -g 2>/dev/null)/oh-my-opencode-slim"; \
OMOS_V="$(node -e "process.stdout.write(require('${OMOS_DIR}/package.json').version)" 2>/dev/null || echo unknown)"; \
OMOS_REV="\"${OMOS_V}\""; \
fi; \
{ \
echo '{'; \
echo " \"release_tag\": \"${RELEASE_TAG}\","; \
echo " \"build_date\": \"${BUILD_DATE}\","; \
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
echo " \"opencode_version\": \"${OPENCODE_V}\","; \
echo " \"components\": {"; \
echo " \"opencode\": \"${OPENCODE_V}\","; \
echo " \"oh-my-opencode-slim\": ${OMOS_REV},"; \
echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\""; \
echo " }"; \
echo '}'; \
} > /etc/opencode-devbox/build-manifest.json; \
echo "── build manifest ──"; cat /etc/opencode-devbox/build-manifest.json
# WORKDIR / ENTRYPOINT / CMD inherited from base.
+60
View File
@@ -0,0 +1,60 @@
# Ideas & backlog
A living list of potential improvements for opencode-devbox that are **not yet
scheduled**. This is intentionally lightweight — a place to park ideas so they
aren't lost between sessions. When an item ships, describe it in
[`CHANGELOG.md`](CHANGELOG.md) and remove it from here.
Rough effort tags: 🟢 small · 🟡 medium · 🔴 large. Status: `idea` (unvetted) ·
`planned` (agreed, not started).
---
## Supply-chain hardening
- 🟡 `planned`**Pin CI actions to commit SHAs.** The workflows use floating
major tags (`actions/checkout@v4`, `docker/build-push-action@v7`,
`docker/setup-buildx-action@v4`, `docker/login-action@v3`,
`docker/setup-qemu-action@v3`). This is inconsistent with the project's own
philosophy of SHA-pinning *content* refs to defeat floating refs. Pin each
action to a SHA with a trailing `# vX.Y.Z` comment. Pairs naturally with the
renovate item below to keep the pins fresh.
- 🟡 `planned`**Vulnerability scanning in CI.** No CVE scan runs on the
published images today. Add a `trivy image` (or grype) job to
`docker-publish-split.yml` after `smoke`. Start non-blocking (report only),
then tighten to fail on `HIGH`/`CRITICAL` with an available fix.
- 🟢🟡 `planned`**Standardize build provenance → buildx SBOM + attestations.**
The image already carries hand-rolled provenance (OCI labels +
`build-manifest`). `docker/build-push-action` can emit a standard SBOM and
SLSA provenance attestation nearly for free (`provenance: mode=max`,
`sbom: true`). Makes provenance machine-consumable and pairs well with the
trivy item (scan the SBOM).
## Dockerfile hardening
- 🟡 `idea`**Address hadolint DL4006 properly.** Currently ignored in
`.hadolint.yaml`. The clean fix is `SHELL ["/bin/bash", "-o", "pipefail",
"-c"]` so piped `RUN`s fail on the first non-zero stage. This changes the
default `RUN` shell from `sh` to `bash` for all subsequent layers, so it is
base-affecting and needs a careful pass over existing `RUN`s before removing
the ignore.
## Developer experience
- 🟢 `idea`**`Makefile`/`justfile` for local iteration.** Reproducing a CI
build locally means hand-assembling many `--build-arg`s. Thin targets
(`make build-base`, `make build-variant`, `make smoke`, `make lint`) would
make local testing painless and document the canonical invocations.
- 🟡 `idea`**Dependency-update automation (renovate).** With CI actions
SHA-pinned (above), a `renovate.json` keeps those pins — plus the pinned tool
versions (`ACTIONLINT_VERSION`, `HADOLINT_VERSION`, gosu, etc.) — current via
automated PRs. Requires a renovate runner against the Gitea instance.
## Housekeeping
- 🟢 `idea`**Registry retention for `base-<hash>` tags.** The base-hash
caching scheme accumulates `base-<hash>` tags over time. Confirm whether the
registry prunes old ones, and add a retention/cleanup step if not.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Joakim Persson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+265 -93
View File
@@ -2,6 +2,8 @@
Portable AI developer environment in a Docker container. Run [opencode](https://opencode.ai) on any Docker-capable machine with configurable LLM providers, dev tools, and host filesystem access.
> **Looking for pi?** As of **v2.0.0** the [pi](https://github.com/earendil-works/pi) coding-agent is no longer bundled here. It now ships as its own self-contained image, **[`joakimp/pi-devbox`](https://gitea.jordbo.se/joakimp/pi-devbox)** (also on [Docker Hub](https://hub.docker.com/r/joakimp/pi-devbox)), built on a shared Debian base and able to share this image's mempalace palace. Pull `joakimp/pi-devbox:latest` instead of the old `*-with-pi` / `pi-only` tags. See the [v2.0.0 CHANGELOG](CHANGELOG.md) for the migration details (including the `~/.pi/npm-global` → `~/.config/opencode/npm-global` prefix move).
## Why?
The official `ghcr.io/anomalyco/opencode` image (now archived) was Alpine-based and minimal — no git, no dev tools, broken PTY support due to musl/glibc incompatibility. This project provides a **Debian-based, production-ready** alternative using the current v1.x release.
@@ -25,7 +27,7 @@ $EDITOR .env
docker compose run --rm devbox
```
This pulls `joakimp/opencode-devbox:latest` from Docker Hub, mounts `WORKSPACE_PATH` at `/workspace`, and drops you straight into opencode. Use `bash` instead of (no command) to land in a shell first — useful for `aws sso login`, `pi`, `omos`, etc.
This pulls `joakimp/opencode-devbox:latest` from Docker Hub and mounts `WORKSPACE_PATH` at `/workspace`. With no command (as above) the image's default `CMD` (`bash -l`) drops you into a **login shell** — from there run `opencode` to start the harness, or do `aws sso login` first, launch `omos`, etc. To start opencode directly and skip the shell, pass it as the command: `docker compose run --rm devbox opencode`.
**Want to hack on the image itself, follow upstream changes, or rebuild from source?** Clone the repo:
@@ -57,6 +59,8 @@ docker compose run --rm devbox
- **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
- **Browser automation** — `agent-browser` + a headless Chromium baked in, so the agent can drive a real browser to *verify* front-end work (live DOM, layout, WebGL) instead of guessing
- **YAML/JSON tooling** — `jq` plus mikefarah **`yq` v4** (note: replaced Debian's Python `yq` in v2.9.0 — v4 syntax, not jq syntax)
- **AWS CLI v2** — built-in SSO/Bedrock authentication with headless device-code flow
- **Multi-arch** — amd64 and arm64
@@ -123,7 +127,7 @@ docker compose exec -u developer devbox aws --version
| Variable | Description | Default |
|---|---|---|
| `OPENCODE_PROVIDER` | LLM provider (`anthropic`, `openai`, `amazon-bedrock`) | `anthropic` |
| `OPENCODE_MODEL` | Model override | Provider default |
| `OPENCODE_MODEL` | Model override — any `<provider>/<model>` string, written verbatim to the config. See [Choosing a provider and model](#choosing-a-provider-and-model) | Provider default (see below) |
| `ANTHROPIC_API_KEY` | Anthropic API key | — |
| `OPENAI_API_KEY` | OpenAI API key | — |
| `AWS_REGION` | AWS region for Bedrock | `us-east-1` |
@@ -132,23 +136,114 @@ 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` |
| `COLORTERM` | Advertises 24-bit colour to colour-aware tools. Export empty (`COLORTERM=`) on a terminal without true-colour support | `truecolor` |
| `AGENT_BROWSER_EXECUTABLE_PATH` | Chromium binary used by `agent-browser` (a stable symlink into Playwright's versioned browser dir) | `/usr/local/bin/agent-chrome` |
| `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` |
| `OMOS_SKILLS` | Symlink bundled OMOS skills from the image into `~/.agents/skills/` each start | `true` |
| `OMOS_RESET` | Force regenerate OMOS config on next start (does not affect skills) | `false` |
| `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` | Enable opencode's native background subagents. Baked on in the image because OMOS V2+ default orchestration depends on it. Set `false` to opt out. opencode marks this **experimental** — see [AGENTS.md](AGENTS.md) removal trigger | `true` |
| `SKILLSET_CONTAINER_PATH` | Path to skillset repo inside container (for auto-deploy when not at /workspace/skillset) | Auto-detect |
### Choosing a provider and model
The image ships a sensible default model **per provider**, but nothing is hard-wired — you can change it without rebuilding.
| `OPENCODE_PROVIDER` | Default model baked in |
|---|---|
| `anthropic` (default) | `anthropic/claude-opus-5` |
| `amazon-bedrock` | `amazon-bedrock/global.anthropic.claude-opus-5` |
| `openai` | `openai/gpt-5.6` |
**These are only defaults.** They apply when `OPENCODE_MODEL` is unset, and only for the provider you selected. To use anything else, set `OPENCODE_MODEL` in your `.env`:
```bash
# A cheaper/faster Anthropic tier
OPENCODE_MODEL=anthropic/claude-sonnet-5
# A different provider entirely — no baked default needed, the value is
# written verbatim as the `model` field, so any provider opencode supports works
OPENCODE_PROVIDER=openai
OPENCODE_MODEL=openai/gpt-5.6-luna
```
A few things worth knowing:
- **You are not locked to Anthropic or Bedrock.** The defaults above lean Anthropic only because that's the most common setup here. Set `OPENCODE_PROVIDER` (plus `OPENCODE_MODEL` if the per-provider default isn't what you want) and the Anthropic defaults never come into play.
- **Defaults only seed a *new* config.** `generate-config.py` never overwrites an existing `~/.config/opencode/opencode.jsonc`, and that file lives on the persistent `devbox-opencode-config` volume — so if you hand-edit the model there, your edit survives restarts *and* image upgrades. Changing `OPENCODE_MODEL` afterwards will **not** rewrite it; edit the config directly, or delete it and let the entrypoint regenerate.
- **Switching model doesn't require a rebuild** — it's an env var, so `docker compose up -d --force-recreate` (with a fresh config, per the point above) is enough.
- Defaults are defined in one place: `DEFAULT_MODELS` in `rootfs/usr/local/lib/opencode-devbox/generate-config.py`.
### 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 runs `setup-lan-access.sh`, which always writes a writable `~/.ssh-local/config` sidecar. The sidecar does two things regardless of host OS: redirect `ControlPath` into the writable `~/.ssh-local/cm/` (so ControlMaster sockets don't hit the read-only `~/.ssh` bind-mount) and `Include ~/.ssh/config`. On VM-backed hosts it additionally inserts the **SSH-jump-via-host block** so you can reach LAN peers; on native Linux that block is omitted (LAN is reachable directly) but the sidecar is still written — so `dssh`/`dscp` and ControlMaster work on native Linux with a read-only `~/.ssh` too. The jump keypair lives in `~/.ssh-local`, persisted by the `devbox-ssh-local` named volume — generated **once** and reused across container updates.
**To enable it on a VM-backed host (one-time setup per machine):**
1. Set `HOST_SSH_USER=<your host username>` in `.env`.
2. Start the container once. When it generates the jump key it prints a ready-to-paste line — run it **on the host** to authorize the key:
```bash
echo 'ssh-ed25519 AAAA…devbox-jump@…' >> ~/.ssh/authorized_keys
```
3. Ensure the host's SSH server is on (on macOS: System Settings → General → Sharing → Remote Login).
4. Reach the host itself with `dssh host`. (`dssh`/`dscp` wrap `ssh -F ~/.ssh-local/config`.)
Because the key is persisted, you do this **once per machine** — not after every `docker compose up --force-recreate`. You'll only see the authorize line again if you reset the `devbox-ssh-local` volume.
That alone gets you `container → host`. To reach **named LAN peers** by name, give them a `ProxyJump host` override. Don't add it to the shared `~/.ssh/config` entries — the host itself reaches those peers *directly*, and a jump-through-`host` would break the host's own access (and that file is mounted read-only anyway). Instead, drop the overrides in a **host-owned** file that the container Includes ahead of your `~/.ssh/config`:
```sshconfig
# ~/.config/devbox-shell/ssh-lan.conf — on the host, bind-mounted in
# Only ProxyJump goes here; HostName/User/IdentityFile are inherited
# (first-value-wins) from the matching block in your ~/.ssh/config.
Host my-nas pve pbs
ProxyJump host
```
Now `dssh my-nas` routes container → host → LAN peer, pulling HostName/User/key from your existing `~/.ssh/config`. See [`ssh-lan.conf.example`](ssh-lan.conf.example).
**Roaming / unnamed peers.** Because the jump always targets `host` (= the host on whatever LAN it's currently joined to), you can reach the *current* LAN from anywhere. To make bare `dssh user@<private-ip>` jump automatically without naming peers, set `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` — it ProxyJumps any RFC1918 address through the host. It matches the address you *type* (not the resolved HostName), so named hosts that already carry their own ProxyJump are unaffected.
**Public IPs go direct.** The container has normal internet egress, so a host with a public IP (or one reached via a *public* jump host) connects straight out — the local `host` jump is not involved. e.g. a `Host bastion` whose `HostName` is public, and everything that `ProxyJump bastion`, works from the container by name with no extra setup.
> This ships the **mechanism** only — your specific target hosts are facts about *your* network (and a laptop roams between several), so they live in your own host-side config, never baked into the image. Set `DEVBOX_LAN_ACCESS=off` to disable, or `=jump` to force it (e.g. native Linux with `extra_hosts: ["host.docker.internal:host-gateway"]`).
#### Gotcha: per-host `ControlPath` and read-only `~/.ssh`
The base image bakes a `Host *` default (`/etc/ssh/ssh_config.d/00-devbox-controlmaster.conf`) that points `ControlPath` at the writable, per-container `/tmp/sshcm/` (created mode-700 on every start by `entrypoint-user.sh`). Multiplexing therefore works out of the box. **But your bind-mounted `~/.ssh/config` is read first, and SSH uses the first value it sees** — so any per-host block that sets its own `ControlPath` under `~/.ssh/` (a common CGNAT-multiplexing pattern, e.g. `ControlPath ~/.ssh/cm/%r@%h:%p`) **wins, and then fails inside the container** because `~/.ssh` is mounted **read-only** — the master socket can't bind (`cannot bind … Read-only file system`).
This bites any in-container tool that opens an SSH connection to a remote host (git over SSH, `rsync`, remote-execution agents): the master fails to establish and the connection either errors or silently degrades.
**Fix (host-side, one line):** in your host's `~/.ssh/config`, either drop the per-host `ControlPath` (to inherit the writable baked default) or point it at a path that's writable inside the container too:
```sshconfig
Host my-remote
# was: ControlPath ~/.ssh/cm/%r@%h:%p ← read-only in the container
ControlPath /tmp/sshcm/%r@%h:%p # writable on both host and container
```
`/tmp/sshcm/` is also writable on the host (macOS/Linux), so native (non-container) `ssh` from the host keeps working and CGNAT multiplexing is preserved (`ControlMaster`/`ControlPersist` unchanged — only the socket *directory* moves). Note SSH does not create the `ControlPath` parent dir; the container makes `/tmp/sshcm` every start, but on the host run `mkdir -p /tmp/sshcm` once if it doesn't already exist.
### Custom opencode config
Opencode configuration is persisted automatically via the named volume `devbox-opencode-config`. This volume is mounted at `/home/developer/.config/opencode` by default — no host directory setup required. All changes to `opencode.jsonc`, skills, and (on the OMOS variant) `oh-my-opencode-slim.json` survive container recreation.
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. Changes to `opencode.jsonc` and (on the OMOS variant) `oh-my-opencode-slim.json` survive container recreation. Auto-deployed skills are *not* stored here — skillset and OMOS skills are symlinked into `~/.agents/skills/` and rebuilt on every start (see [Custom skills](#custom-skills) and [docs/omos-skills.md](docs/omos-skills.md)).
When an existing `opencode.jsonc` is found in the volume, the `OPENCODE_PROVIDER` auto-config is skipped.
When an existing `opencode.jsonc` is found in the volume, the `OPENCODE_PROVIDER` auto-config is skipped — the live config is never overwritten. However, on each start `generate-config.py` checks whether the config it *would* generate for your current environment differs from the live one, and if so writes a **`opencode.jsonc.proposed`** sidecar in the same directory. This is a manual-merge reference only — opencode never loads `.proposed` files. It is removed automatically once your live config matches the current image defaults. A one-line hint is logged when one is present. Differences may reflect new image defaults (e.g. a newly-added MCP server) *or* your own past edits — the file header explains both.
**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:
@@ -171,6 +266,28 @@ When a skillset repo is detected, its skills are symlinked into `~/.agents/skill
> **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.
On the OMOS variant, the five skills bundled with oh-my-opencode-slim are also symlinked into `~/.agents/skills/` on each start — **from the image**, so pulling a newer image updates them with no installer run and no config reset. See [docs/omos-skills.md](docs/omos-skills.md).
#### Image-baked fallback skills (work with no skillset mounted)
Even with **no skillset repo mounted**, the image ships two skills and one
harness instruction so a fresh container still knows how to behave here:
- `opencode-devbox-environment` and `mempalace` skills are baked under
`/usr/local/share/opencode-devbox/skills/` and symlinked into
`~/.agents/skills/` on start — **only when a skill of that name is not already
present**, so a mounted skillset or an OMOS-bundled skill always wins.
- `instructions/opencode-devbox.md` is symlinked into
`~/.config/opencode/instructions/` (which opencode auto-loads at session
start). It points the agent at the `opencode-devbox-environment` skill and the
MemPalace continuity protocol. It uses a distinct filename, so it never
collides with a mounted skillset's own instructions.
Because the link targets live in the image (not the persistent
`devbox-opencode-config` volume), `docker compose pull` + recreate refreshes
them for free. See
[`rootfs/usr/local/share/opencode-devbox/skills/VENDORED.md`](rootfs/usr/local/share/opencode-devbox/skills/VENDORED.md).
### 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:
@@ -180,6 +297,39 @@ volumes:
- ~/.config/nvim:/home/developer/.config/nvim:ro
```
24-bit colour is on by default — a system-wide `sysinit.vim` (in `/etc/xdg/nvim/`) enables `termguicolors` so strings and comments render in full colour over SSH instead of a muddy 256-colour palette. It loads before any personal config and stays overridable per-user (`:set notermguicolors`, or your own `~/.config/nvim/init`).
> **Not a vi person?** The image also ships two non-modal editors alongside nvim:
> **nano** (classic, minimal) and **micro** (modern — desktop-style `Ctrl+S`/`Ctrl+Q`
> keys, mouse, syntax highlighting). `EDITOR` stays `nvim`; opt in per-shell with
> `export EDITOR=nano` (or `micro`), or for git with `git config --global core.editor micro`.
### Document conversion (Markdown → PDF)
The image ships [`pandoc`](https://pandoc.org/) with [`typst`](https://typst.app/) as the PDF engine, so Markdown → PDF works out of the box with no LaTeX install:
```bash
pandoc README.md -o readme.pdf --pdf-engine=typst
```
The bundled pandoc typst template defaults the font to `Libertinus Serif`, so a bare `--pdf-engine=typst` renders without needing `-V mainfont`. For higher-fidelity or complex layouts, install TeX Live on demand and use `--pdf-engine=xelatex` instead.
### Browser automation (agent-browser)
The base bakes the [`agent-browser`](https://www.npmjs.com/package/agent-browser) CLI plus a Playwright-managed headless Chromium, so an agent can drive a **real browser** — open pages, click/fill, `eval` JavaScript, snapshot the DOM, take screenshots — and thereby *verify* front-end work rather than assuming it renders correctly. Useful for checking live DOM state, layout, popup positioning, and WebGL.
`AGENT_BROWSER_EXECUTABLE_PATH` is preset to the baked browser, so it works with no setup:
```bash
agent-browser open https://example.com
agent-browser screenshot --path /workspace/shot.png
agent-browser skills get core --full # full command set, version-matched to the CLI
```
The browser is resolved through the stable symlink `/usr/local/bin/agent-chrome`, which points into Playwright's per-version, per-architecture browser directory — so image upgrades don't break the path. Playwright's redundant `chromium_headless_shell` build is removed at build time; `agent-browser` drives the full Chromium (headless included).
> **Size note:** Chromium is the single largest thing in the base (~625 MB). It ships in *both* variants because verification is broadly useful. If you maintain a fork and don't need it, drop the `agent-browser` layer from `Dockerfile.base` and lower the smoke-test size thresholds accordingly.
### 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:
@@ -351,7 +501,7 @@ Enable optional language runtimes, pin a specific opencode version, or lock any
```bash
docker compose build --build-arg INSTALL_GO=true
docker compose build --build-arg OPENCODE_VERSION=1.5.0
docker compose build --build-arg OPENCODE_VERSION=1.18.13
docker compose build --build-arg NVIM_VERSION=0.12.1 # pin to a specific version
```
@@ -359,15 +509,14 @@ docker compose build --build-arg NVIM_VERSION=0.12.1 # pin to a specific versi
|---|---|---|
| `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_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_REPO` at ref `MEMPALACE_TOOLKIT_REF` (default `main`). Requires `INSTALL_MEMPALACE=true`. |
| `INSTALL_OMOS` | `false` | [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration (installs Bun and plugin) |
| `INSTALL_OPENCODE` | `true` | Install opencode. Set `false` to build a pi-only image (still includes Bun if `INSTALL_OMOS=true`; for a fully stripped pi-only image see the `pi-devbox` repo). |
| `INSTALL_PI` | `false` | Install [pi](https://github.com/earendil-works/pi) as alternative/complementary harness. Both clones [pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit) (~5 MB) and [pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions) (~1 MB) into `/opt/`; entrypoint deploys them on container start. ~150 MB total image growth. |
| `PI_VERSION` | `latest` | npm version of `@earendil-works/pi-coding-agent`. Floats by default (image rebuild = pi update). |
| `PI_TOOLKIT_REF`, `PI_EXTENSIONS_REF` | `main` | Git refs for the toolkit/extensions clones. Pin to a tag/commit for reproducibility. |
| `INSTALL_OPENCODE` | `true` | Install opencode. Set `false` to build a base with no harness (still includes Bun if `INSTALL_OMOS=true`). |
| `OPENCODE_VERSION` | *(pinned per release)* | opencode npm version. Drives the image tag and is intentionally not floated. |
| `NODE_VERSION` | `22` | Node.js major version. Pinned to protect against upstream breaking changes across majors. |
| `GOSU_VERSION`, `FZF_VERSION`, `GIT_LFS_VERSION`, `NVIM_VERSION`, `BAT_VERSION`, `EZA_VERSION`, `ZOXIDE_VERSION`, `UV_VERSION`, `GITEA_MCP_VERSION`, `GO_VERSION`, `OMOS_VERSION` | `latest` | All GitHub/Gitea/go.dev-hosted binaries resolve to the newest upstream release at build time. Override with a specific version to pin. Resolved versions are logged in CI output. |
| `MEMPALACE_VERSION` | *(pinned per release)* | MemPalace PyPI version. Deliberately pinned so every bump is a reviewable diff — a past unpinned install swept in an MCP schema regression. Bumped in lockstep with the sibling `pi-devbox` repo. Unrelated to `MEMPALACE_TOOLKIT_REF` despite the shared prefix. |
| `DEBIAN_VERSION` | `trixie-slim` | OS base image tag. Pinned to a codename; apt resolves updates within that release. |
| `GOSU_VERSION`, `FZF_VERSION`, `GIT_LFS_VERSION`, `GITLEAKS_VERSION`, `NVIM_VERSION`, `BAT_VERSION`, `EZA_VERSION`, `ZOXIDE_VERSION`, `UV_VERSION`, `GITEA_MCP_VERSION`, `GO_VERSION`, `OMOS_VERSION`, `MICRO_VERSION`, `TEALDEER_VERSION`, `TYPST_VERSION`, `YQ_VERSION`, `AGENT_BROWSER_VERSION`, `PLAYWRIGHT_VERSION` | `latest` | All GitHub/Gitea/go.dev/npm-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.
@@ -399,7 +548,7 @@ ENABLE_OMOS=true
docker compose run --rm devbox
```
On first start, the entrypoint runs the oh-my-opencode-slim installer in non-interactive mode. It generates agent configuration at `~/.config/opencode/oh-my-opencode-slim.json` inside the container. The default preset uses OpenAI models — edit the generated config or mount your own to customize.
On first start, the entrypoint runs the oh-my-opencode-slim installer in non-interactive mode. It generates agent configuration at `~/.config/opencode/oh-my-opencode-slim.json` inside the container. The default preset uses OpenAI models — edit the generated config or mount your own to customize. The installer no longer manages skills (`--skills=no`); the bundled skills are symlinked from the image on every start — see [docs/omos-skills.md](docs/omos-skills.md).
### OMOS Environment Variables
@@ -407,8 +556,8 @@ On first start, the entrypoint runs the oh-my-opencode-slim installer in non-int
|---|---|---|
| `ENABLE_OMOS` | `false` | Activate oh-my-opencode-slim on container start |
| `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) |
| `OMOS_SKILLS` | `true` | Symlink the bundled OMOS skills (`clonedeps`, `codemap`, `deepwork`, `oh-my-opencode-slim`, `simplify`) from the image into `~/.agents/skills/` on each start. Independent of `ENABLE_OMOS`. See [docs/omos-skills.md](docs/omos-skills.md) |
| `OMOS_RESET` | `false` | Force regenerate config on next start (backs up existing config). Does **not** affect skills |
### Custom Configuration
@@ -426,79 +575,6 @@ ping all agents
All six agents should respond if your provider authentication is working.
## pi (alternative/complementary harness)
[pi](https://github.com/earendil-works/pi) is a lightweight TUI coding-agent that can run alongside opencode in the same container. Both harnesses share the mempalace install and palace data — wing/diary entries created by one are visible to the other.
### Setup
Pre-built pi-enabled images are available on Docker Hub as `joakimp/opencode-devbox:latest-with-pi` (base + pi) and `joakimp/opencode-devbox:latest-omos-with-pi` (OMOS + pi). Pulling one of those tags is the fastest path. Alternatively, build from source:
### Build
```bash
docker compose build --build-arg INSTALL_PI=true
# Or: pin a pi version
docker compose build --build-arg INSTALL_PI=true --build-arg PI_VERSION=0.73.0
# Or: pi-only image (no opencode, smaller)
docker compose build --build-arg INSTALL_PI=true --build-arg INSTALL_OPENCODE=false
```
### Run
The default `compose run --rm devbox` invocation drops to a login bash so you can choose:
```bash
docker compose run --rm devbox # bash, then `pi` or `opencode` or `aws sso login`
docker compose run --rm devbox pi # launch pi directly
docker compose run --rm devbox opencode
```
For an attached `compose up -d` container, both harnesses are reachable via `compose exec`:
```bash
docker compose exec -u developer devbox pi
docker compose exec -u developer devbox opencode
docker compose exec -u developer devbox bash
```
### What gets installed
- **`pi` CLI** — npm-installed globally at build time. Version pinned by `PI_VERSION`.
- **pi-toolkit** — keybindings.json (mosh/tmux newline fixes), pi-env.zsh (AWS env loader), settings.json template. Cloned to `/opt/pi-toolkit`; deployed to `~/.pi/agent/` on first container start.
- **pi-extensions** — 7 extensions, cloned to `/opt/pi-extensions` and symlinked into `~/.pi/agent/extensions/`:
- `confirm-destructive` — confirm-prompt before dangerous bash commands and session actions.
- `ext-toggle``/ext` slash command to list and enable/disable extensions at runtime (rename-to-disable; survives `/reload`).
- `git-checkpoint` — per-turn `git stash` checkpoint, restorable on `/fork`.
- `mcp-loader` — generic MCP server loader. Reads an `mcp` block from `~/.pi/agent/settings.json` (same shape as opencode and Claude Desktop) and connects to each declared server, exposing the tools as native pi tools. Supports both **local stdio** subprocesses (`uvx mcp-searxng`, `gitea-mcp`, …) and **remote streamable-HTTP** servers per MCP spec 2025-03-26 (e.g. `https://mcp.context7.com/mcp`). Adds a `/mcp` slash command for runtime status / toggle (same UX as `/ext`). See [`pi-extensions/AGENTS.md`](https://gitea.jordbo.se/joakimp/pi-extensions/src/branch/main/AGENTS.md) for transport details and the `headers` config for auth tokens.
- `notify` — native terminal notification when the agent finishes.
- `ssh-controlmaster` — transparent SSH remote execution via persistent ControlMaster socket (when pi is launched with `--ssh user@host`).
- `todo``todo` tool for the agent + `/todos` for the user.
- **mempalace bridge** — separate `mempalace.ts` extension symlinked from the cloned `mempalace-toolkit`. Provides pi's MCP tools for palace search/diary/knowledge-graph with bespoke agent-identity injection from `$MEMPALACE_AGENT_NAME`. Coexists with `mcp-loader` rather than replacing it — don't list `mempalace` in settings.json's `mcp` block too, or you'll get duplicate tool registrations.
- **MCP servers (none baked in beyond mempalace)** — the loader registers nothing by default. Add servers by editing `~/.pi/agent/settings.json` and `/reload`. Examples (mcp-searxng for web search, context7 for live library docs) are in the `pi-extensions` README.
### Persistence
`~/.pi/` is mounted on the `devbox-pi-config` named volume. Everything below survives container recreate **and** image rebuilds:
- `~/.pi/agent/settings.json` (provider/model, theme selection, the `mcp` block, and the `packages` array tracking installed pi packages).
- `~/.pi/agent/extensions/` (hand-placed extensions and the symlinks deployed by `pi-extensions/install.sh`).
- `~/.pi/agent/sessions/`, `~/.pi/agent/auth.json`.
- `~/.pi/agent/git/<host>/<path>/` (pi packages installed via `pi install git:...`).
- `~/.pi/npm-global/` (pi packages installed via `pi install npm:...`, plus any `npm install -g` invoked as the `developer` user). `NPM_CONFIG_PREFIX` is pre-set in the image, the prefix's `bin/` is on `PATH`, and the directory itself lives on the volume — so user-installed themes, skills, and extensions survive everything short of `docker compose down -v`.
The **baked** pi binary (and pi-toolkit / pi-extensions repos under `/opt/`) live on the image filesystem, not the volume. Image rebuild is the upgrade path for those — same contract as `OPENCODE_VERSION`. If you `npm install -g @earendil-works/pi-coding-agent` yourself, the user-installed copy on the volume wins via `PATH` order and survives image rebuilds.
### Configuration
The entrypoint copies `pi-toolkit/settings.example.json` to `~/.pi/agent/settings.json` on first start. Edit it to set provider/model:
```bash
docker compose exec -u developer devbox $EDITOR ~/.pi/agent/settings.json
```
The AWS env loader (`pi-env.zsh`) reads `~/.config/pi/.env` if you bind-mount one; otherwise pi uses container env vars passed via `.env`.
## AWS Bedrock Authentication
When using AWS Bedrock as your LLM provider, you need:
@@ -582,6 +658,28 @@ Add mempalace as an MCP server in your `opencode.jsonc` (inside `~/.config/openc
This gives opencode access to 29 MCP tools for searching memory, querying the knowledge graph, managing wings/rooms/drawers, and agent diaries.
### Sharing one palace across containers (external server)
By default each container runs its own local `mempalace-mcp`. To share **one**
palace across several containers/harnesses (pi + opencode + native), run the
bundled server and point clients at it:
```bash
docker compose -f docker-compose.mempalace.yml up -d # serves mempalace-mcp over HTTP
```
Then set in each client's `.env`:
```bash
MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp
# MEMPALACE_REMOTE_TOKEN=... # optional; sent as Authorization: Bearer
```
`generate-config.py` then registers mempalace as a `remote` MCP endpoint instead
of the local stdio command — no local `mempalace-mcp` is spawned and the
`devbox-palace` volume is irrelevant. ⚠ The HTTP transport is **unauthenticated**;
keep it on a trusted network or behind a reverse proxy that enforces the token.
### Basic usage
```bash
@@ -616,7 +714,66 @@ Both wrappers are idempotent and dedup-aware — re-running them on unchanged in
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.
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. Repoint the clone URL with `--build-arg MEMPALACE_TOOLKIT_REPO=<url>` for forked or air-gapped builds (see below).
### Building a fork / relocated build
The canonical build clones `mempalace-toolkit` from `gitea.jordbo.se`. That URL is an overridable build-arg (defaulting to the canonical origin), so a fork or a build on a host that can't reach that gitea can repoint it at a mirror, another host, or a local `file://` path **without editing the Dockerfiles**:
| Build-arg | Default | Dockerfile |
|---|---|---|
| `MEMPALACE_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/mempalace-toolkit.git` | base |
Each companion also has a matching `*_REF` arg (branch name or commit SHA). Example — build against a forked mempalace-toolkit:
```bash
# base first
docker build -f Dockerfile.base -t myorg/opencode-devbox:base-dev \
--build-arg MEMPALACE_TOOLKIT_REPO=https://github.com/myorg/mempalace-toolkit.git .
# then the variant FROM that base
docker build -f Dockerfile.variant -t myorg/opencode-devbox:dev \
--build-arg BASE_IMAGE=myorg/opencode-devbox:base-dev \
--build-arg OPENCODE_VERSION=1.17.20 .
```
Note: mempalace-toolkit clones anonymously (no token needed). Only the `resolve-versions` CI job calls the gitea API (which needs a token for public repos). A plain `docker build` like the above skips that job entirely, so no credentials are required.
Provenance build-args (all optional; populate the OCI labels and `/etc/opencode-devbox/build-manifest.json` — see below): `RELEASE_TAG`, `BUILD_DATE`, `SOURCE_REVISION`. CI sets these automatically; a manual build leaves them at harmless defaults.
### Build provenance (labels + manifest)
Every published image is self-describing. Inspect the OCI labels without pulling the filesystem:
```bash
docker inspect --format '{{json .Config.Labels}}' joakimp/opencode-devbox:latest | jq .
```
`org.opencontainers.image.{version,revision,created}` plus `se.jordbo.opencode-devbox.{opencode-version,install-omos,omos-version,mempalace-toolkit-ref}` record the intended opencode version, omos status, and companion refs. The on-disk `/etc/opencode-devbox/build-manifest.json` records **ground truth** — the live `opencode --version`, the installed `oh-my-opencode-slim` version (or `null` in the base variant), and the actual checked-out HEAD of `/opt/mempalace-toolkit` — so a tag is reconstructable after CI logs rotate:
```bash
docker run --rm --entrypoint= joakimp/opencode-devbox:latest cat /etc/opencode-devbox/build-manifest.json
```
From **inside** a running container, `opencode-devbox-version` reads that manifest for you — and it is printed automatically as the first line of output when the container starts, so "which image am I in?" is answered before you ask:
```bash
opencode-devbox-version # human-readable summary
opencode-devbox-version --json # raw manifest, for scripting
opencode-devbox-version --quiet # one line: "v2.9.0 (a1b2c3d)"
```
```text
opencode-devbox v2.9.0
built: 2026-08-04T12:00:00Z (source a1b2c3d4e5f6)
opencode: 1.18.13
components:
opencode: 1.18.13
oh-my-opencode-slim: 2.2.9
mempalace-toolkit: 0123456789ab
```
It also performs a **drift check**: because `npm install -g` as the `developer` user lands on the persistent config volume, a locally-installed `opencode` can shadow the baked one. If the live version differs from the baked one, the command says so instead of silently reporting the manifest's value.
### Storage
@@ -687,6 +844,7 @@ Defaults you get out of the box:
- **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.
- **Host SSH reachability check** — on the first shell of a freshly (re)created container, a one-time probe checks whether the Docker host is reachable over SSH. If it isn't (host Remote Login off, or the container's `devbox_jump` key not yet authorized), it prints a clear two-step fix with the container's public key inline; it stays silent once SSH works and is skipped on later shells.
### Overriding the defaults
@@ -720,6 +878,16 @@ cp /etc/skel-devbox/.bash_aliases ~/.bash_aliases
rm ~/.bash_aliases
```
## Terminal compatibility
The base ships `ncurses-term` and `kitty-terminfo` on top of the default `ncurses-base`, plus a compiled `xterm-ghostty` alias, so modern terminal emulators resolve their `TERM` correctly over SSH instead of degrading to a dumb fallback. Covered out of the box: WezTerm, Alacritty, foot, st, kitty (`xterm-kitty`), Ghostty (`xterm-ghostty`), and iTerm2 / xterm (`xterm-256color`).
`COLORTERM=truecolor` is also baked in, so colour-aware tools (Neovim's auto-detect, `bat`, `delta`) render in 24-bit colour instead of falling back to 256 colours. Pairs with the system-wide Neovim `termguicolors` default. If you connect from a terminal that lacks true-colour support, export `COLORTERM=` (empty) to opt out.
## Global gitignore
The image bakes a `~/.gitignore_global` and wires it via `git config --global core.excludesFile`, so personal/tooling artifacts are ignored across every repo in the container without per-repo `.gitignore` entries. Seeded patterns include `*.bak`, `*.bak.*`, `*~`, `*.orig`, `*.swp`, `*.tmp`, and `**/.claude/settings.local.json` (Claude Code's per-machine settings, which can carry credentials). It is seeded only if absent — edit it freely, and your version survives recreate — and the `core.excludesFile` wiring is skipped if you already set one.
## Secret Scanning
A [gitleaks](https://github.com/gitleaks/gitleaks) pre-commit hook prevents accidentally committing API keys, passwords, or other secrets.
@@ -761,8 +929,12 @@ Container (Debian trixie)
├── opencode binary
├── oh-my-opencode-slim (optional — multi-agent orchestration plugin, includes Bun)
├── AWS CLI v2 (SSO + Bedrock auth)
├── neovim 0.12, tmux, htop, bat, eza, zoxide, uv, rustup, make, gcc, g++, rsync
├── git, git-crypt, age, ssh, ripgrep, fd, fzf, jq, curl, tree
├── neovim 0.12, nano, micro, tmux, htop, bat, eza, zoxide, uv, rustup, make, gcc, g++, rsync
├── git, git-crypt, age, gitleaks, ssh, ripgrep, fd, fzf, jq, yq, curl, tree
├── pandoc + typst (Markdown→PDF via --pdf-engine=typst; HTML/etc.)
├── graphviz (dot diagram rendering)
├── tldr (tealdeer — quick command examples)
├── dot-watch (auto-render .dot to PNG on save)
├── Node.js (for MCP servers)
├── Bun (optional — included with oh-my-opencode-slim)
├── entrypoint.sh (UID adjustment, git config, provider setup)
@@ -791,4 +963,4 @@ Container (Debian trixie)
## License
MIT
MIT — see [`LICENSE`](LICENSE). The MIT license covers this repository's own contents (Dockerfiles, entrypoint scripts, `rootfs/` seeds, CI workflows, docs). The **published container images** additionally *bundle* third-party software, each under its own terms — see [`THIRD_PARTY.md`](THIRD_PARTY.md) for the summary.
+51
View File
@@ -0,0 +1,51 @@
# Third-party notices
opencode-devbox is distributed under the MIT License (see [`LICENSE`](LICENSE)),
which covers **this repository's own contents** — the Dockerfiles, entrypoint
scripts, `rootfs/` seeds, CI workflows, and docs.
The **published container images** (`joakimp/opencode-devbox:*`) additionally
*bundle* third-party software, each of which remains under its own license. This
file is a good-faith summary; the authoritative sources are the upstream
projects and, for OS packages, the per-package copyright files inside the image
at `/usr/share/doc/<package>/copyright`.
## opencode and agent components (installed in the variant layer)
| Component | Upstream | License |
| --- | --- | --- |
| opencode | github.com/sst/opencode | MIT |
| oh-my-opencode-slim (OMOS) *(`-omos` variant only)* | npm | MIT |
| mempalace / mempalace-toolkit | authored by the maintainer (Joakim Persson) | MIT |
| gitea-mcp | gitea.com/gitea/gitea-mcp | MIT |
## Tooling baked into the base image
| Component | Upstream | License (best effort) |
| --- | --- | --- |
| gosu | github.com/tianon/gosu | Apache-2.0 |
| Node.js | nodejs.org | MIT (bundles components under their own licenses) |
| uv | github.com/astral-sh/uv | Apache-2.0 OR MIT |
| Neovim | neovim.io | Apache-2.0 + Vim license |
| micro | github.com/zyedidia/micro | MIT |
| Pandoc | pandoc.org | GPL-2.0-or-later |
| Typst | github.com/typst/typst | Apache-2.0 |
| Graphviz | graphviz.org | CPL-1.0 |
| ripgrep / fd / bat / eza / zoxide / tealdeer / yq (mikefarah) | respective repos | MIT / Apache-2.0 / Unlicense (varies) |
| agent-browser | npmjs.com/package/agent-browser | see package |
| Playwright | github.com/microsoft/playwright | Apache-2.0 |
| Chromium *(fetched by Playwright)* | chromium.org | BSD-3-Clause + others |
| bun *(`-omos` variant only)* | github.com/oven-sh/bun | MIT |
## Base OS
The image is built `FROM` a Debian base and installs packages via `apt`. Debian
and its packages are distributed under their respective licenses (GPL, LGPL,
MIT, BSD, and others). See each package's copyright file in the image under
`/usr/share/doc/<package>/copyright`.
---
*Licenses marked "best effort" are widely known but were not each verified at
the exact bundled version; consult the upstream project for authoritative
terms. Corrections welcome.*
+84
View File
@@ -0,0 +1,84 @@
# Shared MemPalace server (optional) — one palace for many clients.
#
# Runs `mempalace-mcp` over HTTP so several containers/harnesses (pi +
# opencode + native) can share ONE palace instead of each keeping its own.
# Point every client at it by setting, in that client's .env:
#
# MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp
#
# (see .env.example). When set, the client connects over HTTP and does NOT
# spawn its own local mempalace-mcp.
#
# Start: docker compose -f docker-compose.mempalace.yml up -d
# Stop: docker compose -f docker-compose.mempalace.yml down
# Logs: docker compose -f docker-compose.mempalace.yml logs -f
#
# Why reuse the devbox image? mempalace-mcp is already installed in it, and
# reusing it GUARANTEES the server's mempalace version matches the clients'
# (both are pinned by the same image build). Override with a slimmer image via
# MEMPALACE_SERVER_IMAGE if you prefer (it must provide `mempalace-mcp`).
#
# ⚠ SECURITY: mempalace-mcp's HTTP transport has NO authentication of its own.
# Do NOT expose port 8765 to an untrusted network. The default below binds to
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, either
# attach them to the shared `mempalace-net` network (container-to-container, no
# host port needed — use http://mempalace-server:8765/mcp), or front it with a
# reverse proxy that enforces MEMPALACE_REMOTE_TOKEN as `Authorization: Bearer`.
name: mempalace-server
services:
mempalace:
image: ${MEMPALACE_SERVER_IMAGE:-joakimp/opencode-devbox:latest}
container_name: mempalace-server
# Bypass the devbox entrypoint (dev-shell/LAN/config setup) and run the
# HTTP MCP server directly. HOME + explicit --palace pin the data path so
# it does not depend on the image's default user/HOME. Runs as root so it
# can initialise the fresh named volume; the volume is dedicated to this
# server (clients reach it over HTTP, never by mounting it).
entrypoint: []
user: "0:0"
environment:
- HOME=/data
command:
- mempalace-mcp
- --transport
- http
- --host
- "0.0.0.0"
- --port
- "8765"
- --palace
- /data/.mempalace
restart: unless-stopped
# Loopback-only by default (see SECURITY note). Use "8765:8765" to expose on
# all host interfaces, or drop `ports:` entirely and rely on mempalace-net.
ports:
- "127.0.0.1:8765:8765"
volumes:
# The shared palace data — precious; back this up.
- mempalace-shared:/data/.mempalace
# Embedding-model cache (~79 MB, disposable) so search does not re-download.
- mempalace-shared-chroma:/data/.cache/chroma
networks:
- mempalace-net
healthcheck:
# A tools/list round-trip proves the server is answering MCP (python3 is
# always present — mempalace itself is a python tool in the image).
test:
- CMD
- python3
- -c
- "import urllib.request,json; d=json.dumps({'jsonrpc':'2.0','id':1,'method':'tools/list','params':{}}).encode(); r=urllib.request.Request('http://127.0.0.1:8765/mcp',data=d,headers={'Content-Type':'application/json','Accept':'application/json'}); urllib.request.urlopen(r,timeout=5).read()"
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
volumes:
mempalace-shared:
mempalace-shared-chroma:
networks:
mempalace-net:
name: mempalace-net
+7 -1
View File
@@ -38,6 +38,10 @@ services:
- TERM=xterm-256color
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
- GITEA_HOST=${GITEA_HOST:-}
# MemPalace transport: empty = LOCAL per-container palace (default);
# set MEMPALACE_REMOTE_URL to share one external palace. See .env.example.
- MEMPALACE_REMOTE_URL=${MEMPALACE_REMOTE_URL:-}
- MEMPALACE_REMOTE_TOKEN=${MEMPALACE_REMOTE_TOKEN:-}
volumes:
# Host workspace — user's project directory
- ${WORKSPACE_PATH:-~/src}:/workspace
@@ -73,7 +77,9 @@ services:
# Persist uv data (Python installs)
- devbox-uv:/home/developer/.local/share/uv
# Optional: persist MemPalace data (conversation memory, knowledge graph)
# Optional: persist MemPalace data (conversation memory, knowledge graph).
# LOCAL palace only (default). In EXTERNAL mode (MEMPALACE_REMOTE_URL set)
# the shared server owns the data, so this volume is irrelevant.
# - devbox-palace:/home/developer/.mempalace
# Optional: persist ChromaDB embedding model cache (~79 MB)
+32 -5
View File
@@ -25,8 +25,6 @@ services:
# args:
# INSTALL_GO: "false"
# INSTALL_OMOS: "false"
# INSTALL_PI: "false"
# # PI_VERSION: "latest"
# # INSTALL_OPENCODE: "true"
container_name: opencode-devbox
stdin_open: true
@@ -38,6 +36,12 @@ services:
- GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN:-}
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
- GITEA_HOST=${GITEA_HOST:-}
# MemPalace transport: leave empty for a LOCAL per-container palace
# (default). Set MEMPALACE_REMOTE_URL to share ONE external MemPalace
# across containers/harnesses; MEMPALACE_REMOTE_TOKEN is an optional
# bearer token. See .env.example.
- MEMPALACE_REMOTE_URL=${MEMPALACE_REMOTE_URL:-}
- MEMPALACE_REMOTE_TOKEN=${MEMPALACE_REMOTE_TOKEN:-}
volumes:
# Host workspace — mount your project here
- ${WORKSPACE_PATH:-.}:/workspace
@@ -58,7 +62,18 @@ services:
# the container's skill/instruction symlinks independent from the host,
# allowing both native and containerized opencode on the same machine.
- devbox-opencode-config:/home/developer/.config/opencode
- devbox-pi-config:/home/developer/.pi
# Legacy pi config volume (pi was removed in v2.0.0). Left commented so
# fresh installs carry no dead weight. If you are UPGRADING from a
# pre-v2.0.0 image and had global npm packages installed via
# `npm install -g` (they lived under ~/.pi/npm-global), uncomment this
# for ONE container start: entrypoint-user.sh's migration shim copies
# them to ~/.config/opencode/npm-global, after which you can remove it.
# - devbox-pi-config:/home/developer/.pi
# Persist the generated LAN-jump keypair (~/.ssh-local) across recreates.
# setup-lan-access.sh generates this key once and reuses it; persisting
# it means you authorize it on the host ONCE rather than re-authorizing
# after every `docker compose up --force-recreate`.
- devbox-ssh-local:/home/developer/.ssh-local
# NOTE: Do NOT bind-mount ~/.agents/skills/ from the host. The
# container manages its own skills directory independently — the
@@ -95,6 +110,14 @@ services:
# - ~/.bash_aliases:/home/developer/.bash_aliases:ro
# - ~/.inputrc:/home/developer/.inputrc:ro
# Optional: host-owned shell config + LAN jump overrides (recommended
# over the single-file ~/.bash_aliases mount above — it's a directory,
# so it survives editors' atomic-save). The image's ~/.bash_aliases
# sources ~/.config/devbox-shell/bash_aliases if present, and
# setup-lan-access.sh reads ~/.config/devbox-shell/ssh-lan.conf for
# named-peer `ProxyJump host` overrides (see ssh-lan.conf.example).
# - ~/.config/devbox-shell:/home/developer/.config/devbox-shell:ro
# Optional: persist uv data (Python installs, tool installs)
# Without this, 'uv python install' must be re-run after container removal.
- devbox-uv:/home/developer/.local/share/uv
@@ -111,7 +134,10 @@ services:
- 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.
# embeddings). Applies to the LOCAL palace only (the default, when
# MEMPALACE_REMOTE_URL is unset). Without this, palace data is lost on
# container recreation. In EXTERNAL mode the shared server owns the data,
# so this volume is irrelevant.
# - devbox-palace:/home/developer/.mempalace
# Optional: persist ChromaDB embedding model cache (~79 MB, downloaded on
@@ -125,7 +151,8 @@ services:
volumes:
devbox-opencode-config:
devbox-pi-config:
# devbox-pi-config: # legacy (pi removed v2.0.0) — uncomment with the mount above only to migrate old global npm packages
devbox-ssh-local:
devbox-data:
devbox-state:
devbox-shell-history:
+247
View File
@@ -0,0 +1,247 @@
# PR-5: Retire pi from opencode-devbox
After pi-devbox has shipped v1.0.0 as a fully independent image (with
its own base + variant Dockerfiles, CI, smoke tests, and docs), the
pi-related paths in opencode-devbox become dead weight. This PR
removes them.
## Pre-conditions before merging
This PR should land **only after** all of the following are stable:
1. `pi-devbox v1.0.0` published, smoke tests passing, in active use
for at least one release cycle.
2. Anyone consuming `joakimp/pi-devbox:base-pi-only` directly (e.g.
forks pinned to it) has been notified and migrated.
3. The deprecation warning (PR-1 of this work — see below) has been
live for at least one release cycle so consumers have visible
notice.
## Files / sections to remove from opencode-devbox
### `Dockerfile.variant`
Remove these blocks entirely:
- The `INSTALL_PI` / `PI_VERSION` / `PI_TOOLKIT_REF` /
`PI_EXTENSIONS_REF` / `PI_FORK_REPO` / `PI_FORK_REF` /
`PI_OBSMEM_REPO` / `PI_OBSMEM_REF` build-args.
- The `RUN if [ "${INSTALL_PI}" = "true" ]; then ...` block (entire
block — git_clone_retry, git_fetch_ref, npm install
pi-coding-agent, the four /opt/pi-* clones, the npm installs in
/opt/pi-fork and /opt/pi-observational-memory, and the four
rev-parse echoes).
- All comments referencing pi-only as "the single source of truth for
the pi-devbox image" (the variant matrix table, the explanatory
paragraph, and the "rationale" comments at the top of the file
about pi-only existing for pi-devbox to FROM).
Update the variant matrix table at the top of `Dockerfile.variant`:
```
variant INSTALL_OPENCODE INSTALL_OMOS
───────────────── ──────────────── ────────────
base true false
omos true true
```
(only two variants now; pi-only and the with-pi/omos-with-pi axis are
gone).
### `entrypoint-user.sh`
Remove:
- The pi-toolkit and pi-extensions install hooks (the section that
runs `(cd /opt/pi-toolkit && ./install.sh --yes)` etc.).
- The `~/.pi/agent/settings.json` seeding from
`/opt/pi-toolkit/settings.example.json`.
- Any other pi-conditional blocks (search for `INSTALL_PI`, `pi-toolkit`,
`pi-extensions`, `~/.pi/`).
Verify that the AWS Bedrock auth bootstrap (the pi-toolkit AWS env
loader) is not relied on by opencode users. If it is, lift it out of
the pi-toolkit dependency (it's small and self-contained).
### `Dockerfile.base`
Remove:
- The `mkdir -p /home/${USER_NAME}/.pi/agent/extensions` line in the
standard-directories block. Replace with the equivalent opencode-
specific paths if any aren't already present (`~/.config/opencode`
is already there).
- `NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global` — change to
`/home/${USER_NAME}/.config/opencode/npm-global` or a more neutral
path. Update the corresponding `PATH` env var.
Also update the long base-image header comment to remove the
"variants for pi-devbox" rationale.
### CI (`.gitea/workflows/docker-publish-split.yml` or equivalent)
Remove:
- The `pi-only` variant build job.
- The `with-pi` and `omos-with-pi` variant build jobs (they're
redundant with the standalone pi-devbox now).
- The `base-pi-only` tag publish step (which pushes to
`joakimp/pi-devbox:base-pi-only` from this repo).
- The `resolve-pi-version` job step (no longer needed).
- Smoke-test invocations with `--variant pi-only`, `--variant with-pi`,
`--variant omos-with-pi`.
Remaining variants in CI: `base`, `omos`. The "with-pi" axis is
fully retired.
### `scripts/smoke-test.sh`
Remove:
- The `--variant pi-only`, `--variant with-pi`, `--variant
omos-with-pi` branches.
- pi-related assertions: `pi --version`, the
`~/.pi/agent/extensions/*.ts ≥ 4` check, the mempalace.ts bridge
gate (mempalace itself stays, but its bridge into pi is no longer
this image's concern).
Remaining variant axis in smoke tests: `base`, `omos`.
### `README.md` (and `AGENTS.md`, `DOCKER_HUB.md`)
- Remove the pi-only variant from the "Image variants" table.
- Remove the with-pi / omos-with-pi variants if they were documented.
- Remove all sections about pi-toolkit, pi-extensions, pi-fork,
pi-observational-memory, ~/.pi paths, and pi-related env vars.
- Remove the "this image also produces base-pi-only for pi-devbox"
notes.
- Add a single-paragraph **"Looking for pi?"** section pointing to
`joakimp/pi-devbox`.
### `Dockerfile` references in `pi-devbox` repo (cleanup of cross-repo coupling)
This isn't a change to opencode-devbox, but it's part of the same
deprecation:
- Once pi-devbox v1.0.0 is the single source of truth, remove
pi-devbox/Dockerfile (the 5-line shim with the long
`joakimp/pi-devbox:base-pi-only` rationale comment). It's replaced
by `Dockerfile.base` + `Dockerfile.variant` produced by PR-1 of
this work.
### Purge the orphaned `base-pi-only*` Hub tags (manual, post-merge)
Until PR-5, the `build-variant-pi-only` job re-publishes
`joakimp/pi-devbox:base-pi-only` (floating) and a fresh
`base-pi-only-vX.Y.Z` on **every** opencode-devbox release. These tags
are orphaned legacy artifacts: pi-devbox v1.0.0+ builds its own
debian-based `base-<hash>` and **no pi-devbox build input references
`base-pi-only`** (verified 2026-06-10 — only historical mentions remain
in pi-devbox's CHANGELOG/AGENTS/DOCKER_HUB). Nothing consumes them.
Once PR-5 deletes the `build-variant-pi-only` job, the publisher is
gone — so this is the moment to purge the accumulated tags from the
**`joakimp/pi-devbox`** Docker Hub repo (NOT opencode-devbox):
- Delete the floating `base-pi-only` tag.
- Delete every versioned snapshot: `base-pi-only-v1.17.2`,
`base-pi-only-v1.16.2`, `base-pi-only-v1.15.13e`, … (all of them).
Do this **after** PR-5 is merged and the first post-PR-5 release has
built, to confirm no new `base-pi-only*` tag reappears. If you purge
before PR-5, the next opencode-devbox release simply recreates the
floating tag (whack-a-mole). Deletion is via the Hub UI
(`hub.docker.com/r/joakimp/pi-devbox/tags`) or the Hub API
(`DELETE /v2/repositories/joakimp/pi-devbox/tags/<tag>/` with a Hub PAT).
## Two-step deprecation path (recommended)
Rather than a single big-bang removal, use a deprecation cycle:
### Step 1 — pre-PR (lands at the same time as pi-devbox v1.0.0)
Add a deprecation warning to opencode-devbox:
1. **Build-time message** — when `INSTALL_PI=true`,
`INSTALL_PI_DEPRECATED=warn` is the default; the variant build
prints to stderr:
```
===========================================================
DEPRECATION WARNING: INSTALL_PI is deprecated in opencode-devbox
and will be removed in v2.0.0. Use joakimp/pi-devbox:latest
instead. See https://gitea.jordbo.se/joakimp/pi-devbox
===========================================================
```
2. **CHANGELOG** entry on opencode-devbox: "INSTALL_PI build-arg path
deprecated; will be removed in v2.0.0."
3. **README and DOCKER_HUB** updates: mark `pi-only`, `with-pi`,
`omos-with-pi` variants as deprecated, point to pi-devbox.
4. The `base-pi-only` tag continues to be published but with a
notice in the description: "Internal artifact for pi-devbox.
Deprecated; pull joakimp/pi-devbox:latest directly."
### Step 2 — removal PR (this document)
Lands one release cycle (or one calendar month, whichever is later)
after step 1. Removes everything listed in the per-file sections
above. Tagged as opencode-devbox v2.0.0 (the major bump signals the
breaking change).
## Risk assessment
### What could go wrong
- **Someone is consuming `base-pi-only` directly** without going
through pi-devbox. The deprecation warning + one-cycle delay should
surface this.
- **Mempalace bridge in pi-extensions** — this stays in pi-devbox; no
impact on opencode-devbox.
- **Shared base assumptions** — opencode-devbox's
`~/.pi/npm-global` NPM_CONFIG_PREFIX was a pi-specific design. In
the cleanup we move it to a neutral path. Existing opencode-devbox
users get a one-time migration: their `npm install -g` packages
installed at the old path stop being on PATH. Document this in the
v2.0.0 changelog and add a one-liner that copies the old prefix
contents to the new one if the old one exists.
### What's safe
- The base apt set, the Go-binary installs, MemPalace, the SSH
ControlMaster setup, the entrypoint UID/GID dance — all of these
stay. They're not pi-specific.
- The `omos` variant — fully unaffected.
- Existing opencode-only users — no change to their workflow.
## Verification
After PR-5 lands, the following should be true:
- `grep -ri "INSTALL_PI\|pi-toolkit\|pi-extensions\|pi-fork\|pi-observational-memory\|base-pi-only" .` in opencode-devbox returns no matches.
- `docker history joakimp/opencode-devbox:latest` shows no pi-related layers.
- The opencode-devbox CI matrix builds only `base` and `omos` variants.
- A post-PR-5 release does NOT recreate any `joakimp/pi-devbox:base-pi-only*` tag (publisher confirmed gone), after which those orphaned tags are purged from the pi-devbox Hub repo.
- pi-devbox CI is unaffected (it's a different repo).
- Both repos build cleanly in their own CI without referencing the other.
## Estimated effort
- Step 1 (deprecation warnings): ~2 hours.
- Step 2 (removal): ~4 hours including local testing of opencode-only
build paths.
- One release cycle of monitoring between them.
Total: ~1 working day of focused effort, spread over a calendar month.
## Order in the broader plan
1. PR-1 on pi-devbox — copy base + variant Dockerfiles, strip
opencode/omos paths, tag v1.0.0.
2. PR-2 on pi-devbox — add pandoc, graphviz, imagemagick, tldr, yq.
3. PR-3 on pi-devbox — add `:latest-studio` variant.
4. (Optional) PR-4 on pi-devbox — add `:latest-studio-tex` variant.
5. PR-pre on opencode-devbox — deprecation warnings (step 1 above).
6. **PR-5 on opencode-devbox — actual removal (this document, step 2).**
PRs 14 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.
+123
View File
@@ -0,0 +1,123 @@
# Manual host-side publish — escape hatch when CI is broken
This runbook is the procedure for publishing an opencode-devbox release **directly from a developer host** when the Gitea Actions → Docker Hub path is broken. Used in anger on 2026-05-28 to ship `v1.15.12` after five consecutive CI publish failures (runs #332/333/334/336 + a rerun) and as a parallel diagnostic that pinpointed the root cause (buildkit `cache-export mode=max` returning HTTP 400 from the Hub CDN).
The procedure is also a **diagnostic probe**. If the host-side publish succeeds where CI fails, the failure is somewhere in the runner → Hub path (cache-export, runner egress, runner-image, action versions). If host-side fails the same way, the failure is in your local buildx + Hub combination and you need a different escape (different network, different account, file an upstream).
## When to reach for this
- Tag pushed, CI keeps failing on `docker buildx build --push`, the failure shape is stable across reruns.
- Failure body looks like a registry-tier rejection (HTTP 4xx, HTML response body, repeats on every retry) — i.e. not a transient.
- You've already disproved the obvious suspects (action pin, runner image, network) per the [`ci-release-watcher` skill](../../../.agents/skills/ci-release-watcher/SKILL.md) playbook.
- You need the release **shipped today** and don't want to wait for a CI fix to land + re-trigger.
If CI is broken because **a workflow change you just made is bad**, fix the workflow and re-tag with a letter suffix. This runbook is for when the workflow looks correct but the publish path itself is broken.
## Prerequisites on the host
- Docker (or Orbstack on macOS) with `docker buildx` available — multi-arch publish needs `setup-qemu` equivalent. Orbstack ships QEMU emulators for both archs by default; on Linux install `qemu-user-static` and run `docker run --privileged --rm tonistiigi/binfmt --install all` once per host.
- `docker login` credentials for `joakimp` on Docker Hub (PAT or password). Confirm with `docker info | grep Username`.
- A clone of `opencode-devbox` checked out at the **exact tag** you want to publish. `git status` clean. `git describe --tags --exact-match HEAD` should print the tag.
- Network connectivity to `registry-1.docker.io` from the host. Verify with `curl -sI https://registry-1.docker.io/v2/ | head -1` (expects `401 Unauthorized` — that's the v2 API saying "auth required", which means you can reach it).
## How to use this runbook
A working reference script lives next to this doc: **[`docs/manual-host-publish.sh`](manual-host-publish.sh)**. It is the literal script that shipped opencode-devbox v1.15.12 on 2026-05-28 from a developer Mac via Orbstack, with the BASE_HASH and version pins of that release. To publish a different release, **copy it to a new file, edit four constants at the top, and run it**:
```bash
cp docs/manual-host-publish.sh /tmp/manual-publish-vX.Y.Z.sh
# Edit at top of file:
# RELEASE_TAG="vX.Y.Z"
# BASE_HASH="<12-char hash from CI's base-decide step>"
# OMOS_VERSION="<from npm registry, see step 2 below>"
bash /tmp/manual-publish-vX.Y.Z.sh
```
The sections below explain what the script does and what you need to know to edit those three constants safely.
## 1. Pin RELEASE_TAG
The git tag you're publishing. Must match a tag in the local clone:
```bash
git fetch && git checkout v1.15.13 # whatever you're publishing
git describe --tags --exact-match HEAD
```
The script asserts `HEAD == ${RELEASE_TAG}^{commit}` before doing anything destructive. If you've drifted, fix it with `git checkout` before running.
## 2. Pin OMOS_VERSION
Gitea CI's `resolve-versions` job queries the npm registry at workflow time and threads the concrete version through the omos variant build, mitigating the silent same-bytes-across-releases regression class documented in `AGENTS.md`. Do the same by hand:
```bash
curl -sf https://registry.npmjs.org/oh-my-opencode-slim/latest | jq -r .version
```
Paste the version string into the script's `OMOS_VERSION` constant. Don't leave the script defaulting to `latest` — the registry buildcache will silently reuse a stale layer if the build-arg byte-equals a previous build.
## 3. Pin BASE_HASH
This is the 12-char hash that CI's `base-decide` job computes from `Dockerfile.base` + `rootfs/**` + `entrypoint*.sh`. Three ways to get it, in order of preference:
**A. From a prior CI run on the same commit** (cheapest — if the Gitea Actions run that triggered on this tag got far enough to log `base-decide`'s output, just read it):
```
Gitea Actions → the run for vX.Y.Z → base-decide job → "Compute base tag" step → last line:
Computed base tag: base-XXXXXXXXXXXX
```
This is the canonical source. The whole reason for the manual escape is that *something later in CI broke*`base-decide` itself is fast, deterministic, and almost always succeeds.
**B. From an existing image on the Hub** if a recent release already published a `base-<hash>` tag and the inputs haven't changed, you can copy that hash. Confirm with `docker manifest inspect joakimp/opencode-devbox:base-latest` and read the digest — if it matches a `base-<hash>` you already see on the Hub, that hash is yours.
**C. Compute it locally**, replicating CI's exact recipe (the script in `.gitea/workflows/docker-publish-split.yml` `base-decide.compute`):
```bash
{
cat Dockerfile.base
find rootfs -type f \
! -path '*/__pycache__/*' \
! -name '*.pyc' \
! -name '.DS_Store' \
! -name '._*' \
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
cat entrypoint.sh entrypoint-user.sh
} | sha256sum | cut -c1-12
```
The junk-file filters (`__pycache__`, `.DS_Store`, `._*` AppleDouble) matter — they are gitignored but `find -type f` picks them up locally and would diverge your hash from CI's clean checkout. Don't skip them.
If method C disagrees with method A, **trust A** and find out why your local tree differs. The hash in CI is what's on the Hub; that's what variants must FROM.
## What the script does (high level)
After the constants are set, the script runs a 5-step procedure. No editing needed inside the body; the whole flow is parameterised by the four constants above plus `IMAGE` (which is fixed to `joakimp/opencode-devbox`).
1. **Preflight** — buildx present, tag exists, `HEAD == tag`, multi-arch builder created if missing.
2. **Base build (conditional)** — probe `${IMAGE}:base-${BASE_HASH}` on the Hub; if missing, build it multi-arch and push. **No `--cache-from` / `--cache-to`.** That's the whole point of this escape. If the base push itself fails the same way CI did, stop — the regression has spread to image push and you need a different host or account, not this runbook.
3. **Promote `base-latest`**`docker buildx imagetools create` re-tags by manifest reference. No rebuild.
4. **Variants × 2** — sequential (not parallel; one host's egress can't saturate multiple multi-arch pushes safely). Each variant is `Dockerfile.variant` `FROM ${IMAGE}:base-${BASE_HASH}` plus the appropriate `INSTALL_OPENCODE` / `INSTALL_OMOS` build-args, tagged `${RELEASE_TAG}${suffix}` and `latest${suffix}`.
5. **Verify** — prints the digest of all 6 expected tags (4 variant + base-hash + base-latest). Spot-check that each `vX.Y.Z*` and its `latest*` alias share a digest.
Expected wall time on a recent Mac: ~25-40 min (base ~3 min if rebuilt, each variant ~3-7 min mostly QEMU arm64 emulation).
## Optional: update DOCKER_HUB.md description
CI's `update-description` job posts the rendered Hub description via the Hub API. The manual script does **not** do this — the release works fine without it. If you want parity, copy the curl invocation from the `update-description` job in `.gitea/workflows/docker-publish-split.yml` and run it from the host with a Hub PAT loaded into `HUB_PAT`. Cosmetic; can wait until CI is healthy and the next release pushes a fresh description automatically.
## After: capture diagnostic value
The whole point of running this manually is the diagnostic. Three things to record before moving on:
1. **Did the host publish succeed?** If yes and CI was failing on the same exact code, you've localised the failure to the runner side (cache-export, network, runner image). If no, the failure is in your local buildx + Hub combination and CI is a victim, not a cause.
2. **What was different from CI?** Document at minimum: `docker buildx version`, the host's `buildx ls` output (driver name + version), whether you used `--cache-to` or not, and which network you were on.
3. **File the upstream.** If the diagnostic narrowed the failure to a specific buildkit/buildx behaviour, file at `moby/buildkit` or `docker/buildx` with: stable failure shape, the exact request URL fragment (`Offset:0` / `_state=...` / digest if visible), the timeline boundary when failures started, and what worked vs what failed in your repro. The 2026-05-28 cache-export-mode=max regression is a worked example.
Restore CI as the primary publish path as soon as the underlying regression is fixed or worked around at workflow level. This runbook should be exercised rarely.
## Variants of this runbook
- **pi-devbox** — same idea, simpler: only one image (`joakimp/pi-devbox`), one tag pair (`vX.Y.Z` + `latest`), no split base. Adapt the script: drop the `BASE_HASH` constant + steps 2-3 + the variant function; replace with a single `docker buildx build --file Dockerfile --build-arg PI_VERSION=... --tag joakimp/pi-devbox:${RELEASE_TAG} --tag joakimp/pi-devbox:latest --push .`.
- **opencode-devbox letter-suffix rebuild** (e.g. `v1.15.12b`) — same procedure end-to-end. The `BASE_HASH` will probably be unchanged from the prior release if no rootfs/entrypoint/Dockerfile.base changes shipped, so the base-build step skips itself automatically via the Hub probe.
- **Single-variant publish** for partial-failure recovery (e.g. CI succeeded for base + 3 variants but the 4th failed) — comment out the three completed `build_variant` calls in your copy of the script. Keep `imagetools create` for `base-latest` only if it didn't already promote. Then re-run.
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Manual publish of opencode-devbox — bypasses a broken Gitea-runner Hub push
# by building & pushing from a developer host (Orbstack/Docker Desktop).
#
# Mirrors what .gitea/workflows/docker-publish-split.yml would do:
# 1. Build & push Dockerfile.base → joakimp/opencode-devbox:base-<hash>
# 2. Promote → joakimp/opencode-devbox:base-latest
# 3. Build & push 2 variants on top of base-<hash>:
# :vX.Y.Z :latest (INSTALL_OPENCODE only)
# :vX.Y.Z-omos :latest-omos (+ OMOS)
#
# pi was removed in v2.0.0 — there are no pi variants here anymore.
#
# Usage on your host:
# 1. Make sure Orbstack/Docker Desktop is running with multi-arch enabled
# (docker buildx ls should show linux/amd64,linux/arm64).
# 2. docker login docker.io (joakimp account)
# 3. cd ~/path/to/opencode-devbox && git fetch && git checkout <RELEASE_TAG>
# 4. Edit RELEASE_TAG / BASE_HASH / OMOS_VERSION below to match the release.
# 5. bash /path/to/this/script.sh
#
# Total expected time: ~15-25 min on a recent Mac (2 multi-arch builds, base
# layers cache after the first variant).
set -euo pipefail
IMAGE="joakimp/opencode-devbox"
RELEASE_TAG="v2.0.0" # EDIT per release
BASE_HASH="REPLACE_ME" # sha256 of Dockerfile.base + rootfs/* + entrypoints (computed by CI logic)
BASE_TAG="base-${BASE_HASH}"
OMOS_VERSION="latest" # resolve from npm oh-my-opencode-slim latest, then pin
PLATFORMS="linux/amd64,linux/arm64"
# -------- preflight --------
echo "==> Preflight"
docker buildx version >/dev/null || { echo "buildx not available"; exit 1; }
git rev-parse --verify "$RELEASE_TAG" >/dev/null 2>&1 || {
echo "Tag $RELEASE_TAG not found locally. git fetch && git checkout $RELEASE_TAG first."; exit 1; }
[[ "$(git rev-parse HEAD)" == "$(git rev-parse "${RELEASE_TAG}^{commit}")" ]] || {
echo "HEAD is not at $RELEASE_TAG. git checkout $RELEASE_TAG first."; exit 1; }
docker buildx inspect default >/dev/null 2>&1 || docker buildx create --use --name multi --driver docker-container
# Probe whether base-<hash> already exists on Hub (CI does this; saves 10 min if yes)
if docker manifest inspect "${IMAGE}:${BASE_TAG}" >/dev/null 2>&1; then
echo "==> Base tag ${IMAGE}:${BASE_TAG} already exists on Hub — skipping base rebuild"
SKIP_BASE=1
else
echo "==> Base tag ${IMAGE}:${BASE_TAG} missing — will build"
SKIP_BASE=0
fi
# -------- 1. base (if needed) --------
if [[ "$SKIP_BASE" == "0" ]]; then
echo "==> [1/7] Build & push Dockerfile.base → ${IMAGE}:${BASE_TAG}"
docker buildx build \
--platform "$PLATFORMS" \
-f Dockerfile.base \
-t "${IMAGE}:${BASE_TAG}" \
--push \
.
fi
# -------- 2. promote base-latest --------
echo "==> [2/7] Promote ${IMAGE}:${BASE_TAG}${IMAGE}:base-latest"
docker buildx imagetools create -t "${IMAGE}:base-latest" "${IMAGE}:${BASE_TAG}"
# -------- 3-4. variants --------
build_variant() {
local suffix="$1" # "" | "-omos"
local install_omos="$2"
local install_opencode="${3:-true}"
local extra_args=()
[[ "$install_omos" == "true" ]] && extra_args+=(--build-arg "OMOS_VERSION=${OMOS_VERSION}")
local versioned="${IMAGE}:${RELEASE_TAG}${suffix}"
local floating="${IMAGE}:latest${suffix}"
echo "==> Build & push variant${suffix:-(default)}${versioned} + ${floating}"
docker buildx build \
--platform "$PLATFORMS" \
-f Dockerfile.variant \
--build-arg "BASE_IMAGE=${IMAGE}:${BASE_TAG}" \
--build-arg "INSTALL_OPENCODE=${install_opencode}" \
--build-arg "INSTALL_OMOS=${install_omos}" \
${extra_args[@]+"${extra_args[@]}"} \
-t "${versioned}" \
-t "${floating}" \
--push \
.
}
echo "==> [3/4] Variant: base (opencode only)"
build_variant "" false
echo "==> [4/4] Variant: omos"
build_variant "-omos" true
echo
echo "==> Done. Verifying tags on Hub:"
for t in \
"${RELEASE_TAG}" "latest" \
"${RELEASE_TAG}-omos" "latest-omos" \
"${BASE_TAG}" "base-latest"
do
d=$(docker manifest inspect "${IMAGE}:${t}" 2>/dev/null | python3 -c "import json,sys,hashlib; m=json.load(sys.stdin); print(m.get('digest','-'))" 2>/dev/null || echo "MISSING")
printf " %-32s %s\n" "$t" "$d"
done
+110
View File
@@ -0,0 +1,110 @@
# OMOS bundled skills
How the five skills bundled with [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim)
(OMOS) are deployed in this image, why the mechanism changed, and how to keep
them up to date.
## TL;DR
- OMOS bundles five skills: `clonedeps`, `codemap`, `deepwork`,
`oh-my-opencode-slim`, `simplify`.
- They are **symlinked from the image** into `~/.agents/skills/` on every
container start by `entrypoint-user.sh` (gated by `OMOS_SKILLS`, default
`true`).
- **To update them, pull a newer image and recreate the container** — no
installer run, no config reset:
```bash
docker compose pull
docker compose up -d --force-recreate
```
## Why it works this way
The skills ship inside the OMOS npm package, baked into the image at
`/usr/lib/node_modules/oh-my-opencode-slim/src/skills/<name>/`. On start the
entrypoint creates one absolute symlink per skill into `~/.agents/skills/`
the same flat directory the [skillset](#relationship-to-skillset) deploy uses
and which opencode scans (directly and via the `~/.claude/skills` pointer).
Because the symlink target lives in the **image**, not in a volume, the skill
content tracks whatever image you run. Pull a newer `*-omos` image and the
skills update on the next container start. `~/.agents/skills/` is itself an
ephemeral container-layer directory rebuilt from scratch on every start, so the
reconcile is idempotent and self-healing.
The symlinks are **absolute** (unlike skillset's relative links): the target is
always inside the container at a fixed `/usr` path, so there is no host/container
path divergence to guard against.
## The old mechanism (and the trap it created)
Previously the OMOS *installer* (`oh-my-opencode-slim install --skills=yes`)
**copied** the skills into `~/.config/opencode/skills/` — but only on the very
first container start, gated by the absence of `oh-my-opencode-slim.json`. That
directory is the persistent `devbox-opencode-config` named volume.
The consequence: once the config existed, the installer was skipped forever, so
the copied skills **froze** at whatever the image shipped on first run. Pulling
a newer image did nothing (the copies lived in the volume, not the image). The
only refresh path was `OMOS_RESET=true`, which runs `install --reset` and
**also overwrites your hand-tuned `opencode.jsonc`** (model choices, agent
config). Updating skills meant clobbering unrelated config — a bad trade.
The installer has no skills-only refresh flag (`--skills=yes|no` is all-or-
nothing within a full install; `--reset` overwrites everything), so the fix was
to stop using the installer for skills entirely. The two `install` invocations
in `entrypoint-user.sh` now pass `--skills=no`; the installer manages only the
agent config (`oh-my-opencode-slim.json`).
## One-time migration of frozen copies
Existing volumes still contain the old frozen real directories under
`~/.config/opencode/skills/`. Because opencode prefers a name found there over
the same name in `~/.agents/skills/`, those stale copies would *shadow* the
fresh image-sourced symlinks. On the first start after upgrading, the entrypoint
**backs each of them up — never deletes** — to
`~/.config/opencode/skills/<name>.bak.<epoch>` and writes a marker at
`~/.config/opencode/.omos-skills-migrated` so the migration runs exactly once.
Only real directories are touched; any symlink in that directory is left alone.
If everything looks right after a few sessions, the backups are safe to remove:
```bash
rm -rf ~/.config/opencode/skills/*.bak.*
```
## Relationship to skillset
The [skillset](https://gitea.jordbo.se/joakimp/skillset) repo deploys its own
version-controlled skills into `~/.agents/skills/` via
`deploy-skills.sh --bootstrap --prune-stale`, which the entrypoint runs on every
start — *before* the OMOS reconcile. If a skill name exists in both, **OMOS
wins**: the reconcile uses `ln -sfn`, which replaces any existing symlink. Today
the only overlap is `simplify` (the OMOS copy is richer — it bundles
`codemap.md` and a `README.md`), so `simplify` was removed from the skillset
repo and the image copy owns the name.
## Configuration
| Variable | Default | Effect |
|---|---|---|
| `OMOS_SKILLS` | `true` | Symlink the bundled skills into `~/.agents/skills/` on each start. Set `false` to deploy no skills from the image. |
`OMOS_SKILLS` is **independent of `ENABLE_OMOS`**: the skills are useful to plain
opencode even when the multi-agent orchestration config is not enabled. The
reconcile is additionally gated on the bundled-skills source being present, so
it is automatically a no-op on the non-OMOS (`base`) image variant.
## Troubleshooting
- **A skill didn't update after `docker compose pull`.** Make sure you
recreated the container (`docker compose up -d --force-recreate`); a plain
restart reuses the old container layer. Confirm the link target —
`readlink ~/.agents/skills/deepwork` should point under
`/usr/lib/node_modules/oh-my-opencode-slim/src/skills/`.
- **A skill disappeared.** If OMOS upstream restructured its package the symlink
target may no longer exist. The build-time smoke test asserts the source path,
so this should be caught in CI; if you hit it at runtime, look for a dangling
link in `ls -l ~/.agents/skills/`.
- **I want a frozen copy back.** It's at
`~/.config/opencode/skills/<name>.bak.<epoch>` until you delete it.
+236
View File
@@ -0,0 +1,236 @@
# 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. ✓ *(v2.2.0: sidecar now rendered on
all OSes; native Linux no longer skipped — jump block still omitted there)*
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.
+187 -51
View File
@@ -1,6 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
# ── Startup banner: which opencode-devbox build is this? ───────────
# Printed FIRST, before the setup noise below, so it's the first thing visible
# when the container starts (CMD is `bash -l`, tty:true in compose, so this
# reaches the same stream as the interactive shell the user lands in). Reads the
# ground-truth manifest baked in Dockerfile.variant; a no-op with a short stderr
# notice on images built before it existed.
command -v opencode-devbox-version >/dev/null 2>&1 && opencode-devbox-version || true
# ── 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 + writable SSH sidecar: host-OS-agnostic helper ──────
# Generates the writable ~/.ssh-local/config on EVERY host OS: a `Host *`
# ControlPath redirect into ~/.ssh-local/cm (so `ssh -F` / dssh / dscp work
# even when ~/.ssh is bind-mounted read-only) plus `Include ~/.ssh/config`. On
# VM-backed hosts (macOS OrbStack / Docker Desktop) it ALSO adds an
# SSH-jump-via-host block so the container can reach the host's
# directly-attached LAN peers; on native Linux (LAN reachable directly) the
# jump block is omitted but the sidecar is still rendered. 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
@@ -8,13 +41,39 @@ set -euo pipefail
# directly.
SKEL_DIR="/etc/skel-devbox"
if [ -d "$SKEL_DIR" ]; then
for f in .bash_aliases .inputrc; do
for f in .bash_aliases .inputrc .gitignore_global; do
if [ -f "$SKEL_DIR/$f" ] && [ ! -e "$HOME/$f" ]; then
cp "$SKEL_DIR/$f" "$HOME/$f"
fi
done
fi
# ── v2.0.0 migration: relocate npm global prefix off the legacy ~/.pi path ──
# Pre-v2.0.0 images set NPM_CONFIG_PREFIX=~/.pi/npm-global (a pi-specific
# path). v2.0.0 removed pi and moved the prefix to
# ~/.config/opencode/npm-global, which is a persistent named volume in every
# compose layout (the old ~/.pi volume was only in docker-compose.yml). If a
# user upgraded with the old ~/.pi volume still mounted, copy their
# previously globally-installed npm packages to the new prefix once so they
# remain on PATH. The marker keeps this idempotent and a no-op for fresh
# installs; the whole block is harmless when the old path is absent.
NEW_NPM_PREFIX="$HOME/.config/opencode/npm-global"
OLD_NPM_PREFIX="$HOME/.pi/npm-global"
MIGRATION_MARKER="$NEW_NPM_PREFIX/.migrated-from-dot-pi"
if [ -d "$OLD_NPM_PREFIX" ] && [ ! -f "$MIGRATION_MARKER" ]; then
echo "Migrating npm global prefix: ~/.pi/npm-global -> ~/.config/opencode/npm-global"
mkdir -p "$NEW_NPM_PREFIX"
# cp -n: never overwrite a file already in the new prefix (a freshly
# installed package wins over the legacy copy).
for sub in lib bin share; do
if [ -d "$OLD_NPM_PREFIX/$sub" ]; then
mkdir -p "$NEW_NPM_PREFIX/$sub"
cp -an "$OLD_NPM_PREFIX/$sub/." "$NEW_NPM_PREFIX/$sub/" 2>/dev/null || true
fi
done
touch "$MIGRATION_MARKER" 2>/dev/null || true
fi
# ── MemPalace: initialize palace for the workspace if mempalace is installed
# Creates the palace directory structure on first run. Idempotent — skips
# if palace already exists, so upgrades from older versions preserve
@@ -41,52 +100,22 @@ fi
if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null; then
git config --global user.email "$GIT_USER_EMAIL"
fi
# Global gitignore for personal/tooling artifacts (*.bak, *~, *.orig, ...).
# Seeded above into $HOME/.gitignore_global from /etc/skel-devbox. Point git at
# it only if the user has not already set their own core.excludesFile.
if [ -f "$HOME/.gitignore_global" ] && ! git config --global core.excludesFile &>/dev/null; then
git config --global core.excludesFile "$HOME/.gitignore_global"
fi
# ── Generate opencode config from env vars if no config mounted ──────
# 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.
# The script never overwrites an existing opencode.json/.jsonc (bind-mounted
# from host, persisted in named volume, or previously generated) and no-ops if
# OPENCODE_PROVIDER is unset. When a config already exists it instead writes a
# NON-loaded opencode.jsonc.proposed sidecar (only when newer image defaults
# differ) for manual review/merge.
python3 /usr/local/lib/opencode-devbox/generate-config.py
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
# Runs only when pi was baked into the image (INSTALL_PI=true at build).
# Each install.sh is idempotent and backs up real files before linking,
# so re-running across container restarts is safe.
#
# Order: pi-toolkit first (creates ~/.pi/agent/keybindings.json symlink
# and writes the AWS env loader), then pi-extensions (symlinks our 6
# extensions), then settings.json bootstrap from the toolkit template,
# then the mempalace bridge symlink (one-liner; mempalace-toolkit's
# install_skill is intentionally skipped to avoid racing with skillset
# auto-deploy below).
if command -v pi &>/dev/null; then
if [ -d /opt/pi-toolkit ]; then
(cd /opt/pi-toolkit && ./install.sh --yes) || \
echo "WARN: pi-toolkit install.sh failed (continuing)"
fi
if [ -d /opt/pi-extensions ]; then
(cd /opt/pi-extensions && ./install.sh --yes) || \
echo "WARN: pi-extensions install.sh failed (continuing)"
fi
# Bootstrap settings.json from template if absent (pi rewrites this
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
if [ ! -f "$HOME/.pi/agent/settings.json" ] && \
[ -f /opt/pi-toolkit/settings.example.json ]; then
cp /opt/pi-toolkit/settings.example.json "$HOME/.pi/agent/settings.json"
fi
# pi↔mempalace MCP bridge — single extension symlink.
if [ -f /opt/mempalace-toolkit/extensions/pi/mempalace.ts ] && \
command -v mempalace &>/dev/null && \
[ ! -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
"$HOME/.pi/agent/extensions/mempalace.ts"
fi
fi
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
# run the deploy script to create relative symlinks for skills and instructions.
@@ -109,6 +138,116 @@ if [ -n "$SKILLSET_DEPLOY" ]; then
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
fi
# ── OMOS bundled skills: symlink from the image into the flat skills dir ──
# The oh-my-opencode-slim package bundles its skills at a fixed, image-internal
# path (npm global prefix /usr — see Dockerfile.variant). Historically the omos
# *installer* COPIED them into ~/.config/opencode/skills/ on first run only,
# freezing them in the persistent `devbox-opencode-config` named volume: pulling
# a newer image never refreshed them, and the only update path was
# `OMOS_RESET=true` (which also clobbers the user's hand-tuned opencode config).
#
# Instead we symlink them from the IMAGE into ~/.agents/skills/ — the same flat
# dir skillset uses, which opencode scans (directly and via the ~/.claude/skills
# pointer). Because the link targets live in the image, `docker compose pull` +
# recreate updates the skills for free: no installer run, no config reset.
#
# ~/.agents/skills/ is authoritative. The legacy ~/.config/opencode/skills/ real
# dir is intentionally bypassed; a one-time migration backs up (never destroys)
# the frozen copies it holds so they stop shadowing the fresh image-sourced
# skills. The migration marker lives in the parent config dir, not inside
# skills/, so it never interferes with that directory's contents.
#
# Absolute symlink (not relative like skillset): the target is always inside the
# container at a fixed /usr path, and ~/.agents/skills/ is an ephemeral
# container-layer dir rebuilt each start — there is no host/container path
# divergence to guard against. The whole block is non-fatal (`{ … } || true`):
# a transient ln/mv failure must never brick container startup, mirroring the
# skillset deploy above. Runs AFTER skillset deploy so OMOS wins any name
# collision (e.g. `simplify`) via `ln -sfn`. Gated by OMOS_SKILLS (default true)
# and the presence of the bundled skills (omos-variant images only).
if [ "${OMOS_SKILLS:-true}" = "true" ]; then
OMOS_SKILLS_SRC=""
for cand in \
/usr/lib/node_modules/oh-my-opencode-slim/src/skills \
/usr/local/lib/node_modules/oh-my-opencode-slim/src/skills; do
if [ -d "$cand" ]; then OMOS_SKILLS_SRC="$cand"; break; fi
done
if [ -n "$OMOS_SKILLS_SRC" ]; then
{
AGENTS_SKILLS_DIR="$HOME/.agents/skills"
OPENCODE_SKILLS_DIR="$HOME/.config/opencode/skills"
OMOS_SKILLS_MARKER="$HOME/.config/opencode/.omos-skills-migrated"
mkdir -p "$AGENTS_SKILLS_DIR"
for skill_path in "$OMOS_SKILLS_SRC"/*/; do
[ -d "$skill_path" ] || continue
name="$(basename "$skill_path")"
# OMOS wins collisions: -f replaces an existing symlink (e.g. skillset's).
ln -sfn "${skill_path%/}" "$AGENTS_SKILLS_DIR/$name"
# One-time unshadow: back up — never destroy — the frozen real copy the
# old installer left in the persistent config volume. `! -L` ensures we
# only ever touch a real dir, never a symlink a user/skillset created.
if [ ! -f "$OMOS_SKILLS_MARKER" ] \
&& [ -d "$OPENCODE_SKILLS_DIR/$name" ] \
&& [ ! -L "$OPENCODE_SKILLS_DIR/$name" ]; then
mv "${OPENCODE_SKILLS_DIR:?}/$name" \
"${OPENCODE_SKILLS_DIR}/${name}.bak.$(date +%s)"
fi
done
touch "$OMOS_SKILLS_MARKER" 2>/dev/null || true
} || true
fi
fi
# ── Image-baked fallback skills + harness instruction ────────────
# Baked under /usr/local/share/opencode-devbox/ (see that dir's VENDORED.md).
# Linked into place on every start so a container behaves correctly even with
# NO skillset mounted. Targets live in the image, so `docker compose pull` +
# recreate refreshes them for free. Whole block is non-fatal (`{ … } || true`):
# a transient link failure must never brick container startup.
#
# Precedence is intentional: this runs AFTER the skillset deploy and the OMOS
# bundled-skills block above, and the skill links are ONLY-WHEN-ABSENT, so a
# mounted skillset or an OMOS-bundled skill of the same name always wins; the
# baked copies merely fill gaps.
DEVBOX_SKILLS_SRC=/usr/local/share/opencode-devbox/skills
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
{
mkdir -p "$HOME/.agents/skills"
for _sk in "$DEVBOX_SKILLS_SRC"/*/; do
[ -d "$_sk" ] || continue
_skname=$(basename "$_sk")
# Only-when-absent: never clobber a skillset/OMOS/user skill of this name.
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
ln -s "${_sk%/}" "$HOME/.agents/skills/$_skname"
fi
done
} || true
fi
# Harness instructions: opencode auto-loads ~/.config/opencode/instructions/*.md
# as a session-start system prompt. ~/.config/opencode is the persistent
# devbox-opencode-config volume, so we SYMLINK to the fixed image path (never
# copy — a copy would freeze in the volume) and refresh our own symlink each
# start. A real file of the same name (e.g. one a user dropped) is left alone;
# the baked filename (opencode-devbox.md) is distinct from skillset's
# instructions, so there is no collision with a mounted skillset.
DEVBOX_INSTR_SRC=/usr/local/share/opencode-devbox/instructions
if [ -d "$DEVBOX_INSTR_SRC" ]; then
{
mkdir -p "$HOME/.config/opencode/instructions"
for _instr in "$DEVBOX_INSTR_SRC"/*.md; do
[ -f "$_instr" ] || continue
_iname=$(basename "$_instr")
_dst="$HOME/.config/opencode/instructions/$_iname"
# Leave a real file alone; (re)create our own symlink otherwise.
if [ -e "$_dst" ] && [ ! -L "$_dst" ]; then
continue
fi
ln -sfn "$_instr" "$_dst"
done
} || true
fi
CONFIG_DIR="$HOME/.config/opencode"
OMOS_CONFIG="$CONFIG_DIR/oh-my-opencode-slim.json"
@@ -130,15 +269,14 @@ if [ "${ENABLE_OMOS:-false}" = "true" ]; then
OMOS_TMUX_FLAG="yes"
fi
OMOS_SKILLS_FLAG="yes"
if [ "${OMOS_SKILLS:-true}" = "false" ]; then
OMOS_SKILLS_FLAG="no"
fi
# Skills are NOT installer-managed any more — they are symlinked from the
# image into ~/.agents/skills/ by the OMOS bundled-skills block above
# (gated by OMOS_SKILLS). Always pass --skills=no so the installer never
# writes frozen copies into the persistent config volume.
bun x oh-my-opencode-slim@latest install \
--no-tui \
--tmux="${OMOS_TMUX_FLAG}" \
--skills="${OMOS_SKILLS_FLAG}"
--skills=no
echo "oh-my-opencode-slim configured successfully."
else
@@ -149,13 +287,11 @@ if [ "${ENABLE_OMOS:-false}" = "true" ]; then
echo "OMOS_RESET=true — regenerating oh-my-opencode-slim config..."
OMOS_TMUX_FLAG="no"
[ "${OMOS_TMUX:-false}" = "true" ] && OMOS_TMUX_FLAG="yes"
OMOS_SKILLS_FLAG="yes"
[ "${OMOS_SKILLS:-true}" = "false" ] && OMOS_SKILLS_FLAG="no"
bun x oh-my-opencode-slim@latest install \
--no-tui \
--tmux="${OMOS_TMUX_FLAG}" \
--skills="${OMOS_SKILLS_FLAG}" \
--skills=no \
--reset
fi
fi
+1 -1
View File
@@ -87,7 +87,7 @@ for dir in \
/home/"$USER_NAME"/.vscode-server \
/home/"$USER_NAME"/.config/opencode \
/home/"$USER_NAME"/.config/nvim \
/home/"$USER_NAME"/.pi \
/home/"$USER_NAME"/.ssh-local \
/home/"$USER_NAME"/.agents/skills; do
[ -d "$dir" ] || continue
+18
View File
@@ -0,0 +1,18 @@
" opencode-devbox — system-wide Neovim defaults.
"
" This is Neovim's *system vimrc*: it loads for every user before any personal
" ~/.config/nvim, and personal configs can still override it.
"
" Enable 24-bit ("true") colour. Without it, Neovim's default theme is squeezed
" into a 256-colour palette where strings/comments become a muddy, low-contrast
" dark colour — a common complaint over ssh/kitty where COLORTERM often isn't
" propagated into the container. Modern terminals (kitty, WezTerm, iTerm2,
" Alacritty, ...) all support true colour; the bundled kitty-terminfo also lets
" Neovim auto-detect it, but forcing it here guarantees readable colour
" regardless of how the terminal type / COLORTERM reach the container.
"
" Opt out for a session: :set notermguicolors
" Override permanently: set your own value in ~/.config/nvim/init.lua
if has('termguicolors')
set termguicolors
endif
+51 -1
View File
@@ -54,6 +54,49 @@ alias gs='git status'
alias gd='git diff'
alias gl='git log --oneline --graph --decorate -20'
# ── Host SSH reachability check (once per container lifetime) ─────────────
# Warns at first shell startup if the Mac host is not reachable via SSH.
# Only runs inside a container, only if the jump key exists, and only once
# per container lifetime (/tmp flag is cleared on recreate).
_devbox_check_host_ssh() {
[ -f "/.dockerenv" ] || return 0
local ssh_cfg="$HOME/.ssh-local/config"
[ -f "$ssh_cfg" ] || return 0
local key_pub="$HOME/.ssh-local/devbox_jump_ed25519.pub"
[ -f "$key_pub" ] || return 0
local flag="/tmp/.devbox_host_ssh_ok"
[ -f "$flag" ] && return 0
if ssh -F "$ssh_cfg" \
-o BatchMode=yes \
-o ConnectTimeout=2 \
-o StrictHostKeyChecking=accept-new \
mac true 2>/dev/null; then
touch "$flag"
return 0
fi
local pub_key
pub_key=$(cat "$key_pub")
printf '\n\033[1;33m⚠ devbox: Mac host not reachable via SSH\033[0m\n'
printf ' Some tools use SSH to run commands on the Mac host.\n'
printf ' Fix (run both on the Mac):\n\n'
printf ' \033[1mStep 1\033[0m System Settings → General → Sharing → Remote Login → ON\n\n'
printf ' \033[1mStep 2\033[0m echo '"'"'%s'"'"' >> ~/.ssh/authorized_keys\n' "$pub_key"
printf '\n Then open a new shell in the container to verify.\n\n'
}
_devbox_check_host_ssh
unset -f _devbox_check_host_ssh
# ── 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'
@@ -78,9 +121,16 @@ fi
# 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.
#
# The guard MUST stay shell-local (NOT exported): if it leaks into child
# processes, every nested shell -- crucially each tmux pane, which inherits
# the tmux server's env -- skips installing `history -a` and only persists
# history on a clean exit. Abrupt termination (docker stop, tmux kill-server,
# SIGKILL) then loses that shell's in-memory history. Keeping it unexported
# means each new interactive shell re-installs its own per-prompt flush.
if [ -z "${DEVBOX_HIST_SET:-}" ]; then
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a"
export DEVBOX_HIST_SET=1
DEVBOX_HIST_SET=1
fi
# ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container
+15
View File
@@ -0,0 +1,15 @@
# Global gitignore — personal/tooling artifacts (applies to all repos in the container)
# Seeded into $HOME/.gitignore_global by entrypoint-user.sh and wired via
# `git config --global core.excludesFile`. Edit freely; it is yours after first boot.
# backup / editor / merge artifacts
*.bak
*.bak.*
*~
*.orig
*.swp
*.tmp
# Claude Code per-machine local settings: holds machine-specific permissions
# and can carry credentials — must never be committed.
**/.claude/settings.local.json
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# dot-watch — auto-rerender a graphviz .dot file to PNG on every save.
#
# WHY THIS EXISTS
# pi-studio renders mermaid natively but has no graphviz/DOT renderer.
# Its markdown preview DOES render local image links (.png/.jpg/.gif/.webp),
# and the editor offers "refresh from disk". This helper closes the loop:
# edit a .dot file -> dot-watch regenerates <name>.png -> hit refresh in
# Studio to see the update. Uses mtime polling (no inotify dependency,
# which isn't in the trixie-slim base).
#
# USAGE
# dot-watch <file.dot> [layout] [dpi]
# layout: dot|neato|fdp|circo|twopi (default: dot)
# dpi: output resolution (default: 150)
# env: DOT_WATCH_INTERVAL=<seconds> poll interval (default: 1)
#
# EXAMPLES
# dot-watch /workspace/graph.dot
# dot-watch graph.dot neato 200
set -euo pipefail
SRC="${1:?usage: dot-watch <file.dot> [layout] [dpi]}"
LAYOUT="${2:-dot}"
DPI="${3:-150}"
[[ -f "$SRC" ]] || { echo "error: no such file: $SRC" >&2; exit 1; }
command -v "$LAYOUT" >/dev/null || { echo "error: layout engine '$LAYOUT' not found" >&2; exit 1; }
OUT="${SRC%.dot}.png"
INTERVAL="${DOT_WATCH_INTERVAL:-1}" # seconds between polls
ERRLOG="$(mktemp -t dot-watch.XXXXXX.err)"
trap 'rm -f "$ERRLOG"' EXIT
render() {
if "$LAYOUT" -Tpng -Gdpi="$DPI" "$SRC" -o "$OUT" 2> "$ERRLOG"; then
printf '[%s] rendered -> %s\n' "$(date +%H:%M:%S)" "$OUT"
else
printf '[%s] DOT error:\n' "$(date +%H:%M:%S)"
sed 's/^/ /' "$ERRLOG"
fi
}
# portable mtime (GNU stat, fallback to BSD stat)
mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; }
echo "watching $SRC ($LAYOUT, ${DPI}dpi) -> $OUT [Ctrl-C to stop]"
render
last="$(mtime "$SRC")"
while true; do
sleep "$INTERVAL"
[[ -f "$SRC" ]] || continue
now="$(mtime "$SRC")"
if [[ "$now" != "$last" ]]; then
last="$now"
render
fi
done
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# opencode-devbox-version — show which opencode-devbox image build is running.
#
# WHY THIS EXISTS
# The image bakes ground-truth build info into
# /etc/opencode-devbox/build-manifest.json at `docker build` time (see
# Dockerfile.variant): the release tag, build date, source commit, the live
# `opencode --version` at build time, the installed oh-my-opencode-slim
# version (omos variant only), and the actual checked-out commit of the
# /opt/mempalace-toolkit clone. That answers "what image am I running?" —
# but only if you know to go look for the file. This wraps it into one
# command, prints it human-first at container start (see entrypoint-user.sh),
# and stays available on demand for the rest of the session.
#
# USAGE
# opencode-devbox-version human-readable summary (default)
# opencode-devbox-version --json raw manifest JSON (for scripting)
# opencode-devbox-version --quiet one-line "release_tag (source_revision)"
#
# EXIT STATUS
# 0 on success. 1 if the manifest is missing (e.g. an image built before
# this file existed, or a non-opencode-devbox base) — prints a short notice
# to stderr rather than failing silently.
set -euo pipefail
MANIFEST=/etc/opencode-devbox/build-manifest.json
MODE="human"
case "${1:-}" in
--json) MODE="json" ;;
--quiet|-q) MODE="quiet" ;;
--help|-h)
sed -n '2,22p' "$0" | sed 's/^# \?//'
exit 0
;;
esac
if [ ! -f "$MANIFEST" ]; then
echo "opencode-devbox-version: no build manifest at $MANIFEST" >&2
echo " (image predates the manifest, or this isn't an opencode-devbox image)" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "opencode-devbox-version: jq not found; dumping raw manifest instead" >&2
cat "$MANIFEST"
exit 0
fi
if [ "$MODE" = "json" ]; then
cat "$MANIFEST"
exit 0
fi
release_tag=$(jq -r '.release_tag' "$MANIFEST")
build_date=$(jq -r '.build_date' "$MANIFEST")
source_rev=$(jq -r '.source_revision' "$MANIFEST")
opencode_version_baked=$(jq -r '.opencode_version' "$MANIFEST")
if [ "$MODE" = "quiet" ]; then
printf '%s (%s)\n' "$release_tag" "${source_rev:0:7}"
exit 0
fi
# Live drift check: has `opencode` been upgraded since this container was built?
# The image is immutable, but `npm install -g` as the developer user lands on the
# persistent devbox-opencode-config volume (NPM_CONFIG_PREFIX is
# ~/.config/opencode/npm-global), which CAN shadow the baked /usr binary. So we
# report the live version and flag a mismatch rather than trusting the manifest
# blindly — same "ground truth over intent" spirit as how the manifest itself is
# generated in Dockerfile.variant.
opencode_version_live=""
if command -v opencode >/dev/null 2>&1; then
opencode_version_live=$(opencode --version 2>/dev/null | head -n1 | tr -d '\r\n')
fi
printf 'opencode-devbox %s\n' "$release_tag"
printf ' built: %s (source %s)\n' "$build_date" "${source_rev:0:12}"
if [ -n "$opencode_version_live" ] && [ "$opencode_version_live" != "$opencode_version_baked" ]; then
printf ' opencode: %s \033[33m(baked as %s — drift detected)\033[0m\n' \
"$opencode_version_live" "$opencode_version_baked"
else
printf ' opencode: %s\n' "${opencode_version_live:-$opencode_version_baked}"
fi
printf ' components:\n'
jq -r '.components | to_entries[] | select(.value != null) | " \(.key): \(.value[0:12])"' "$MANIFEST"
@@ -3,9 +3,12 @@
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.
- NEVER overwrites an existing config (opencode.json / opencode.jsonc),
whether bind-mounted from the host, persisted in a named volume, or
previously generated. When a config already exists, this script instead
writes a NON-loaded `opencode.jsonc.proposed` sidecar (only when the
freshly-generated config would differ) so new image defaults can be
reviewed and merged by hand. opencode never loads the .proposed file.
- Requires OPENCODE_PROVIDER to be set. Without it, no file is written.
Environment variables:
@@ -13,29 +16,43 @@ Environment variables:
OPENCODE_MODEL Optional. Overrides the provider default model.
AWS_REGION Bedrock only. Default: us-east-1.
AWS_PROFILE Bedrock only. Default: default.
MEMPALACE_REMOTE_URL Optional. Point MemPalace at a shared HTTP endpoint
instead of spawning a local stdio server.
MEMPALACE_REMOTE_TOKEN Optional. Bearer token for MEMPALACE_REMOTE_URL.
MCP servers are auto-registered for tools detected on PATH:
- mempalace (if installed) enabled
- mempalace LOCAL stdio if `mempalace-mcp` is installed, or EXTERNAL
(remote HTTP) when MEMPALACE_REMOTE_URL is set (shared palace)
- gitea-mcp (if installed) registered but disabled by default
Output path: $HOME/.config/opencode/opencode.json
Output path: $HOME/.config/opencode/opencode.jsonc
(existing config preserved; newer defaults surfaced as
$HOME/.config/opencode/opencode.jsonc.proposed)
"""
from __future__ import annotations
import json
import os
import re
import shutil
import sys
from pathlib import Path
# Default model per provider. Update here when upstream changes.
# Default model per provider. Update here when upstream ships a newer model.
#
# THESE ARE ONLY DEFAULTS — they apply when OPENCODE_MODEL is unset, and only
# for the provider selected by OPENCODE_PROVIDER. Any of them is overridden by
# setting OPENCODE_MODEL=<provider>/<model> in .env (see .env.example), and the
# generated opencode.jsonc is never overwritten on later starts, so a hand-edit
# of the config also survives. Users who don't use Anthropic or Bedrock should
# set OPENCODE_PROVIDER (and OPENCODE_MODEL if the per-provider default below
# isn't what they want) rather than editing this file — see the README section
# "Choosing a provider and model".
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"
),
"anthropic": "anthropic/claude-opus-5",
"openai": "openai/gpt-5.6",
"amazon-bedrock": "amazon-bedrock/global.anthropic.claude-opus-5",
}
# Fallback when OPENCODE_PROVIDER is set but not recognized.
@@ -74,13 +91,27 @@ def register_mcp_servers(config: dict) -> list[str]:
"""
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"):
# MemPalace — AI memory. Two modes, same env contract as the mempalace.ts
# pi extension (mempalace-toolkit), so one shared MemPalace can serve pi +
# opencode + native:
# LOCAL (default): stdio subprocess. `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.
# EXTERNAL: set MEMPALACE_REMOTE_URL to a shared MemPalace HTTP endpoint
# (e.g. http://mempalace.lan:8765/mcp). MEMPALACE_REMOTE_TOKEN, if set,
# becomes a Bearer auth header. No local mempalace-mcp is spawned, so
# the binary need not even be installed in this mode.
remote_url = os.environ.get("MEMPALACE_REMOTE_URL", "").strip()
if remote_url:
entry: dict = {"type": "remote", "url": remote_url}
token = os.environ.get("MEMPALACE_REMOTE_TOKEN", "").strip()
if token:
entry["headers"] = {"Authorization": f"Bearer {token}"}
servers["mempalace"] = entry
elif shutil.which("mempalace-mcp"):
servers["mempalace"] = {
"type": "local",
"command": ["mempalace-mcp"],
@@ -110,6 +141,113 @@ def register_mcp_servers(config: dict) -> list[str]:
return list(servers.keys())
def render_config(provider: str, model: str) -> tuple[dict, str, list[str]]:
"""Build the config dict and its JSONC rendering for a provider/model.
Shared by first-generation and the proposed-config side-channel so the
two can never drift. Returns (config_dict, jsonc_text, mcp_servers_added).
"""
config = build_config(provider, model)
added = register_mcp_servers(config)
# Write as JSONC so we can include helpful comments.
content = json.dumps(config, indent=2)
# Insert a comment about the 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,
)
return config, content, added
def _loads_jsonc(text: str) -> dict:
"""Parse JSONC (JSON + // line comments), preserving // inside strings.
Uses the same string-aware comment stripper as scripts/smoke-test.sh, so a
value such as an https:// URL is never corrupted. Raises on invalid JSON
(e.g. trailing commas) callers treat that as 'cannot compare'.
"""
pattern = r'"(?:\\.|[^"\\])*"|//[^\n]*'
stripped = re.sub(
pattern,
lambda m: m.group(0) if m.group(0).startswith('"') else "",
text,
)
return json.loads(stripped)
PROPOSED_HEADER = """\
//
// PROPOSED opencode config NOT loaded by opencode.
//
// This is what opencode-devbox would generate for your CURRENT environment
// plus THIS image's defaults. It is written only when it differs from your
// live opencode.jsonc, as a manual-merge reference e.g. a newer image added
// a default MCP server you do not have yet. opencode only loads
// opencode.json / opencode.jsonc, never this .proposed file.
//
// NOTE: this reflects env + image defaults, so a difference may be a new image
// default OR simply one of your own past edits (changed model, gitea
// enabled=true, ). Diff against your live config and merge what you want.
// Delete this file any time it is rewritten on the next start if still
// relevant, and removed automatically once your live config matches.
//
"""
def write_proposed(
proposed_file: Path, live_file: Path, config: dict, content: str
) -> None:
"""Non-destructively surface a newer default config beside the live one.
Writes <proposed_file> ONLY when the freshly-rendered config differs from
the live config (or the live config cannot be parsed for comparison).
Removes a stale proposed file when the live config already matches. NEVER
touches the live config itself.
"""
try:
live = _loads_jsonc(live_file.read_text())
differs = live != config
comparable = True
except (OSError, ValueError):
# Can't read or parse the live config — surface the proposal rather
# than silently guess they are equivalent.
comparable = False
differs = True
if comparable and not differs:
if proposed_file.exists():
try:
proposed_file.unlink()
print(
f"Live opencode config matches image defaults; removed "
f"stale {proposed_file.name}.",
file=sys.stderr,
)
except OSError:
pass
return
try:
proposed_file.write_text(PROPOSED_HEADER + content + "\n")
except OSError as e:
print(f"WARN: could not write {proposed_file}: {e}", file=sys.stderr)
return
why = "" if comparable else " (existing config could not be parsed for comparison)"
print(
f"A newer default opencode config is available at {proposed_file}{why}. "
"It is NOT applied automatically — diff/merge it into your live config "
"manually, or delete it to dismiss.",
file=sys.stderr,
)
def main() -> int:
provider = os.environ.get("OPENCODE_PROVIDER", "").strip()
if not provider:
@@ -120,19 +258,7 @@ def main() -> int:
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
proposed_file = config_dir / "opencode.jsonc.proposed"
if provider not in DEFAULT_MODELS:
print(
@@ -145,30 +271,37 @@ def main() -> int:
provider, FALLBACK_MODEL
)
config, content, added = render_config(provider, model)
# 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. When a config already exists we instead
# surface any newer image defaults via a NON-loaded opencode.jsonc.proposed
# sidecar for manual merge (see write_proposed) — the live file is untouched.
existing = None
if config_file.exists():
existing = config_file
elif config_file_legacy.exists():
existing = config_file_legacy
if existing is not None:
print(
f"Existing config found at {existing} — not overwritten.",
file=sys.stderr,
)
write_proposed(proposed_file, existing, config, content)
return 0
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")
# The fresh config now equals the image defaults — clear any stale proposal.
if proposed_file.exists():
try:
proposed_file.unlink()
except OSError:
pass
if added:
print(
+253
View File
@@ -0,0 +1,253 @@
#!/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 render the same writable
# config (for the ControlPath redirect + Include ~/.ssh/config) but emit no
# jump block, since LAN peers are reachable directly there.
#
# 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 host jump only on VM-backed hosts. The writable
# sidecar config (ControlPath redirect + Include) is always
# rendered, on every OS.
# 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
}
# ── Writable socket dir + sidecar (ALWAYS, every host OS) ─────────────
# The ControlPath redirect in the generated config needs a writable directory
# regardless of host OS or jump mode. ~/.ssh is typically read-only, so the
# master socket lives under the writable ~/.ssh-local. We create it and render
# the config UNCONDITIONALLY so the redirect (and `Include ~/.ssh/config`) works
# even on native Linux — where we set up no host jump but a read-only ~/.ssh
# would otherwise still break ControlMaster sockets.
mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true
chmod 700 "${SSH_LOCAL}" "${SSH_LOCAL}/cm" 2>/dev/null || true
# ── Decide whether to set up the host jump ────────────────────────────
# Jump = reach the container host (host.docker.internal) as an SSH ProxyJump
# onward to the host's LAN peers. Needed on VM-backed hosts (macOS / Docker
# Desktop) or when forced with DEVBOX_LAN_ACCESS=jump. On native Linux LAN
# peers are reachable directly, so NEED_JUMP=0 and we emit no jump block — but
# we still render the config for the ControlPath redirect + Include.
NEED_JUMP=0
if [ "$MODE" = "jump" ] || { [ "$MODE" = "auto" ] && is_vm_backed; }; then
NEED_JUMP=1
fi
# ── Jump key (only when a jump is needed; generated once, preserved) ──
# 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 [ "$NEED_JUMP" = "1" ] && command -v ssh-keygen >/dev/null 2>&1 && [ ! -f "$KEY" ]; then
if ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1; then
chmod 600 "$KEY" 2>/dev/null || true
KEY_JUST_GENERATED=1
fi
fi
# ── Render the writable config ────────────────────────────────────────
# Jump-specific blocks (the host alias, host-owned peer overrides, and the
# optional RFC1918 catch-all) only make sense when a jump is set up; on native
# Linux they are all empty and only the ControlPath redirect + Include remain.
JUMP_BLOCK=""
LAN_CONF_BLOCK=""
AUTOJUMP_BLOCK=""
if [ "$NEED_JUMP" = "1" ]; then
USER_LINE=""
if [ -n "${HOST_SSH_USER:-}" ]; then
USER_LINE=" User ${HOST_SSH_USER}"
fi
JUMP_BLOCK=$(cat <<EOF
# 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
EOF
)
# 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"
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.
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
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).
# To make a LAN peer jump via the host, add 'ProxyJump host' to its entry in
# the host-owned ~/.config/devbox-shell/ssh-lan.conf (Included above) — NOT
# here in ~/.ssh/config, which is typically bind-mounted read-only.
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
${JUMP_BLOCK}
${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.
if [ "$NEED_JUMP" = "1" ]; then
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
fi
exit 0
@@ -0,0 +1,37 @@
<!-- opencode-devbox:managed-instruction — baked into the image at
/usr/local/share/opencode-devbox/instructions/opencode-devbox.md and
symlinked into ~/.config/opencode/instructions/ by entrypoint-user.sh on
every container start (opencode auto-loads instructions/*.md as a
session-start system prompt). A mounted skillset deploys its own
instructions under different filenames, so this never collides. Keep this
short: it is a pointer, the depth lives in the skills. -->
## Running inside opencode-devbox
If the directory `/usr/local/lib/opencode-devbox/` exists (or your shell prompt
is prefixed `[devbox]`, or `~/.ssh-local/config` is present), you are in an
**opencode-devbox container** — a Docker environment whose persistence,
networking, DNS, host/LAN reachability, and Python/REPL behaviour differ from a
normal workstation. Before any task that touches **reaching the host or its
LAN, SSH, DNS/name resolution, what survives container recreate, running
Python/REPLs, or the oh-my-opencode-slim variant**, read
`~/.agents/skills/opencode-devbox-environment/SKILL.md`.
Key reflex from that skill: **the deployment specifics are not universal** — the
host OS, hostnames, internal domains, and nameservers vary per instance and must
be discovered at runtime, never assumed. And interactive shell aliases
(`dssh`, `dscp`, `cat``bat`) do **not** exist in your non-interactive bash
tool, so spell out the underlying command (e.g.
`ssh -F "$HOME/.ssh-local/config" mac …`).
## Session start: load the mempalace skill
If MemPalace MCP tools (e.g. `mempalace_status`, `mempalace_search`,
`mempalace_diary_write`) are in your tool list, **read
`~/.agents/skills/mempalace/SKILL.md` before doing non-trivial work** and follow
its protocol: search the palace before answering about past work, and write a
diary entry before the session ends. This is especially load-bearing here — an
opencode-devbox container is frequently recreated, so the palace is your only
memory across recreates. Without the habit it is just storage, not memory.
(The skill is the consumer side; feeding the palace is the separate
`opencode-mempalace-bridge` skill, if present.)
@@ -0,0 +1,47 @@
# Vendored fallback skills + harness instruction
This tree is **image-baked** content that `entrypoint-user.sh` links into place
on container start so the container behaves correctly **even when no private
`skillset` repo is mounted**:
- `skills/*` → symlinked into `~/.agents/skills/` (only when a skill of the same
name is not already present, so a mounted `skillset` or the OMOS bundled
skills always win).
- `instructions/*.md` → symlinked into `~/.config/opencode/instructions/`, which
opencode auto-loads as a session-start system prompt (only when a real file of
the same name is not already there).
| item | owner | how it gets here |
|------|-------|------------------|
| `skills/opencode-devbox-environment` | opencode-devbox (this repo) | authored here; the canonical copy |
| `skills/mempalace` | the `skillset` repo | **vendored fallback** (snapshot only) |
| `instructions/opencode-devbox.md` | opencode-devbox (this repo) | authored here; the proactive-load pointer |
## Why fallbacks exist
opencode discovers skills under `~/.agents/skills/` and loads harness
instructions from `~/.config/opencode/instructions/*.md` at session start. A
container started **without** the private `skillset` repo mounted would have
neither — so the agent would not know the container-shaped facts and would not
follow the MemPalace continuity protocol. Baking `opencode-devbox-environment`
and `mempalace` closes that *availability* gap, and the baked
`instructions/opencode-devbox.md` adds the matching *proactive-load* directive
(read those two skills at session start) so a fresh container actually picks
them up rather than relying on description-matching.
Note there is **no `pi-extensions` skill here**: opencode has no `fork`/`recall`
extensions (that is a pi-only concern), so the pi-devbox vendored set does not
carry over 1:1.
## Filename discipline
`instructions/opencode-devbox.md` deliberately uses a name distinct from the
skillset's `instructions/mempalace.md`. Both can be deployed at once (a mounted
skillset adds its own); the distinct name means our fallback is collision-free
and the entrypoint's never-overwrite-a-real-file guard never has to arbitrate.
## Refreshing the snapshots
cp <skillset>/skills/mempalace/SKILL.md skills/mempalace/SKILL.md
Snapshot provenance at last refresh: skillset `63f3bf5`.
@@ -0,0 +1,326 @@
---
name: mempalace
description: MemPalace agent memory protocol. Use on every session to maintain continuity across conversations — search before answering about past work, write diary entries before session ends, and mine new projects into the palace. Load this skill at session start.
---
# MemPalace Agent Memory Protocol
## Overview
MemPalace gives you persistent memory across sessions via an MCP server. It stores project knowledge (mined from files), conversation summaries (diary entries), and entity relationships (knowledge graph). Without this protocol, you have tools but no habits — and memory without habits is just storage.
**Core principle:** Storage is not memory. Storage + protocol = memory.
## When to Load This Skill
- At the **start of every session** (proactively, before the user asks)
- When the user mentions **past conversations, decisions, or work**
- When working on a **new project or repository** for the first time
- When the user asks about **people, projects, or relationships**
## Session Lifecycle
### Phase 1: Wake Up (session start)
Run these immediately when a session begins, before responding to the user:
1. **Load palace overview:**
```
mempalace_status
```
This returns wing/room counts, the AAAK spec, and the memory protocol reminder.
2. **Read your recent diary:**
```
mempalace_diary_read(agent_name="<your_agent_name>", last_n=5)
```
Scan for context about recent sessions — what was worked on, what matters, what's pending.
3. **Check the knowledge graph** for the user or active project if relevant:
```
mempalace_kg_query(entity="<project_or_person>")
```
Do NOT announce this to the user. Just do it silently to orient yourself.
### Temporal grounding — compute time deltas, don't guess
Diary entries and drawers carry real timestamps (`timestamp`, `created_at`).
Before describing *when* something happened — "yesterday", "earlier today",
"last week", "a while back" — **establish the current date/time first and
compute the delta against the actual timestamp.** Get "now" from the injected
session date or by running `date` in a shell; never infer it.
**A container recreate or a fresh session is NOT a day boundary.** A devbox
container (pi-devbox or opencode-devbox) is frequently restarted — often several
times within the *same* day — and each restart begins a new session with a fresh
wake-up. Do not reason "new session ⇒ last session was yesterday": two diary
entries 90 minutes apart can straddle a container recreate. The only
authoritative clock is the timestamp on the memory, not the session/container
boundary.
**Practical rule:** prefer explicit, checkable phrasing — e.g. "earlier today,
~8h ago (both 2026-06-25)" — over a vague relative term. If you catch yourself
about to write "yesterday" / "last week", subtract `now entry.timestamp` and
state the computed result. (Remember timestamps may be UTC while the wall clock
is local — reconcile the offset before computing the delta.) Note too that
session feeders can lag up to a week (see *Multi-harness palace*), so a recent
absence in `wing_conversations` is not proof nothing happened.
### Phase 2: Active Session (during work)
#### Search Before You Speak
Before answering questions about past work, decisions, people, or projects:
```
mempalace_search(query="<keywords>", wing="<project>")
```
**Never guess about facts that might be in the palace.** Wrong is worse than slow. Say "let me check" and query.
#### Mine New Projects
When working on a new codebase for the first time:
1. Check if it's already mined:
```
mempalace_list_wings
```
2. **Decide what to mine — docs first, code never (by default).**
The palace is for *context and intent*, not code recall. Code is better read from the working tree via `Read`/`Grep`/`glob` — always authoritative, never stale. Embedding source code produces thousands of low-signal drawers (e.g. `def __init__(self, ...)` across every class) that pollute search for years.
**Mine by default:**
- `*.md`, `*.rst`, `*.txt` — docs, READMEs, CHANGELOGs, architecture notes
- `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, design/decision docs — highest signal per byte
- `*.sh`, `Dockerfile`, `Makefile`, entrypoints — small, intent-bearing
- `*.yml`, `*.yaml`, `*.toml`, selective `*.json` (`docker-compose`, `pyproject`, `mkdocs.yml`, CI workflows) — skip lockfiles
**Do NOT mine by default:**
- `*.py`, `*.ts`, `*.tsx`, `*.js`, `*.go`, `*.rs`, `*.java`, `*.cpp`, `*.c`, `*.rb` — raw source code
- Test files, fixtures, generated code
- `node_modules/`, `.venv/`, `__pycache__/`, `.mypy_cache/`, `.pytest_cache/`, `.ruff_cache/` (the miner respects `.gitignore` but double-check)
Exception: if a code file *is* the documentation (e.g. a heavily-commented reference script, or a protocol definition), file it manually via `mempalace_add_drawer`.
3. **Before mining**, inspect the repo to estimate drawer count:
```bash
# Quick audit — what will actually get mined?
find <dir> -type f \
-not -path '*/.git/*' -not -path '*/node_modules/*' \
-not -path '*/.venv/*' -not -path '*/__pycache__/*' \
\( -name '*.md' -o -name '*.sh' -o -name '*.yml' -o -name '*.yaml' \
-o -name '*.toml' -o -name 'Dockerfile*' -o -name 'Makefile' \) | wc -l
```
A docs-heavy repo should produce ~510 drawers per file. If a mine produces >15 drawers/file on average, code leaked in — investigate.
4. Run the mine:
```bash
mempalace init --yes <directory>
mempalace mine <directory> --agent <your_agent_name>
```
The miner currently lacks a `--docs-only` or `--exclude-ext` flag (as of v3.3.3). Until it does, either:
- (a) Add a `mempalace.yaml` at the repo root with explicit include globs, OR
- (b) Mine everything, then surgically remove code-sourced drawers via SQL on `~/.mempalace/palace/chroma.sqlite3` (delete by `embedding_metadata.source_file LIKE '%.py'`), followed by `mempalace repair --yes`.
5. If the CLI miner misses a file you *do* want (e.g., `.zsh`, an undocumented extension), file it manually:
```
mempalace_add_drawer(wing="<project>", room="<aspect>", content="<verbatim content>", source_file="<path>")
```
6. After mining, reconnect to pick up the new embeddings:
```
mempalace_reconnect
```
If search errors occur after mining ("Error finding id"), repair the index:
```bash
mempalace repair --yes
```
#### Track Facts in the Knowledge Graph
When you learn new facts about people, projects, or relationships:
```
mempalace_kg_add(subject="ProjectX", predicate="uses", object="PostgreSQL")
mempalace_kg_add(subject="Alice", predicate="owns", object="ProjectX", valid_from="2026-01-15")
```
When facts change (ended, no longer true):
```
mempalace_kg_invalidate(subject="Alice", predicate="works_at", object="OldCorp", ended="2026-03-01")
```
#### Cross-Reference with Tunnels
When content in one project relates to another, create a tunnel:
```
mempalace_create_tunnel(
source_wing="project_api", source_room="endpoints",
target_wing="project_db", target_room="schema",
label="API endpoints map to these DB tables"
)
```
#### Feeding opencode session history (opencode + mempalace-toolkit only)
MemPalace has no upstream integration with [opencode](https://github.com/anomalyco/opencode) as of v3.3.3 — `hooks_cli.py` only supports `claude-code` and `codex` harnesses. Opencode persists every turn in a local SQLite DB at `~/.local/share/opencode/opencode.db`, but nothing moves that data into the palace automatically.
On a machine with opencode + the [`mempalace-toolkit`](https://gitea.jordbo.se/joakimp/mempalace-toolkit) installed, session history is fed into `wing_conversations` via `mempalace-session` — either manually, or on a weekly systemd user timer / cron schedule shipped in `mempalace-toolkit/contrib/`. If this is missing, opencode conversations exist only in the local SQLite DB and are invisible to `mempalace_search`.
**How to tell if it's set up:**
```
mempalace_list_wings
```
If `wing_conversations` exists and has a drawer count comparable to the user's opencode session count, session feeding is working. If it's empty or suspiciously small, suggest:
1. Check if the toolkit is installed: `which mempalace-session`.
2. If installed, suggest running `mempalace-session --dry-run` to preview and `mempalace-session` to file.
3. If not installed, point the user at `gitea.jordbo.se/joakimp/mempalace-toolkit` for setup.
**Don't try to paper over the gap by dumping turn-level content into the palace manually via `mempalace_add_drawer`** — that reinvents what `mempalace-session` does with normalization and dedup. Use the tool.
Full routine (triggers, cadence, automation) is in the [`opencode-mempalace-bridge`](https://gitea.jordbo.se/joakimp/mempalace-toolkit) skill and the toolkit's `ARCHITECTURE.md` §5. The two skills pair: this one (`mempalace`) covers using the palace; that one (`opencode-mempalace-bridge`) covers feeding it from opencode.
### Phase 3: Wind Down (session end)
**Always write a diary entry before the session ends.** This is the most important habit.
```
mempalace_diary_write(
agent_name="<your_agent_name>",
entry="<AAAK compressed summary>",
topic="session-summary"
)
```
#### Why still write diaries when sessions may be mined automatically?
On machines running opencode + `mempalace-toolkit`, every session is mined into `wing_conversations` on a weekly (or user-defined) schedule. A common and incorrect conclusion: *"since every turn is captured automatically, writing a diary entry is redundant."* It isn't.
Session mining captures **what was said** (every turn, verbatim). A diary captures **what the session meant** — editorial judgment by the agent who lived it:
- Lessons learned, patterns noticed, pending items rolled forward
- Meta-observations that were never said aloud during the session
- Aggregate counts (commits shipped, bugs fixed, hours spent)
- A compressed, recency-scannable summary for the *next* agent's wake-up
Mining raw turns cannot surface these because the words don't exist verbatim — they're the agent's reflection at wind-down. Think of the split as *release notes* (diary) vs. *git log with diffs* (session mine): a repo keeps both because they answer different questions. So does the palace.
**Practical rule:** automated mining does not replace Phase 3. Both systems cover each other's failure modes — a skipped diary is recovered from the raw turns; a missed mine is recovered from the diary summary. For the full treatment (comparison table, retrieval patterns, token economics), see [`mempalace-toolkit/ARCHITECTURE.md` §5 → "Diary vs session mine: why keep both?"](https://gitea.jordbo.se/joakimp/mempalace-toolkit/src/branch/main/ARCHITECTURE.md#diary-vs-session-mine-why-keep-both).
#### AAAK Diary Format
Write diary entries in compressed AAAK format for efficiency. Structure:
```
SESSION:<date>|<what.you.worked.on>|
TASKS:
1.<task.description>→<outcome>|
2.<task.description>→<outcome>|
DISCOVERED:<unexpected.findings>|
ENTITIES:<people.or.projects.encountered>|
<importance: one to five stars>
```
Example:
```
SESSION:2026-04-28|api.refactor+db.migration|
TASKS:
1.refactored.auth.endpoints→split.into.3.modules|
2.added.user.roles.migration→postgres.enum.type|
DISCOVERED:legacy.session.table.unused.since.v2|
ENTITIES:ProjectX;Alice(reviewer)|
***
```
Rules:
- Use dots instead of spaces within phrases
- Use pipes as field separators
- Use arrows for cause/effect or transitions
- Stars indicate session importance (one to five)
- Keep it tight — a future agent should get the gist in seconds
#### What to Capture
Prioritize recording:
- **Decisions made** and their rationale
- **Discoveries** — things that surprised you or that a future session needs to know
- **Unfinished work** — what's pending, what was deferred
- **User preferences** observed during the session
- **Entities encountered** — people, projects, tools, services
### Phase 4: Fact Updates
If facts changed during the session, update the knowledge graph before writing the diary:
```
mempalace_kg_invalidate(subject="...", predicate="...", object="...", ended="<today>")
mempalace_kg_add(subject="...", predicate="...", object="...", valid_from="<today>")
```
## Palace Structure
### Wings
Wings are top-level categories, typically one per project or domain:
- Named after the project directory (e.g., `cli_utils`, `opencode_devbox`)
- Agent diaries live in `wing_<agent_name>` (e.g., `wing_orchestrator`, `wing_pi`)
#### Multi-harness palace
A single palace can be fed by multiple coding-agent harnesses. On this machine the palace is shared between **opencode** and **pi** (Mario Zechner's pi-coding-agent). Implications:
- **`wing_conversations` mixes sources.** Both harnesses' session feeders write into the same wing. To tell them apart, look at the `source_file` metadata on each drawer:
- `pi_<uuid>.jsonl` → pi session
- `<slug>_ses_<id>.jsonl` → opencode session
- The first chunk of each session also carries a `| source: opencode` or `| source: pi` marker in the synthetic header line.
- **Other wings may belong to other harnesses.** For example `wing_pi` is pi's diary, not opencode's. Don't assume every diary entry was written by you — check `agent_name` on the entry.
- **Session feeders run on different schedules.** Pi sessions are fed Tue 03:00, opencode sessions Mon 03:00. Recent sessions from either harness can lag the palace by up to a week, so absence-of-evidence in `wing_conversations` is not evidence-of-absence for recent work.
- **Reading another harness's diary is useful.** When orienting after a gap, `mempalace_diary_read agent_name=pi` (or whichever sibling agent has been active) often gives a fresher picture than waiting for the conversations feeder to catch up.
### Rooms
Rooms are aspects within a wing:
- `fzf`, `scripts`, `configuration`, `general` — whatever the miner detects
- Diary entries go into rooms by topic tag
### Drawers
Drawers hold verbatim content — never summarized, always searchable.
### Tunnels
Cross-wing connections linking related content across projects.
### Knowledge Graph
Entity-relationship triples with temporal validity. Query with `mempalace_kg_query`, browse with `mempalace_kg_timeline`.
## Troubleshooting
| Problem | Fix |
|---|---|
| "No palace found" | Run `mempalace init <dir>` then `mempalace mine <dir>` |
| "Error finding id" after mining | Run `mempalace repair --yes` then `mempalace_reconnect` |
| Search returns irrelevant results | Use `max_distance=1.0` for stricter matching; add `wing` filter |
| Miner skips file types | File manually with `mempalace_add_drawer` or use `--no-gitignore` |
| Stale results after external changes | Call `mempalace_reconnect` |
## Anti-Patterns
- **Don't guess when you can search.** If a question touches past work, search first.
- **Don't infer elapsed time from session or container boundaries.** A restart isn't a new day. Compare the actual timestamp (`timestamp` / `created_at`) against the current date/time before saying "yesterday", "last week", etc.
- **Don't skip the diary.** A session without a diary entry is a session forgotten.
- **Don't summarize drawer content.** File verbatim — the embedding model needs the original words.
- **Don't mine .git directories or node_modules.** The CLI miner respects .gitignore by default.
- **Don't create duplicate drawers.** Use `mempalace_check_duplicate` before adding manually.
- **Don't treat the palace as a task list.** It's for knowledge and context, not todos.
@@ -0,0 +1,226 @@
---
name: opencode-devbox-environment
description: >-
Operate correctly inside an opencode-devbox container. Load when running
inside opencode-devbox (detection: the directory `/usr/local/lib/opencode-devbox/`
exists, the shell prompt is prefixed `[devbox]`, or `~/.ssh-local/config` is
present) and the task touches any of: reaching the Docker host or its LAN,
SSH, DNS name resolution, what survives container recreate (persistence vs
ephemerality), running Python or other REPLs, or the oh-my-opencode-slim
(OMOS) variant. Covers the persistence model (and why image-owned content
must live under /usr, not a home dir a named volume shadows), the
interactive-vs-tool-shell alias gotcha (dssh/dscp/cat=bat exist only in
interactive bash), host + LAN SSH reachability and ControlMaster, split-horizon
DNS mechanisms, and uv-first Python. This skill teaches MECHANISMS only —
concrete hostnames, usernames, internal domains, nameservers, and even the
host OS vary per deployment and MUST be discovered at runtime, never assumed
or hardcoded.
---
# opencode-devbox environment
You are (or may be) running inside **opencode-devbox**: a Docker container that
ships opencode, MemPalace, and a curated tool stack, with the host source tree
mounted at `/workspace`. This skill is about the *container-shaped* facts that
change how you should act — things that are easy to get wrong because they
differ from a normal workstation shell.
> **Golden rule: this environment is a template, not a fixed deployment.**
> The host could be macOS, Windows, or Linux. There may or may not be LAN
> peers, a VPN, split-DNS, a skillset mount, or the `-omos` variant. Detect
> and verify the specifics live (commands below) — do **not** assume any
> particular hostname, domain, nameserver, or OS. Where this skill shows
> example values they are illustrative placeholders.
## 0. Am I in opencode-devbox, and what's true *here*?
Cheap detection signals (any one is sufficient):
```sh
[ -d /usr/local/lib/opencode-devbox ] && echo "opencode-devbox image"
[ -r "$HOME/.ssh-local/config" ] && echo "LAN/host SSH sidecar present"
case "$PS1" in *'[devbox]'*) echo "interactive devbox shell";; esac
```
Then orient before acting:
```sh
cat /etc/os-release | head -2 # container distro (usually Debian)
ls -la /usr/local/lib/opencode-devbox/ # which devbox helpers exist
sed -n '/^Host /,$p' ~/.ssh-local/config 2>/dev/null # host/LAN reachability, if any
mount | grep -E ' /workspace | /home/\S+/\.ssh ' # what's bind-mounted
```
## 1. Persistence vs ephemerality — know before you write
The container has **three storage tiers with very different lifetimes**. Pick
the right one or work is silently lost on the next recreate/update.
| Tier | Examples | Survives `down`? | Survives `down -v`? | Survives image update / `--force-recreate`? |
|---|---|---|---|---|
| **Host bind-mount** | `/workspace`, usually `~/.ssh` (ro), optionally `~/.mempalace` | yes | yes (lives on host) | yes |
| **Named volume** | `~/.config/opencode`, `~/.local/share/opencode`, `~/.local/state/opencode`, `~/.ssh-local`, `~/.cache/bash`, `~/.local/share/{uv,nvim,zoxide}` | yes | **no** | yes |
| **Writable container layer** | anything else: `sudo apt install …`, `rustup`/`ghc`/`R` toolchains, files in `/tmp`, `/opt` edits | yes | **no** | **no** |
Practical consequences:
- **Durable work goes in `/workspace`** (it's the host filesystem, UID-aligned —
what you write appears with the user's normal ownership on the host).
- **Runtime-installed system packages and language toolchains are ephemeral.**
If a task needs them reproducibly, it belongs in the image (Dockerfile) or a
project manifest, not an ad-hoc `apt install`. Tell the user when you install
something that won't survive.
- **`~/.config/opencode` is a named volume** (`devbox-opencode-config`), so
things baked into the *image* under `/home/<user>/...` are **shadowed** by the
volume on existing containers and only seen on a fresh volume. This is exactly
why image-owned content that must always be live (the OMOS bundled skills, the
image-baked fallback skills, the harness instructions) lives under an image
path like `/usr/local/share/opencode-devbox/...` and is **symlinked** in by
the entrypoint on every start — never copied into a home dir a volume covers.
Copying image content into `~/.config/opencode` *freezes* it: a later
`docker compose pull` will not refresh it.
## 2. Interactive shell vs. your tool shell (a real footgun)
The conveniences below are defined in `~/.bash_aliases` and **only exist in an
interactive login shell.** Your `bash` *tool* runs non-interactively, so these
are "command not found" there — you must spell out the underlying command.
| Interactive alias | Non-interactive equivalent to actually run |
|---|---|
| `dssh <host>` | `ssh -F "$HOME/.ssh-local/config" <host>` |
| `dscp …` | `scp -F "$HOME/.ssh-local/config" …` |
| `cat file` (→ `bat`) | `cat file` works, but output differs; use `command cat` for raw |
| `ll`, `la` (→ `eza`/`ls`) | `ls -lh`, `ls -lha` |
If a command "works in my terminal but not when the agent runs it," this alias
gap is the first thing to suspect.
**`dscp`/`scp` with accented filenames on a macOS host.** macOS stores filenames
in Unicode **NFD** (decomposed — e.g. `ä` is `a` + combining U+0308), while the
string you type or paste is usually **NFC** (precomposed `ä`, U+00E4). The bytes
differ, so a precomposed remote path *silently* fails to match on the host —
`scp … "mac:'~/Desktop/Skärmavbild ….png'"` returns *No such file or directory*
even though the file plainly exists. Sidestep the encoding entirely: let the
**remote shell expand a wildcard**, or list the directory first and copy the
exact name it prints.
```sh
# glob dodges the NFC/NFD mismatch (the remote shell matches the real bytes):
scp -F "$HOME/.ssh-local/config" "mac:~/Desktop/Sk*rmavbild*.png" ./
# or read the exact filename first, then copy that:
ssh -F "$HOME/.ssh-local/config" mac 'ls -1 ~/Desktop/*.png'
```
## 3. Reaching the Docker host and its LAN over SSH
When the host is VM-backed (e.g. OrbStack / Docker Desktop on macOS) the
entrypoint's `setup-lan-access.sh` writes a **writable SSH sidecar** at
`~/.ssh-local/config`. It always provides:
- A `Host *` block redirecting `ControlPath` into the writable `~/.ssh-local/cm`
(because `~/.ssh` is typically bind-mounted **read-only**, so a master socket
can't be created under it), plus `Include ~/.ssh/config`.
- Aliases **`host` / `mac`** → `host.docker.internal` (user comes from
`HOST_SSH_USER`) — i.e. SSH back into the Docker host.
- On VM-backed hosts only: an **SSH-jump-via-host** block so the container can
reach the host's directly-attached LAN peers (`ProxyJump host`). On a native
Linux host the LAN is usually reachable directly and this jump block is
omitted — **so don't assume a jump path exists; read the sidecar.**
Use it (remember §2 — spell it out in tool bash):
```sh
ssh -F "$HOME/.ssh-local/config" mac 'hostname; whoami' # reach the host
ssh -F "$HOME/.ssh-local/config" <lan-peer> '…' # reach a LAN peer (if configured)
```
Related mechanisms (don't reinvent them):
- **ControlMaster multiplexing** is preconfigured (sockets under
`~/.ssh-local/cm`) to survive CGNAT per-destination flow caps on residential
ISPs. If `~/.ssh/config` pins a `ControlPath` under the read-only `~/.ssh`,
override with `-o ControlPath=none` (or use the sidecar, which already
redirects it).
- To name a LAN peer (give it a stable alias + `ProxyJump host`), put the block
in the host-owned `~/.config/devbox-shell/ssh-lan.conf` (bind-mounted in and
`Include`d), **not** in the read-only `~/.ssh/config`. opencode has no
built-in "run my tools on a remote host" rewiring — the sidecar + plain `ssh`
is the path.
## 4. DNS / name resolution — environment-specific, verify live
How a name resolves here is **not universal** and depends on the host's
networking. The container's own resolver is just `/etc/resolv.conf`, but the
*host* (which you reach via §3, and whose DNS the container may inherit) can use
**split-horizon DNS** to send certain internal domains to specific nameservers
while everything else goes to a default resolver/VPN gateway. The mechanism is
OS-specific and **may not be present at all**:
- **macOS host:** per-domain files in `/etc/resolver/<domain>`, each listing
`nameserver` lines. Reading them (over `ssh … mac`) is a fine way to learn the
real split-DNS map — *for that one machine.*
- **Linux host:** typically `systemd-resolved` split DNS (per-link `Domains=`
routing) or `/etc/resolv.conf` `search`/`nameserver`.
- **Windows host:** the NRPT (Name Resolution Policy Table) plays the per-suffix
role; WSL2 inherits host resolution via mirrored networking + DNS tunneling.
Operating rules:
1. **Never hardcode a domain→nameserver mapping or a specific nameserver IP**
it is per-deployment and changes between users and even VPN states.
2. **Verify by reading the live config**, e.g. `cat /etc/resolv.conf` in the
container, or `ssh … mac 'cat /etc/resolver/* 2>/dev/null'` on a macOS host.
3. **Reachability needs both DNS *and* a route.** A name resolving to an
internal address is useless if packets to that subnet don't have a path
(e.g. via the VPN or the §3 jump). Check both when something "resolves but
won't connect."
4. If you discover deployment-specific facts (a domain, a nameserver, a
reachable peer), prefer recording them in MemPalace over baking them into
code or this skill.
## 5. Python and other languages: uv-first, toolchains are ephemeral
- A system `python3` exists, but **prefer `uv`** for REPLs and project envs —
it's installed and its store (`~/.local/share/uv`) is a persisted volume.
- Throwaway REPL: `uv run --with ipython ipython`
- Project env: `cd /workspace/proj && uv init && uv add <pkgs> && uv run …`
(the `pyproject.toml` + `uv.lock` travel with the repo — the durable choice).
- Other language toolchains (Rust via rustup, R, GHC, Clojure, Go) are
**runtime opt-ins on the ephemeral layer** unless baked into the image — they
do not survive `down -v` or an image update. Flag this when installing.
## 6. The oh-my-opencode-slim (OMOS) variant
Present only in the `-omos` image (detection: `/usr/lib/node_modules/oh-my-opencode-slim`
exists, or `ENABLE_OMOS=true`). It adds the bun runtime and multi-agent
orchestration. Two environment facts matter:
- **OMOS skills are symlinked from the image** into `~/.agents/skills/` by the
entrypoint on every start — they are **not** installed into the
`~/.config/opencode` volume (that froze them historically; see §1). A
`docker compose pull` + recreate refreshes them for free. Don't run the OMOS
installer with `--skills` to "fix" missing skills; the symlinks are the
mechanism.
- Optional tmux orchestration is gated by `OMOS_TMUX`; the OMOS config lives at
`~/.config/opencode/oh-my-opencode-slim.json` and is regenerated only via
`OMOS_RESET=true`.
## 7. MemPalace is the shared brain
MemPalace data is usually a **host bind-mount** (or an optional `devbox-palace`
named volume), so an opencode on the host and one in this container can share
one palace (SQLite WAL: many readers, one writer). Use it to persist the
deployment-specific facts this skill deliberately refuses to hardcode. Details
are in the `mempalace` skill — and the harness instruction shipped with this
image points you at it at session start.
## Checklist before acting in this environment
- [ ] Writing durable output? → `/workspace`, not the ephemeral layer.
- [ ] Using `dssh`/`dscp`/`ll` in the bash tool? → spell out the real command.
- [ ] Assuming a hostname / domain / nameserver / host OS? → stop, detect it.
- [ ] "Resolves but won't connect"? → check route *and* DNS (§3 + §4).
- [ ] `apt`/toolchain install? → tell the user it's ephemeral unless imaged.
- [ ] Shipping image-owned content opencode reads from `~/.config/opencode`? →
symlink it from `/usr/...`, never copy into the volume (§1).
@@ -0,0 +1,14 @@
# xterm-ghostty — alias of the maintained ncurses `ghostty` terminfo entry.
#
# Ghostty sets TERM=xterm-ghostty by default, but the ncurses terminfo
# database (Debian: ncurses-term) ships the entry under the name `ghostty`
# only — there is no `xterm-ghostty` alias, and no distro packages one. This
# thin alias makes xterm-ghostty resolve to the same upstream-maintained
# capability set, so SSH sessions from a Ghostty terminal work without
# vendoring Ghostty's full (Zig-generated) terminfo here.
#
# `use=ghostty` is resolved by `tic` at compile time against the base
# `ghostty` entry from ncurses-term (installed in Dockerfile.base before the
# compile step). Compiled with `tic -x`.
xterm-ghostty|Ghostty terminal emulator (xterm-ghostty alias),
use=ghostty,
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# check-base-hash.sh — guard the base-rebuild invariant.
#
# Every floating `ARG *_REF` consumed by Dockerfile.base MUST be folded
# into the base_tag hash in the docker-publish workflow. Otherwise a
# ref-only change to that dependency does not change the base hash, the
# Docker Hub probe finds the old base tag, and the base is NOT rebuilt —
# the dependency fix silently fails to land. This is the v1.1.2-class
# staleness footgun (then it was mempalace-toolkit; this guard stops the
# next one before it ships).
#
# Runs in CI (base-decide job) and locally: bash scripts/check-base-hash.sh
set -euo pipefail
cd "$(dirname "$0")/.."
WF=".gitea/workflows/docker-publish-split.yml"
DF="Dockerfile.base"
# Extract the hash-compute block: the `HASH=$( … ) | sha256sum | cut`
# brace-group in the "Compute base tag" step. This lives in a separate
# file from the workflow, so scanning $WF here is free of the self-match
# hazard an inline workflow step would have.
block=$(awk '/HASH=\$\(/{f=1} f{print} f && /cut -c1-12/{exit}' "$WF")
if [ -z "$block" ]; then
echo "::error::could not locate the HASH=\$( … ) | sha256sum block in $WF"
exit 1
fi
refs=$(grep -oE '^ARG [A-Z0-9_]+_REF' "$DF" | awk '{print $2}' | sort -u)
fail=0
for r in $refs; do
lc=$(printf '%s' "$r" | tr '[:upper:]' '[:lower:]')
if ! printf '%s' "$block" | grep -q "outputs.$lc"; then
echo "::error::Dockerfile.base declares '$r' but it is NOT folded into the base_tag hash in $WF."
echo "::error::Add echo \"\${{ needs.resolve-versions.outputs.$lc }}\" inside the HASH=\$( … ) | sha256sum block, or a $r-only change will silently fail to rebuild the base."
fail=1
fi
done
if [ "$fail" = 0 ]; then
echo "OK: all Dockerfile.base *_REF args are folded into base_tag (${refs:-none})."
fi
exit $fail
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Gitea-accurate guard against the "bash syntax under the default sh/dash
# shell" footgun. Ported from pi-devbox, where this class bit twice
# (ed49b8d resolve-versions; b7197e8/b33e9dc promote-base-latest, run 418).
# opencode-devbox has not been bitten yet — this is a PREVENTIVE guard so a
# future author can't reintroduce the class.
#
# WHY A CUSTOM CHECK AND NOT JUST actionlint:
# actionlint models *GitHub* Actions, whose default `run` shell is bash. It
# therefore assumes a step that omits `shell:` runs under bash, and does NOT
# flag `set -o pipefail` there. Gitea Actions' default is `sh` (dash), so the
# exact bug (omit `shell:`, use bash syntax) is invisible to actionlint.
# actionlint only fires when a step *explicitly* declares `shell: sh`.
#
# THE INVARIANT THIS ENFORCES:
# Every `run:` step in every .gitea/workflows/*.yml must resolve to an
# effective shell of `bash` — via the step's own `shell:`, a job-level
# `defaults.run.shell`, or a workflow-level `defaults.run.shell`. Any step
# that would fall through to Gitea's `sh` default is a FAILURE, because a
# future author adding bash syntax to it fails silently in CI.
#
# Pair this with actionlint (which catches explicit `shell: sh` + bash syntax,
# expression errors, and much else). Together they cover the class on Gitea.
set -euo pipefail
WF_DIR="${1:-.gitea/workflows}"
python3 - "$WF_DIR" <<'PY'
import sys, glob, os
try:
import yaml
except ImportError:
sys.stderr.write("ERROR: python3 yaml module missing (apt install python3-yaml)\n")
sys.exit(2)
wf_dir = sys.argv[1]
files = sorted(glob.glob(os.path.join(wf_dir, "*.yml")) + glob.glob(os.path.join(wf_dir, "*.yaml")))
if not files:
sys.stderr.write(f"ERROR: no workflow files under {wf_dir}\n")
sys.exit(2)
problems = []
for f in files:
with open(f) as fh:
doc = yaml.safe_load(fh) or {}
wf_shell = (((doc.get("defaults") or {}).get("run") or {}).get("shell"))
jobs = doc.get("jobs") or {}
for jname, job in jobs.items():
job = job or {}
job_shell = (((job.get("defaults") or {}).get("run") or {}).get("shell"))
steps = job.get("steps") or []
for i, step in enumerate(steps):
step = step or {}
if "run" not in step:
continue # `uses:` steps have no shell
eff = step.get("shell") or job_shell or wf_shell or "sh" # Gitea default = sh
if eff != "bash":
name = step.get("name") or f"step[{i}]"
problems.append(f"{f}: job '{jname}' / '{name}': effective shell = '{eff}' (Gitea default is sh; declare shell: bash or a bash default)")
if problems:
sys.stderr.write("Workflow shell guard FAILED — bash default not guaranteed:\n")
for p in problems:
sys.stderr.write(f" - {p}\n")
sys.exit(1)
print(f"Workflow shell guard OK — all run: steps in {len(files)} workflow file(s) resolve to bash.")
PY
+15 -11
View File
@@ -56,19 +56,25 @@ HUB_TEMPLATE = f"""# opencode-devbox
Portable AI developer environment for [opencode](https://opencode.ai). Debian-based, with git, SSH, Node.js, AWS CLI v2, and common dev tools pre-installed.
> **Current `:latest` ships opencode `{{{{OPENCODE_VERSION}}}}`** (the baked version is asserted by smoke tests, so this page never drifts from the image).
Designed for teams who want a reproducible coding-agent setup that runs the same on every laptop and CI runner without forcing each developer to install Bun, Node, AWS CLI, mempalace, or maintain shell config drift across machines.
## Image Variants
| Tag | Description |
|---|---|
| `latest` / `vX.Y.Z` | Base image opencode, Node.js, AWS CLI, dev tools |
| `latest` / `vX.Y.Z` | Base image opencode `{{{{OPENCODE_VERSION}}}}`, Node.js, AWS CLI, dev tools |
| `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun |
| `latest-with-pi` / `vX.Y.Z-with-pi` | Base + [pi](https://github.com/earendil-works/pi) as alternative/complementary harness (shares the mempalace install with opencode) |
| `latest-omos-with-pi` / `vX.Y.Z-omos-with-pi` | OMOS + pi together |
All variants support `linux/amd64` and `linux/arm64`.
> **Looking for pi?** As of v2.0.0 the pi coding-agent is no longer bundled in
> opencode-devbox. It ships as the dedicated
> [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox) image, which
> shares the same mempalace memory layer. See
> <https://gitea.jordbo.se/joakimp/pi-devbox>.
## Quick Start
For a fully-configured environment with persistent state (opencode config, mempalace memory, neovim plugins, bash history) surviving container recreation, use docker-compose. **You don't need to clone the repo** — just grab two template files:
@@ -82,7 +88,7 @@ curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.
docker compose run --rm devbox
```
This drops you straight into opencode with your project mounted at `/workspace`. Use `bash` as the command (e.g. `docker compose run --rm devbox bash`) to land in a shell first useful for `aws sso login`, `pi` (on `*-with-pi` variants), or multi-harness workflows.
This mounts your project at `/workspace`. With no command (as above) the image's default `CMD` (`bash -l`) drops you into a login shell — run `opencode` to start the harness, or `aws sso login` first, etc. To start opencode directly, pass it as the command: `docker compose run --rm devbox opencode`.
**One-shot run, no persistence:**
@@ -102,11 +108,10 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Bedroc
## What's Inside
- **[opencode](https://opencode.ai)** primary coding-agent harness. Multi-provider (Anthropic, OpenAI, Bedrock, Google, Groq, etc.).
- **[pi](https://github.com/earendil-works/pi)** *(in `*-with-pi` variants)* lightweight TUI coding-agent that coexists with opencode and shares the same mempalace install. Includes the `mcp-loader` extension so any local-stdio or remote streamable-HTTP MCP server (searxng, gitea, context7, ) can be added by editing `~/.pi/agent/settings.json`.
- **[mempalace](https://github.com/MemPalace/mempalace)** persistent AI memory layer (ChromaDB + SQLite). Wing/diary/knowledge-graph entries are mutually visible to opencode and pi.
- **[mempalace](https://github.com/MemPalace/mempalace)** persistent AI memory layer (ChromaDB + SQLite). Wing/diary/knowledge-graph entries are shareable with the sibling [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox) image when both point at the same palace.
- **[oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim)** *(in `*-omos` variants)* multi-agent orchestration on top of opencode (council, fallback chains, named agents).
- **AWS CLI v2** with SSO support, **Node.js LTS**, **Bun** (OMOS variants), **uv** (Python), **gosu** for clean UID/GID adjustment to match your host workspace.
- **MCP wrappers** for mempalace pre-installed and pre-wired to both harnesses.
- **MCP wrappers** for mempalace pre-installed and pre-wired to opencode.
## Authentication
@@ -124,8 +129,7 @@ Full Bedrock walkthrough (IAM roles, permissions, multi-account setups): see the
| Volume | Mount | Survives |
|---|---|---|
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild |
| `devbox-pi-config` | `~/.pi` | container recreate, image rebuild incl. user-installed pi packages via `pi install` (`NPM_CONFIG_PREFIX` points into the volume) |
| `devbox-opencode-config` | `~/.config/opencode` | container recreate, image rebuild incl. user-installed npm globals via `npm install -g` (`NPM_CONFIG_PREFIX` points into the volume) |
| `devbox-palace` (uncomment) | `~/.mempalace` | container recreate, image rebuild palace data is precious, treat as primary storage |
| `devbox-chroma-cache` | `~/.cache/chroma` | container recreate (model cache, disposable re-downloads in seconds) |
@@ -142,7 +146,7 @@ Full persistence reference, including multi-user (`SIGNUM`) isolation and host b
## Sibling images
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** pi-only image built on top of this image's base layer. Smaller (~700 MB) and version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly. Use this if you want pi without opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
- **[`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox)** the pi coding-agent in its own self-contained image, built on a shared Debian base. Version-tracks the [pi npm package](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) directly and can share this image's mempalace palace. Use it if you want pi instead of (or alongside) opencode. Source: <https://gitea.jordbo.se/joakimp/pi-devbox>
## License
@@ -150,7 +154,7 @@ MIT. See <{GITEA}/src/branch/main/LICENSE>.
---
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate.
> This description is generated by `scripts/generate-dockerhub-md.py` from a hand-maintained template. Edit the template (not this file) and regenerate. The `{{{{OPENCODE_VERSION}}}}` placeholder is filled by CI at publish time.
"""
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env bash
# Runtime post-recreate verification for opencode-devbox.
#
# Verifies that after `docker compose up -d --force-recreate`:
# - The new image is actually live (opencode version matches Dockerfile.variant)
# - Persisted named volumes survived (mempalace palace, opencode.db, bash-history)
# - OMOS runtime skill symlinks resolve (omos variant only)
# - Shell defaults re-seeded from /etc/skel-devbox
# - /opt toolkits intact
# - Known expected-absences don't regress
#
# This is repo/maintainer tooling — the runtime peer of smoke-test.sh. It is
# NOT baked into the published Docker Hub image; run it from a checkout of the
# opencode-devbox repo (which a maintainer already has for CI builds). A plain
# `docker pull` consumer is not the audience and will not have this file.
#
# Usage: ./scripts/recreate-sanity-check.sh [--expected-version X.Y.Z] [--variant base|omos]
#
# Exit codes:
# 0 all checks passed
# 1 one or more checks failed
# 2 usage error
set -euo pipefail
EXPECTED_VERSION=""
VARIANT=""
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--expected-version)
EXPECTED_VERSION="$2"
shift 2
;;
--variant)
VARIANT="$2"
shift 2
;;
*)
echo "usage: $0 [--expected-version X.Y.Z] [--variant base|omos]" >&2
exit 2
;;
esac
done
FAILED=0
pass() { echo "$1"; }
fail() { echo "$1" >&2; FAILED=$((FAILED + 1)); }
warn() { echo "$1" >&2; }
# Determine expected opencode version from Dockerfile.variant if not provided
if [ -z "$EXPECTED_VERSION" ]; then
EXPECTED_VERSION="$(grep -oE 'OPENCODE_VERSION=[0-9.]+' "$REPO_DIR/Dockerfile.variant" | head -1 | cut -d= -f2)"
if [ -z "$EXPECTED_VERSION" ]; then
echo "error: could not determine OPENCODE_VERSION from $REPO_DIR/Dockerfile.variant" >&2
exit 2
fi
fi
# Auto-detect variant if not provided
if [ -z "$VARIANT" ]; then
if command -v bun >/dev/null 2>&1 || [ -d /usr/lib/node_modules/oh-my-opencode-slim ] || [ -d /usr/local/lib/node_modules/oh-my-opencode-slim ]; then
VARIANT="omos"
else
VARIANT="base"
fi
fi
# Print header with git context
echo "=== Recreate sanity check (variant: $VARIANT) ==="
if GIT_TAG=$(git -C "$REPO_DIR" describe --tags 2>/dev/null); then
echo " Repo HEAD: $GIT_TAG (version-match only meaningful when image tag matches)"
else
echo " Repo HEAD: (not a git repo or no tags)"
fi
echo
echo "-- opencode version --"
if ACTUAL_VERSION=$(opencode --version 2>&1 | head -1); then
if [ "$ACTUAL_VERSION" = "$EXPECTED_VERSION" ]; then
pass "opencode version $ACTUAL_VERSION"
else
fail "opencode version mismatch: expected $EXPECTED_VERSION, got $ACTUAL_VERSION"
fi
else
fail "opencode --version failed"
fi
echo
echo "-- Persisted named volumes (must survive --force-recreate) --"
# mempalace palace volume
if [ -f "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
SIZE=$(du -h "$HOME/.mempalace/palace/chroma.sqlite3" | cut -f1)
if [ -s "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
pass "~/.mempalace/palace/chroma.sqlite3 exists ($SIZE)"
else
fail "~/.mempalace/palace/chroma.sqlite3 exists but is empty"
fi
else
fail "~/.mempalace/palace/chroma.sqlite3 missing"
fi
# opencode session history volume
if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then
SIZE=$(du -h "$HOME/.local/share/opencode/opencode.db" | cut -f1)
if [ -s "$HOME/.local/share/opencode/opencode.db" ]; then
pass "~/.local/share/opencode/opencode.db exists ($SIZE)"
else
fail "~/.local/share/opencode/opencode.db exists but is empty"
fi
else
fail "~/.local/share/opencode/opencode.db missing"
fi
# bash-history volume mount point (empty .bash_history right after recreate is NORMAL)
if [ -d "$HOME/.cache/bash" ]; then
pass "~/.cache/bash exists as directory"
else
fail "~/.cache/bash missing or not a directory"
fi
echo
echo "-- omos runtime skill symlinks (omos variant only; skip on base) --"
if [ "$VARIANT" = "omos" ]; then
SKILLS_OK=0
SKILLS_TOTAL=5
for skill in clonedeps codemap deepwork oh-my-opencode-slim simplify; do
SKILL_PATH="$HOME/.agents/skills/$skill"
if [ -L "$SKILL_PATH" ]; then
TARGET=$(readlink -f "$SKILL_PATH")
# Check if target resolves to a real directory and contains the expected path
if [ -d "$TARGET" ] && echo "$TARGET" | grep -q "node_modules/oh-my-opencode-slim/src/skills/$skill"; then
SKILLS_OK=$((SKILLS_OK + 1))
else
fail "~/.agents/skills/$skill symlink target invalid: $TARGET"
fi
else
fail "~/.agents/skills/$skill missing or not a symlink"
fi
done
if [ "$SKILLS_OK" -eq "$SKILLS_TOTAL" ]; then
pass "$SKILLS_OK/$SKILLS_TOTAL omos skill symlinks resolve"
fi
# Migration marker
if [ -f "$HOME/.config/opencode/.omos-skills-migrated" ]; then
pass "~/.config/opencode/.omos-skills-migrated exists"
else
fail "~/.config/opencode/.omos-skills-migrated missing"
fi
else
echo " - skipped (base variant)"
fi
echo
echo "-- Image-baked fallback skills + harness instruction (both variants) --"
# Baked under /usr/local/share and linked in by entrypoint-user.sh.
# opencode-devbox-environment uses a name unlikely to be overridden, so it is a
# reliable probe that the skills reconcile ran; the instruction confirms the
# ~/.config/opencode/instructions symlink survived the named volume.
if [ -e "$HOME/.agents/skills/opencode-devbox-environment/SKILL.md" ]; then
pass "~/.agents/skills/opencode-devbox-environment resolves"
else
fail "~/.agents/skills/opencode-devbox-environment missing"
fi
if [ -e "$HOME/.agents/skills/mempalace/SKILL.md" ]; then
pass "~/.agents/skills/mempalace resolves"
else
fail "~/.agents/skills/mempalace missing"
fi
if [ -e "$HOME/.config/opencode/instructions/opencode-devbox.md" ]; then
pass "~/.config/opencode/instructions/opencode-devbox.md resolves"
else
fail "~/.config/opencode/instructions/opencode-devbox.md missing"
fi
echo
echo "-- Shell defaults re-seeded from /etc/skel-devbox --"
if [ -f "$HOME/.bash_aliases" ]; then
pass "~/.bash_aliases exists"
else
fail "~/.bash_aliases missing"
fi
if [ -f "$HOME/.inputrc" ]; then
pass "~/.inputrc exists"
else
fail "~/.inputrc missing"
fi
echo
echo "-- cli_utils bind-mount --"
if [ -d /workspace/cli_utils ] && [ -d /workspace/cli_utils/.git ]; then
pass "/workspace/cli_utils exists with .git subdir"
else
fail "/workspace/cli_utils missing or .git subdir absent"
fi
echo
echo "-- Baked /opt toolkits --"
if [ -d /opt/mempalace-toolkit ]; then
if MEMPALACE_SESSION_PATH=$(command -v mempalace-session 2>/dev/null); then
RESOLVED=$(readlink -f "$MEMPALACE_SESSION_PATH")
pass "/opt/mempalace-toolkit exists, mempalace-session resolves to $RESOLVED"
else
fail "/opt/mempalace-toolkit exists but mempalace-session not on PATH"
fi
else
fail "/opt/mempalace-toolkit missing"
fi
echo
echo "-- Known expected-absences (regressions vs by-design) --"
if [ ! -d "$HOME/.local/bin" ]; then
warn "~/.local/bin absent — expected; mempalace toolkit relocated to /opt (not a wrapper-loss regression)"
else
pass "~/.local/bin exists (toolkit may have been installed locally)"
fi
if ! command -v go >/dev/null 2>&1; then
warn "go absent — expected unless image built with INSTALL_GO=true"
else
pass "go is on PATH"
fi
echo
if [ "$FAILED" -gt 0 ]; then
echo "=== FAILED: $FAILED check(s) ===" >&2
exit 1
fi
echo "=== PASSED ==="
+212 -70
View File
@@ -3,12 +3,17 @@
#
# Verifies:
# - Core binaries are on PATH and runnable
# - non-modal editors nano + micro are present (alongside nvim)
# - 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)
# - typst PDF engine for pandoc — `pandoc --pdf-engine=typst`
# - terminfo for modern emulators: xterm-kitty, xterm-ghostty, wezterm,
# alacritty, foot (kitty-terminfo + ncurses-term + compiled ghostty alias)
# - Neovim true-colour default (sysinit.vim termguicolors)
#
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos|with-pi|omos-with-pi]
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos]
#
# Exit codes:
# 0 all checks passed
@@ -23,13 +28,20 @@ if [ "${2:-}" = "--variant" ]; then
fi
if [ -z "$IMAGE" ]; then
echo "usage: $0 <image> [--variant base|omos|with-pi|omos-with-pi]" >&2
echo "usage: $0 <image> [--variant base|omos]" >&2
exit 2
fi
FAILED=0
pass() { echo "$1"; }
fail() { echo "$1" >&2; FAILED=$((FAILED + 1)); }
warn() { echo "$1" >&2; }
# Registration assertions for fork/recall were removed in v2.0.0 along with
# pi. STRICT_REGISTRATION is retained as an inert env var for backward
# compatibility with any external caller that still sets it; it has no
# effect now that no pi packages are deployed.
STRICT_REGISTRATION="${STRICT_REGISTRATION:-0}"
run() {
# Run a command inside the image and capture its output.
@@ -43,6 +55,25 @@ run() {
fi
}
# Stricter version of `run` that also asserts an expected substring in
# the command's stdout. Used to catch the "image bytes silently identical
# to previous release" class of regression — Docker layer-cache hit on
# a bare `npm install -g <pkg>` (or @latest) because the build-arg
# string is identical across builds, even when 'latest' would have
# resolved differently. Discovered in pi-devbox 2026-05-23 (every
# release v0.74.0..v0.75.5 shipped the same image bytes); preventatively
# applied here for PI_VERSION + OMOS_VERSION.
run_expect() {
local label="$1"; local cmd="$2"; local expect="$3"
local out
out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" 2>&1) || true
if echo "$out" | grep -Fq "$expect"; then
pass "$label (got $expect)"
else
fail "$label — expected substring '$expect', got: $out"
fi
}
echo "=== Smoke test: $IMAGE (variant: $VARIANT) ==="
echo
echo "-- Resolved component versions --"
@@ -53,9 +84,6 @@ docker run --rm --entrypoint="" "$IMAGE" sh -c '
if command -v opencode >/dev/null 2>&1; then
printf " %-15s %s\n" "opencode" "$(opencode --version 2>&1 | head -1)"
fi
if command -v pi >/dev/null 2>&1; then
printf " %-15s %s\n" "pi" "$(pi --version 2>&1 | head -1)"
fi
printf " %-15s %s\n" "node" "$(node --version)"
printf " %-15s %s\n" "npm" "$(npm --version)"
printf " %-15s %s\n" "nvim" "$(nvim --version | head -1)"
@@ -68,6 +96,8 @@ docker run --rm --entrypoint="" "$IMAGE" sh -c '
printf " %-15s %s\n" "rg" "$(rg --version | head -1)"
printf " %-15s %s\n" "gosu" "$(gosu --version)"
printf " %-15s %s\n" "git-lfs" "$(git-lfs --version)"
printf " %-15s %s\n" "git-crypt" "$(git-crypt --version 2>&1 | head -1)"
printf " %-15s %s\n" "gitleaks" "$(gitleaks version 2>&1 | head -1)"
printf " %-15s %s\n" "gitea-mcp" "$(gitea-mcp --version 2>&1 | head -1)"
printf " %-15s %s\n" "aws" "$(aws --version 2>&1)"
if command -v bun >/dev/null 2>&1; then
@@ -83,7 +113,7 @@ docker run --rm --entrypoint="" "$IMAGE" sh -c '
echo
echo "-- Core binaries --"
# opencode is gated on INSTALL_OPENCODE=true (default). When absent, the
# image is a pi-only build (or a pure base no harness at all).
# image is a pure base with no harness at all.
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v opencode" >/dev/null 2>&1; then
run "opencode" "opencode --version"
else
@@ -93,6 +123,12 @@ run "node" "node --version"
run "npm" "npm --version"
run "git" "git --version"
run "nvim" "nvim --version | head -1"
run "nvim true-colour default (sysinit.vim)" "nvim --headless -c 'lua os.exit(vim.o.termguicolors and 0 or 1)'"
run "kitty-terminfo" "infocmp -x xterm-kitty >/dev/null 2>&1"
run "terminfo: modern emulators (ncurses-term)" 'for t in wezterm alacritty foot ghostty st-256color; do infocmp -x "$t" >/dev/null 2>&1 || exit 1; done'
run "terminfo: xterm-ghostty alias (tic)" "infocmp -x xterm-ghostty >/dev/null 2>&1"
run "nano" "nano --version | head -1"
run "micro" "micro --version"
run "bat" "bat --version"
run "eza" "eza --version | head -1"
run "zoxide" "zoxide --version"
@@ -103,10 +139,51 @@ run "fzf" "fzf --version"
run "fd" "fd --version"
run "rg" "rg --version | head -1"
run "jq" "jq --version"
# yq MUST be mikefarah's Go yq v4, NOT Debian's `yq` apt package (the unrelated
# Python kislyuk/yq — a jq wrapper on a 3.x line with incompatible syntax). v2.9.0
# swapped the apt package for the mikefarah binary. Pinning the contract to major
# v4 makes BOTH a regression to the Python package AND a surprise future yq v5
# fail CI loudly, instead of silently breaking the cloud-init repo's
# provision.sh/deploy.sh which require v4 syntax.
run "yq is mikefarah v4" "yq --version | grep -qE 'mikefarah.*version v4' && yq --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"
run "pandoc" "pandoc --version | head -1"
run "typst" "typst --version"
# agent-browser + its Chromium. The ENV must point at a resolvable executable:
# AGENT_BROWSER_EXECUTABLE_PATH -> /usr/local/bin/agent-chrome -> Playwright's
# per-version, per-ARCH chrome binary. Asserting the resolved target is
# executable catches a Playwright layout change (the reason the Dockerfile
# `find`s the binary instead of hardcoding the path) rather than just checking
# that a dangling symlink exists.
run "agent-browser" "agent-browser --version"
run "agent-chrome resolves to an executable" \
"test -x \"\$(readlink -f /usr/local/bin/agent-chrome)\" && readlink -f /usr/local/bin/agent-chrome"
run_expect "AGENT_BROWSER_EXECUTABLE_PATH baked" \
"printenv AGENT_BROWSER_EXECUTABLE_PATH" "/usr/local/bin/agent-chrome"
run "pandoc+typst PDF engine" "printf '# hi\n' | pandoc --pdf-engine=typst -o /tmp/_smoke.pdf - && test -s /tmp/_smoke.pdf; rm -f /tmp/_smoke.pdf"
run "graphviz (dot)" "dot -V"
run "tldr (tealdeer)" "tldr --version"
run "dot-watch" "test -x /usr/local/bin/dot-watch && bash -n /usr/local/bin/dot-watch && echo ok"
# Background subagents: opencode gates them behind this experimental env var,
# and OMOS V2+ default orchestration depends on it. Baked ON as an ENV in
# Dockerfile.base — assert it's present in the image environment (both variants).
run_expect "bg-subagents env baked" "printenv OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS" "true"
# True-colour advertisement for colour-aware tools (bat, delta, Neovim's
# auto-detect). Pairs with the terminfo entries + sysinit.vim termguicolors.
run_expect "COLORTERM env baked" "printenv COLORTERM" "truecolor"
# 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 --"
@@ -128,70 +205,33 @@ elif docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev
echo " - mempalace-toolkit not installed (INSTALL_MEMPALACE_TOOLKIT=false)"
fi
# pi: present when built with INSTALL_PI=true. Verifies pi itself plus
# the runtime-deployed pi-toolkit + pi-extensions + mempalace bridge
# symlinks under ~/.pi/agent/. Note: extension symlinks are created by
# entrypoint-user.sh on first start, so we test by running the entry
# point chain (not just `docker run --entrypoint=""`).
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v pi" >/dev/null 2>&1; then
run "pi" "pi --version"
run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD"
run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD"
# Run the full entrypoint as developer to verify install.sh deployment.
# Spin up a long-running container so we can `docker exec` into it from
# the host — the `run` helper above invokes commands INSIDE the image
# and has no docker CLI to nest with.
CID=$(docker run -d --rm "$IMAGE" tail -f /dev/null)
trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT
# Wait for entrypoint-user.sh to finish deploying pi-toolkit + extensions.
# Marker: keybindings.json symlink lands once pi-toolkit/install.sh has run.
# Up to 30s — omos-with-pi has more setup work than base+pi.
for _ in $(seq 1 30); do
if docker exec "$CID" test -L /home/developer/.pi/agent/keybindings.json 2>/dev/null; then
break
fi
sleep 1
done
exec_test() {
local label="$1"; shift
local out
if out=$(docker exec -u developer "$CID" sh -c "$*" 2>&1); then
pass "$label ($(echo "$out" | head -1))"
else
fail "$label: $out"
fi
}
exec_test "~/.pi/agent/keybindings.json (pi-toolkit)" \
'test -L $HOME/.pi/agent/keybindings.json && echo ok'
exec_test "~/.pi/agent/extensions/*.ts ≥ 4 (pi-extensions)" \
'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"'
exec_test "~/.pi/agent/extensions/mempalace.ts (bridge)" \
'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok'
exec_test "~/.pi/agent/settings.json (template bootstrap)" \
'test -f $HOME/.pi/agent/settings.json && echo ok'
docker rm -f "$CID" >/dev/null 2>&1 || true
trap - EXIT
else
echo " - pi not installed (INSTALL_PI=false)"
fi
# bun: only in the omos and omos-with-pi variants
if [ "$VARIANT" = "omos" ] || [ "$VARIANT" = "omos-with-pi" ]; then
# bun: only in the omos variant
if [ "$VARIANT" = "omos" ]; then
run "bun (omos)" "bun --version"
run "bunx symlink (omos)" "test -L /usr/local/bin/bunx && readlink /usr/local/bin/bunx"
# oh-my-opencode-slim is npm-installed globally (not a bun install);
# verify it shows up in the global module list. We must explicitly point
# npm at the system prefix (/usr) here: the image's NPM_CONFIG_PREFIX env
# is set to /home/developer/.pi/npm-global so user-installed packages
# is set to /home/developer/.config/opencode/npm-global so user-installed
# packages
# land on the persistent volume — which means a default `npm ls -g`
# queries the user prefix and would miss the baked binaries even though
# they're correctly on PATH at /usr/bin.
run "oh-my-opencode-slim" "NPM_CONFIG_PREFIX=/usr npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim"
if [ -n "${EXPECTED_OMOS_VERSION:-}" ]; then
run_expect "omos version matches build-arg" \
"NPM_CONFIG_PREFIX=/usr npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim" \
"$EXPECTED_OMOS_VERSION"
fi
# OMOS bundled-skills SOURCE must be present at the fixed image path that
# entrypoint-user.sh symlinks into ~/.agents/skills/ on container start. If
# upstream restructures the package (moves src/skills), the runtime symlinks
# would dangle SILENTLY and the skills would just disappear — assert the
# source here so that breakage fails the build loudly instead. We check the
# source (not the runtime symlinks) because smoke tests run with
# --entrypoint="" and never execute entrypoint-user.sh.
run "omos bundled-skills source" \
"for n in clonedeps codemap deepwork oh-my-opencode-slim simplify; do test -d /usr/lib/node_modules/oh-my-opencode-slim/src/skills/\$n || exit 1; done && echo ok"
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"
@@ -200,6 +240,57 @@ else
fi
fi
echo
echo "-- Image-baked fallback skills + harness instruction --"
# Baked under /usr/local/share (base image, both variants). entrypoint-user.sh
# symlinks these into ~/.agents/skills/ and ~/.config/opencode/instructions/ on
# container start; assert the SOURCE here (smoke runs with --entrypoint="").
run "baked opencode-devbox-environment skill" \
"test -f /usr/local/share/opencode-devbox/skills/opencode-devbox-environment/SKILL.md && echo ok"
run "baked mempalace fallback skill" \
"test -f /usr/local/share/opencode-devbox/skills/mempalace/SKILL.md && echo ok"
run "baked harness instruction (opencode-devbox.md)" \
"test -f /usr/local/share/opencode-devbox/instructions/opencode-devbox.md && echo ok"
echo
echo "-- Build provenance (manifest + OCI labels) --"
run "/etc/opencode-devbox/build-manifest.json present" \
"test -f /etc/opencode-devbox/build-manifest.json"
run_expect "manifest records opencode component" \
"cat /etc/opencode-devbox/build-manifest.json" '"opencode"'
run_expect "manifest records opencode_version" \
"cat /etc/opencode-devbox/build-manifest.json" '"opencode_version"'
run_expect "manifest records mempalace-toolkit component" \
"cat /etc/opencode-devbox/build-manifest.json" '"mempalace-toolkit"'
run_expect "manifest records release_tag" \
"cat /etc/opencode-devbox/build-manifest.json" '"release_tag"'
# The manifest is only useful if something can READ it. v2.9.0 added
# opencode-devbox-version as that reader (and entrypoint-user.sh prints it at
# container start), so assert the command itself works in all three modes —
# otherwise the manifest stays an invisible artifact, which is what it was for
# every release before this one.
run_expect "opencode-devbox-version --json emits the manifest" \
"opencode-devbox-version --json" '"release_tag"'
run "opencode-devbox-version --quiet" "opencode-devbox-version --quiet"
run "opencode-devbox-version (human)" "opencode-devbox-version | head -1"
# Every resolved component must be a real value, never the 'unknown'
# sentinel that rev()/version lookups emit on failure. (oh-my-opencode-slim
# is JSON null in the base variant — that is expected, not 'unknown'.)
run "manifest has no unresolved ('unknown') components" \
"! grep -q '\"unknown\"' /etc/opencode-devbox/build-manifest.json"
if [ "$VARIANT" = "omos" ]; then
run "manifest omos component is resolved (not null) in omos variant" \
"! grep -q '\"oh-my-opencode-slim\": null' /etc/opencode-devbox/build-manifest.json"
fi
# OCI labels live in the image config, not the container fs — inspect them
# from the host docker rather than via `docker run`.
LBL=$(docker inspect --format '{{ index .Config.Labels "se.jordbo.opencode-devbox.opencode-version" }}' "$IMAGE" 2>/dev/null || true)
if [ -n "$LBL" ] && [ "$LBL" != "<no value>" ]; then
pass "OCI label se.jordbo.opencode-devbox.opencode-version=$LBL"
else
fail "OCI label se.jordbo.opencode-devbox.opencode-version missing or empty"
fi
echo
echo "-- Entrypoint behaviour --"
@@ -281,6 +372,37 @@ if docker run --rm \
else
fail "$label: existing config was modified!"
fi
# Proposed-config side-channel: when a config already exists, a NEWER default
# config is surfaced as a NON-loaded opencode.jsonc.proposed (write-on-diff,
# removed once the live config matches). The live config is never touched.
label="generate-config writes .proposed only when config differs"
if docker run --rm \
-e OPENCODE_PROVIDER=anthropic \
-e HOME=/tmp/home \
--entrypoint="" \
"$IMAGE" sh -c '
set -e
d=/tmp/home/.config/opencode
mkdir -p "$d"
gc=/usr/local/lib/opencode-devbox/generate-config.py
# (a) differing existing config → proposed written, live NOT clobbered
printf "{\n \"model\": \"old/model\"\n}\n" > "$d/opencode.jsonc"
python3 "$gc" 2>/dev/null
test -f "$d/opencode.jsonc.proposed"
grep -q "old/model" "$d/opencode.jsonc"
# (b) live matches defaults + stale proposed present → proposed removed
rm -f "$d/opencode.jsonc" "$d/opencode.jsonc.proposed"
python3 "$gc" 2>/dev/null
cp "$d/opencode.jsonc" "$d/opencode.jsonc.proposed"
python3 "$gc" 2>/dev/null
test ! -f "$d/opencode.jsonc.proposed"
echo ok
' 2>/dev/null | grep -q ok; then
pass "$label"
else
fail "$label: proposed-config behaviour incorrect"
fi
rm -rf "$tmp"
echo
@@ -289,20 +411,40 @@ SIZE_BYTES=$(docker image inspect --format='{{.Size}}' "$IMAGE")
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
echo " Uncompressed size: ${SIZE_MB} MB"
# Thresholds (uncompressed): base 2500 MB, omos 3300 MB, with-pi adds ~150 MB.
# Thresholds (uncompressed): base 2600 MB, omos 3300 MB.
# omos bumped 3000→3200 on v1.14.31c — mempalace-toolkit bake-in pushed the
# baseline; bumped 3200→3300 on v1.15.0 — opencode 1.15.0 came in at
# 3206 MB, leaving zero headroom for routine apt-get upgrade drift.
# omos-with-pi bumped 3400→3500 on v1.15.0 alongside the omos bump.
# omos-with-pi bumped 3500→3700 on v1.15.4b — omos+pi compounded as both
# upstream packages grew (opencode 1.15.0→1.15.4, pi 0.74.0→0.75.3) and
# the variant landed just over 3500 in v1.15.4's smoke.
# base 2500→2600 on v1.15.13c — base crept to 2506 MB (LAN-access script +
# updated entrypoint + routine apt-get upgrade drift), tripping the
# deliberately zero-headroom 2500 ceiling and skipping promote-base-latest.
# omos variant to ~3.1 GB. Functional smoke checks all pass; this is a
# guardrail, not a performance limit.
THRESHOLD=2500
[ "$VARIANT" = "omos" ] && THRESHOLD=3300
[ "$VARIANT" = "with-pi" ] && THRESHOLD=2700
[ "$VARIANT" = "omos-with-pi" ] && THRESHOLD=3700
# v1.16.2: all thresholds bumped +150 MB preemptively ahead of the combined
# opencode 1.15.13->1.16.2 (minor) + pi 0.78.1->0.79.0 (minor) bump. Both
# base (2506/2600) and omos (3206/3300) were sitting on ~94 MB headroom and
# a minor opencode bump has tripped them before (v1.15.0 omos). Restoring
# ~250 MB headroom avoids a partial-publish + letter-suffix recovery cycle.
# CI's smoke size print + resolved-versions table records the actual landed
# sizes; tighten later if they come in low.
# v2.6.0: bumped +200 MB (2750->2950 base, 3450->3650 omos). The pi-devbox
# CLI-parity additions (pandoc ~150 MB + graphviz ~40 MB + tealdeer ~5 MB, all
# on the base layer) consumed almost all of the ~250 MB headroom v1.16.2 had
# restored — v2.6.0 passed 2750/3450 but with a slim margin. Restoring ~250 MB
# headroom keeps the guardrail catching *runaway* growth (accidental texlive/
# chrome bake-in) rather than tripping on routine apt drift or a minor opencode
# bump. smoke still prints the actual landed size each run; tighten if low.
# v2.9.0: bumped +650 MB (2950->3600 base, 3650->4300 omos) for agent-browser +
# Playwright Chromium on the BASE layer (~625 MB after dropping the redundant
# chromium_headless_shell build). Sized to keep roughly the same ~250 MB of
# headroom the previous thresholds had, so the guardrail still catches *runaway*
# growth (an accidental texlive or a second browser) rather than tripping on
# routine apt drift. NOTE: do NOT copy pi-devbox's threshold number across — it
# sums `docker history` while this script uses `docker image inspect .Size`, so
# the two are not directly comparable. smoke prints the actual landed size every
# run; tighten these if they come in low.
THRESHOLD=3600
[ "$VARIANT" = "omos" ] && THRESHOLD=4300
if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then
fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT"
else
+45
View File
@@ -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