Compare commits

...

25 Commits

Author SHA1 Message Date
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
32 changed files with 2761 additions and 206 deletions
+25 -2
View File
@@ -31,6 +31,19 @@ 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
@@ -98,5 +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)
# ── 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
+10 -1
View File
@@ -75,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:
@@ -90,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
```
+109 -15
View File
@@ -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}"
@@ -121,23 +140,56 @@ jobs:
image: catthehacker/ubuntu:act-latest
outputs:
omos_version: ${{ steps.resolve.outputs.omos_version }}
mempalace_toolkit_ref: ${{ steps.resolve.outputs.mempalace_toolkit_ref }}
steps:
- name: Resolve omos version from npm registry
id: resolve
shell: bash
run: |
set -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.
OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.version')
OMOS_VERSION=$(curl -sf "https://registry.npmjs.org/oh-my-opencode-slim/latest" | jq -r '.version' 2>/dev/null || true)
if ! printf '%s' "${OMOS_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::Could not resolve oh-my-opencode-slim version from npm (got '${OMOS_VERSION:-<empty>}'). Refusing to build with an unresolved version."
exit 1
fi
echo "omos_version=${OMOS_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved OMOS_VERSION=${OMOS_VERSION}"
# Resolve mempalace-toolkit main HEAD to a commit SHA. Unlike omos
# (an npm pkg baked into the VARIANT), mempalace-toolkit is cloned
# in Dockerfile.base, so this SHA is ALSO folded into the
# base-decide hash to force a base rebuild when the toolkit moves
# (without it, a toolkit-only fix silently fails to land unless
# Dockerfile.base itself changes). gitea allows unauthenticated
# public-repo commit listing; the token header is harmless if the
# env vars are unset (degrades to anon, still HTTP 200).
MEMPALACE_TOOLKIT_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \
"https://gitea.jordbo.se/api/v1/repos/joakimp/mempalace-toolkit/commits?limit=1&sha=main" \
| jq -r '.[0].sha // empty' 2>/dev/null || true)
require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF"
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
echo "Resolved MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}"
# ── Phase 2: build & push base (multi-arch), only when needed ──────
build-base:
needs: [base-decide]
needs: [base-decide, resolve-versions]
if: needs.base-decide.outputs.need_build == 'true'
runs-on: ubuntu-latest
container:
@@ -185,6 +237,7 @@ jobs:
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
@@ -205,6 +258,7 @@ jobs:
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
@@ -266,6 +320,8 @@ jobs:
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
INSTALL_OPENCODE=true
INSTALL_OMOS=false
RELEASE_TAG=smoke
SOURCE_REVISION=${{ github.sha }}
- name: Smoke test (amd64)
run: bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base
@@ -309,6 +365,8 @@ jobs:
INSTALL_OPENCODE=true
INSTALL_OMOS=true
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
@@ -316,7 +374,7 @@ jobs:
# ── 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
@@ -355,8 +413,10 @@ jobs:
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
@@ -370,6 +430,10 @@ jobs:
--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"
@@ -425,8 +489,10 @@ jobs:
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: |
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.
@@ -440,6 +506,10 @@ jobs:
--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"
@@ -460,11 +530,16 @@ jobs:
- base-decide
- build-variant-base
- build-variant-omos
# 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":
@@ -473,8 +548,7 @@ jobs:
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
@@ -499,11 +573,31 @@ 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:
+68
View File
@@ -0,0 +1,68 @@
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
+37 -10
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
+53 -14
View File
@@ -18,34 +18,52 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d
- `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 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 setup.
- `rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh` — host-OS-agnostic LAN reachability helper. Detects VM-backed hosts (macOS OrbStack / Docker Desktop, via `host.docker.internal` resolution) and generates a writable `~/.ssh-local/config` using the host as an SSH jump; no-op on native Linux. Controlled by `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` / `DEVBOX_HOST_ALIAS` / `DEVBOX_LAN_AUTOJUMP_PRIVATE`. Ships the mechanism only (generic `host` jump alias); user targets stay host-side — named-peer `ProxyJump host` overrides go in a bind-mounted `~/.config/devbox-shell/ssh-lan.conf` (Included before `~/.ssh/config`), never baked into the image. **Scoping invariant:** every `Include` in the generated config MUST be preceded by a bare `Host *` reset — an `Include` is scoped to the enclosing `Host`/`Match` block, so without the reset the included config only applies when targeting `host`/`mac` and named peers fall back to SSH defaults. The top `Host *` block also overrides `UserKnownHostsFile` and `ControlPath` into the writable `~/.ssh-local` sidecar (first-value-wins), because the bind-mounted `~/.ssh` is read-only — otherwise multiplexed hosts (`ControlPath ~/.ssh/cm/...`) fail to create their master socket. Non-fatal. Counted in the base hash, so editing it advances `base-latest`.
- `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 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.
- **Pre-flight check before cutting any non-letter-suffixed tag** — verify the bump is real:
- **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 in your tag
npm view opencode-ai version # must equal the X.Y.Z you pin in Dockerfile.variant
```
If the npm version equals the *previous* release's `X.Y.Z`, you're cutting a letter-suffix rebuild (`vX.Y.Zc`, `vX.Y.Zd`, …), not a new minor. **A bare `vX.Y.Z` tag is a claim that opencode upstream just released `X.Y.Z`** — if that claim is wrong, future opencode releases will collide with your tag namespace and the version-tracking story breaks.
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.
Cautionary example: 2026-05-28 morning, `v1.15.12` was cut while opencode-ai was still at `1.15.11`. The commit message itself acknowledged "OPENCODE_VERSION stays at 1.15.11" but tagged `v1.15.12` anyway. Re-cut as `v1.15.11c` the same afternoon (see CHANGELOG). The `v1.15.12` git tag and Hub images stayed as historical artifacts; the slip cost a CI cycle and a CHANGELOG-rewrite. **Run the npm view check at the top of every release-day cut.**
CI produces four Docker Hub tags **under `opencode-devbox`** per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos` — one tag pair (versioned + floating alias) per variant (two variants: `base`, `omos`).
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.
@@ -58,7 +76,7 @@ When drafting a release CHANGELOG entry, pull notes from the **canonical upstrea
| `opencode-ai` (npm) | <https://github.com/anomalyco/opencode/releases> | Per-version release notes with Core / TUI / Desktop / SDK sections, contributor attributions. Some versions have empty bodies (internal/no-user-visible); most do not. |
| Other floated tools (gosu, fzf, bat, eza, zoxide, uv, nvim, gitea-mcp, Go, oh-my-opencode-slim) | Each project's own GitHub releases page | Usually less material per release; quote selectively. |
**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):
@@ -86,12 +104,16 @@ curl -s https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.15.10 |
- **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`.
- **`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.
- **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).
@@ -109,6 +131,23 @@ curl -s https://api.github.com/repos/anomalyco/opencode/releases/tags/v1.15.10 |
- **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:
+471 -2
View File
@@ -2,11 +2,480 @@
All notable changes to the opencode-devbox container image.
Tags follow `v{opencode_version}[letter]`bare tag for the first build on a new opencode release, letter suffix (`b`, `c`, …) for container-level rebuilds on the same version. See [AGENTS.md](AGENTS.md#versioning-scheme) for details.
Tags follow **independent semver** (since `v2.0.0`)they version *this image*, not the bundled opencode release. MAJOR = breaking run/config changes, MINOR = backward-compatible features, PATCH = opencode/tool version bumps and small fixes. Pre-`v2.0.0` tags used the older `v{opencode_version}[letter]` scheme. See [AGENTS.md](AGENTS.md#versioning-scheme) for details.
---
## Unreleased
## v2.6.0 — 2026-07-08
Minor release. Headline: **CLI-toolset parity with the sibling `pi-devbox`
image** (adds `yq`, `pandoc`, `graphviz`, `tealdeer`/`tldr`, and the `dot-watch`
wrapper) plus **background subagents enabled by default**
(`OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`), and an opencode bump
`1.17.13 → 1.17.15`. Touches `Dockerfile.base` (apt list, new binary, new ENV)
so the base image rebuilds (`base-<hash>` advances); the opencode bump rebuilds
the variant layers.
### Added
- **CLI tooling parity with pi-devbox.** Added `yq` (YAML query/transform,
pairs with `jq`), `pandoc` (Markdown↔HTML/etc. document converter), and
`graphviz` (`dot` diagram rendering) via apt; `tealdeer` (the `tldr` command —
quick command examples) as a ~5 MB static musl binary via the floated
`TEALDEER_VERSION` arg; and the `dot-watch` wrapper (`rootfs/usr/local/bin/dot-watch`,
auto-renders a `.dot` file to PNG on save, graphviz-only). Deliberately did
**not** port `socat`, `imagemagick`, or the pi-studio-specific `studio-expose`
bridge. smoke-test asserts presence of all five.
- **Background subagents enabled by default (`OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true`).**
Baked as a base-level `ENV` in `Dockerfile.base`. opencode gates native
background subagents behind this experimental flag; OMOS V2+ makes background
orchestration its default workflow, so the omos variant was effectively
degraded without it. Overridable at runtime (`-e OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=false`).
Documented in README env table and `.env.example`; smoke-test asserts the var
is present in the image environment. Marked experimental upstream — carries a
removal-trigger note in `Dockerfile.base` + AGENTS.md for when opencode
promotes it out of experimental (no upstream roadmap date as of opencode
1.17.15 / omos 2.1.0).
### Changed
- **opencode `1.17.13``1.17.15`.** v1.17.14 adds a code-mode MCP adapter for
confined orchestration scripts, hides the `execute` tool unless code mode is
enabled, and **fixes paginated MCP tool catalogs losing tool metadata +
output-schema validation** (relevant — this image wires the mempalace,
gitea-mcp, and context7 MCP servers). v1.17.15 is bugfixes + desktop/TUI UX
(Z.ai context-overflow error classification, macOS Sequoia titlebar, model
picker). No breaking changes. Release notes:
<https://github.com/anomalyco/opencode/releases>.
---
## v2.5.0 — 2026-07-02
Minor release. Headline: **optional shared/external MemPalace**
`generate-config.py` can now register mempalace as a `remote` MCP endpoint
(`MEMPALACE_REMOTE_URL`, optional `MEMPALACE_REMOTE_TOKEN` → Bearer) so one
palace can serve pi + opencode + native, instead of a per-container local
server; adds `docker-compose.mempalace.yml` for the shared server. Also fixes a
`validate.yml` false-red on base-changing commits (skips variant validation and
corrects the `rootfs/` base-input detector regex). opencode stays `1.17.13`; the
base image rebuilds (`rootfs/…/generate-config.py` changed), so the new config
generator lands in `base-<hash>`.
### Added
- **Optional shared/external MemPalace via `MEMPALACE_REMOTE_URL`.** MemPalace
can now be pointed at one shared HTTP endpoint instead of each container
running its own local server. Set `MEMPALACE_REMOTE_URL=http://<host>:8765/mcp`
(optionally `MEMPALACE_REMOTE_TOKEN`, sent as `Authorization: Bearer`) in
`.env`; `generate-config.py` then registers the `mempalace` MCP server as a
`remote` endpoint instead of the local `mempalace-mcp` stdio command (no local
server is spawned, and the `devbox-palace` volume becomes irrelevant). Leaving
it unset keeps the default local-per-container palace. Mirrors the same env
contract as pi-devbox's `mempalace.ts` bridge, so one palace can serve pi +
opencode + native.
- **`docker-compose.mempalace.yml`** — an optional shared MemPalace server
(`mempalace-mcp --transport http`) that several containers/harnesses can point
`MEMPALACE_REMOTE_URL` at. Binds to `127.0.0.1:8765` by default (the HTTP
transport is unauthenticated — keep it on a trusted network or behind a proxy).
### Fixed
- **`validate.yml` no longer reports a false failure on base-changing commits.**
`validate-base`/`validate-omos` build variants from the published
`base-latest`; when a commit changed base inputs *and* tightened
`smoke-test.sh` in lockstep (as v2.4.0's nano/micro did), those jobs
hard-failed against the still-stale `base-latest` until the release rebuilt it.
The `base-change-warning` job now exports a `base_changed` output and those two
jobs **skip** when base inputs changed, deferring full base validation to the
release path (`docker-publish-split.yml`). The scary red becomes a neutral skip
plus the existing warning. Also fixes the base-input detector itself: its
`^(…|rootfs/|…)$` regex anchored the whole alternation, so the `rootfs/` branch
only matched a file literally named `rootfs/` and never real paths like
`rootfs/usr/…/generate-config.py``rootfs/` is now a prefix match, so
rootfs-only base changes are detected too.
---
## v2.4.0 — 2026-07-01
Minor release. Adds two **non-modal editors** (`nano` + `micro`) alongside
nvim, ports **CI hardening** from pi-devbox (the sh-vs-bash workflow guard and
the `base-latest` digest-based promote fix), bakes a **global gitignore** into
the image, and bumps **opencode `1.17.10` → `1.17.13`**. Because
`Dockerfile.base` changed (nano + micro), this release rebuilds the base image.
### Changed
- **opencode `1.17.10``1.17.13`** (three upstream patches). Highlights:
session snapshots + revert controls (1.17.11); MCP OAuth reconnect/refresh
fixes, a TUI yolo auto-approve mode, and better default small models
(1.17.12); forced reasoning mode for OpenAI-compatible reasoning models plus
a GitHub Copilot stale-response-ID fix (1.17.13). Full notes:
<https://github.com/anomalyco/opencode/releases>.
### Added
- **Global gitignore baked into the image.** A `~/.gitignore_global`
(`*.bak`, `*.bak.*`, `*~`, `*.orig`, `*.swp`, `*.tmp`) is seeded into the home
dir from `/etc/skel-devbox/` on first boot (seed-if-absent, like
`.bash_aliases`/`.inputrc`, so user edits survive recreate) and wired via
`git config --global core.excludesFile`. Personal/tooling backup artifacts are
now ignored across all repos in the container without per-repo `.gitignore`
entries. The `core.excludesFile` wiring is skipped if the user already set one.
- **Non-modal editors `nano` + `micro` alongside `nvim`.** The image shipped
only nvim (`EDITOR=nvim`), a modal vi-style editor. Added both a classic and
a modern non-modal option for users who don't want vi keybindings:
- **nano** (apt): ~2.8 MB installed; its deps (libc6, libncursesw6,
libtinfo6) are already present via nvim/less/htop/tmux, so no extra
packages are 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 (<0.5% of the image). `EDITOR` stays `nvim`; both 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.
Base-image change, so it lands on the next `base-<hash>` rebuild. Ported from
pi-devbox 3a59e15.
- **Workflow-lint guard (`.gitea/workflows/lint.yml` + `scripts/check-workflow-shell.sh`).**
New cheap (~10s) lint workflow that runs on every push/PR (not just release
tags): a Gitea-accurate shell guard plus pinned `actionlint` + `shellcheck`.
The custom guard asserts every `run:` step in every `.gitea/workflows/*.yml`
resolves to an effective shell of `bash`, closing the actionlint blind spot
(actionlint models GitHub, whose default `run` shell is bash, so it does not
flag bash syntax in a step that merely omits `shell:` — the exact way the
sh-vs-bash footgun manifests on Gitea, whose default is `sh`/dash). Ported
from pi-devbox.
### Changed (CI)
- **Workflow-wide `defaults: run: shell: bash`** added to
`docker-publish-split.yml` and `validate.yml`. Gitea Actions' default step
shell is `sh` (dash), so bash-only syntax (`set -o pipefail`, `[[ ]]`,
arrays) in a step that forgets `shell: bash` fails silently. Setting the
default workflow-wide eliminates the whole class. All pre-existing steps use
only POSIX syntax, so bash (a superset) runs them unchanged — no behavioural
change. Preventive port from pi-devbox, where this class bit twice.
### Fixed (CI)
- **`promote-base-latest` re-points `base-latest` by digest, not `need_build`.**
The job gate keyed off `need_build == 'true'`, assuming `need_build == false`
meant `base-latest` was already current. A dry-run dispatch
(`promote_latest=false`) that pre-builds `base-<hash>` falsifies that: the
later tag run sees `need_build == false`, skips promotion, and leaves
`base-latest` one base behind. The gate now runs on every tag release /
promote dispatch, and the no-op optimization moved into the step as a `crane
digest` compare — it re-tags only when `base-latest` actually differs from the
released `base-<hash>` (genuine cache-hit releases stay a no-op). Workflow-only
change; base hash unaffected (no base rebuild). Ported from pi-devbox b7197e8
(which hit this on its v1.2.3 release, 2026-06-27).
---
## v2.3.0 — 2026-06-25
Minor release. Adds an **image-baked fallback skills + harness-instruction**
mechanism (ported from pi-devbox v1.2.0/v1.2.1, adapted to opencode's
`instructions/` model), bumps **opencode `1.17.8` → `1.17.10`** and **mempalace
`3.4.0``3.5.0`** (dropping the now-obsolete `diary_write` schema
workaround), and ports the one outstanding doc fix from pi-devbox's SSH sidecar
work. Defaults are unchanged when a skillset is mounted, so the canonical CI
build differs only by the version bumps and the additive image layer.
### Added: image-baked fallback skills + harness instruction
The image now ships two skills and one harness instruction under
`/usr/local/share/opencode-devbox/`, linked into place by `entrypoint-user.sh`
on every start, so a container behaves correctly **even with no `skillset` repo
mounted**:
- **`opencode-devbox-environment`** (authored here) — the container-shaped facts
an agent needs: the three persistence tiers (and why image-owned content must
live under `/usr` rather than a home dir the `devbox-opencode-config` volume
shadows), the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`/`cat``bat`
only exist in interactive bash), host + LAN SSH reachability via the
`~/.ssh-local` sidecar and ControlMaster, split-horizon DNS, uv-first Python,
and the OMOS variant. Adapted from pi-devbox's `pi-devbox-environment`, minus
the pi-only pieces (pi-studio, fork/recall).
- **`mempalace`** — a vendored snapshot of the skillset's consumer skill
(memory-continuity protocol). No `pi-extensions` skill is carried over —
opencode has no `fork`/`recall` extensions.
- **`instructions/opencode-devbox.md`** — symlinked into
`~/.config/opencode/instructions/`, which opencode auto-loads as a
session-start system prompt. It proactively points the agent at the
`opencode-devbox-environment` skill and the MemPalace continuity protocol, so
a fresh container picks them up rather than relying on description-matching.
Precedence is preserved: skills link **only-when-absent** (a mounted skillset or
an OMOS-bundled skill of the same name always wins), and the instruction uses a
distinct filename so it never collides with a mounted skillset's instructions.
Link targets live in the image, so `docker compose pull` + recreate refreshes
them for free — nothing is copied into the persistent config volume (which would
freeze it; cf. the OMOS-skills history in `docs/omos-skills.md`). Build-time
(`smoke-test.sh`) and runtime (`recreate-sanity-check.sh`) assertions cover both
the baked source and the resolved links. See
`rootfs/usr/local/share/opencode-devbox/skills/VENDORED.md`.
### Changed
- **opencode `1.17.8``1.17.10`.** Highlights across 1.17.91.17.10: MCP
server instructions are now added to session context; MCP resource template
listing + resource read tools; a new `--mini` CLI mode; skill base
directories emitted as filesystem paths instead of `file://` URLs; assorted
MCP/OAuth and agent-step-limit bugfixes. (Source: `anomalyco/opencode`
releases.)
- **mempalace `3.4.0``3.5.0`** (lockstep with pi-devbox v1.2.2). 3.5.0 ships
the upstream fix for the top-level-`anyOf` `diary_write` schema (issue #1728 /
PR #1717, merged 2026-06-14): the advertised schema is now
`"required": ["agent_name"]` with entry/content enforced at dispatch, which
the Anthropic tools API accepts.
### Removed
- **The `diary_write` top-level-`anyOf` workaround in `Dockerfile.base`.** The
`perl` patch of the installed `mcp_server.py` is gone now that 3.5.0 fixes it
at the source (verified against the published wheel).
### Fixed
- **Stale `ssh-lan.conf` guidance comment** in `setup-lan-access.sh`. The
`INCLUDE_BLOCK` comment previously implied LAN-peer `ProxyJump` overrides go
in `~/.ssh/config` (typically bind-mounted read-only); corrected to point at
the host-owned `~/.config/devbox-shell/ssh-lan.conf` (mirrors pi-devbox
`8de0fad`). Comment-only; no behavior change.
---
## v2.2.0 — 2026-06-19
Ports the build-provenance, CI-hardening, SSH and shell fixes that landed in
the sibling **pi-devbox** repo (v1.1.4v1.1.6) into opencode-devbox, adapted to
this image's companions and two-variant (`base`/`omos`) shape. Also bumps
opencode. Defaults are unchanged, so the canonical CI build stays byte-identical
apart from the opencode bump and the (cache-free) provenance layer.
### Fixed: read-only `~/.ssh` ControlPath / LAN sidecar on native Linux
`rootfs/usr/local/lib/opencode-devbox/setup-lan-access.sh` previously
`exit 0`-ed early on native-Linux hosts (`auto` mode, not VM-backed) **before**
rendering the writable `~/.ssh-local/config` sidecar. On such hosts with a
read-only `~/.ssh` bind-mount, `dssh`/`dscp` got no config and the `Host *`
ControlPath redirect into `~/.ssh-local/cm` never happened, so a user
`~/.ssh/config` carrying the CGNAT idiom `ControlPath ~/.ssh/cm/%r@%h:%p`
broke ControlMaster. The sidecar (ControlPath redirect + `Include
~/.ssh/config`) is now rendered on **every** host OS; only the jump-specific
blocks (host alias, key generation, peer overrides, RFC1918 catch-all) stay
gated behind a new `NEED_JUMP` flag. `Dockerfile.base` and `entrypoint-user.sh`
comments updated to document the always-render behavior and the
plain-`ssh <host>` caveat. (Mirrors pi-devbox v1.1.5; the pi-only
`ssh-controlmaster` extension layer has no opencode equivalent and is N/A.)
### Fixed: bash history loss in nested / tmux shells
`rootfs/home/developer/.bash_aliases` exported the `DEVBOX_HIST_SET` flush
guard, so it leaked into child processes — every nested shell (crucially each
tmux pane, which inherits the tmux server's env) saw the guard already set and
skipped installing `history -a` in `PROMPT_COMMAND`. Those shells only
persisted history on a clean exit, silently losing in-memory history on abrupt
termination (`docker stop`, `tmux kill-server`, SIGKILL). The guard is now
shell-local (dropped `export`). (Mirrors pi-devbox v1.1.4.)
### Added: build provenance — OCI labels + on-disk manifest
The variant build now bakes OCI labels
(`org.opencontainers.image.{version,revision,created}` +
`se.jordbo.opencode-devbox.{opencode-version,install-omos,omos-version,mempalace-toolkit-ref}`)
and writes `/etc/opencode-devbox/build-manifest.json` from **ground truth**
the live `opencode --version`, the installed `oh-my-opencode-slim` version
(JSON `null` in the `base` variant), and the actual checked-out HEAD of
`/opt/mempalace-toolkit` — so a published tag is self-describing and
reconstructable after CI logs rotate. Provenance ARGs (`RELEASE_TAG`,
`BUILD_DATE`, `SOURCE_REVISION`, re-declared `MEMPALACE_TOOLKIT_REF`) are
declared last in `Dockerfile.variant` so they never bust the expensive
npm-install layers. Wired into both `build-variant-*` and `smoke-*` jobs;
`scripts/smoke-test.sh` now asserts the manifest exists, is complete, has no
`unknown` components, and that the `opencode-version` OCI label is present.
(Mirrors pi-devbox v1.1.6.)
### Added: base-rebuild hash guard (`scripts/check-base-hash.sh`)
New CI guard (run first in the `base-decide` job) that fails the build if any
floating `ARG *_REF` consumed by `Dockerfile.base` is not folded into the
`base_tag` hash — preventing the v1.1.2-class staleness footgun where a
ref-only dependency change silently fails to rebuild the base. Passes today
(`MEMPALACE_TOOLKIT_REF` is already folded in); this is forward protection.
(Mirrors pi-devbox v1.1.6.)
### Changed: fail-loud version/ref resolution
The `resolve-versions` step now validates each resolved value — the
mempalace-toolkit ref must be a 40-hex commit SHA, the omos version must be
semver — and **aborts the release** on failure instead of silently falling
back to a floating `main` ref (which defeats both cache-busting and
reproducibility). The step also gains `shell: bash`, because `set -o pipefail`
is illegal under the runner's default dash/sh and would otherwise abort the
step (this exact latent bug bit pi-devbox's first v1.1.6 run). (Mirrors
pi-devbox v1.1.6.)
### Added: overridable `MEMPALACE_TOOLKIT_REPO` build-arg
`Dockerfile.base` no longer hardcodes the mempalace-toolkit clone URL inline;
it is now an `ARG MEMPALACE_TOOLKIT_REPO` defaulting to the canonical gitea
origin, so a relocated/forked build can repoint it via `--build-arg` without
editing the Dockerfile. Default unchanged. (Mirrors pi-devbox v1.1.6.)
### Bumped: opencode-ai 1.17.7 → 1.17.8
`OPENCODE_VERSION` ARG in `Dockerfile.variant`. `1.17.8` is the current npm
`latest` stable. Only the variant layer rebuilds; the base is unaffected.
### Added: opencode.json merge-on-recreate — non-destructive `.proposed` sidecar
The pi-devbox v1.1.4 deep-merge into a preserved `settings.json` does not port
cleanly here: opencode's config is *generated from env vars* and written as
JSONC with comments (not a static image-owned template), and overwriting or
`jq`-merging a possibly-bind-mounted host config is destructive. Instead,
`generate-config.py` keeps its "never touch an existing config" guarantee and
adds a non-destructive side-channel: when a live config exists, it writes
`opencode.jsonc.proposed` — the config it *would* generate for the current
environment plus this image's defaults — **only when that differs** from the
live config, and removes it once they match. opencode never loads a `.proposed`
file, so it is purely a manual-merge reference (e.g. surfacing a default MCP
server added in a newer image). A one-line hint is logged when one is written;
an unparseable live config surfaces the proposal rather than guessing. The
proposed config is regenerated from env + image defaults, so a diff may reflect
your own past edits as well as new image defaults — the file header says so.
Covered by a new `scripts/smoke-test.sh` assertion (write-on-diff, removal on
match, live config never clobbered).
---
## v2.1.2 — 2026-06-16
Image-semver **patch**: bumps opencode to `1.17.7`. No devbox-side changes
beyond the `OPENCODE_VERSION` ARG, so only the variant layer is rebuilt; the
base is unaffected by this change.
### Bumped: opencode-ai 1.17.6 → 1.17.7
`OPENCODE_VERSION` ARG in `Dockerfile.variant`. Upstream `1.17.7` (published
2026-06-14) is a bugfix-and-minor-improvement patch with no breaking,
runtime-dependency, bundled-Bun, or CPU/AVX changes — a pure version bump on
the devbox side. Upstream highlights:
- **Core (bugfixes):** plugin client requests now reuse the active server
instead of assuming the default local port; ACP shell tool calls show the
command and working directory from the start; plugin-provided shell
environment variables now apply to PTY sessions.
- **Core (improvements):** MCP servers can now receive the current workspace as
a client root.
- **TUI:** MCP debug now uses the SDK's latest protocol version.
- **Desktop:** the new-session route stays scoped to its own draft server, so
prompts and state target the right workspace.
- **SDK:** clients refresh model and provider availability when integrations
change; credential update and remove calls accept `location`.
Full notes:
<https://github.com/anomalyco/opencode/releases/tag/v1.17.7>.
## v2.1.1 — 2026-06-14
Image-semver **patch**: bumps opencode and lands the `mempalace-toolkit`
SHA-resolution CI fix plus two doc corrections that accumulated on `main` since
`v2.1.0`. The toolkit change folds a live SHA into the base-tag hash, so this
release carries a full base rebuild and a `base-latest` re-promote.
### Bumped: opencode-ai 1.17.5 → 1.17.6
`OPENCODE_VERSION` ARG in `Dockerfile.variant`. Upstream `1.17.6` (published
2026-06-13) is a single Core bugfix — *"Improved MCP server compatibility by
declaring OpenCode's supported client capabilities"* — with no breaking,
runtime-dependency, bundled-Bun, or CPU/AVX changes. Pure version bump on the
devbox side. Full notes:
<https://github.com/anomalyco/opencode/releases/tag/v1.17.6>.
### Changed
- **`mempalace-toolkit` is now CI-resolved to a commit SHA.** It is the only
dependency cloned in `Dockerfile.base` (everything else is in the variant),
so it bypassed the `resolve-versions` → build-arg plumbing and its ref stayed
a literal `main` — meaning a toolkit-only fix would silently fail to land
unless `Dockerfile.base` itself changed. Now `resolve-versions` resolves the
`mempalace-toolkit` `main` HEAD to a SHA (new `mempalace_toolkit_ref` output,
via the gitea commits API — unauthenticated, no secret needed), `base-decide`
folds that SHA into the base-tag hash (so a moved toolkit forces a base
rebuild) and now **depends on `resolve-versions`**, and `build-base` passes it
as `--build-arg MEMPALACE_TOOLKIT_REF`. The base clone switched from
`git clone --branch` to a SHA-capable `git fetch <ref> + checkout FETCH_HEAD`.
Trade-off: `base_tag` now reflects a live gitea lookup — an API blip falls
back to `main` and triggers one *extra* rebuild, never a *missed* one. Updated
`.gitea/README.md` Step 1 and `AGENTS.md` Critical conventions in lockstep.
### Docs (no image change)
- Correct the MemPalace `diary_write` anyOf workaround watch-target: upstream
PR #1735 was **closed unmerged** (2026-06-11), so the old “remove once #1735
ships” TODO pointed at a dead PR. Issue #1728 is still open; PR #1717 is the
current live candidate; mempalace PyPI latest is still 3.4.0 (== our pin), so
the workaround stays. Rewrote the `Dockerfile.base` tracking comment and added
a durable note under `AGENTS.md` Critical conventions.
- Fix the quick-start description in `README.md` and the Hub `HUB_TEMPLATE`
(`scripts/generate-dockerhub-md.py`, regenerated `DOCKER_HUB.md`): bare
`docker compose run --rm devbox` lands in a **login shell** (default `CMD` is
`bash -l`), not opencode. The old copy claimed the opposite and had a garbled
"Use `bash` instead of (no command)" half-sentence. Pass `opencode` explicitly
to start the harness directly. Doc-only — does not trigger a new image build.
## v2.1.0 — 2026-06-13
Image-semver **minor**: adds the OMOS bundled-skills image-symlink mechanism and
bumps opencode. `v2.0.0` decoupled image versioning from the opencode version
(see [AGENTS.md](AGENTS.md#versioning-scheme)); this is the first feature release
on the semver line, mirroring how the sibling `pi-devbox` repo moved to semver
after its own `v1.0.0` decouple.
### Bumped: opencode-ai 1.17.4 → 1.17.5
`OPENCODE_VERSION` ARG in `Dockerfile.variant`.
### Changed: OMOS bundled skills are now symlinked from the image, not copied
The five skills bundled with `oh-my-opencode-slim` (`clonedeps`, `codemap`,
`deepwork`, `oh-my-opencode-slim`, `simplify`) are now **symlinked from the
image** into `~/.agents/skills/` on every container start, instead of being
**copied** into `~/.config/opencode/skills/` once on first run by the OMOS
installer.
Why: the old copy landed in the persistent `devbox-opencode-config` volume and
was gated by config-existence, so it **froze** at whatever the image shipped on
first run — pulling a newer image never refreshed the skills, and the only
update path (`OMOS_RESET=true`) also clobbered the user's hand-tuned
`opencode.jsonc`. With the symlink approach the link target lives in the image,
so **`docker compose pull` + recreate updates the skills for free** — no
installer run, no config reset.
- `entrypoint-user.sh`: new OMOS bundled-skills reconcile block (runs after the
skillset deploy so OMOS wins the `simplify` name collision; absolute symlinks;
non-fatal; gated by `OMOS_SKILLS`, now independent of `ENABLE_OMOS` and the
presence of the bundled-skills source on the omos variant).
- `entrypoint-user.sh`: both `oh-my-opencode-slim install` calls now pass
`--skills=no` — the installer manages only `oh-my-opencode-slim.json`.
- One-time migration backs up (never deletes) any frozen real copies in
`~/.config/opencode/skills/` to `<name>.bak.<epoch>` (marker:
`~/.config/opencode/.omos-skills-migrated`).
- `scripts/smoke-test.sh`: asserts the bundled-skills source path exists on the
omos variant (catches an upstream package restructure).
- Docs: new `docs/omos-skills.md`; updated `README.md`, `AGENTS.md`,
`.env.example` (`OMOS_SKILLS` semantics).
**Editing `entrypoint-user.sh` advances the base hash** — this release carries a
full base rebuild and a `base-latest` re-promote.
## v2.0.0 — 2026-06-13
+1 -1
View File
@@ -34,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` or multi-agent 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:**
+114 -34
View File
@@ -35,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 \
@@ -45,6 +50,7 @@ RUN apt-get update && \
openssh-client \
gnupg \
jq \
yq \
ripgrep \
fd-find \
tree \
@@ -66,6 +72,9 @@ RUN apt-get update && \
rsync \
python3-pip \
python3-venv \
nano \
pandoc \
graphviz \
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -94,6 +103,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".' \
@@ -195,6 +213,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) && \
@@ -253,6 +298,22 @@ 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
# ── MemPalace — local-first AI memory system ─────────────────────────
# Provides semantic search over conversation history via 29 MCP tools.
# Always installed in the base (variant-independent). Set
@@ -263,7 +324,13 @@ ARG INSTALL_MEMPALACE=true
# diary_write top-level-anyOf schema (3.3.x/3.4.0) that breaks the
# Anthropic tools API; pinning makes every bump a deliberate, reviewable
# diff. Bump this in lockstep with pi-devbox's MEMPALACE_VERSION.
ARG MEMPALACE_VERSION=3.4.0
#
# 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 \
@@ -272,42 +339,35 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
fi
# ── workaround: strip top-level anyOf from mempalace_diary_write schema ──
# Mempalace 3.3.x/3.4.0 advertise diary_write's input_schema with a
# top-level `anyOf: [{required:[entry]}, {required:[content]}]` to express
# "either entry or content must be supplied". Anthropic's tools API rejects
# top-level anyOf/oneOf/allOf, so pi/Claude fail at session start with
# `tools.<n>.custom.input_schema: input_schema does not support oneOf,
# allOf, or anyOf at the top level`.
#
# Patch the advertised schema to require ["agent_name", "entry"] and remove
# the anyOf block. The handler keeps accepting `content` server-side as a
# kwarg alias so existing callers still work.
#
# Idempotent and self-deactivating: once upstream releases the fix the
# regex no longer matches and this RUN is a silent no-op.
# Upstream tracking:
# https://github.com/MemPalace/mempalace/issues/1728
# https://github.com/MemPalace/mempalace/pull/1735
# TODO: remove this RUN once a mempalace release containing PR #1735 is on
# PyPI and installed by the line above.
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
MP_FILE="$(find /opt/uv-tools/mempalace -path '*/mempalace/mcp_server.py' | head -n1)" && \
if [ -z "$MP_FILE" ]; then echo "mempalace mcp_server.py not found" >&2; exit 1; fi && \
perl -0777 -i -pe 's/(?:[ \t]*\#[^\n]*\n)*[ \t]*"required":\s*\[\s*"agent_name"\s*\]\s*,\s*\n[ \t]*"anyOf":\s*\[\s*\n[ \t]*\{\s*"required":\s*\[\s*"entry"\s*\]\s*\}\s*,\s*\n[ \t]*\{\s*"required":\s*\[\s*"content"\s*\]\s*\}\s*,?\s*\n[ \t]*\]\s*,\s*\n/ "required": ["agent_name", "entry"],\n/s' "$MP_FILE" && \
if grep -q '"required": \["agent_name", "entry"\]' "$MP_FILE"; then \
echo "mempalace diary_write anyOf workaround: applied (or already clean)"; \
else \
echo "WARN: mempalace diary_write anyOf workaround did not match expected schema — upstream may have changed shape" >&2; \
fi ; \
fi
# (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 && \
@@ -343,6 +403,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
@@ -419,14 +490,23 @@ 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
# ── 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
+55 -1
View File
@@ -39,7 +39,7 @@ 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.17.4
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 ; \
@@ -91,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.
+106 -13
View File
@@ -27,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`, `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:
@@ -146,8 +146,9 @@ 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
@@ -157,7 +158,7 @@ The devbox works the same way whether the host is **native Linux Docker** or a *
- **Native Linux Docker:** the host NATs container egress onto its LAN, so other devices on your LAN are reachable directly. Nothing to configure.
- **VM-backed (macOS / Docker Desktop):** the container runs in a Linux VM behind the host's network stack. The host's *directly-attached* LAN peers are **not** bridged into the container by default — only the host itself and *routed* subnets are reachable.
On every start the entrypoint detects which case applies. On VM-backed hosts it generates a writable `~/.ssh-local/config` that uses the **host as an SSH jump** to reach LAN peers; on native Linux it does nothing. The jump keypair lives in `~/.ssh-local`, which is persisted by the `devbox-ssh-local` named volume — so it's generated **once** and reused across container updates.
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):**
@@ -207,9 +208,9 @@ Host my-remote
### 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:
@@ -232,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:
@@ -241,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:
@@ -420,7 +448,7 @@ 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 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. |
@@ -457,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
@@ -465,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
@@ -567,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
@@ -601,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
@@ -746,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)
+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)
+10 -1
View File
@@ -36,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
@@ -128,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
+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.
+2 -1
View File
@@ -219,7 +219,8 @@ Add (for `with-pi`/`omos-with-pi`/pi-devbox):
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. ✓
`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
+138 -19
View File
@@ -12,12 +12,16 @@ set -euo pipefail
mkdir -p /tmp/sshcm
chmod 700 /tmp/sshcm
# ── LAN access: generic host-OS-agnostic reachability helper ────────
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
# reach the host's directly-attached LAN peers by default; this generates a
# writable ~/.ssh-local/config that uses the host as an SSH jump. On native
# Linux (LAN reachable directly) it is a no-op. Controlled by DEVBOX_LAN_ACCESS
# (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the script header.
# ── 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
@@ -29,7 +33,7 @@ fi
# 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
@@ -88,12 +92,20 @@ 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
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
@@ -118,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"
@@ -139,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
@@ -158,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
+8 -1
View File
@@ -89,9 +89,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
+11
View File
@@ -0,0 +1,11 @@
# 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
+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(
@@ -14,7 +14,9 @@
# The one thing reachable from a container on every OS is the host itself
# (host.docker.internal). So on VM-backed hosts we generate a writable SSH
# config that reaches the host and lets the user ProxyJump onward to LAN
# peers the host can reach. On native Linux we do nothing.
# 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
@@ -30,7 +32,9 @@
#
# CONTROLS (env)
# DEVBOX_LAN_ACCESS = auto (default) | jump | off
# auto → set up the jump config only on VM-backed hosts; no-op on Linux.
# 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
@@ -84,42 +88,72 @@ is_vm_backed() {
getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1
}
if [ "$MODE" = "auto" ] && ! is_vm_backed; then
# Native Linux host: LAN peers are reachable directly. Nothing to do.
exit 0
fi
# From here: MODE=jump, or MODE=auto on a VM-backed host.
command -v ssh-keygen >/dev/null 2>&1 || exit 0
# ── 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
# ── Jump key (generated once; preserved across restarts) ──────────────
# ── 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 [ ! -f "$KEY" ]; then
ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1 || exit 0
chmod 600 "$KEY" 2>/dev/null || true
KEY_JUST_GENERATED=1
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 ────────────────────────────────────────
USER_LINE=""
if [ -n "${HOST_SSH_USER:-}" ]; then
USER_LINE=" User ${HOST_SSH_USER}"
fi
# Optional host-owned named-peer jump overrides (portable: lives on the host,
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
# 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=""
if [ -r "$SSH_LAN_CONF" ]; then
LAN_CONF_BLOCK=$(cat <<'EOF'
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.
@@ -127,14 +161,13 @@ Host *
Include ~/.config/devbox-shell/ssh-lan.conf
EOF
)
fi
fi
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
# host. Matches the typed address, never the resolved HostName, so named hosts
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
AUTOJUMP_BLOCK=""
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
AUTOJUMP_BLOCK=$(cat <<'EOF'
# 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
@@ -146,6 +179,7 @@ Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22
ProxyJump host
EOF
)
fi
fi
INCLUDE_BLOCK=""
@@ -154,7 +188,9 @@ if [ -r "${HOME}/.ssh/config" ]; then
# Your own target hosts. Scope reset to match-all so this Include applies to
# every target (an Include is otherwise scoped to the enclosing Host block).
# Add 'ProxyJump host' to LAN entries here (or in ssh-lan.conf above).
# 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
@@ -176,17 +212,7 @@ Host *
UserKnownHostsFile ~/.ssh-local/known_hosts
StrictHostKeyChecking accept-new
ControlPath ~/.ssh-local/cm/%r@%h:%p
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
Host host mac
HostName ${HOST_ALIAS_HOSTNAME}
${USER_LINE}
IdentityFile ~/.ssh-local/devbox_jump_ed25519
IdentitiesOnly yes
ControlMaster auto
ControlPath ~/.ssh-local/cm/%r@%h:%p
ControlPersist 4h
ServerAliveInterval 30
${JUMP_BLOCK}
${LAN_CONF_BLOCK}
${AUTOJUMP_BLOCK}
${INCLUDE_BLOCK}
@@ -199,6 +225,7 @@ chmod 600 "$CONFIG" 2>/dev/null || true
# 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
@@ -221,5 +248,6 @@ elif [ "$KEY_JUST_GENERATED" = "1" ]; then
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).
+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
+1 -1
View File
@@ -88,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` or multi-agent 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:**
+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 ==="
+93
View File
@@ -3,6 +3,7 @@
#
# 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
@@ -118,6 +119,8 @@ run "node" "node --version"
run "npm" "npm --version"
run "git" "git --version"
run "nvim" "nvim --version | head -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"
@@ -128,12 +131,22 @@ 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 "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
@@ -180,6 +193,15 @@ if [ "$VARIANT" = "omos" ]; 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"
@@ -188,6 +210,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 --"
@@ -269,6 +331,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