Compare commits

..

54 Commits

Author SHA1 Message Date
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
43 changed files with 5110 additions and 869 deletions
+57 -30
View File
@@ -31,6 +31,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 +111,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
+57 -48
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:
@@ -32,8 +34,8 @@ The split-base architecture is what the `docker-publish-split.yml` workflow exer
│ │ probe Docker Hub.
│ hash inputs: │ (resolve-versions
│ Dockerfile.base│ runs in parallel:
│ rootfs/ │ npm view pi/omos
│ entrypoint*.sh │ → concrete versions)
│ 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,7 +75,13 @@ The split-base architecture is what the `docker-publish-split.yml` workflow exer
└──────────────────────────┘
```
### Step 1: `base-decide` (and `resolve-versions` in parallel)
### Step 1: `resolve-versions`, then `base-decide`
**`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:
@@ -88,6 +96,9 @@ the base image's 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
```
@@ -111,13 +122,12 @@ dependency between them) and resolves the floating npm packages whose
`*_VERSION` build-args default to `latest`:
```sh
PI_VERSION=$(npm view @earendil-works/pi-coding-agent version)
OMOS_VERSION=$(npm view oh-my-opencode-slim version)
```
The outputs (`pi_version`, `omos_version`) are consumed by every variant
smoke and build job that installs pi or omos. **Why this exists:** without
it, the `npm install -g` RUN layer in `Dockerfile.variant` hashes
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
@@ -125,9 +135,9 @@ cache-hit silent-regression class of bug that shipped pi-devbox v0.74.0
through v0.75.5 with identical image bytes (fixed in pi-devbox v0.75.5b
2026-05-23). Currently masked here by `OPENCODE_VERSION` bumping every
release (parent-chain cache-key invalidation), but masking would fail on
a `vN.N.Nb` opencode-version-unchanged release that only bumps pi or
omos. Smoke jobs additionally assert `EXPECTED_PI_VERSION` /
`EXPECTED_OMOS_VERSION` against the resolved values.
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)
@@ -139,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
@@ -165,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
@@ -174,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.
@@ -182,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`
@@ -194,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:
@@ -216,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
@@ -238,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
@@ -252,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.
+248 -267
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,45 +121,75 @@ jobs:
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} missing — will build."
fi
# ── Phase 1b: resolve floating npm versions (pi, omos) to concrete
# ── 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 PI_VERSION / OMOS_VERSION
# default to 'latest', the docker/build-push-action build-arg string
# is byte-identical across builds, so the resulting layer-hash is
# identical, so the registry buildcache silently reuses the layer
# from whatever pi/omos version was current when the cache was first
# populated. Same class of bug as pi-devbox v0.74.0..v0.75.5 (fixed in
# v0.75.5b 2026-05-23). Currently masked here because OPENCODE_VERSION
# is hard-coded in Dockerfile.variant and bumps every release —
# invalidating the parent-chain cache key for the pi/omos layers — but
# that masking would fail the moment we cut a vN.N.Nb opencode-version-
# unchanged release that only bumps pi or omos. Fix is preventative.
# 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:
pi_version: ${{ steps.resolve.outputs.pi_version }}
omos_version: ${{ steps.resolve.outputs.omos_version }}
mempalace_toolkit_ref: ${{ steps.resolve.outputs.mempalace_toolkit_ref }}
steps:
- name: Resolve pi + omos versions from npm registry
- name: Resolve omos version from npm registry
id: resolve
shell: bash
run: |
set -eu
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.
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version')
OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.version')
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
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 PI_VERSION=${PI_VERSION}, OMOS_VERSION=${OMOS_VERSION}"
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:
@@ -184,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
@@ -242,7 +320,8 @@ 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
@@ -285,108 +364,17 @@ jobs:
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=false
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
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
smoke-with-pi:
needs: [base-decide, build-base, resolve-versions]
if: |
always() &&
needs.base-decide.result == 'success' &&
needs.resolve-versions.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-with-pi
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
INSTALL_PI=true
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
- env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
run: bash scripts/smoke-test.sh opencode-devbox:smoke-with-pi --variant with-pi
smoke-omos-with-pi:
needs: [base-decide, build-base, resolve-versions]
if: |
always() &&
needs.base-decide.result == 'success' &&
needs.resolve-versions.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.variant
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-omos-with-pi
build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=true
INSTALL_PI=true
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
- env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
EXPECTED_OMOS_VERSION: ${{ needs.resolve-versions.outputs.omos_version }}
run: bash scripts/smoke-test.sh opencode-devbox:smoke-omos-with-pi --variant omos-with-pi
# ── 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
@@ -420,18 +408,45 @@ 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, resolve-versions]
@@ -468,118 +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
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
tags: ${{ steps.tags.outputs.tags }}
build-variant-with-pi:
needs: [base-decide, smoke-with-pi, resolve-versions]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
id: tags
- 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
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
tags: ${{ steps.tags.outputs.tags }}
build-variant-omos-with-pi:
needs: [base-decide, smoke-omos-with-pi, resolve-versions]
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- run: |
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
/usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3
with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host}
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute version-specific tags
id: tags
run: |
VERSION="${{ env.RELEASE_TAG }}"
{ echo "tags<<EOF"
echo "${IMAGE}:${VERSION}-omos-with-pi"
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
echo "${IMAGE}:latest-omos-with-pi"
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
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
OMOS_VERSION=${{ needs.resolve-versions.outputs.omos_version }}
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:
@@ -587,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
@@ -628,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/
@@ -659,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 }}"}' \
@@ -668,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 \
+92
View File
@@ -0,0 +1,92 @@
name: Lint workflows
# Durable guard against CI-workflow bugs — most importantly 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. This is cheap (~10s) and independent
# of the build pipeline, so it fires on every push/PR — not just on release
# tags, which is where docker-publish-split.yml is otherwise only triggered.
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
+37 -127
View File
@@ -35,6 +35,14 @@ on:
branches:
- main
# 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
@@ -52,13 +60,19 @@ jobs:
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 +80,29 @@ 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
# 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.yml fully exercises the published base-latest."
exit 0
fi
echo "::warning::This commit changes base-image inputs (Dockerfile.base, rootfs/, or entrypoint*.sh). validate.yml uses Hub's base-latest as the parent for variant builds, so the new base is NOT exercised by this workflow. Cut a release tag, or run a workflow_dispatch of docker-publish-split.yml against a test tag (e.g. v0.0.0-base-test, promote_latest=false) for end-to-end validation of the new base."
echo "Changed base-input files:"
git diff --name-only HEAD~1 HEAD | grep -E '^(Dockerfile\.base|rootfs/|entrypoint.*\.sh)$'
validate-base:
needs: [base-change-warning]
if: ${{ needs.base-change-warning.outputs.base_changed != 'true' }}
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
@@ -139,6 +164,8 @@ jobs:
bash scripts/smoke-test.sh opencode-devbox:ci-base --variant base
validate-omos:
needs: [base-change-warning]
if: ${{ needs.base-change-warning.outputs.base_changed != 'true' }}
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
@@ -195,120 +222,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
+75 -22
View File
@@ -4,31 +4,66 @@
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).
- `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/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` — cheap (~10s) workflow-lint on every push/PR/dispatch: a Gitea-accurate shell guard (`scripts/check-workflow-shell.sh`) plus pinned actionlint + shellcheck. The guard asserts every `run:` step resolves to `bash` under Gitea's `sh` default, closing the actionlint blind spot (actionlint models GitHub, whose default `run` shell is bash). 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** — whenever an opencode bump is part of the release,
verify it 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.
@@ -39,10 +74,9 @@ When drafting a release CHANGELOG entry, pull notes from the **canonical upstrea
| Package | Canonical upstream | What you'll find there |
|---|---|---|
| `opencode-ai` (npm) | <https://github.com/anomalyco/opencode/releases> | Per-version release notes with Core / TUI / Desktop / SDK sections, contributor attributions. Some versions have empty bodies (internal/no-user-visible); most do not. |
| `@earendil-works/pi-coding-agent` (npm) | The `CHANGELOG.md` shipped inside the npm tarball: `npm pack @earendil-works/pi-coding-agent@<version>` then extract `package/CHANGELOG.md`. | Rich changelog with New Features / Added / Changed / Fixed sections per version. |
| Other floated tools (gosu, fzf, bat, eza, zoxide, uv, nvim, gitea-mcp, Go, oh-my-opencode-slim) | Each project's own GitHub releases page | Usually less material per release; quote selectively. |
**Trap to avoid:** there is a `github.com/sst/opencode` repo that some search results surface; that's a fork (and probably the historical name people associate with opencode given the upstream lineage). It does NOT track the same release timeline. Use `anomalyco/opencode` for opencode release notes.
**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):
@@ -52,9 +86,6 @@ npm view opencode-ai time --json | python3 -c 'import sys,json,re; d=json.load(s
# Release notes for a specific version
curl -s https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.15.10 | python3 -c 'import sys,json; print(json.load(sys.stdin).get("body","(empty)"))'
# pi changelog
cd /tmp && npm pack @earendil-works/pi-coding-agent@0.75.5 && tar -xzf earendil-works-pi-coding-agent-0.75.5.tgz package/CHANGELOG.md && head -40 package/CHANGELOG.md
```
## Critical conventions
@@ -72,29 +103,51 @@ cd /tmp && npm pack @earendil-works/pi-coding-agent@0.75.5 && tar -xzf earendil-
**Between releases the same coupling applies.** Doc drift is not just a release-day concern — a workflow tweak, entrypoint change, or `generate-config.py` refactor can leave any of these four files lying. Before committing a non-release change, grep the docs for references to what you touched: `git diff --name-only HEAD | xargs -I{} grep -l 'thing-you-changed' README.md AGENTS.md DOCKER_HUB.md .gitea/README.md .env.example`. If a doc says "four variants" / "two phases" / "runs on amd64 only" and your change made that no longer true, fix it in the same commit.
- **GitHub/Gitea-sourced binaries float by default** — gosu, fzf, git-lfs, gitleaks, nvim, bat, eza, zoxide, uv, gitea-mcp, Go, oh-my-opencode-slim all default to `latest`. Each build-time install step reads the `/releases/latest` Location redirect (or the go.dev JSON feed for Go) and derives the concrete version. Use the same `ARCH` case-switch pattern for multi-arch support (amd64/arm64) — mind project-specific arch-name deviations (gitleaks uses `x64`, bat/eza/zoxide use `x86_64`/`aarch64`, gosu uses `amd64`/`arm64`). Intentional pins: `OPENCODE_VERSION` (drives the image tag), `NODE_VERSION=22` (major pin), `DEBIAN_VERSION=trixie-slim` (OS base). Adding a new upstream tool: follow the existing floated-version pattern, don't hardcode a specific tag.
- **Resolved versions are logged by the smoke test** — `scripts/smoke-test.sh` prints a "Resolved component versions" table as its first step. CI logs always capture what got baked into a given image even when ARGs default to `latest`.
- **`PI_VERSION` and `OMOS_VERSION` MUST be passed by CI as concrete versions**, not left at the `latest` default. The npm install steps in `Dockerfile.variant` (`npm install -g @earendil-works/pi-coding-agent` / `oh-my-opencode-slim@${OMOS_VERSION}`) produce identical layer-hashes when the ARG values are byte-identical across builds; combined with the registry buildcache (`base-buildcache`) the layer gets reused even when `latest` would have resolved to a newer upstream. This is the same class of bug that bit pi-devbox v0.74.0 → v0.75.5 (silent same-bytes-across-releases regression discovered 2026-05-23, fixed in pi-devbox v0.75.5b). It is currently *masked* in opencode-devbox by `OPENCODE_VERSION` being a hard-coded ARG that bumps every release — that bump invalidates the parent-chain cache key for the downstream pi/omos layers — but the masking would fail the moment a `vN.N.Nb` opencode-version-unchanged release ships that only bumps pi or omos. Preventative fix: `.gitea/workflows/docker-publish-split.yml` has a `resolve-versions` job that runs `npm view @earendil-works/pi-coding-agent version` and `npm view oh-my-opencode-slim version`, exposing concrete values as outputs that every variant smoke + build job consumes via build-args. Smoke tests assert via `EXPECTED_PI_VERSION` / `EXPECTED_OMOS_VERSION` env vars — would catch the regression on the next release rather than four releases later. **If you change the variant build-args list, the resolve-versions job, or the smoke EXPECTED_*_VERSION wiring, audit all affected jobs in lockstep.**
- **`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 + 5 variant push steps in `.gitea/workflows/docker-publish-split.yml`; smoke-test builds (`load: true`, no push) are untouched. **This does NOT mask deterministic failures** — a true regression (like the cache-export 400 of 2026-05-23..28) fails all 3 attempts identically and the job still fails. Orthogonal to the cache-export disablement above: cache-export was about a deterministic protocol mismatch, retry is about absorbing genuine transients. Both are belt-and-braces with the `ci-release-watcher` skill's transient-rerun heuristic. If you change the matrix of push steps, keep the retry wrapper consistent across them — the pattern is duplicated rather than factored out because Gitea Actions doesn't support reusable composite shell steps cleanly.
- **Shell scripts use `set -euo pipefail`** — both entrypoints are strict. Errors in volume chown or SSH permission operations are intentionally suppressed with `|| true`.
- **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.17.15 / omos 2.1.0 (2026-07). Documented in lockstep in README env table, `.env.example`, and asserted by `scripts/smoke-test.sh` (`bg-subagents env baked`).
- **MemPalace `diary_write` anyOf workaround — upstream watch target** — `Dockerfile.base` carries a perl RUN block that strips a root-level `anyOf` from `mempalace_diary_write`'s advertised `inputSchema`. Mempalace 3.3.x/3.4.0 advertise `anyOf: [{required:[entry]},{required:[content]}]`, 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. The workaround is idempotent and self-deactivating: when upstream ships the real fix the regex stops matching and the build prints `WARN: ... upstream may have changed shape` — **that WARN is the signal to delete the RUN block.** Upstream status (last checked **2026-06-14**): issue **#1728 is still OPEN**; PR **#1735 is CLOSED UNMERGED (2026-06-11) — do NOT watch it, it is dead**; PR **#1717 is the current live fix candidate**; mempalace PyPI latest is **3.4.0 (== our pin)**, so **no release contains the fix yet** and the workaround must stay. **Removal trigger:** a mempalace release **> 3.4.0** that actually strips the root-level `anyOf` lands on PyPI — then bump `MEMPALACE_VERSION` (in lockstep with pi-devbox) and drop the RUN block. NOTE: `MEMPALACE_VERSION` (the pip 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]`).
- `update-description` job runs when the base variant published (`needs: [build-variant-base, build-variant-omos]`, gated with `always()` + an explicit `build-variant-base.result == 'success'` check so a partial-publish run still refreshes the Hub description).
- Tags must be pushed to trigger the publish workflow. The validate workflow runs on push to main and PRs.
- Smoke tests run on amd64 only (single-arch load into the local daemon). The multi-arch push happens after smoke passes.
- **Gitea Actions runner has ~40 GB disk, often 70%+ used at job start.** All eight `load: true` jobs (`validate-base`, `validate-omos`, `validate-with-pi`, `validate-omos-with-pi`, `smoke-base`, `smoke-omos`, `smoke-with-pi`, `smoke-omos-with-pi`) include a `Reclaim runner disk` step that strips catthehacker-resident toolchains and prunes stale docker state before `setup-buildx-action`. Build jobs use a lighter version (push-by-digest doesn't need `docker system prune`). Don't remove these steps without testing on a fresh runner.
- **Gitea Actions runner has ~40 GB disk, often 70%+ used at job start.** All `load: true` jobs (`validate-base`, `validate-omos`, `smoke-base`, `smoke-omos`) include a `Reclaim runner disk` step that strips catthehacker-resident toolchains and prunes stale docker state before `setup-buildx-action`. Build jobs use a lighter version (push-by-digest doesn't need `docker system prune`). Don't remove these steps without testing on a fresh runner.
- **`docker/build-push-action@v7` with `platforms: linux/amd64,linux/arm64` handles multi-arch push natively in a single job** — produces a proper manifest list, no matrix or merge step needed. An earlier revision split into per-arch matrix jobs with digest artifacts, but that pattern requires `actions/{upload,download}-artifact@v4+` which Gitea Actions doesn't support (see below).
- **`actions/upload-artifact` and `actions/download-artifact` must stay at @v3 on Gitea.** v4+ uses a GitHub-Enterprise-specific Artifact API; runs fail with `GHESNotSupportedError`. If you need artifacts for a new reason (build logs, SBOMs, etc.), pin @v3 explicitly.
- **Step scripts run under `/bin/sh` (dash), not bash.** Avoid bash-isms like `${VAR//a/b}` parameter-pattern substitution; use POSIX alternatives (`tr`, `sed`) or declare `shell: bash` on the step.
- **`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).
## Testing changes
The smoke test (`scripts/smoke-test.sh`) is the canonical check and runs automatically in CI. To run locally:
+1092 -4
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.
+199 -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 \
@@ -46,6 +50,7 @@ RUN apt-get update && \
openssh-client \
gnupg \
jq \
yq \
ripgrep \
fd-find \
tree \
@@ -67,6 +72,12 @@ 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/*
@@ -95,6 +106,15 @@ RUN apt-get update && \
# 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".' \
@@ -196,6 +216,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) && \
@@ -254,25 +301,110 @@ 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
# ── 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.)
ARG MEMPALACE_VERSION=3.5.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 && \
@@ -308,6 +440,17 @@ ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
ENV EDITOR=nvim
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
# 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.17.15 / omos 2.1.0 (2026-07).
ENV OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true
# ── Node.js (required for opencode/pi/omos at variant build + MCP servers) ──
ARG NODE_VERSION=22
@@ -339,7 +482,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 \
@@ -359,31 +502,65 @@ 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
# 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/lib/opencode-devbox/*.py
# Start as root — entrypoint adjusts UID/GID then drops to developer
+77 -61
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}
@@ -36,56 +39,12 @@ ARG USER_NAME=developer
# 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.11
ARG OPENCODE_VERSION=1.17.15
RUN if [ "${INSTALL_OPENCODE}" = "true" ]; then \
NPM_CONFIG_PREFIX=/usr npm install -g opencode-ai@${OPENCODE_VERSION} && \
opencode --version ; \
fi
# ── Optional: pi coding-agent ────────────────────────────────────────
# pi-toolkit and pi-extensions are cloned into /opt/. entrypoint-user.sh
# runs each repo's install.sh on container start so symlinks land under
# ~/.pi/agent/ on the named volume.
# PI_VERSION should be passed explicitly by CI as a concrete version
# (resolved from `npm view @earendil-works/pi-coding-agent version`,
# see .gitea/workflows/docker-publish-split.yml § resolve-versions).
# The default `latest` is for local dev convenience only — it has a
# known cache-hit footgun when used in registry-cached CI builds: the
# resulting build-arg string is byte-identical across builds, the
# layer-hash is identical, and the registry buildcache silently reuses
# the layer from whatever pi version was current when the cache was
# first populated. Currently masked here because OPENCODE_VERSION (a
# parent layer) bumps every release; will manifest the moment a
# vN.N.Nb opencode-version-unchanged release ships. See pi-devbox
# v0.75.5b 2026-05-23 for the discovery + canonical fix.
ARG INSTALL_PI=false
ARG PI_VERSION=latest
ARG PI_TOOLKIT_REF=main
ARG PI_EXTENSIONS_REF=main
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
@@ -105,10 +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 shares the same cache-hit footgun as PI_VERSION when
# left at the `latest` default in registry-cached CI builds. CI
# resolves it via `npm view oh-my-opencode-slim version` and passes
# the concrete value as a build-arg. See PI_VERSION block above.
# 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 \
@@ -129,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.
+167 -89
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:
@@ -132,6 +134,10 @@ docker compose exec -u developer devbox aws --version
| `GIT_USER_EMAIL` | Git commit author email | — |
| `WORKSPACE_PATH` | Host path to mount | `.` |
| `SSH_KEY_PATH` | Host SSH key directory | `~/.ssh` |
| `DEVBOX_LAN_ACCESS` | LAN-access mode: `auto` (jump only on VM-backed hosts), `jump` (always), `off` | `auto` |
| `HOST_SSH_USER` | Username to SSH into the host as (required for the LAN jump) | — |
| `DEVBOX_HOST_ALIAS` | Hostname used to reach the container host | `host.docker.internal` |
| `DEVBOX_LAN_AUTOJUMP_PRIVATE` | `1` = ProxyJump *any* RFC1918 (private) IP through the host, so bare `dssh user@<ip>` works on whatever LAN the host is currently on | `0` |
| `USER_UID` | Override container user UID | Auto-detect from `/workspace` |
| `USER_GID` | Override container user GID | Auto-detect from `/workspace` |
| `LANG` | System locale | `en_US.UTF-8` |
@@ -140,15 +146,71 @@ docker compose exec -u developer devbox aws --version
| `EDITOR` | Default text editor | `nvim` |
| `ENABLE_OMOS` | Enable oh-my-opencode-slim multi-agent orchestration | `false` |
| `OMOS_TMUX` | Enable tmux pane integration for OMOS | `false` |
| `OMOS_SKILLS` | Install OMOS recommended skills on first run | `true` |
| `OMOS_RESET` | Force regenerate OMOS config on next start | `false` |
| `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 |
### 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 +233,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 +264,11 @@ volumes:
- ~/.config/nvim:/home/developer/.config/nvim:ro
```
> **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`.
### 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:
@@ -359,12 +448,9 @@ 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. |
@@ -399,7 +485,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 +493,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 +512,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 +595,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 +651,46 @@ 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.8 .
```
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
```
### Storage
@@ -761,8 +835,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, gitleaks, 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 (Markdown↔HTML/PDF/etc. conversion)
├── 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)
+48
View File
@@ -0,0 +1,48 @@
# 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) |
| 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.
+168 -51
View File
@@ -12,6 +12,20 @@ set -euo pipefail
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
@@ -19,13 +33,39 @@ chmod 700 /tmp/sshcm
# 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
@@ -52,52 +92,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.
@@ -120,6 +130,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"
@@ -141,15 +261,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
@@ -160,13 +279,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
@@ -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,18 +16,25 @@ 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
@@ -74,13 +84,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 +134,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 +251,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 +264,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 `8e8db64`.
@@ -0,0 +1,301 @@
---
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.
### 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 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,210 @@
---
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.
## 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 ==="
+137 -74
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.
@@ -72,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)"
@@ -104,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
@@ -114,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"
@@ -124,12 +139,24 @@ run "fzf" "fzf --version"
run "fd" "fd --version"
run "rg" "rg --version | head -1"
run "jq" "jq --version"
run "yq" "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"
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"
# SSH ControlMaster baked defaults: the config file must exist (image-level)
# and ssh -G must report ControlPath rooted at /tmp/sshcm/ for an arbitrary
@@ -158,70 +185,15 @@ 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
if [ -n "${EXPECTED_PI_VERSION:-}" ]; then
run_expect "pi version matches build-arg" "pi --version" "$EXPECTED_PI_VERSION"
else
run "pi" "pi --version"
fi
run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD"
run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD"
# 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.
@@ -231,6 +203,15 @@ if [ "$VARIANT" = "omos" ] || [ "$VARIANT" = "omos-with-pi" ]; then
"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"
@@ -239,6 +220,46 @@ 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"'
# 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 --"
@@ -320,6 +341,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
@@ -328,20 +380,31 @@ 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.
THRESHOLD=2950
[ "$VARIANT" = "omos" ] && THRESHOLD=3650
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