Compare commits

..

82 Commits

Author SHA1 Message Date
joakimp 9d7c3e5ad8 Bump opencode to 1.14.30
Validate / docs-check (push) Successful in 1m51s
Validate / validate-base (push) Successful in 13m37s
Validate / validate-omos (push) Successful in 23m0s
Publish Docker Image / build-base (push) Successful in 54m42s
Publish Docker Image / build-omos (push) Successful in 1h25m20s
Publish Docker Image / update-description (push) Successful in 20s
2026-04-30 15:11:49 +02:00
joakimp 23bae2ab7d Use mempalace-mcp entry point directly, drop redundant wrapper
Validate / docs-check (push) Successful in 20s
Validate / validate-base (push) Successful in 11m32s
Validate / validate-omos (push) Successful in 15m18s
Publish Docker Image / build-base (push) Successful in 53m5s
Publish Docker Image / build-omos (push) Successful in 1h11m3s
Publish Docker Image / update-description (push) Successful in 15s
The mempalace Python package ships a 'mempalace-mcp' console entry
point; 'uv tool install' places it on PATH as a shim whose shebang
points at the isolated venv's Python. Our hand-rolled wrapper at
/usr/local/bin/mempalace-mcp-server was duplicating what uv installs
for free — one less file to maintain.

Fixes the MCP error users saw after the v1.14.28b → v1.14.29 upgrade
path: custom opencode.json files typically had the pre-v1.14.29
command ['python3', '-m', 'mempalace.mcp_server'] which worked with
the old pip install but fails silently after the uv-tool migration
because system python3 cannot import from the venv. Opencode surfaced
this as 'MCP error -32000: connection closed'.

- generate-config.py now emits ['mempalace-mcp'] and keys its detect
  on shutil.which('mempalace-mcp').
- Dockerfile drops 'COPY rootfs/usr/local/bin/' and the chmod of the
  wrapper. Build shrinks from 30 to 29 stages.
- rootfs/usr/local/bin/ removed entirely.
- Smoke test asserts /usr/local/bin/mempalace-mcp is executable and
  prints its symlink target.
- README's MemPalace section shows ['mempalace-mcp'] and explicitly
  warns against the old pattern with the observed failure mode.
- CHANGELOG adds a v1.14.29c entry.
2026-04-29 15:27:30 +02:00
joakimp e0b6c2082f Add apt-get upgrade to core packages layer
Validate / docs-check (push) Successful in 12s
Validate / validate-base (push) Successful in 12m18s
Validate / validate-omos (push) Successful in 17m2s
Publish Docker Image / build-base (push) Successful in 52m23s
Publish Docker Image / build-omos (push) Successful in 1h8m34s
Publish Docker Image / update-description (push) Successful in 17s
Pair 'apt-get upgrade -y --no-install-recommends' with the existing
update + install in the first RUN step. Picks up security/CVE fixes
that land in the Debian repos between base-image rebuilds. Same layer
as the install to avoid bloating history; combined with apt-get clean
and rm -rf /var/lib/apt/lists/* at the end so no index cache is kept.

Today this is a no-op (debian:trixie-slim is current: 0 upgraded).
Future-proofs against the lag between a CVE fix being published and
the next base-image rebuild.
2026-04-29 10:25:36 +02:00
joakimp 2c889b472e Add --retry to all Dockerfile curl invocations
Validate / docs-check (push) Successful in 18s
Validate / validate-omos (push) Has been cancelled
Validate / validate-base (push) Has been cancelled
Publish Docker Image / build-base (push) Has been cancelled
Publish Docker Image / build-omos (push) Has been cancelled
Publish Docker Image / update-description (push) Has been cancelled
The first v1.14.29b build attempt failed with an HTTP 502 from
GitHub's release CDN mid-download of zoxide. Single-shot curl had no
retry, so one transient 502 failed the entire OMOS build.

- curl --retry 5 --retry-delay 5 --retry-all-errors on every tool
  download (both -fsSL GETs and -sI HEAD redirect lookups).
- [ -n "$V" ] assertion after each version-resolution step, so a
  failed HEAD lookup fails fast with a clear message instead of
  producing an empty tag that then 404s on the download URL.
- Same hardening applied to the optional Go install block and the
  nodesource setup_22.x pipe.
2026-04-29 10:14:42 +02:00
joakimp 349bb633ff Fix OMOS bunx detection
Validate / docs-check (push) Successful in 1m13s
Validate / validate-omos (push) Successful in 15m30s
Validate / validate-base (push) Successful in 17m11s
Publish Docker Image / build-omos (push) Failing after 14m43s
Publish Docker Image / update-description (push) Has been cancelled
Publish Docker Image / build-base (push) Has been cancelled
entrypoint-user.sh gated OMOS auto-install on 'command -v bunx', but
neither upstream bun installer nor our Dockerfile creates a bunx
symlink — only the bun binary exists on PATH. The check always failed
on a fresh OMOS image, printing 'ENABLE_OMOS=true but bun is not
installed.' even though bun was right there. Latent until now because
the only exercised path had a persisted oh-my-opencode-slim.json from
a prior install.

Fixes:
- Gate on 'command -v bun' instead of bunx.
- Call 'bun x oh-my-opencode-slim@latest install ...' (bun x is the
  real subcommand that actually works with only the bun binary).
- Add 'ln -sf bun /usr/local/bin/bunx' in the Dockerfile OMOS block
  so interactive users can still type bunx by habit.
- Smoke test asserts the bunx symlink exists on the OMOS variant.

Also verify 'test -L /usr/local/bin/bunx' as a build-time sanity check.
Can't use 'bunx --help' for that — bun exits 1 on help output even
though it prints the usage correctly.
2026-04-29 09:01:23 +02:00
joakimp 3b3533d40b Bump opencode to 1.14.29
Validate / docs-check (push) Successful in 1m17s
Validate / validate-omos (push) Successful in 13m57s
Validate / validate-base (push) Successful in 16m39s
Publish Docker Image / build-base (push) Successful in 53m13s
Publish Docker Image / build-omos (push) Successful in 1h9m16s
Publish Docker Image / update-description (push) Successful in 13s
Also finalizes the CHANGELOG Unreleased section as v1.14.29 — 2026-04-28.
This is the first release to carry the infrastructure pass from the
preceding commit: floating upstream versions, uv-tool-installed
mempalace, sentinel-based chown optimization, and the new CI validate
workflow with smoke tests.
2026-04-28 23:28:53 +02:00
joakimp 113c9f0bb0 Infrastructure pass: CI smoke tests, floating versions, chown sentinel, generate-config script
Main changes:

- Extract opencode.json generation from entrypoint-user.sh into a
  standalone Python script (rootfs/usr/local/lib/opencode-devbox/
  generate-config.py). Preserves the never-overwrite-existing-config
  guarantee. Cuts entrypoint-user.sh from 176 to 97 lines.

- Install MemPalace via 'uv tool install' into an isolated venv at
  /opt/uv-tools/mempalace/ with a /usr/local/bin/mempalace-mcp-server
  wrapper, replacing the 'pip install --break-system-packages' escape
  hatch. The wrapper is what generate-config.py references in the
  auto-generated opencode.json. Also fix 'mempalace init' in
  entrypoint-user.sh to use --yes so first-start initialization isn't
  interactive (this used to hang or print prompts into the user's
  terminal). Gated by INSTALL_MEMPALACE build arg (default true) so
  users who don't need AI memory can shave ~300 MB.

- Sentinel-file pattern in entrypoint.sh volume-ownership loop: write
  .devbox-owner after a successful chown -R, skip the recursive walk
  on subsequent starts when the sentinel matches FINAL_UID:FINAL_GID.
  Cuts multi-second startup costs to milliseconds on large volumes
  (nvim plugins, palace data). UID changes still trigger a full chown.

- Float all GitHub/Gitea-hosted binary versions: gosu, fzf, git-lfs,
  neovim, bat, eza, zoxide, uv, gitea-mcp now default to 'latest' and
  resolve the newest upstream release at build time via the /releases/
  latest redirect. Go (go.dev JSON feed) and oh-my-opencode-slim (npm
  @latest) likewise. Intentional pins still in place: OPENCODE_VERSION,
  NODE_VERSION=22, DEBIAN_VERSION=trixie-slim. Each *_VERSION ARG
  accepts an explicit value to lock a specific version when needed.

- New scripts/smoke-test.sh verifies binary presence, opencode startup,
  entrypoint user drop, generate-config idempotency, bun's presence-
  per-variant, and image size against thresholds (2500 MB base, 3000
  MB OMOS). Prints resolved component versions as its first step so
  CI logs always record what got baked into a given image.

- New .gitea/workflows/validate.yml runs on push to main and PRs:
  single-arch amd64 build, smoke test, DOCKER_HUB.md sync check. Tag-
  triggered docker-publish.yml now smoke-tests each variant on amd64
  before the full multi-arch push.

- scripts/generate-dockerhub-md.py auto-generates DOCKER_HUB.md from
  README.md using explicit SECTION_RULES. --check mode fails CI when
  the committed file is out of sync. Enforces the 25 kB Docker Hub
  limit. Adding a new README section forces an explicit keep/drop/
  replace decision.

- Remove dead INSTALL_PYTHON build arg (was a no-op since mempalace
  added python3 unconditionally).
2026-04-28 23:28:43 +02:00
Joakim Persson 4efc4e8005 Remove dead INSTALL_PYTHON build arg
Python 3 has been unconditionally present since the Debian trixie
upgrade (e58962a, Apr 13) — python3 3.13 ships as a transitive
dependency of the trixie base image. python3-pip (e1029bb) and
python3-venv (3a7ec45) were later added to the base layer on Apr 23
so Mason could install Python-based LSPs (ruff, ansible-lint) into
venvs on nvim startup. MemPalace's pip install (b9c08c3) just
piggybacks on what was already there.

In other words, INSTALL_PYTHON=true has been a no-op reinstall of
already-installed packages for two weeks before MemPalace existed.
The flag is dead weight and the docs that advertise it as meaningful
are misleading. Remove it everywhere.

Users who want Python tooling should use the pre-installed uv/uvx.
2026-04-28 06:56:40 +00:00
joakimp 49fad7cad9 Update sample docker-compose.yml in DOCKER_HUB.md
The sample was missing volumes and env vars added since the original:
shell-history, zoxide, nvim-data, palace, chroma-cache, and the
GitHub/Gitea token forwarding env vars. Now matches the actual
docker-compose.yml shipped in the repo.
2026-04-27 23:58:48 +02:00
joakimp ca44da71e1 Add official Gitea MCP server to base image
Publish Docker Image / build-base (push) Successful in 47m50s
Publish Docker Image / build-omos (push) Successful in 1h2m18s
Publish Docker Image / update-description (push) Successful in 14s
Install gitea-mcp v1.1.0 (Go binary from gitea.com/gitea/gitea-mcp)
using the same multi-arch pattern as gosu/fzf/bat. Provides 50+ MCP
tools for Gitea API — repos, issues, PRs, releases, branches, wiki,
and Actions.

Disabled by default in auto-generated opencode.json (requires
GITEA_ACCESS_TOKEN and GITEA_HOST to be useful). Users enable it by
setting those env vars in .env and flipping enabled to true in their
opencode.json.

Env vars forwarded into the container via docker-compose.yml and
docker-compose.shared.yml environment blocks. Same {env:VAR} pattern
as the GitHub MCP server.

Docs updated: README.md (new Gitea MCP section with setup steps),
DOCKER_HUB.md (tools list), CHANGELOG.md (v1.14.28b entry).
2026-04-27 22:28:15 +02:00
joakimp 8e605e87d4 Add chroma cache to entrypoint chown loop, finalize v1.14.28b changelog
Publish Docker Image / build-base (push) Has started running
Publish Docker Image / update-description (push) Has been cancelled
Publish Docker Image / build-omos (push) Has been cancelled
The ChromaDB embedding model cache at ~/.cache/chroma was missing
from the volume ownership-fix loop in entrypoint.sh. Without it,
the devbox-chroma-cache named volume would be root-owned on first
creation and mempalace search would fail with permission errors.

Update CHANGELOG.md from 'Unreleased' to v1.14.28b with the full
mempalace feature set (MCP auto-registration, two-volume split).
2026-04-27 21:45:16 +02:00
joakimp 7a8de0463f Separate ChromaDB model cache into its own named volume
The ONNX embedding model (~79 MB) downloads to ~/.cache/chroma/ on
first mempalace search. Without persistence it re-downloads on every
container recreation. Add a separate devbox-chroma-cache volume
rather than mixing it into the palace data volume — model cache is
disposable (delete and re-download), palace data is precious (back
up and migrate). Both volumes are commented out by default (opt-in).

Updated README.md storage section to explain the two-volume split
and the air-gapped pre-population path. Added chroma cache row to
DOCKER_HUB.md data storage table.
2026-04-27 20:05:45 +02:00
joakimp adaf7ba2ff Auto-register mempalace MCP server in generated opencode.json
When entrypoint-user.sh generates a fresh opencode.json (from
OPENCODE_PROVIDER env var), post-process it to add the mempalace
MCP server config if mempalace is installed. Uses python3 for safe
JSON merging — works for all 4 provider variants without duplicating
the mcp block in each heredoc.

The MCP server gives opencode access to 29 mempalace tools (search,
knowledge graph, diaries, wing/room/drawer management) with zero
manual config. Users who mount their own opencode.json are unaffected
(the generation block only runs when no config file exists).
2026-04-27 19:58:36 +02:00
joakimp d426e92745 Ignore .docker/ buildx state directory 2026-04-27 19:29:54 +02:00
joakimp b9c08c3dbb Add MemPalace local-first AI memory system to base image
Install mempalace via pip in the Dockerfile. Provides 29 MCP tools
for semantic search over conversation history, knowledge graph
queries, agent diaries, and wing/room/drawer management. Everything
runs locally — no API keys, no data egress.

Integration:
- Dockerfile: pip install mempalace (with --break-system-packages
  for Debian trixie PEP 668 compliance)
- entrypoint-user.sh: auto-initializes palace for /workspace on
  first run (idempotent, skips if palace exists)
- entrypoint.sh: adds ~/.mempalace to the volume ownership-fix loop
- docker-compose.yml + shared: optional devbox-palace named volume
  at ~/.mempalace (commented out by default — user opts in)

Users configure MCP integration by adding a mempalace server entry
to their opencode.json. No wrapper plugin needed — the upstream
Python MCP server is used directly.

Docs updated: README.md (new MemPalace section with setup, MCP
config, usage examples, storage details), DOCKER_HUB.md (data
storage table + tools list), CHANGELOG.md (unreleased entry).
2026-04-27 19:25:38 +02:00
joakimp 45d7e02faf Bump opencode to 1.14.28
Publish Docker Image / build-omos (push) Successful in 47m20s
Publish Docker Image / build-base (push) Successful in 50m13s
Publish Docker Image / update-description (push) Successful in 20s
2026-04-27 18:50:57 +02:00
joakimp 4de0bc9993 Document CI runner Docker pruning setup in deploy/README.md
Publish Docker Image / build-base (push) Successful in 44m32s
Publish Docker Image / build-omos (push) Successful in 54m49s
Publish Docker Image / update-description (push) Successful in 20s
Gitea Actions runners accumulate buildkit cache, stale containers,
and unused images. Without periodic cleanup the disk fills and builds
stall during image push (observed: build-omos hung at 'pushing layers'
for 1.5h on a 77%-full disk).

Add a 'CI runner maintenance' section to deploy/README.md with two
cleanup layers: a daily cron job (prunes anything >72h old) and
Docker daemon builder GC (caps buildkit cache at 10 GB).
2026-04-25 19:35:33 +02:00
joakimp b648d83928 Bump opencode to 1.14.25
Publish Docker Image / build-base (push) Successful in 40m15s
Publish Docker Image / build-omos (push) Failing after 1h45m55s
Publish Docker Image / update-description (push) Has been skipped
2026-04-25 17:36:30 +02:00
joakimp f2f8a70dae Add CHANGELOG.md and link it from Docker Hub description
Generated from annotated git tag messages. Covers every release from
v1.4.2 (initial) through v1.14.22b. One-line summaries for simple
bumps, bullet-point detail for feature/fix releases.

DOCKER_HUB.md gains a Changelog link in the Source section so Docker
Hub users can find release history without navigating the git forge.
2026-04-23 21:13:31 +02:00
joakimp c34cf3641b Add devbox-shell bridge line to baked .bash_aliases
Publish Docker Image / build-base (push) Successful in 41m27s
Publish Docker Image / build-omos (push) Successful in 53m45s
Publish Docker Image / update-description (push) Successful in 15s
If the host bind-mounts ~/.config/devbox-shell/ into the container
(the directory-mount pattern that avoids single-file inode breakage),
the container needs a bridge line in .bashrc or .bash_aliases to
source the mounted file. Previously this bridge had to be re-added
manually after every --force-recreate because it lived in the
container's writable layer.

Baking it into the skel .bash_aliases makes it automatic: every
fresh container sources ~/.config/devbox-shell/bash_aliases if it
exists, with zero manual steps. Hosts that don't use the devbox-shell
pattern are unaffected — the [ -r ... ] test silently skips.
2026-04-23 20:39:40 +02:00
joakimp 3a7ec45f4b Add python3-venv to base image (Mason needs ensurepip for venv creation)
python3-pip alone wasn't enough — Debian trixie ships python3 and
python3-pip as separate packages from python3.13-venv. Mason creates
a venv per package then pip-installs into it. Without python3-venv,
'python3 -m venv' fails with 'ensurepip is not available' and every
Mason Python package (ruff, ansible-lint, etc.) errors on every nvim
start.

Adding python3-venv (which pulls in ensurepip + pip-whl + setuptools-whl)
completes the chain: venv creation works, pip is available inside the
venv, Mason installs succeed.
2026-04-23 20:24:07 +02:00
joakimp e1029bbf27 Add python3-pip to base image for Mason LSP installs
Mason (neovim's package manager) creates a Python venv and runs
'pip install' inside it to install Python-based LSP servers like
ruff and ansible-lint. Debian trixie's python3 package ships without
ensurepip, so the venv has no pip and Mason fails with
'spawn: python3 failed with exit code 1'.

Adding python3-pip to the apt install list gives Mason what it needs.
uv is still available as the preferred user-facing Python tool
manager; pip is here specifically for Mason's internal use.
2026-04-23 20:21:40 +02:00
joakimp 8c919074dd Persist neovim plugin/Mason data across container recreations
Mason LSP installs and Lazy plugin cache live at ~/.local/share/nvim,
which was in the container's writable layer. Every --force-recreate
triggered a full re-download of all plugins and LSP servers on next
nvim launch — slow and wasteful.

Add devbox-nvim-data named volume in docker-compose.yml and
docker-compose.shared.yml, add to entrypoint ownership-fix loop,
update persistence tables in README.md and DOCKER_HUB.md.
2026-04-23 19:56:35 +02:00
joakimp bca403c540 Bump opencode to 1.14.22
Publish Docker Image / build-omos (push) Successful in 44m33s
Publish Docker Image / build-base (push) Successful in 46m35s
Publish Docker Image / update-description (push) Successful in 19s
2026-04-23 18:10:08 +02:00
joakimp c182ada0dd Persist zoxide directory history across container recreations
Publish Docker Image / build-base (push) Successful in 40m32s
Publish Docker Image / build-omos (push) Successful in 50m17s
Publish Docker Image / update-description (push) Successful in 13s
Zoxide stores its database at ~/.local/share/zoxide/db.zo. Without a
named volume, the 'z <fragment>' jump targets are lost on every
'docker compose up --force-recreate'.

Add devbox-zoxide named volume in docker-compose.yml and
docker-compose.shared.yml, add ~/.local/share/zoxide to the
entrypoint ownership-fix loop per AGENTS.md convention, and update
the data-persistence tables in README.md and DOCKER_HUB.md.
2026-04-23 09:17:39 +02:00
joakimp b9657415c4 Bump opencode to 1.14.21
Publish Docker Image / update-description (push) Has been cancelled
Publish Docker Image / build-base (push) Has been cancelled
Publish Docker Image / build-omos (push) Has been cancelled
2026-04-23 09:04:44 +02:00
joakimp b37740bcce Fix incorrect 'Linux unaffected' claim in bind-mount caveat
The previous note scoped the single-file bind-mount staleness bug to
Docker Desktop only. It actually affects ALL platforms including native
Linux: Docker bind-mounts the inode, not the path. Editors that do
atomic save (vim, nvim, VS Code, sed -i) create a new inode via
rename(), leaving the container pinned to the old unlinked one. This
is a kernel limitation (moby/moby#15793, open since 2015, unfixable).

Rewrite both the README.md caveat and the docker-compose.yml inline
note to describe the real mechanism (inode replacement), name the
affected editors, note that append-only writes are safe, and link to
the upstream issue.
2026-04-23 00:27:07 +02:00
joakimp 3982e9f18c Document Docker Desktop single-file bind-mount gotcha
On Docker Desktop (macOS/Windows), single-file bind-mounts can
silently stop propagating host edits — the file gets materialized
onto the VM's ext4 disk and reused forever. This affects anyone who
uncomments the ~/.bash_aliases or ~/.inputrc mount lines.

Add a caveat note in README.md's 'Overriding the defaults / Option A'
section with the verification command and the directory-mount
workaround. Add a matching inline NOTE comment in docker-compose.yml
above the commented mount lines. Linux hosts are unaffected.
2026-04-23 00:25:01 +02:00
joakimp 4d0c270196 Pin project name in default docker-compose.yml
Without an explicit name, Docker Compose derives the project name
from the directory basename. Renaming the directory silently orphans
all named volumes (devbox-data, devbox-state, devbox-shell-history,
etc.) because the new project name no longer matches the old volume
prefixes. Pin to 'opencode-devbox' so volumes survive directory
moves and renames.
2026-04-22 22:41:57 +02:00
joakimp aed5ff106b Add multi-user setup pointer in DOCKER_HUB.md
DOCKER_HUB.md focuses on single-user setup. Rather than duplicating
the multi-user docs, add a short section linking to the source repo's
Multi-user setup section which covers volume isolation, the shared
compose layout, and the SIGNUM / $USER auto-detection.
2026-04-22 21:48:05 +02:00
joakimp 425d53cb57 Update multi-user docs to reflect own-account vs shared-account modes
The shared-machine section in README.md still claimed named volumes
were isolated by directory-name prefixing alone, which was the bug
we just fixed. Rewrite to document both modes (own-account with
automatic $USER fallback, shared-account with explicit SIGNUM) and
explicitly note that the Docker daemon is system-wide — directory
name prefixing is NOT sufficient for volume isolation.
2026-04-22 21:24:59 +02:00
joakimp 60208b2203 Auto-detect username for volume isolation in own-account mode
The previous SIGNUM variable was required (${SIGNUM:?...}), which
broke for users with their own OS accounts who shouldn't need to set
anything manually. Replace with ${SIGNUM:-${USER}} so:

- Own-account mode: leave SIGNUM unset in .env — project name and
  container name default to devbox-$USER automatically. Each OS
  user gets isolated volumes with zero configuration.
- Shared-account mode: set SIGNUM=<id> in .env as before.

Both container_name and the top-level name: field use the same
fallback, so volumes and container names stay consistent.

Updated .env.shared.example to document both modes with the SIGNUM
line commented out by default (own-account is the common case).
2026-04-22 21:21:22 +02:00
joakimp d65f8cc077 Fix volume collision in shared-machine compose: scope project name by SIGNUM
The Docker daemon is system-wide — named volumes are prefixed by the
compose project name, which defaults to the basename of the directory
holding docker-compose.yml. Two users whose compose file lives under
a directory with the same name (e.g. ~/alice/opencode-devbox and
~/bob/opencode-devbox) would silently share volumes, corrupting each
other's opencode data, bash history, and TUI settings.

Add an explicit top-level 'name: devbox-${SIGNUM}' so the project
name (and therefore all volume prefixes) is unique per user. The old
comment claiming directory-name prefixing was sufficient was wrong —
it only works if directory basenames differ, which isn't guaranteed
on multi-user hosts or when users follow the same setup instructions.
2026-04-22 21:17:07 +02:00
joakimp 4560702550 Document the upgrade-ritual for reconciling VM compose files
New releases may add named volumes or bind-mount lines to
docker-compose.yml. The image can't update compose files on the VM —
they're user-owned — so a plain 'docker compose pull && up -d' picks
up the new image but silently misses new mount points.

Example from v1.14.19c → v1.14.20: bash history persistence needs
the devbox-shell-history named volume at /home/developer/.cache/bash.
The v1.14.20 image is configured to write history there either way,
but without the volume mount on the VM, writes land in the container's
writable layer and vanish on every --force-recreate.

Add a 'Upgrading an existing VM to a new release' subsection to
deploy/README.md describing the backup → diff → merge → recreate
ritual, so future upgrades don't quietly drop features the same way.
2026-04-22 10:29:03 +02:00
joakimp c851b4cc8d Clarify tag-letter convention: suffix is build ordinal, 'a' is never used
Publish Docker Image / build-omos (push) Successful in 43m57s
Publish Docker Image / build-base (push) Successful in 45m46s
Publish Docker Image / update-description (push) Successful in 16s
Previous phrasing treated the letter suffix as a plain alphabetical
sequence, which led to confusion about whether the first rebuild
should be 'a' or 'b'. Spell out the intent: the suffix is the build
ordinal, and the letter 'a' is reserved to mean '1st build' — which
always uses the bare tag (no letter). So letters start at 'b' for
the 2nd build, 'c' for the 3rd, and so on.

Examples for opencode version 1.14.20:
  1st build: v1.14.20
  2nd build: v1.14.20b
  3rd build: v1.14.20c
2026-04-21 23:58:12 +02:00
joakimp 9bb93025f0 Fix [devbox] prompt marker disappearing after 'exec bash'
The previous guard used an exported DEVBOX_PS1_SET env var to avoid
double-prefixing on re-source. But env vars survive 'exec bash'
while PS1 does not — a new bash rebuilds PS1 from .bashrc. Result:
the guard saw DEVBOX_PS1_SET=1, skipped the prefix, and the new
shell ran with bare PS1 (no [devbox] marker).

Replace the env-var guard with a substring check on PS1 itself.
If PS1 already contains '[devbox]' we skip, otherwise we prepend.
Correct in all three cases: first shell (PS1 has no marker → add),
exec bash (fresh PS1 has no marker → add), re-source within same
shell (PS1 still has marker → skip, no doubling).
2026-04-21 23:52:03 +02:00
joakimp c05ec7503c Bump opencode to 1.14.20 and clarify versioning convention
Publish Docker Image / build-omos (push) Successful in 44m59s
Publish Docker Image / build-base (push) Successful in 45m10s
Publish Docker Image / update-description (push) Successful in 16s
Bump OPENCODE_VERSION ARG from 1.14.19 to 1.14.20 to track the new
upstream release on npm.

Clarify the tagging convention in AGENTS.md: the first build on a new
opencode version uses the bare 'v{opencode_version}' tag (no letter
suffix). Letter suffixes (a, b, c, ...) are reserved for container-
level rebuilds on the same opencode version (CVE fixes, doc changes,
entrypoint bugs). The previous wording implied a letter was always
required, which was never the actual behaviour.
2026-04-21 21:16:47 +02:00
joakimp 84b5ed4412 Fix PROMPT_COMMAND collision with zoxide causing ';;' parse error
Publish Docker Image / update-description (push) Has been cancelled
Publish Docker Image / build-base (push) Has been cancelled
Publish Docker Image / build-omos (push) Has been cancelled
v1.14.19c installed 'history -a; ' at the start of PROMPT_COMMAND
before zoxide's init ran. Zoxide's init uses ';' as its separator
when prepending __zoxide_hook, producing 'history -a;;__zoxide_hook'.
Every interactive prompt then emitted:

  bash: PROMPT_COMMAND: syntax error near unexpected token ';;'

History flushing still worked (the 'history -a' half parsed fine),
but the error spam made the shell feel broken.

Fix by moving the history-flush PROMPT_COMMAND assignment AFTER
zoxide's init, and using a newline separator (via ${PROMPT_COMMAND:+...}
parameter expansion) so there's no semicolon involved at all. Each
PROMPT_COMMAND line runs as its own statement, no parsing contention.

Known upstream issue: https://github.com/ajeetdsouza/zoxide/issues/722
2026-04-21 21:05:20 +02:00
joakimp 8535f73ad3 Ship shell defaults via /etc/skel-devbox so user files are preserved
Publish Docker Image / build-base (push) Successful in 40m28s
Publish Docker Image / build-omos (push) Successful in 50m37s
Publish Docker Image / update-description (push) Successful in 15s
Previous behaviour (e4063b5) COPY'd .bash_aliases and .inputrc
directly into /home/developer/ during image build. That silently
shadowed any host bind-mount or in-container customization for users
upgrading from v1.14.19b — if you'd written your own .bash_aliases
and rebuilt the container, our baked version would overwrite it
without warning.

Ship the files to /etc/skel-devbox/ instead. The entrypoint copies
them to $HOME only if the target file does not already exist, so:

- Fresh containers get the defaults automatically (unchanged)
- Host bind-mounts win (they materialize before the entrypoint runs)
- Existing in-container customizations survive upgrades
- Defaults remain discoverable at /etc/skel-devbox/ for anyone who
  wants to copy, diff, or reset back to upstream

Docs (README.md, DOCKER_HUB.md, deploy/README.md) describe the new
skel layout and the restore/diff commands.
2026-04-21 19:44:29 +02:00
joakimp e4063b5559 Persist bash history and bake shell quality-of-life defaults
Two changes that address a longstanding frustration: bash history is
lost on every container recreate, and the container's ~/.bashrc and
~/.inputrc are stock Debian (no history tuning, no prefix search on
arrow keys, no integrations).

Added a named volume 'devbox-shell-history' mounted at ~/.cache/bash
with HISTFILE pointing there; history now survives 'docker compose up
--force-recreate'. The volume is added to both docker-compose.yml and
docker-compose.shared.yml, and ~/.cache/bash is registered in the
entrypoint ownership-fix loop per the AGENTS.md convention.

Baked rootfs/home/developer/.bash_aliases (sourced automatically by
Debian's default ~/.bashrc) and rootfs/home/developer/.inputrc into
the image. They give new containers: 100k-entry timestamped dedup
history with per-prompt flush, Up/Down arrow prefix history search,
case-insensitive coloured completion, aliases that prefer eza and
bat when present, git shortcuts, interactive rm/mv/cp, zoxide and
fzf (via 'fzf --bash') integration, and a [devbox] prompt marker.
The fzf integration uses 'fzf --bash' because we install fzf from
GitHub releases, not apt — the apt-path key-bindings aren't present.

Users who prefer their host's own shell config can uncomment two
commented bind-mount lines in docker-compose.yml to shadow the
baked defaults.
2026-04-21 19:30:22 +02:00
joakimp cb4971b4a6 Document SSH banner-timeout workaround for residential CGNAT users
Add a Troubleshooting subsection to deploy/README.md describing the
ISP-CGNAT per-destination flow-table exhaustion that manifests as
'Connection timed out during banner exchange' or pure TCP connect
timeouts after the first 3-4 SSH connects.

The fix is SSH ControlMaster/ControlPersist on the client side, which
multiplexes all SSH sessions over one TCP flow and stays within the
CGNAT cap. sync-to-vm.sh already uses this pattern internally; this
note makes it discoverable for users hitting the issue in interactive
or scripted SSH use outside the deploy/ scripts.
2026-04-21 09:04:59 +02:00
joakimp 3d632ef02f Detect workspace UID and GID independently in entrypoint
The auto-detection block was gated on UID mismatch alone: if the host
user already had UID 1000 (the container's default) but a non-default
GID, the GID was never adopted. That left host-written files with a
numeric GID the container couldn't resolve to a name (e.g. '1001' on
Debian VMs where useradd assigns a dedicated group starting at 1001).

Split UID and GID detection into independent conditions. Each dimension
is adopted from /workspace if its env var is unset and the workspace
value differs from the container default, regardless of the other
dimension's state. Preserves existing USER_UID / USER_GID overrides.
2026-04-20 23:59:32 +02:00
joakimp 3669bec8ff Stop leaking host GIDs into VM via rsync -a
Publish Docker Image / build-base (push) Successful in 39m15s
Publish Docker Image / build-omos (push) Successful in 50m20s
Publish Docker Image / update-description (push) Successful in 16s
Replace 'rsync -az' with 'rsync -rlptDz' (archive minus owner/group
preservation). Running as a non-root user on the VM, rsync can't
preserve UID anyway, but it was successfully preserving GID whenever
the numeric GID happened to exist on the target. That caused synced
dirs (~/.aws, ~/.config/opencode, ~/.config/nvim, ~/.agents/skills,
~/.ssh) to end up with group 1001 on the VM, which was confusing
and, for group-writable mode, potentially insecure.

With -o and -g dropped, received files get the receiving user's
UID:GID (devbox:devbox), which is what you want.
2026-04-20 22:12:19 +02:00
joakimp f210d533eb Fix root-owned parent dirs left behind by nested volume mounts
When a named volume is mounted at a nested path like
/home/developer/.local/state/opencode, Docker creates the parent
directory (.local/state) as root:root. The existing chown loop only
fixed the leaf mount points, leaving parents unwritable by the
developer user.

Add a non-recursive pre-pass that fixes ownership of the common
parent dirs (.local, .local/share, .local/state, .config) so that
anything creating new subdirs beneath them works correctly after a
fresh container recreate. Regression introduced by commit 967ce7d
(devbox-state volume) and only partially addressed by a06dc5f.
2026-04-20 22:12:14 +02:00
joakimp 00d4f1596d Ignore personal deploy/my-cloud-init.yml override 2026-04-20 21:27:05 +02:00
joakimp 3c19b836cf Clarify OMOS-only features and host-mount portability in docs
Two related documentation fixes for users mounting ~/.config/opencode
from the host:

1. Gate oh-my-opencode-slim references (file and agents) to the OMOS
   variant in the Custom opencode config sections and data persistence
   tables. Base-variant users no longer see oh-my-opencode-slim.json
   listed as if it were always present.

2. Add a portability note warning that host-absolute paths in
   opencode.json (e.g. file:///usr/local/lib/node_modules/... or
   file:///opt/homebrew/...) will not resolve inside the Linux
   container, and to prefer bare package specifiers that work on
   both macOS and Linux hosts.
2026-04-20 21:25:44 +02:00
joakimp fffaeffb7a Refresh default model IDs for current providers
Update auto-generated opencode.json defaults to model IDs that are
valid as of April 2026:

- anthropic: claude-sonnet-4-5 -> claude-sonnet-4-6
- openai:    gpt-4o (retired Apr 3 2026) -> gpt-5.4
- bedrock:   anthropic.claude-sonnet-4-5-v1 (invalid) ->
             global.anthropic.claude-sonnet-4-5-20250929-v1:0

The Bedrock ID now uses the global inference profile (no regional
10% premium) and includes the required date stamp and :0 suffix.
2026-04-20 21:25:37 +02:00
joakimp b4d2f09e77 Document rsync in README and DOCKER_HUB tool lists 2026-04-20 20:27:09 +02:00
joakimp d74adc14dc Add rsync to base image 2026-04-20 20:26:24 +02:00
joakimp 9fa8b5c1e3 Fix misleading --rm wording: data loss happens on any container recreation 2026-04-20 15:03:00 +02:00
joakimp 3724519402 Document devbox-state volume for TUI settings persistence 2026-04-20 14:53:07 +02:00
joakimp a06dc5f47c Add state volume to entrypoint ownership fix loop 2026-04-20 14:48:12 +02:00
joakimp 967ce7df49 Add devbox-state volume to persist TUI settings across container recreations 2026-04-20 14:37:58 +02:00
joakimp c209d873ba Bump opencode to v1.14.19
Publish Docker Image / build-base (push) Successful in 39m5s
Publish Docker Image / build-omos (push) Successful in 50m52s
Publish Docker Image / update-description (push) Successful in 14s
2026-04-20 12:26:23 +02:00
joakimp e52ac46237 Document gcc and g++ in README and DOCKER_HUB tool lists 2026-04-20 10:26:52 +02:00
joakimp 83fb3d6de5 Add gcc and g++ to base image for C/C++ compilation support 2026-04-20 10:25:44 +02:00
joakimp d9d3a4c1d2 Fix Bun download URL: remove non-existent LATEST file fetch
Publish Docker Image / build-base (push) Successful in 36m8s
Publish Docker Image / build-omos (push) Successful in 47m45s
Publish Docker Image / update-description (push) Successful in 14s
2026-04-19 23:05:31 +02:00
joakimp 7b8c74852e Add fzf and ripgrep to VM provisioning packages 2026-04-19 23:03:21 +02:00
joakimp c32d50b364 Use Bun baseline build for AVX2-less CPU compatibility (Sandy Bridge)
Publish Docker Image / build-omos (push) Failing after 14m30s
Publish Docker Image / build-base (push) Has been cancelled
Publish Docker Image / update-description (push) Has been cancelled
2026-04-19 22:35:45 +02:00
joakimp dd63607a3f Ensure WORKSPACE_PATH from remote .env exists on VM 2026-04-19 20:15:22 +02:00
joakimp 3852d3b1ad Exclude AWS CLI and SSO cache from sync-to-vm.sh 2026-04-19 20:07:36 +02:00
joakimp ddea23e80a Exclude node_modules and other generated files from sync-to-vm.sh 2026-04-19 19:58:47 +02:00
joakimp 466383b546 Add rsync to installed packages for sync-to-vm.sh support 2026-04-19 19:54:56 +02:00
joakimp f21cf87881 Fix rsync flag for macOS compatibility 2026-04-19 19:30:31 +02:00
joakimp 3c7df3f888 Add sync-to-vm.sh to copy local config directories to remote VM 2026-04-19 19:25:18 +02:00
joakimp 6fc74b1f19 Add bind mount pre-creation note to deploy post-setup instructions 2026-04-19 19:11:36 +02:00
joakimp 05998bd6a2 Add Bedrock setup notes to deploy docs and cloud-init final message 2026-04-19 19:04:15 +02:00
joakimp b1e25a45b2 Default docker-compose.yml to pull from Docker Hub, sync with DOCKER_HUB.md 2026-04-19 18:50:12 +02:00
joakimp 16ff29101e Bump opencode to v1.14.18
Publish Docker Image / build-omos (push) Successful in 41m30s
Publish Docker Image / build-base (push) Successful in 43m45s
Publish Docker Image / update-description (push) Successful in 15s
2026-04-19 18:28:39 +02:00
joakimp 81100fd5bb Add caveats and two-step fallback for inline boot-from-volume command 2026-04-19 18:15:53 +02:00
joakimp 4893be4133 Add locale customization instructions to cloud-init template 2026-04-19 18:09:30 +02:00
joakimp 9ebff2e037 Fix --block-device syntax to match current OpenStack CLI key names 2026-04-19 16:49:26 +02:00
joakimp 5bac08dd03 Fix image name casing to match OpenStack: Debian-13-Trixie 2026-04-19 16:47:10 +02:00
joakimp addccd4a82 Remove --key-name from OpenStack examples, clarify SSH key is in cloud-init 2026-04-19 16:36:15 +02:00
joakimp 7b0f6ed880 Add floating IP instructions to OpenStack deploy docs 2026-04-19 16:22:52 +02:00
joakimp fa3bb12d44 Skip ufw on OpenStack in cloud-init, matching setup-host.sh behavior 2026-04-19 13:22:07 +02:00
joakimp d091b6b50f Add optional console password (chpasswd) to cloud-init and deploy docs 2026-04-19 13:10:12 +02:00
joakimp fb9629db2b Add NVMe performance volume example to OpenStack deploy docs 2026-04-19 11:33:55 +02:00
joakimp 265cbdb14c Document full OpenStack server create command with flavor, image, network 2026-04-19 11:18:31 +02:00
joakimp 68204f573b Skip ufw on OpenStack (auto-detected), add security group setup script
setup-host.sh now detects OpenStack via metadata endpoint and skips ufw.
New setup-openstack-secgroup.sh creates the required security group with
SSH, mosh, and ICMP rules via the OpenStack CLI.
2026-04-19 11:04:09 +02:00
joakimp e0258a928e Add VM host deployment scripts (cloud-init + post-install)
Recommended base: Debian 13 Trixie (matches opencode-devbox base image).
- cloud-init.yml: automated VM provisioning for Proxmox/OpenStack/cloud providers
- setup-host.sh: interactive post-install script for manually-created VMs
- README.md: documents both paths and VM sizing recommendations

Installs Docker (official repo), Compose v2, ufw firewall, mosh support,
and the IPv4 DNS preference workaround for Docker Hub IPv6 issues.
2026-04-19 10:43:41 +02:00
joakimp 4bd543050a Bump opencode to v1.4.17, add file utility to base image
Publish Docker Image / build-omos (push) Successful in 41m7s
Publish Docker Image / build-base (push) Successful in 43m7s
Publish Docker Image / update-description (push) Successful in 15s
2026-04-19 09:31:21 +02:00
24 changed files with 2943 additions and 501 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
OPENCODE_PROVIDER=anthropic
# Model override (optional, defaults per provider)
# OPENCODE_MODEL=anthropic/claude-sonnet-4-5
# OPENCODE_MODEL=anthropic/claude-sonnet-4-6
# ── API Keys (set the one matching your provider) ────────────────────
# ANTHROPIC_API_KEY=
+9 -3
View File
@@ -1,7 +1,13 @@
# ── Shared machine setup ─────────────────────────────────────────────
# Your corporate signum / username (REQUIRED)
# This isolates your container, config, and data from other users.
SIGNUM=your-signum-here
# SIGNUM isolates your container name and named volumes from other users.
#
# Own-account mode (each user has their own OS login):
# Leave SIGNUM commented out — it defaults to your OS username ($USER).
# SIGNUM=
#
# Shared-account mode (everyone logs in as the same OS user):
# Uncomment and set to your unique identifier.
# SIGNUM=your-signum-here
# ── Provider ─────────────────────────────────────────────────────────
OPENCODE_PROVIDER=amazon-bedrock
+28
View File
@@ -39,6 +39,19 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Build and load amd64 image for smoke test
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:smoke-base
- name: Smoke test (amd64)
run: |
bash scripts/smoke-test.sh opencode-devbox:smoke-base --variant base
- name: Build and push (base)
uses: docker/build-push-action@v7
with:
@@ -82,6 +95,21 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Build and load amd64 image for smoke test
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
push: false
load: true
build-args: |
INSTALL_OMOS=true
tags: opencode-devbox:smoke-omos
- name: Smoke test (amd64)
run: |
bash scripts/smoke-test.sh opencode-devbox:smoke-omos --variant omos
- name: Build and push (omos)
uses: docker/build-push-action@v7
with:
+97
View File
@@ -0,0 +1,97 @@
name: Validate
# Lightweight validation on pushes to main. Builds single-arch (amd64),
# runs the smoke test, and checks image size — without pushing anything
# to Docker Hub. Tag pushes are handled by docker-publish.yml which
# does the full multi-arch build-and-push.
on:
push:
branches:
- main
paths-ignore:
- 'CHANGELOG.md'
- 'README.md'
- 'DOCKER_HUB.md'
- 'deploy/**'
- '.gitleaks.toml'
pull_request:
branches:
- main
jobs:
docs-check:
# Fails if DOCKER_HUB.md is out of sync with what generate-dockerhub-md.py
# would produce from README.md. Keeps the two docs from drifting.
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check DOCKER_HUB.md is in sync with README.md
run: |
python3 scripts/generate-dockerhub-md.py --check
validate-base:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: |
echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Build base image (amd64, load to local daemon)
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
push: false
load: true
tags: opencode-devbox:ci-base
- name: Smoke test
run: |
bash scripts/smoke-test.sh opencode-devbox:ci-base --variant base
validate-omos:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Force IPv4 for Docker Hub
run: |
echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Build omos image (amd64, load to local daemon)
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64
push: false
load: true
build-args: |
INSTALL_OMOS=true
tags: opencode-devbox:ci-omos
- name: Smoke test
run: |
bash scripts/smoke-test.sh opencode-devbox:ci-omos --variant omos
+14
View File
@@ -3,3 +3,17 @@
*.swo
*~
.DS_Store
# Docker buildx state (created by 'docker compose build')
.docker/
# Personal cloud-init overrides (not shared)
deploy/my-cloud-init.yml
# MemPalace per-project files (issue #185)
mempalace.yaml
entities.json
# Python bytecode (from running scripts/ and rootfs/.../*.py locally)
__pycache__/
*.pyc
+45 -16
View File
@@ -6,38 +6,67 @@ Docker image packaging [opencode](https://opencode.ai) into a production-ready d
## File roles
- `Dockerfile` — single multi-stage build for both variants. OMOS variant is controlled by `INSTALL_OMOS=true` build arg. All GitHub-sourced binaries are pinned with version ARGs.
- `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes. Then drops to developer via gosu.
- `entrypoint-user.sh` — runs as developer: git config, opencode.json generation from env vars, OMOS setup.
- `DOCKER_HUB.md` — pushed to Docker Hub description via CI API call. Must stay under 25KB. Short description field must be ≤100 bytes.
- `README.md` — source repo documentation. Must stay in sync with DOCKER_HUB.md (both describe the same features but for different audiences).
- `.gitea/workflows/docker-publish.yml` — CI pipeline: three parallel jobs (build-base, build-omos, update-description). Triggered by tag push only.
- `Dockerfile` — single multi-stage build for both variants. OMOS variant is controlled by `INSTALL_OMOS=true` build arg; mempalace is controlled by `INSTALL_MEMPALACE` (default `true`). All GitHub-sourced binaries are pinned with version ARGs.
- `entrypoint.sh` — runs as root: UID/GID adjustment, SSH permissions, volume ownership fixes (skipped via `.devbox-owner` sentinel when ownership is already correct). Then drops to developer via gosu.
- `entrypoint-user.sh` — runs as developer: git config, opencode.json generation (delegated to `generate-config.py`), OMOS setup.
- `rootfs/usr/local/lib/opencode-devbox/generate-config.py` — generates `~/.config/opencode/opencode.json` from env vars. Never overwrites an existing config. Auto-registers MCP servers for detected tools (mempalace via the `mempalace-mcp` entry point, gitea-mcp).
- `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/generate-dockerhub-md.py` — generates `DOCKER_HUB.md` from `README.md` using explicit section rules. `--check` fails if the committed file is out of sync (enforced by the `validate` workflow).
- `DOCKER_HUB.md`**auto-generated** from README. 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. Sections are selected/dropped/replaced for DOCKER_HUB.md per `SECTION_RULES` in `scripts/generate-dockerhub-md.py`.
- `.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.yml` — CI pipeline on tag push: smoke-test each variant on amd64, then full multi-arch (amd64 + arm64) build-and-push, then update Docker Hub description.
## Versioning scheme
Tags follow `v{opencode_version}{letter}` — e.g. `v1.4.3k`. The number matches the opencode npm version. The letter suffix increments for container-level changes (tooling, docs, CVE fixes) on the same opencode version. CI produces four Docker Hub tags per release: `vX.Y.Zn`, `latest`, `vX.Y.Zn-omos`, `latest-omos`.
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.
- The number tracks the opencode npm version (see `OPENCODE_VERSION` ARG in `Dockerfile`).
- **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.
CI produces four Docker Hub tags per release: `vX.Y.Z[n]`, `latest`, `vX.Y.Z[n]-omos`, `latest-omos`.
When bumping the opencode version, also bump `OPENCODE_VERSION` in `Dockerfile` and update the comment in `.env.example` if it names a specific model/version for context.
## Critical conventions
- **entrypoint.sh volume ownership loop** — when adding a new named volume mount point, add it to the `for dir in ...` loop in `entrypoint.sh` so root-owned volumes get chowned on startup.
- **Three docs to keep in sync** — Dockerfile changes that add tools or features must be reflected in `README.md`, `DOCKER_HUB.md`, and `.env.example`. The docker-compose examples in both docs must match the source `docker-compose.yml` pattern.
- **GitHub-sourced binaries** — fzf, gosu, git-lfs, neovim, bat, eza, zoxide, uv, rustup are installed from upstream releases (not apt) with pinned versions. Use the same `ARCH` case-switch pattern for multi-arch support (amd64/arm64).
- **entrypoint.sh volume ownership loop** — when adding a new named volume mount point, add it to the `for dir in ...` loop in `entrypoint.sh` so root-owned volumes get chowned on startup. The loop writes a `.devbox-owner` sentinel after a successful chown so subsequent starts skip the recursive walk. Users should not touch these files.
- **Two docs to keep in sync (automated)** — `README.md` is the source of truth. `DOCKER_HUB.md` is auto-generated by `scripts/generate-dockerhub-md.py`. When adding a new top-level section to README, either add it to `SECTION_RULES` in that script or the `--check` run will fail CI. `.env.example` must still be hand-updated to match Dockerfile/entrypoint behavior.
- **GitHub/Gitea-sourced binaries float by default** — gosu, fzf, git-lfs, nvim, bat, eza, zoxide, uv, gitea-mcp, Go, oh-my-opencode-slim all default to `latest`. Each build-time install step reads the `/releases/latest` Location redirect (or the go.dev JSON feed for Go) and derives the concrete version. Use the same `ARCH` case-switch pattern for multi-arch support (amd64/arm64). Intentional pins: `OPENCODE_VERSION` (drives the image tag), `NODE_VERSION=22` (major pin), `DEBIAN_VERSION=trixie-slim` (OS base). Adding a new upstream tool: follow the existing floated-version pattern, don't hardcode a specific tag.
- **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`.
- **Shell scripts use `set -euo pipefail`** — both entrypoints are strict. Errors in volume chown or SSH permission operations are intentionally suppressed with `|| true`.
- **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.json` — system Python can't import from the uv venv.
- **generate-config.py idempotency** — the script MUST never overwrite an existing `opencode.json`. Users bind-mount their config directory or persist it across container recreations; accidentally clobbering that file would destroy hand-edits. The smoke test asserts this.
- **Docker Hub description update** — uses `/v2/auth/token` endpoint (not the deprecated `/v2/users/login`). Auth uses `identifier`/`secret` fields, returns `access_token`, sent as `Bearer`. Short description must be ≤100 bytes.
## CI quirks
- Both build jobs include an IPv4 preference step (`gai.conf` + `driver-opts: network=host` for buildx) to work around intermittent IPv6 failures on the Gitea runners.
- `update-description` job runs only when both builds succeed (`needs: [build-base, build-omos]`).
- Tags must be pushed to trigger CI. Pushing to `main` alone does not build images.
- Tags must be pushed to trigger the publish workflow. The validate workflow runs on push to main and PRs.
- Smoke tests run on amd64 only (single-arch load into the local daemon). The multi-arch push happens after smoke passes.
## Testing changes
No test suite. Verify by:
1. Building locally: `docker compose build`
2. Running: `docker compose run --rm devbox bash`
3. Checking tool availability inside container: `nvim --version`, `bat --version`, `uv --version`, etc.
4. For entrypoint changes: test with a non-1000 UID workspace to verify UID adjustment and volume ownership fixes.
The smoke test (`scripts/smoke-test.sh`) is the canonical check and runs automatically in CI. To run locally:
```bash
# Base image
docker compose build
bash scripts/smoke-test.sh opencode-devbox --variant base
# OMOS image
docker build --build-arg INSTALL_OMOS=true -t opencode-devbox:omos .
bash scripts/smoke-test.sh opencode-devbox:omos --variant omos
```
For manual/exploratory testing:
1. `docker compose run --rm devbox bash`
2. Check specific tools inside: `nvim --version`, `bat --version`, `uv --version`, `mempalace --help`, etc.
3. For entrypoint changes: test with a non-1000 UID workspace to verify UID adjustment, volume ownership fixes, and the `.devbox-owner` sentinel behavior.
4. For `generate-config.py` changes: run standalone with `HOME=/tmp/fake OPENCODE_PROVIDER=anthropic python3 rootfs/usr/local/lib/opencode-devbox/generate-config.py`.
## Commit style
+211
View File
@@ -0,0 +1,211 @@
# Changelog
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.
---
## v1.14.30 — 2026-04-30
Bump opencode to 1.14.30.
## v1.14.29c — 2026-04-29
**Drop redundant mempalace-mcp-server wrapper, use the entry point mempalace ships.**
- **Fix:** MCP integration with mempalace was still broken for users with custom `opencode.json` files because they typically had `["python3", "-m", "mempalace.mcp_server"]` from v1.14.28b and earlier. With the uv-tool install path, system python3 can't import mempalace and the MCP server subprocess exits immediately — opencode surfaced this as `MCP error -32000: connection closed`. Users should migrate to `["mempalace-mcp"]`. The auto-generated config in new containers already emits the new form.
- **Cleanup:** Remove the hand-rolled `/usr/local/bin/mempalace-mcp-server` wrapper. The mempalace Python package ships a `mempalace-mcp` console entry point; `uv tool install` places it on PATH as a shim whose shebang points at the isolated venv's Python. The wrapper was duplicating what uv installs for free. Removed `rootfs/usr/local/bin/` and its COPY + chmod lines from the Dockerfile.
- **Docs:** README's MemPalace section now shows `["mempalace-mcp"]` and explicitly warns against `["python3", "-m", "mempalace.mcp_server"]` with the observed failure mode.
- **Tests:** Smoke test asserts `/usr/local/bin/mempalace-mcp` is executable and prints its symlink target, replacing the previous wrapper-present check.
## v1.14.29b — 2026-04-29
**Fix OMOS `bunx` detection + CI build reliability.**
- **Fix:** `entrypoint-user.sh` checked `command -v bunx` to gate the OMOS auto-install, but the OMOS image only ships the `bun` binary — upstream's bun installer never creates a `bunx` symlink and neither did our Dockerfile. The check always failed on a fresh OMOS image, so `bun x oh-my-opencode-slim@latest install` never ran and first-start OMOS setup would have printed `ENABLE_OMOS=true but bun is not installed.` even though bun was right there. Latent until now because the only exercised path had a persisted `oh-my-opencode-slim.json` from a prior install.
- Changed the gate to `command -v bun`.
- Changed both install invocations from `bunx oh-my-opencode-slim@latest install ...` to `bun x oh-my-opencode-slim@latest install ...`.
- Added `ln -sf bun /usr/local/bin/bunx` to the Dockerfile's OMOS block so interactive users can still type `bunx` by habit, and verified the symlink at build time (`test -L /usr/local/bin/bunx`).
- Smoke test now asserts the `bunx` symlink is present on the OMOS variant.
- **Fix:** CI build robustness against transient GitHub/Gitea CDN failures. The first attempt at building v1.14.29b tripped on a single HTTP 502 from GitHub's release CDN mid-download (`zoxide-0.9.9-x86_64-unknown-linux-musl.tar.gz`), failing the entire OMOS build with no retry. Fix applied to every tool-download curl in the Dockerfile:
- `curl --retry 5 --retry-delay 5 --retry-all-errors` on both the `-fsSL` GET requests and the `-sI` HEAD requests used for `/releases/latest` redirect resolution. 5 attempts with 5 s back-off eats most transient CDN hiccups without failing the build.
- Added `[ -n "$V" ]` assertion after each version-resolution step. If the HEAD redirect ever fails to produce a tag name, the build fails fast with an empty-version message rather than trying to download `.../v//...` and producing a confusing 404.
- Same hardening applied to the optional Go install block (go.dev JSON feed + tarball download) and the nodesource apt-repo setup script.
- **Security:** Added `apt-get upgrade -y` to the core-packages RUN step. Picks up any security/CVE fixes published between `debian:trixie-slim` base-image rebuilds. Paired with the existing `update` and `install` in the same layer so image history isn't bloated. Today this produced `0 upgraded` (base image is current), but it future-proofs against the next CVE drop.
## v1.14.29 — 2026-04-28
**Opencode 1.14.29 + infrastructure and maintainability pass.**
- Bump opencode to 1.14.29.
- **Cleanup:** Remove dead `INSTALL_PYTHON` build arg. Python 3 + pip + venv have been unconditionally installed in the base layer since mempalace was added; the flag was a no-op. Users should use `uv` (pre-installed) or `uvx` for Python tooling.
- **Fix:** `mempalace init` in `entrypoint-user.sh` now uses `--yes` for non-interactive operation. Previously the command prompted the user (`Your choice [enter/edit/add]:`) on first container start, which either hung or printed prompts into the user's terminal. The init is still gated by `[ ! -d "$PALACE_DIR/palace" ]` so existing palace data from prior versions is preserved untouched on upgrade.
- **Feature:** MemPalace is now installed via `uv tool install` into an isolated venv at `/opt/uv-tools/mempalace/`, reached through a new `/usr/local/bin/mempalace-mcp-server` wrapper. Replaces the previous `pip install --break-system-packages` approach — removes the PEP 668 workaround and keeps mempalace deps out of system Python site-packages. The wrapper is what `generate-config.py` now references in the auto-generated `opencode.json`. Users with custom `opencode.json` files should update their mempalace MCP command from `["python3", "-m", "mempalace.mcp_server"]` to `["mempalace-mcp-server"]`.
- **Feature:** New `INSTALL_MEMPALACE` build arg (default `true`). Rebuild with `--build-arg INSTALL_MEMPALACE=false` to shave ~300 MB off the image when local AI memory isn't needed.
- **Refactor:** `opencode.json` generation extracted from `entrypoint-user.sh` into a standalone Python script at `/usr/local/lib/opencode-devbox/generate-config.py`. Easier to read, test, and extend with new providers. Default models are declared at the top of the script rather than hard-coded in bash heredocs. Reduces `entrypoint-user.sh` from 176 to 97 lines. Behavior is unchanged — the script preserves the critical guarantee of never overwriting an existing `opencode.json`.
- **Perf:** Container startup avoids the recursive `chown -R` on named volumes that already have correct ownership. A `.devbox-owner` sentinel file written after a successful chown lets subsequent starts short-circuit via a single `cat`. On volumes with thousands of files (nvim plugins, palace data) this cuts multi-second startup costs to milliseconds. If `USER_UID` changes between runs, the sentinel mismatches and the full chown still runs.
- **CI:** New `validate` workflow runs on every push to main and PR — single-arch amd64 build, smoke test, and DOCKER_HUB.md sync check. Catches broken Dockerfile changes without waiting for a tag push.
- **CI:** `docker-publish.yml` now smoke-tests each variant on amd64 before the full multi-arch push. A failing smoke test blocks the release.
- **CI:** Image size is tracked and fails the build if it exceeds thresholds (base: 2500 MB uncompressed, OMOS: 3000 MB). Makes bloat visible rather than silent.
- **Docs:** `DOCKER_HUB.md` is now auto-generated from `README.md` via `scripts/generate-dockerhub-md.py`. Editing it directly is a mistake — the `--check` step in CI fails if the committed file is out of sync. Section inclusion is controlled by explicit rules (`SECTION_RULES`, `TRIM_SUBSECTIONS`); adding a new section to README forces an explicit keep/drop/replace decision. Keeps the 25 kB Docker Hub limit in sight and eliminates manual sync burden.
- **Tests:** New `scripts/smoke-test.sh` asserts: (a) all core binaries are runnable and print a version, (b) opencode starts, (c) entrypoint correctly drops to the developer user, (d) `generate-config.py` produces valid JSON with the expected shape, (e) `generate-config.py` never overwrites an existing config, (f) bun is present only in the OMOS variant, (g) image size is under threshold. The smoke test also logs resolved versions of every component as its first step so CI output always records what got baked in.
- **Versioning:** All GitHub/Gitea-hosted binaries (gosu, fzf, git-lfs, neovim, bat, eza, zoxide, uv, gitea-mcp) and the go.dev-hosted Go toolchain now default to `latest` at build time. Each `*_VERSION` ARG resolves the newest upstream release by reading the `/releases/latest` Location redirect (or the go.dev JSON feed). Previously these were hand-pinned to a specific version, which meant rebuilds didn't pick up upstream CVE fixes until someone remembered to bump the pin. Pinning is still supported — pass `--build-arg NVIM_VERSION=0.12.1` etc. to lock a specific version. Intentionally still pinned: `OPENCODE_VERSION` (drives the image tag), `NODE_VERSION=22` (major only), `DEBIAN_VERSION=trixie-slim` (OS base).
## v1.14.28b — 2026-04-27
- **Feature:** Add MemPalace local-first AI memory system to base image. Provides 29 MCP tools for semantic search over conversation history, knowledge graph queries, and agent diaries. Palace data persists via optional `devbox-palace` named volume, ChromaDB embedding model cache via `devbox-chroma-cache`. No API keys required.
- **Feature:** Auto-register mempalace MCP server in generated opencode.json (when mempalace is installed and config is auto-generated from OPENCODE_PROVIDER).
- **Feature:** Add official Gitea MCP server (`gitea-mcp`) to base image. Provides 50+ MCP tools for Gitea API (repos, issues, PRs, releases, Actions). Disabled by default — requires `GITEA_ACCESS_TOKEN` and `GITEA_HOST` env vars.
## v1.14.28 — 2026-04-26
Bump opencode to 1.14.28.
## v1.14.25 — 2026-04-25
Bump opencode to 1.14.25. Also includes container-level changes since v1.14.22b:
- Add `python3-pip` and `python3-venv` to base image (fixes Mason LSP installs).
- Add `devbox-nvim-data` named volume for neovim plugin/Mason persistence.
- Add `devbox-zoxide` named volume for zoxide directory history persistence.
- Bake devbox-shell bridge line into `/etc/skel-devbox/.bash_aliases`.
- Add CHANGELOG.md with full release history.
## v1.14.22b — 2026-04-23
**Fix Mason LSP installs, persist nvim data, devbox-shell bridge.**
- **Fix:** Add `python3-pip` and `python3-venv` to base image. Mason creates a Python venv per LSP package and pip-installs into it; Debian trixie ships python3 without ensurepip, so venv creation failed and every Mason Python package (ruff, ansible-lint) errored on every nvim start.
- **Feature:** Add `devbox-nvim-data` named volume at `~/.local/share/nvim` — Lazy plugin cache and Mason LSP installs now persist across `--force-recreate`.
- **Feature:** Add `devbox-zoxide` named volume at `~/.local/share/zoxide` — zoxide directory history persists across recreates.
- **Feature:** Bake the devbox-shell bridge line into `/etc/skel-devbox/.bash_aliases` — hosts using the `~/.config/devbox-shell/` directory-mount pattern get automatic sourcing without manual setup after recreate.
## v1.14.22 — 2026-04-23
Bump opencode to 1.14.22.
## v1.14.21 — 2026-04-23
**Opencode 1.14.21 + zoxide persistence + multi-user fixes.**
- Bump opencode to 1.14.21.
- Fix single-file bind-mount caveat: document the kernel-level inode issue (affects all platforms, not just Docker Desktop).
- Pin project name in default `docker-compose.yml` — directory renames no longer orphan named volumes.
- Fix volume collision in shared-machine compose: scope project name by `SIGNUM`.
- Auto-detect OS username (`$USER`) for volume isolation in own-account mode.
- Document the upgrade ritual for reconciling VM compose files.
- Add multi-user setup pointer in DOCKER_HUB.md.
## v1.14.20b — 2026-04-21
**Fix `[devbox]` prompt marker lost on `exec bash`.**
- The PS1 prefix guard used an exported env var that survived `exec bash`, but PS1 itself doesn't — so the new shell skipped adding the prefix. Replaced with a substring check on PS1 itself.
- Clarify tag-letter convention in AGENTS.md: suffix is the build ordinal, `a` is never used.
## v1.14.20 — 2026-04-21
**Opencode 1.14.20 + PROMPT_COMMAND/zoxide fix.**
- Bump opencode to 1.14.20.
- Fix `PROMPT_COMMAND` collision with zoxide: `history -a;` followed by zoxide's `;__zoxide_hook` produced `;;` which bash rejected on every prompt. Moved history-flush after zoxide init, using newline separator.
- Includes all v1.14.19c shell-defaults work (baked `.bash_aliases`/`.inputrc` via `/etc/skel-devbox/`, skel-copy on first run, `devbox-shell-history` named volume).
## v1.14.19d — 2026-04-21
*Superseded by v1.14.20 before building. Tagged but never built.*
## v1.14.19c — 2026-04-21
**Bash history persistence, shell defaults, GID auto-detect.**
- **Feature:** Bash history persists across `--force-recreate` via `devbox-shell-history` named volume at `~/.cache/bash`.
- **Feature:** Quality-of-life shell defaults shipped in `/etc/skel-devbox/` and copied to `~/` only if absent: prefix history search on Up/Down, 100k-entry timestamped dedup history, coloured case-insensitive tab completion, eza/bat aliases, zoxide/fzf integrations, `[devbox]` prompt marker.
- **Feature:** Skel-copy pattern — host bind-mounts and in-container customizations are never overwritten on upgrade.
- **Fix:** Entrypoint now detects workspace UID and GID independently. Hosts with UID 1000 but non-1000 GID (e.g. Debian's `useradd` default GID 1001) get correct group remapping.
- **Docs:** SSH banner-timeout troubleshooting (CGNAT), shell defaults section, skel restore/diff commands.
## v1.14.19b — 2026-04-20
**Ownership fixes and config/docs refresh.**
- **Fix:** Root-owned parent dirs left behind by nested named-volume mounts. Entrypoint now chowns `.local`, `.local/share`, `.local/state`, `.config` before leaf mount points.
- **Fix:** `deploy/sync-to-vm.sh` no longer preserves host GIDs (`rsync -a``-rlptDz`).
- Default model IDs refreshed (claude-sonnet-4-6, gpt-5.4, global Bedrock inference profile).
- Documentation gates oh-my-opencode-slim references to the OMOS variant.
## v1.14.19 — 2026-04-20
Bump opencode to 1.14.19.
## v1.14.18 — 2026-04-19
Fix Bun download URL: remove non-existent LATEST file fetch.
## v1.4.17 — 2026-04-19
Bump opencode to v1.4.17, add `file` utility to base image.
## v1.4.12 — 2026-04-18
Bump opencode to v1.4.12.
## v1.4.11 — 2026-04-18
Bump opencode to v1.4.11.
## v1.4.7 — 2026-04-17
Bump opencode to v1.4.7.
## v1.4.6 — 2026-04-15
Bump opencode to v1.4.6.
## v1.4.3k — 2026-04-13
Fix Bedrock config: add `AWS_PROFILE` to generated config, add `.agents/skills` to volume ownership fix.
## v1.4.3j — 2026-04-13
Upgrade base image from Debian bookworm to trixie (current stable). Bookworm EOL June 2026; trixie supported until 2028/LTS 2030.
## v1.4.3i — 2026-04-12
Add rustup for on-demand Rust support, document JS/TS development.
## v1.4.3h — 2026-04-12
Add uv package manager to base image for on-demand Python support.
## v1.4.3g — 2026-04-12
Fix IPv6 connectivity failures: force IPv4 preference in CI builds.
## v1.4.3f — 2026-04-11
Add error handling to Docker Hub description update step.
## v1.4.3e — 2026-04-10
Fix CVEs: install git-lfs from GitHub (Go 1.25), document Go versions for gosu/fzf.
## v1.4.3d — 2026-04-10
Fix CVEs: install gosu 1.19 and fzf 0.71.0 from GitHub releases instead of Debian packages.
## v1.4.3c — 2026-04-10
Fix CVEs: install gosu from GitHub release instead of Debian package (Go 1.19.8 → current).
## v1.4.3b — 2026-04-10
Fix entrypoint crash on read-only SSH mount.
## v1.4.3 — 2026-04-10
Bump opencode to 1.4.3.
## v1.4.2 — 2026-04-10
Initial release. Fix CI: use vars for username, secrets for token.
+400 -342
View File
@@ -13,6 +13,8 @@ Two image variants are published for each release:
Both variants support `linux/amd64` and `linux/arm64`.
> **NOTE:** This file is auto-generated from `README.md` by `scripts/generate-dockerhub-md.py`. Edit README.md and regenerate rather than editing this file directly.
## Quick Start
```bash
@@ -28,9 +30,7 @@ docker run -it --rm \
This drops you straight into opencode with your project mounted at `/workspace`.
## Interactive Shell
To get a shell first (useful for AWS SSO login or running other commands):
For an interactive shell first (useful for AWS SSO login):
```bash
docker run -it --rm \
@@ -43,229 +43,141 @@ docker run -it --rm \
Then run `opencode` when ready.
## Running Multiple Shells
For docker-compose users, see the source repo for `docker-compose.yml` and `.env.example` templates.
Once opencode is running it takes over the terminal. To have a separate shell for `aws`, `git`, or other commands, run the container in the background and attach multiple times:
## Features
- **Debian trixie** base — glibc, full PTY/terminal support
- **Configurable providers** — Anthropic, OpenAI, AWS Bedrock via env vars
- **Host filesystem access** — bind mount any directory as `/workspace`
- **SSH key forwarding** — git push/pull to private repos
- **MCP server support** — Node.js included for `npx`-based MCP servers
- **Non-root user** — runs as `developer` with UID auto-matched to workspace owner (sudo available)
- **Python via uv** — `uv` package manager included; install Python on demand with `uv python install`
- **Rust via rustup** — `rustup-init` included; bootstrap Rust on demand with `rustup-init -y`
- **Optional runtimes** — Python (apt), Go via build args (Node.js always included — required for opencode v1.x)
- **Multi-agent orchestration** — optional [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) integration via build arg
- **AWS CLI v2** — built-in SSO/Bedrock authentication with headless device-code flow
- **Multi-arch** — amd64 and arm64
## Usage
### Prerequisites
Bind-mounted directories must exist on the host before starting the container. Docker creates missing directories as root-owned, which causes permission issues.
```bash
# Start in background
docker run -d --name devbox \
-e ANTHROPIC_API_KEY=your-key \
-e OPENCODE_PROVIDER=anthropic \
-v ~/projects:/workspace \
-v ~/.ssh:/home/developer/.ssh:ro \
joakimp/opencode-devbox:latest sleep infinity
# Required: workspace for your projects
mkdir -p ~/projects
# Shell 1: run opencode
docker exec -it -u developer devbox opencode
# Shell 2 (separate terminal): aws, git, etc.
docker exec -it -u developer devbox bash
# When done
docker rm -f devbox
# If mounting opencode config (recommended for persistent settings)
mkdir -p ~/.config/opencode
```
> **Note:** Always use `-u developer` with `docker exec` — the container starts as root for UID adjustment, then drops to `developer`. Without `-u developer`, exec runs as root.
### Connecting to the container
## Environment Variables
From your laptop, SSH into the remote server where Docker is running, then start the container:
All configuration is done via environment variables, typically stored in a `.env` file.
```bash
# 1. SSH into the remote server
ssh user@remote-server
### Provider Configuration
# 2. Navigate to the project
cd opencode-devbox
# 3. Start the container with an interactive shell
docker compose run --rm devbox bash
# You're now inside the container — run commands here
aws sso login --sso-session <your-sso-session> --use-device-code
opencode
```
### Running modes
**Interactive shell** — enter the container, run multiple commands:
```bash
docker compose run --rm devbox bash
```
**Direct to opencode** — skips the shell, launches opencode immediately:
```bash
docker compose run --rm devbox
```
**Background container** — keep it running, attach when needed:
```bash
# Start in background
docker compose up -d
# Attach a shell to the running container
docker compose exec -u developer devbox bash
# Or run a single command inside it
docker compose exec -u developer devbox aws --version
```
> `run` creates a new container (cleaned up with `--rm`). `exec` attaches to an already running one.
## Configuration
### Environment Variables
| Variable | Description | Default |
|---|---|---|
| `OPENCODE_PROVIDER` | LLM provider (`anthropic`, `openai`, `amazon-bedrock`) | `anthropic` |
| `OPENCODE_MODEL` | Model override | Provider default |
### API Keys
Set the key matching your provider:
| Variable | Provider |
|---|---|
| `ANTHROPIC_API_KEY` | Anthropic |
| `OPENAI_API_KEY` | OpenAI |
| `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` | AWS Bedrock (static creds) |
### AWS Bedrock
| Variable | Description | Default |
|---|---|---|
| `AWS_REGION` | AWS region | `us-east-1` |
| `ANTHROPIC_API_KEY` | Anthropic API key | — |
| `OPENAI_API_KEY` | OpenAI API key | — |
| `AWS_REGION` | AWS region for Bedrock | `us-east-1` |
| `AWS_PROFILE` | AWS SSO profile name | `default` |
### Git
| Variable | Description |
|---|---|
| `GIT_USER_NAME` | Git commit author name |
| `GIT_USER_EMAIL` | Git commit author email |
### User ID Mapping
The container runs as user `developer` (UID 1000 by default). If your host user has a different UID, file permission mismatches can occur on mounted volumes.
The entrypoint automatically detects the owner of `/workspace` and adjusts the container user's UID/GID to match. You can also set it explicitly:
| Variable | Description | Default |
|---|---|---|
| `USER_UID` | Container user UID | Auto-detect from `/workspace` owner |
| `USER_GID` | Container user GID | Auto-detect from `/workspace` owner |
### Locale and Editor
The container defaults to English (`en_US.UTF-8`) and neovim as the editor. Override via environment variables:
| Variable | Description | Default |
|---|---|---|
| `GIT_USER_NAME` | Git commit author name | — |
| `GIT_USER_EMAIL` | Git commit author email | — |
| `WORKSPACE_PATH` | Host path to mount | `.` |
| `SSH_KEY_PATH` | Host SSH key directory | `~/.ssh` |
| `USER_UID` | Override container user UID | Auto-detect from `/workspace` |
| `USER_GID` | Override container user GID | Auto-detect from `/workspace` |
| `LANG` | System locale | `en_US.UTF-8` |
| `LANGUAGE` | Language priority list | `en_US:en` |
| `LC_ALL` | Override all locale settings | `en_US.UTF-8` |
| `EDITOR` | Default text editor | `nvim` |
| `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` |
Pre-generated locales: `en_US`, `en_GB`, `sv_SE`, `da_DK`, `nb_NO`, `fi_FI`, `de_DE`, `fr_FR`, `es_ES`, `it_IT`, `pt_BR`, `nl_NL`, `pl_PL`, `ja_JP`, `ko_KR`, `zh_CN` (all UTF-8).
### Custom opencode config
Example for Swedish:
For full control over opencode settings (MCP servers, custom models, and — on the OMOS variant — oh-my-opencode-slim agents), mount the entire config directory from the host:
```bash
LANG=sv_SE.UTF-8
LANGUAGE=sv_SE:sv
LC_ALL=sv_SE.UTF-8
```yaml
volumes:
- ~/.config/opencode:/home/developer/.config/opencode
```
To add a locale not in the list, run inside the container:
This persists all configuration changes across container restarts, including `opencode.json`, skills, and (on the OMOS variant) `oh-my-opencode-slim.json`. When an existing `opencode.json` is found, the `OPENCODE_PROVIDER` auto-config is skipped.
```bash
sudo sed -i '/xx_XX.UTF-8/s/^# //g' /etc/locale.gen
sudo locale-gen
> **Portability note:** The mounted config runs inside a Linux container. Any absolute paths inside `opencode.json` (for example, host-specific `plugin` entries like `file:///usr/local/lib/node_modules/...` or `file:///opt/homebrew/...`) will not resolve inside the container. Prefer bare package specifiers (e.g. `"oh-my-opencode-slim"`) that resolve via `node_modules` lookup, which works on both macOS and Linux hosts.
### Custom skills
Mount agent skills from the host:
```yaml
volumes:
- ~/.agents/skills:/home/developer/.agents/skills:ro
```
Replace `xx_XX` with the desired locale (e.g. `ru_RU`, `tr_TR`). This change does not persist across container restarts — for permanent additions, build from source and modify the Dockerfile.
## Initial Setup
### 1. Create host directories
Bind-mounted directories must exist on the host before starting the container. Docker creates missing directories as root-owned, which causes permission issues.
```bash
# Required
mkdir -p ~/projects
# If mounting opencode config (recommended for persistent settings)
mkdir -p ~/.config/opencode
# If using AWS Bedrock
# mkdir -p ~/.aws
# If mounting neovim config
# mkdir -p ~/.config/nvim
```
### 2. Create a `.env` file
Create a `.env` file with your configuration. Examples for each provider:
**Anthropic:**
```bash
OPENCODE_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-...
GIT_USER_NAME=Your Name
GIT_USER_EMAIL=you@example.com
```
**OpenAI:**
```bash
OPENCODE_PROVIDER=openai
OPENAI_API_KEY=sk-...
GIT_USER_NAME=Your Name
GIT_USER_EMAIL=you@example.com
```
**AWS Bedrock (SSO):**
```bash
OPENCODE_PROVIDER=amazon-bedrock
OPENCODE_MODEL=amazon-bedrock/eu.anthropic.claude-opus-4-6-v1
AWS_REGION=eu-west-1
AWS_PROFILE=your-profile-name
GIT_USER_NAME=Your Name
GIT_USER_EMAIL=you@example.com
```
### 3. AWS SSO setup (Bedrock users only)
AWS SSO requires a `~/.aws/config` file on the host with your SSO session configuration. If you already have this on another machine, copy it:
```bash
scp -r user@other-machine:~/.aws ~/.aws
```
Or configure from scratch:
```bash
aws configure sso
```
You'll be prompted for:
- SSO session name
- SSO start URL
- SSO region
- Registration scopes (typically `sso:account:access`)
The `~/.aws` directory must be mounted into the container (see docker-compose example below).
## Data Storage and Persistence
Understanding what survives container restarts and what doesn't:
| Path in container | Source | Survives restart? | Contains |
|---|---|---|---|
| `/workspace` | Host bind mount | ✅ Yes — lives on host | Your project files |
| `/home/developer/.ssh` | Host bind mount (ro) | ✅ Yes — lives on host | SSH keys |
| `/home/developer/.aws` | Host bind mount | ✅ Yes — lives on host | AWS credentials/SSO cache |
| `/home/developer/.local/share/opencode` | Named volume (if configured) | ✅ Yes — Docker volume | Session history, memory, auth tokens |
| `/home/developer/.local/share/uv` | Named volume (if configured) | ✅ Yes — Docker volume | Python installs, uv tool installs |
| `/home/developer/.rustup` | Named volume (if configured) | ✅ Yes — Docker volume | Rust toolchains |
| `/home/developer/.cargo` | Named volume (if configured) | ✅ Yes — Docker volume | Cargo binaries, registry cache |
| `/home/developer/.vscode-server` | Named volume (if configured) | ✅ Yes — Docker volume | VS Code server and extensions |
| `/home/developer/.config/opencode` | Host bind mount (if configured) | ✅ Yes — lives on host | opencode.json, oh-my-opencode-slim.json, skills |
### Key points
- **Project files** (`/workspace`) are always safe — they're your host filesystem.
- **opencode config** is auto-generated from `OPENCODE_PROVIDER` env var on each start if no existing config is found. To persist config changes, mount the config directory from the host (see Custom opencode Config below).
- **opencode data** (session history, memory) is lost with `--rm` unless you add a named volume.
- **Python installs** via `uv python install` are lost unless you add the `devbox-uv` named volume.
- **Rust toolchains** via `rustup-init` are lost unless you add the `devbox-rustup` and `devbox-cargo` named volumes.
- **AWS SSO tokens** persist across restarts when `~/.aws` is mounted (recommended for Bedrock users).
## Custom opencode Config
For full control over opencode settings (MCP servers, custom models, oh-my-opencode-slim agents, etc.), mount the entire config directory from the host:
```bash
docker run -it --rm \
-v ~/.config/opencode:/home/developer/.config/opencode \
... \
joakimp/opencode-devbox:latest
```
This persists all configuration changes across container restarts. When an existing `opencode.json` is found, the `OPENCODE_PROVIDER` auto-config is skipped.
## Neovim Configuration
### 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:
```bash
docker run -it --rm \
-v ~/.config/nvim:/home/developer/.config/nvim:ro \
... \
joakimp/opencode-devbox:latest
```yaml
volumes:
- ~/.config/nvim:/home/developer/.config/nvim:ro
```
## Python Development with uv
### 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:
@@ -290,18 +202,19 @@ uvx ruff check .
uv python install 3.14
```
To persist Python installs across container restarts, add a named volume:
Python installations are stored in `~/.local/share/uv/`. To persist them across container restarts, add the `devbox-uv` named volume to your `docker-compose.yml`:
```bash
docker run -it --rm \
-v devbox-uv:/home/developer/.local/share/uv \
... \
joakimp/opencode-devbox:latest
```yaml
volumes:
- devbox-uv:/home/developer/.local/share/uv
volumes:
devbox-uv:
```
Project virtual environments (`.venv`) are stored in your workspace directory and persist automatically via the `/workspace` bind mount.
## Rust Development with rustup
### Rust development with rustup
The image includes `rustup-init`, the Rust toolchain installer. Rust is not pre-installed but can be bootstrapped on demand:
@@ -316,17 +229,19 @@ cargo build
cargo run
```
To persist Rust toolchains and cargo data across container restarts, add named volumes:
To persist Rust toolchains and cargo data across container restarts, add named volumes to your `docker-compose.yml`:
```bash
docker run -it --rm \
-v devbox-rustup:/home/developer/.rustup \
-v devbox-cargo:/home/developer/.cargo \
... \
joakimp/opencode-devbox:latest
```yaml
volumes:
- devbox-rustup:/home/developer/.rustup
- devbox-cargo:/home/developer/.cargo
volumes:
devbox-rustup:
devbox-cargo:
```
## JavaScript and TypeScript
### JavaScript and TypeScript
The base image includes **Node.js 22** and **npm** — sufficient for most JavaScript and TypeScript development:
@@ -339,6 +254,9 @@ npm install
# Run TypeScript (via tsx, ts-node, etc.)
npx tsx src/index.ts
# Use npx for one-off tools
npx tsc --init
```
The OMOS image variant also includes **Bun**, a faster JavaScript runtime and package manager:
@@ -351,172 +269,76 @@ bun run src/index.ts
Node modules are stored in your project directory under `/workspace` and persist automatically.
## VS Code Integration
### VS Code integration
VS Code can connect directly to a running opencode-devbox container for a full IDE experience with IntelliSense, debugging, and extensions running inside the container.
**Requirements:** Install the [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension. For remote Docker hosts, also install [Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh).
**Local Docker (Docker running on your workstation):**
**Steps:**
1. Install the [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension
2. Start the container: `docker compose up -d`
3. In VS Code: `Ctrl+Shift+P` → "Dev Containers: Attach to Running Container" → select `opencode-devbox`
1. Start the container: `docker compose up -d`
2. In VS Code: `Ctrl+Shift+P` → "Dev Containers: Attach to Running Container" → select `opencode-devbox`
**Remote Docker (Docker running on a remote server, e.g. via SSH):**
For remote Docker hosts (e.g. connecting to a server via SSH), first connect to the remote host with Remote-SSH, then attach to the container from there.
1. Install the [Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) and [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extensions
2. Connect to the remote host: `Ctrl+Shift+P` → "Remote-SSH: Connect to Host"
3. On the remote host, start the container: `docker compose up -d`
4. In VS Code (now connected to the remote): `Ctrl+Shift+P` → "Dev Containers: Attach to Running Container"
VS Code extensions installed inside the container persist as long as the container exists. For persistent extension storage across container recreations, add a named volume:
```bash
docker run -it --rm \
-v devbox-vscode:/home/developer/.vscode-server \
... \
joakimp/opencode-devbox:latest
```
## Using docker-compose
Create a directory with a `docker-compose.yml` and a `.env` file:
```bash
mkdir opencode-devbox && cd opencode-devbox
```
`.env` — your settings (never commit this):
```bash
OPENCODE_PROVIDER=amazon-bedrock
OPENCODE_MODEL=amazon-bedrock/eu.anthropic.claude-opus-4-6-v1
AWS_REGION=eu-west-1
AWS_PROFILE=your-profile-name
GIT_USER_NAME=Your Name
GIT_USER_EMAIL=you@example.com
```
`docker-compose.yml`:
VS Code extensions installed inside the container persist as long as the container exists (not removed with `docker compose down`). For persistent extension storage across container recreations, add a named volume:
```yaml
services:
devbox:
image: joakimp/opencode-devbox:latest
# For multi-agent orchestration, use the omos variant instead:
# image: joakimp/opencode-devbox:latest-omos
stdin_open: true
tty: true
env_file:
- .env
environment:
- TERM=xterm-256color
volumes:
- ~/projects:/workspace
- ~/.ssh:/home/developer/.ssh:ro
- devbox-data:/home/developer/.local/share/opencode
# Optional: persist Python/uv installs across restarts
# - devbox-uv:/home/developer/.local/share/uv
# Optional: persist Rust toolchains and cargo data
# - devbox-rustup:/home/developer/.rustup
# - devbox-cargo:/home/developer/.cargo
# Optional: persist VS Code server and extensions
# - devbox-vscode:/home/developer/.vscode-server
# Mount AWS config for Bedrock SSO (required for amazon-bedrock provider)
# - ~/.aws:/home/developer/.aws
# Optional: mount opencode config directory (persists config changes across restarts)
# - ~/.config/opencode:/home/developer/.config/opencode
# Optional: mount opencode agent skills from host
# - ~/.agents/skills:/home/developer/.agents/skills:ro
# Optional: mount neovim config from host (plugins auto-install on first start)
# - ~/.config/nvim:/home/developer/.config/nvim:ro
volumes:
devbox-data:
# devbox-uv:
# devbox-rustup:
# devbox-cargo:
# devbox-vscode:
- devbox-vscode:/home/developer/.vscode-server
```
Docker Compose loads `.env` automatically from the same directory. All variables from `.env` are passed to the container via `env_file`. Do **not** hardcode provider settings in the `environment:` section — use `.env` instead.
## oh-my-opencode-slim (Multi-Agent Orchestration)
Then:
[oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) adds a multi-agent layer on top of opencode — an Orchestrator delegates tasks to specialized agents (Explorer, Oracle, Librarian, Designer, Fixer), each configurable with different models and providers.
### Setup
A pre-built OMOS image is available on Docker Hub as `joakimp/opencode-devbox:latest-omos`. Alternatively, build from source:
**1. Build the image with OMOS support:**
```bash
# Start in background
docker compose up -d
# Open a shell (always use -u developer with exec)
docker compose exec -u developer devbox bash
# For Bedrock: authenticate, then start opencode
aws sso login --sso-session <your-session> --use-device-code
opencode
# Or run opencode directly (if no SSO needed)
docker compose exec -u developer devbox opencode
# One-shot mode (creates and removes container)
docker compose run --rm devbox # direct to opencode
docker compose run --rm devbox bash # interactive shell
docker compose build --build-arg INSTALL_OMOS=true
```
## What's Included
This installs Bun and the oh-my-opencode-slim package into the image.
### Base image (`latest`)
- **Debian trixie-slim** — glibc, full terminal/PTY support
- **opencode** — AI coding assistant
- **Node.js 22** — for npx-based MCP servers
- **AWS CLI v2** — SSO and Bedrock authentication
- **Dev tools** — git, git-lfs, git-crypt, age, ssh, ripgrep, fd, fzf, bat, eza, zoxide, uv, rustup, jq, make, curl, wget, neovim 0.12, tmux, htop, tree
- **Non-root user** — runs as `developer` with UID auto-matched to workspace owner (sudo available)
### OMOS image (`latest-omos`)
Everything in the base image, plus:
- **[oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim)** — multi-agent orchestration plugin
- **Bun** — JavaScript runtime required by oh-my-opencode-slim
- **6 specialized agents** — Orchestrator, Explorer, Oracle, Librarian, Designer, Fixer
### Additional runtimes (build from source)
When [building from source](https://gitea.jordbo.se/joakimp/opencode-devbox), additional runtimes are available via build args:
- **Python 3** (`INSTALL_PYTHON=true`) — Python 3 + pip + venv
- **Go** (`INSTALL_GO=true`) — Go toolchain
## oh-my-opencode-slim (OMOS variant)
The `-omos` image variant includes [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim), which adds a multi-agent layer on top of opencode. An Orchestrator delegates tasks to specialized agents, each configurable with different models and providers.
### Quick start with OMOS
**2. Enable in `.env`:**
```bash
docker run -it --rm \
-e OPENAI_API_KEY=your-key \
-e OPENCODE_PROVIDER=openai \
-e ENABLE_OMOS=true \
-v ~/projects:/workspace \
-v ~/.ssh:/home/developer/.ssh:ro \
joakimp/opencode-devbox:latest-omos
ENABLE_OMOS=true
```
On first start, the entrypoint configures oh-my-opencode-slim automatically. The default preset uses OpenAI models.
**3. Run as normal:**
### OMOS environment variables
```bash
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.
### OMOS Environment Variables
| Variable | Default | Description |
|---|---|---|
| `ENABLE_OMOS` | `false` | Activate oh-my-opencode-slim on container start |
| `OMOS_TMUX` | `false` | Enable tmux pane integration (watch agents in split panes) |
| `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) |
### Custom OMOS configuration
### Custom Configuration
If you mount the opencode config directory (see Custom opencode Config above), the `oh-my-opencode-slim.json` file is included and persists across restarts. Edit it directly to control which models power each agent, fallback chains, council setup, and more.
If you mount the opencode config directory (see Custom opencode config above), the `oh-my-opencode-slim.json` file is included and persists across restarts. Edit it directly to control which models power each agent, fallback chains, council setup, and more.
See the [oh-my-opencode-slim configuration docs](https://github.com/alvinunreal/oh-my-opencode-slim/blob/master/docs/configuration.md) for the full reference.
### Verifying agents
### Verifying Agents
After starting opencode with OMOS enabled, run inside the opencode session:
@@ -526,6 +348,242 @@ ping all agents
All six agents should respond if your provider authentication is working.
## AWS Bedrock Authentication
When using AWS Bedrock as your LLM provider, you need:
### 1. AWS config on the host
The container needs access to your `~/.aws/config` with SSO session configuration. If you already have this on another machine, copy it:
```bash
scp -r user@other-machine:~/.aws ~/.aws
```
Or configure from scratch on the host:
```bash
aws configure sso
```
### 2. Mount `~/.aws` into the container
Uncomment the AWS volume mount in `docker-compose.yml`:
```yaml
- ~/.aws:/home/developer/.aws
```
Note: do **not** use `:ro` — SSO writes token cache files to this directory.
### 3. Authenticate inside the container
Since the container runs headless (no browser), use the device-code flow:
```bash
# Start the container
docker compose up -d
docker compose exec -u developer devbox bash
# Authenticate — prints a URL and code you open in your local browser
aws sso login --sso-session <your-sso-session> --use-device-code
# Once approved in the browser, start opencode
opencode
```
The `--use-device-code` flag outputs a URL and short code instead of trying to open a browser. Copy the URL into any browser (on your laptop, phone, etc.), enter the code, and complete the 2FA flow. The CLI in the container picks up the session automatically.
SSO sessions typically last 812 hours before requiring re-authentication. Since `~/.aws` is mounted from the host, tokens persist across container restarts.
## MemPalace — persistent AI memory
The image includes [MemPalace](https://github.com/MemPalace/mempalace), a local-first AI memory system that stores conversation history verbatim and retrieves it via semantic search. Nothing leaves your machine.
> MemPalace adds ~300 MB to the image (chromadb, embedding model deps). If you don't use it, rebuild with `--build-arg INSTALL_MEMPALACE=false` to shrink the image.
### Enabling persistence
Uncomment the palace volume in `docker-compose.yml`:
```yaml
- devbox-palace:/home/developer/.mempalace
```
Without the volume, palace data lives in the container's writable layer and is lost on `--force-recreate`.
### MCP integration with opencode
Add mempalace as an MCP server in your `opencode.json` (inside `~/.config/opencode/`):
```json
{
"mcp": {
"mempalace": {
"type": "local",
"command": ["mempalace-mcp"]
}
}
}
```
> The image installs mempalace into an isolated `uv tool` venv at `/opt/uv-tools/mempalace/`. `uv tool install` places `mempalace-mcp` on `PATH` as a shim whose shebang points at the venv's Python, so MCP clients can invoke it as a normal binary without worrying about the venv. Do **not** use `["python3", "-m", "mempalace.mcp_server"]` — the system Python cannot import from the uv-managed venv and you'll get `ModuleNotFoundError` / `MCP error -32000: connection closed`.
This gives opencode access to 29 MCP tools for searching memory, querying the knowledge graph, managing wings/rooms/drawers, and agent diaries.
### Basic usage
```bash
# Mine project files into the palace
mempalace mine /workspace
# Mine conversation transcripts
mempalace mine ~/.local/share/opencode/ --mode convos
# Search memory
mempalace search "why did we switch to eno1"
# Load context for a new session
mempalace wake-up
```
Each workspace gets its own isolated "wing" — memories never leak between projects.
### Storage
Two separate named volumes keep different data classes apart:
- **Palace data** (`~/.mempalace/`): ChromaDB vectors, SQLite knowledge graph, drawers. This is your memory — back it up, treat it as precious. Persists via the `devbox-palace` named volume.
- **Embedding model cache** (`~/.cache/chroma/`): ONNX model (~79 MB), downloaded automatically on first search. Disposable — blow it away and it re-downloads in ~4 seconds. Persists via the `devbox-chroma-cache` named volume so you don't re-download on every container recreation.
- **No API keys required** for core functionality (local embeddings via ONNX).
Both volumes are commented out by default in `docker-compose.yml` — uncomment to enable:
```yaml
- devbox-palace:/home/developer/.mempalace
- devbox-chroma-cache:/home/developer/.cache/chroma
```
**Air-gapped environments:** pre-populate the `devbox-chroma-cache` volume with the `all-MiniLM-L6-v2/` model contents. The palace volume needs no pre-population.
## Gitea MCP server
The image includes the [official Gitea MCP server](https://gitea.com/gitea/gitea-mcp) (`gitea-mcp`), providing 50+ MCP tools for interacting with self-hosted Gitea instances — repositories, issues, pull requests, releases, branches, wiki, and Actions.
### Setup
1. Create a Personal Access Token on your Gitea instance (Settings → Applications → Generate Token, scopes: `repo`, `read:user`).
2. Add to your `.env`:
```env
GITEA_HOST=https://your-gitea-instance.example.com
GITEA_ACCESS_TOKEN=your_token_here
```
3. Enable the gitea MCP server in your `opencode.json`:
```json
{
"mcp": {
"gitea": {
"type": "local",
"command": ["gitea-mcp", "-t", "stdio", "--host", "{env:GITEA_HOST}"],
"environment": {
"GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}"
},
"enabled": true
}
}
}
```
The server is installed but disabled by default — it requires authentication to be useful.
## Shell defaults
The image ships a baked `.bash_aliases` and `.inputrc` with quality-of-life defaults. On first container start they are copied from `/etc/skel-devbox/` into `/home/developer/` **only if the target file does not already exist** — so host bind-mounts and any version you've customized inside the container are never overwritten on upgrade.
Defaults you get out of the box:
- **Prefix history search** on Up/Down arrows (type `git `, press Up, walk back through prior `git ...` commands only). Ctrl-Up / Ctrl-Down still step through full history.
- **Persistent history** — `$HISTFILE` points at `~/.cache/bash/history`, backed by the `devbox-shell-history` named volume so history survives container recreation. Timestamps, 100 000 entries, dedup.
- **Case-insensitive tab completion**, coloured completion lists, `show-all-if-ambiguous`.
- **Aliases** — `ls`/`ll`/`la` use `eza`, `cat` uses `bat`, `gs`/`gd`/`gl` for git, safe `rm`/`mv`/`cp`.
- **Integrations** — `zoxide` (`z <fragment>` to jump), `fzf` Ctrl-R / Ctrl-T key bindings.
- **Prompt marker** — `[devbox]` prefix so it's always obvious you're inside the container.
### Overriding the defaults
**Option A — bind-mount host files.** Uncomment the bind-mount lines in `docker-compose.yml`:
```yaml
- ~/.bash_aliases:/home/developer/.bash_aliases:ro
- ~/.inputrc:/home/developer/.inputrc:ro
```
> **Single-file bind-mount caveat (all platforms):** Docker bind-mounts the file's **inode**, not its path. When editors like vim, nvim, VS Code, or `sed -i` save a file, they write to a temp file and `rename()` it over the original — creating a new inode. The container stays pinned to the old (now unlinked) inode and never sees the update. This is a kernel limitation ([Docker #15793](https://github.com/moby/moby/issues/15793)), not fixable by Docker. Append-only writes (`echo "alias foo=bar" >> file`) are safe because they modify the same inode. **Workaround:** mount the parent directory instead of the single file (e.g. `~/.config/devbox-shell:/home/developer/.config/devbox-shell:ro`) and source files from there.
**Option B — customize inside the container.** Just edit `~/.bash_aliases` or `~/.inputrc` as normal. Pair this with a bind-mount or named volume on the home dir if you want the edits to survive container recreation.
### Restoring or diffing defaults
The skel files remain available inside every container at `/etc/skel-devbox/`. Useful commands:
```bash
# See what the image currently ships
cat /etc/skel-devbox/.bash_aliases
# Diff your current config against the upstream defaults
diff ~/.bash_aliases /etc/skel-devbox/.bash_aliases
# Reset to the baked defaults
cp /etc/skel-devbox/.bash_aliases ~/.bash_aliases
# …or delete the file and recreate the container — the entrypoint
# copies from /etc/skel-devbox/ on next start if the target is absent
rm ~/.bash_aliases
```
## Architecture
```
Host Machine
├── ~/projects/my-app ──bind mount──▶ /workspace (container)
├── ~/.ssh ──bind mount──▶ /home/developer/.ssh (ro)
├── ~/.aws ──bind mount──▶ /home/developer/.aws (Bedrock SSO)
└── .env ──env vars───▶ provider config + API keys
Container (Debian trixie)
├── opencode binary
├── oh-my-opencode-slim (optional — multi-agent orchestration plugin, includes Bun)
├── AWS CLI v2 (SSO + Bedrock auth)
├── neovim 0.12, tmux, htop, bat, eza, zoxide, uv, rustup, make, gcc, g++, rsync
├── git, git-crypt, age, ssh, ripgrep, fd, fzf, jq, curl, tree
├── Node.js (for MCP servers)
├── Bun (optional — included with oh-my-opencode-slim)
├── entrypoint.sh (UID adjustment, git config, provider setup)
└── /workspace ← your code lives here
```
### Data persistence
| Path in container | Source | Survives `--rm`? | Contains |
|---|---|---|---|
| `/workspace` | Host bind mount | ✅ Yes | Your project files |
| `/home/developer/.ssh` | Host bind mount (ro) | ✅ Yes | SSH keys |
| `/home/developer/.aws` | Host bind mount (if configured) | ✅ Yes | AWS credentials/SSO cache |
| `/home/developer/.local/share/opencode` | Named volume `devbox-data` | ✅ Yes | Session history, memory |
| `/home/developer/.local/state/opencode` | Named volume `devbox-state` | ✅ Yes | TUI settings (theme, toggles) |
| `/home/developer/.cache/bash` | Named volume `devbox-shell-history` | ✅ Yes | Bash history (`$HISTFILE`), survives container recreate |
| `/home/developer/.local/share/zoxide` | Named volume `devbox-zoxide` | ✅ Yes | Zoxide directory history (`z <fragment>` jump targets) |
| `/home/developer/.local/share/nvim` | Named volume `devbox-nvim-data` | ✅ Yes | Neovim plugins, Mason LSP installs, Lazy plugin cache |
| `/home/developer/.local/share/uv` | Named volume `devbox-uv` (if configured) | ✅ Yes | Python installs, uv tool installs |
| `/home/developer/.rustup` | Named volume `devbox-rustup` (if configured) | ✅ Yes | Rust toolchains |
| `/home/developer/.cargo` | Named volume `devbox-cargo` (if configured) | ✅ Yes | Cargo binaries, registry cache |
| `/home/developer/.vscode-server` | Named volume `devbox-vscode` (if configured) | ✅ Yes | VS Code server and extensions |
| `/home/developer/.config/opencode` | Host bind mount (if configured) | ✅ Yes | opencode.json, skills, plus `oh-my-opencode-slim.json` on the OMOS variant |
**opencode config** (`opencode.json`) is auto-generated from `OPENCODE_PROVIDER` on each start. It sets provider and model only — no MCP servers. To persist config changes and use custom settings, mount the config directory from the host (see Custom opencode config above).
## Source
Build from source or contribute: [opencode-devbox on Gitea](https://gitea.jordbo.se/joakimp/opencode-devbox)
MIT licensed. Source, issues, and `docker-compose.yml` templates: <https://gitea.jordbo.se/joakimp/opencode-devbox>
+195 -42
View File
@@ -5,7 +5,7 @@ ARG DEBIAN_VERSION=trixie-slim
FROM debian:${DEBIAN_VERSION} AS base
ARG TARGETARCH
ARG OPENCODE_VERSION=1.4.12
ARG OPENCODE_VERSION=1.14.30
LABEL maintainer="joakimp"
LABEL description="Portable opencode developer container"
@@ -15,7 +15,12 @@ LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/opencode-
ENV DEBIAN_FRONTEND=noninteractive
# ── Core system packages ─────────────────────────────────────────────
RUN apt-get update && apt-get install -y --no-install-recommends \
# 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.
RUN apt-get update && \
apt-get upgrade -y --no-install-recommends && \
apt-get install -y --no-install-recommends \
ca-certificates \
curl \
wget \
@@ -34,81 +39,197 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
diffutils \
git-crypt \
age \
file \
sudo \
locales \
procps \
unzip \
gcc \
g++ \
rsync \
python3-pip \
python3-venv \
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
#
# Version policy for the binaries below:
# • Default is `latest` — resolved at build time by following the
# /releases/latest redirect on GitHub and reading the tag from the
# Location header. This means every tagged image picks up the newest
# upstream release, with no risk of running months-old CVE-affected
# binaries.
# • Explicit pins still work: pass `--build-arg GOSU_VERSION=1.19` etc.
# Useful for reproducibility or rolling back a bad upstream release.
# • Resolved versions are printed during build and re-checked by the
# smoke test (scripts/smoke-test.sh), so drift is visible in CI logs.
#
# The helper `resolve_latest` reads the redirected tag (e.g. "v0.26.1")
# and strips a leading "v" if present, yielding a plain version string.
# gosu — privilege de-escalation (built with Go 1.24.6)
ARG GOSU_VERSION=1.19
# gosu — privilege de-escalation
ARG GOSU_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
curl -fsSL "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${ARCH}" -o /usr/local/bin/gosu && \
V="${GOSU_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing gosu ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/download/${V}/gosu-${ARCH}" -o /usr/local/bin/gosu && \
chmod +x /usr/local/bin/gosu && \
gosu --version
# fzf — fuzzy finder (built with Go 1.23.12)
ARG FZF_VERSION=0.71.0
# fzf — fuzzy finder
ARG FZF_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
curl -fsSL "https://github.com/junegunn/fzf/releases/download/v${FZF_VERSION}/fzf-${FZF_VERSION}-linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin fzf && \
V="${FZF_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing fzf ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/download/v${V}/fzf-${V}-linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin fzf && \
fzf --version
# git-lfs — Git Large File Storage (built with Go 1.25)
ARG GIT_LFS_VERSION=3.7.1
# git-lfs — Git Large File Storage
ARG GIT_LFS_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
curl -fsSL "https://github.com/git-lfs/git-lfs/releases/download/v${GIT_LFS_VERSION}/git-lfs-linux-${ARCH}-v${GIT_LFS_VERSION}.tar.gz" | tar -xz -C /tmp && \
install /tmp/git-lfs-${GIT_LFS_VERSION}/git-lfs /usr/local/bin/git-lfs && \
rm -rf /tmp/git-lfs-${GIT_LFS_VERSION} && \
V="${GIT_LFS_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing git-lfs ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/download/v${V}/git-lfs-linux-${ARCH}-v${V}.tar.gz" | tar -xz -C /tmp && \
install /tmp/git-lfs-${V}/git-lfs /usr/local/bin/git-lfs && \
rm -rf /tmp/git-lfs-${V} && \
git lfs install --system && \
git-lfs --version
# neovim — modern text editor (pre-built release from GitHub)
ARG NVIM_VERSION=0.12.1
# neovim — modern text editor
ARG NVIM_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
curl -fsSL "https://github.com/neovim/neovim/releases/download/v${NVIM_VERSION}/nvim-linux-${ARCH}.tar.gz" | tar -xz -C /opt && \
V="${NVIM_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing neovim ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/download/v${V}/nvim-linux-${ARCH}.tar.gz" | tar -xz -C /opt && \
ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \
nvim --version | head -1
# bat — syntax-highlighted cat replacement
ARG BAT_VERSION=0.26.1
ARG BAT_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
curl -fsSL "https://github.com/sharkdp/bat/releases/download/v${BAT_VERSION}/bat-v${BAT_VERSION}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
install /tmp/bat-v${BAT_VERSION}-${ARCH}-unknown-linux-musl/bat /usr/local/bin/bat && \
rm -rf /tmp/bat-v${BAT_VERSION}-* && \
V="${BAT_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing bat ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/download/v${V}/bat-v${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
install /tmp/bat-v${V}-${ARCH}-unknown-linux-musl/bat /usr/local/bin/bat && \
rm -rf /tmp/bat-v${V}-* && \
bat --version
# eza — modern ls replacement
ARG EZA_VERSION=0.23.4
ARG EZA_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VERSION}/eza_${ARCH}-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin && \
V="${EZA_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing eza ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/download/v${V}/eza_${ARCH}-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin && \
eza --version | head -1
# zoxide — smarter cd command
ARG ZOXIDE_VERSION=0.9.9
ARG ZOXIDE_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
curl -fsSL "https://github.com/ajeetdsouza/zoxide/releases/download/v${ZOXIDE_VERSION}/zoxide-${ZOXIDE_VERSION}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /usr/local/bin zoxide && \
V="${ZOXIDE_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing zoxide ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/download/v${V}/zoxide-${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /usr/local/bin zoxide && \
zoxide --version
# uv — fast Python package manager (replaces pip, venv, pyenv)
ARG UV_VERSION=0.11.7
# Note: uv releases don't prefix tags with "v" (e.g. tag is "0.11.8").
ARG UV_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
curl -fsSL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
V="${UV_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing uv ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/download/${V}/uv-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
install /tmp/uv-${ARCH}-unknown-linux-musl/uv /usr/local/bin/uv && \
install /tmp/uv-${ARCH}-unknown-linux-musl/uvx /usr/local/bin/uvx && \
rm -rf /tmp/uv-* && \
uv --version
# ── Optional: MemPalace — local-first AI memory system ───────────────
# Provides semantic search over conversation history via 29 MCP tools.
# Palace data persists via the devbox-palace named volume.
# The embedding model (~300 MB) is downloaded on first use and cached
# in the palace directory.
#
# Installed via `uv tool install` into an isolated venv at
# /opt/uv-tools/mempalace/. The `mempalace` CLI goes directly on PATH;
# the MCP server is reached via the /usr/local/bin/mempalace-mcp-server
# wrapper (rootfs/usr/local/bin/mempalace-mcp-server), since system
# python3 cannot import from the isolated venv.
#
# Disable with --build-arg INSTALL_MEMPALACE=false to shave ~300 MB off
# the image (chromadb, torch-adjacent deps).
ARG INSTALL_MEMPALACE=true
ENV UV_TOOL_DIR=/opt/uv-tools
ENV UV_TOOL_BIN_DIR=/usr/local/bin
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
mkdir -p /opt/uv-tools && \
uv tool install --no-cache mempalace && \
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
fi
# rustup — Rust toolchain manager
# Installs the rustup-init binary only. Users bootstrap Rust with:
# rustup-init -y && source ~/.cargo/env
# Toolchains persist via devbox-rustup and devbox-cargo volumes.
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
curl -fsSL "https://static.rust-lang.org/rustup/dist/${ARCH}-unknown-linux-gnu/rustup-init" -o /usr/local/bin/rustup-init && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://static.rust-lang.org/rustup/dist/${ARCH}-unknown-linux-gnu/rustup-init" -o /usr/local/bin/rustup-init && \
chmod +x /usr/local/bin/rustup-init
# gitea-mcp — MCP server for Gitea API (official, Go binary, hosted on gitea.com)
ARG GITEA_MCP_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
V="${GITEA_MCP_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing gitea-mcp ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/download/v${V}/gitea-mcp_Linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin/ gitea-mcp && \
chmod +x /usr/local/bin/gitea-mcp && \
gitea-mcp --version
# Set locale — generate common UTF-8 locales (override via LANG/LC_ALL env vars)
# To add more locales, run: sudo sed -i '/<locale>.UTF-8/s/^# //g' /etc/locale.gen && sudo locale-gen
RUN sed -i -E '/(en_US|en_GB|sv_SE|da_DK|nb_NO|fi_FI|de_DE|fr_FR|es_ES|it_IT|pt_BR|nl_NL|pl_PL|ja_JP|ko_KR|zh_CN)\.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
@@ -120,7 +241,7 @@ ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
# ── Node.js (required for opencode v1.x install + MCP servers) ──────
ARG NODE_VERSION=22
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
RUN curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
apt-get install -y --no-install-recommends nodejs && \
rm -rf /var/lib/apt/lists/*
@@ -135,26 +256,28 @@ RUN ARCH=$(case "${TARGETARCH}" in \
arm64) echo "aarch64" ;; \
*) echo "x86_64" ;; \
esac) && \
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \
unzip -q /tmp/awscli.zip -d /tmp && \
/tmp/aws/install && \
rm -rf /tmp/aws /tmp/awscli.zip && \
aws --version
# ── Optional: Python ─────────────────────────────────────────────────
ARG INSTALL_PYTHON=false
RUN if [ "${INSTALL_PYTHON}" = "true" ]; then \
apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv && \
rm -rf /var/lib/apt/lists/*; \
fi
aws --version
# ── Optional: Go ─────────────────────────────────────────────────────
# Latest stable Go is resolved from https://go.dev/dl/?mode=json when
# GO_VERSION=latest (default). Pass an explicit version like "1.26.2"
# to pin.
ARG INSTALL_GO=false
ARG GO_VERSION=1.26.2
ARG GO_VERSION=latest
RUN if [ "${INSTALL_GO}" = "true" ]; then \
GOARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" | tar -C /usr/local -xz && \
V="${GO_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/?mode=json" | \
awk -F'"' '/"version":/ { sub(/^go/,"",$4); print $4; exit }'); \
fi && \
[ -n "$V" ] && \
echo "Installing Go ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/go${V}.linux-${GOARCH}.tar.gz" | tar -C /usr/local -xz && \
ln -s /usr/local/go/bin/go /usr/local/bin/go && \
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \
fi
@@ -162,11 +285,25 @@ RUN if [ "${INSTALL_GO}" = "true" ]; then \
# ── Optional: oh-my-opencode-slim (multi-agent orchestration) ────────
# Installs Bun runtime and the oh-my-opencode-slim npm package.
# Runtime activation is controlled by ENABLE_OMOS env var in entrypoint.
# Uses the baseline Bun build (SSE4.2 only) for compatibility with older
# CPUs that lack AVX2 (e.g. Sandy Bridge on OpenStack).
ARG INSTALL_OMOS=false
ARG OMOS_VERSION=latest
RUN if [ "${INSTALL_OMOS}" = "true" ]; then \
curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash && \
ARCH=$(uname -m) && \
if [ "$ARCH" = "x86_64" ]; then \
BUN_ARCH="x64-baseline"; \
elif [ "$ARCH" = "aarch64" ]; then \
BUN_ARCH="aarch64"; \
fi && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-${BUN_ARCH}.zip" -o /tmp/bun.zip && \
unzip -o /tmp/bun.zip -d /tmp/bun && \
mv /tmp/bun/bun-linux-${BUN_ARCH}/bun /usr/local/bin/bun && \
chmod +x /usr/local/bin/bun && \
ln -sf bun /usr/local/bin/bunx && \
rm -rf /tmp/bun /tmp/bun.zip && \
bun --version && \
test -L /usr/local/bin/bunx && \
npm install -g oh-my-opencode-slim@${OMOS_VERSION}; \
fi
@@ -184,13 +321,29 @@ RUN mkdir -p /workspace \
/home/${USER_NAME}/.config/opencode/skills \
/home/${USER_NAME}/.agents/skills \
/home/${USER_NAME}/.local/share/opencode \
/home/${USER_NAME}/.cache/bash \
/home/${USER_NAME}/.ssh && \
chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME}
# ── Shell defaults (bash history, aliases, readline) ─────────────────
# Shipped under /etc/skel-devbox/ rather than copied directly to the
# user's home. The entrypoint copies them to /home/developer/ only if
# the target file does not already exist, so host bind-mounts and
# previously-customized files are never overwritten. Users can restore
# the baked defaults anytime via:
# cp /etc/skel-devbox/.bash_aliases ~/.bash_aliases
# History itself persists via the devbox-shell-history named volume
# mounted at ~/.cache/bash (HISTFILE points there).
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
# ── Entrypoint ────────────────────────────────────────────────────────
COPY rootfs/usr/local/lib/opencode-devbox/ /usr/local/lib/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
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
/usr/local/lib/opencode-devbox/*.py
# Start as root — entrypoint adjusts UID/GID then drops to developer
WORKDIR /workspace
+180 -16
View File
@@ -128,14 +128,16 @@ docker compose exec -u developer devbox aws --version
### Custom opencode config
For full control over opencode settings (MCP servers, custom models, oh-my-opencode-slim agents, etc.), mount the entire config directory from the host:
For full control over opencode settings (MCP servers, custom models, and — on the OMOS variant — oh-my-opencode-slim agents), mount the entire config directory from the host:
```yaml
volumes:
- ~/.config/opencode:/home/developer/.config/opencode
```
This persists all configuration changes across container restarts, including `opencode.json`, `oh-my-opencode-slim.json`, and skills. When an existing `opencode.json` is found, the `OPENCODE_PROVIDER` auto-config is skipped.
This persists all configuration changes across container restarts, including `opencode.json`, skills, and (on the OMOS variant) `oh-my-opencode-slim.json`. When an existing `opencode.json` is found, the `OPENCODE_PROVIDER` auto-config is skipped.
> **Portability note:** The mounted config runs inside a Linux container. Any absolute paths inside `opencode.json` (for example, host-specific `plugin` entries like `file:///usr/local/lib/node_modules/...` or `file:///opt/homebrew/...`) will not resolve inside the container. Prefer bare package specifiers (e.g. `"oh-my-opencode-slim"`) that resolve via `node_modules` lookup, which works on both macOS and Linux hosts.
### Custom skills
@@ -271,11 +273,17 @@ volumes:
- devbox-vscode:/home/developer/.vscode-server
```
### Shared machine setup (multiple users, single OS account)
### Multi-user setup
For machines where multiple users share one OS account (e.g. a common `garage` user), a separate compose file isolates each user's config and data using a `SIGNUM` variable.
The shared-machine compose file (`docker-compose.shared.yml`) supports two modes:
Each user creates their own directory and setup:
**Own-account mode** (each user has their own OS login — the common case):
Leave `SIGNUM` unset in `.env`. The project name defaults to `devbox-$USER`, so each OS user automatically gets isolated container names and named volumes with zero configuration.
**Shared-account mode** (everyone logs in as the same OS user, e.g. `garage`):
Each user sets `SIGNUM=<unique-id>` in `.env` to get isolation.
Setup per user:
```bash
# Replace <signum> with your username/identifier
@@ -289,17 +297,17 @@ cp /path/to/opencode-devbox/.env.shared.example .env
# Create per-user config directory
mkdir -p ~/<signum>/.config/opencode
# Edit .env with your signum, provider, keys, etc.
# Edit .env — set SIGNUM only if you're in shared-account mode
vim .env
# Start
docker compose up -d
docker compose exec -u developer devbox-<signum> opencode
docker compose exec -u developer devbox opencode
```
Each user's container, config, and named volumes are fully isolated:
- Container name: `devbox-<signum>` (no collisions)
- Named volumes: prefixed with the project directory name (automatic per-user isolation)
- Container name: `devbox-<signum>` (or `devbox-$USER` in own-account mode)
- Named volumes: prefixed with the project name (`devbox-<signum>_devbox-data`, etc.) — the Docker daemon is system-wide, so directory-name prefixing alone is NOT sufficient for isolation
- Opencode config: `~/<signum>/.config/opencode/` (per-user settings, OMOS config, etc.)
See `docker-compose.shared.yml` and `.env.shared.example` for the full configuration.
@@ -319,19 +327,24 @@ docker compose run --rm --build devbox
### Build Args
Enable optional language runtimes or pin a specific opencode version:
Enable optional language runtimes, pin a specific opencode version, or lock any of the tooling components:
```bash
docker compose build --build-arg INSTALL_PYTHON=true --build-arg INSTALL_GO=true
docker compose build --build-arg INSTALL_GO=true
docker compose build --build-arg OPENCODE_VERSION=1.5.0
docker compose build --build-arg NVIM_VERSION=0.12.1 # pin to a specific version
```
| Arg | Default | Description |
|---|---|---|
| `INSTALL_PYTHON` | `false` | Python 3 + pip + venv |
| `INSTALL_GO` | `false` | Go toolchain |
| `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_OMOS` | `false` | [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration (installs Bun and plugin) |
| `OMOS_VERSION` | `latest` | Pin a specific oh-my-opencode-slim version |
| `OPENCODE_VERSION` | *(pinned per release)* | opencode npm version. Drives the image tag and is intentionally not floated. |
| `NODE_VERSION` | `22` | Node.js major version. Pinned to protect against upstream breaking changes across majors. |
| `GOSU_VERSION`, `FZF_VERSION`, `GIT_LFS_VERSION`, `NVIM_VERSION`, `BAT_VERSION`, `EZA_VERSION`, `ZOXIDE_VERSION`, `UV_VERSION`, `GITEA_MCP_VERSION`, `GO_VERSION`, `OMOS_VERSION` | `latest` | All GitHub/Gitea/go.dev-hosted binaries resolve to the newest upstream release at build time. Override with a specific version to pin. Resolved versions are logged in CI output. |
> **Reproducibility note:** With `latest` defaults, two builds of the same `v{opencode}` tag may embed different tool versions if upstream releases have happened in between. This is intentional — it means every rebuild picks up upstream CVE fixes automatically. If you need a bit-for-bit reproducible build, pass explicit `*_VERSION` args. The CI smoke test logs the resolved versions for every release build.
## oh-my-opencode-slim (Multi-Agent Orchestration)
@@ -436,6 +449,153 @@ The `--use-device-code` flag outputs a URL and short code instead of trying to o
SSO sessions typically last 812 hours before requiring re-authentication. Since `~/.aws` is mounted from the host, tokens persist across container restarts.
## MemPalace — persistent AI memory
The image includes [MemPalace](https://github.com/MemPalace/mempalace), a local-first AI memory system that stores conversation history verbatim and retrieves it via semantic search. Nothing leaves your machine.
> MemPalace adds ~300 MB to the image (chromadb, embedding model deps). If you don't use it, rebuild with `--build-arg INSTALL_MEMPALACE=false` to shrink the image.
### Enabling persistence
Uncomment the palace volume in `docker-compose.yml`:
```yaml
- devbox-palace:/home/developer/.mempalace
```
Without the volume, palace data lives in the container's writable layer and is lost on `--force-recreate`.
### MCP integration with opencode
Add mempalace as an MCP server in your `opencode.json` (inside `~/.config/opencode/`):
```json
{
"mcp": {
"mempalace": {
"type": "local",
"command": ["mempalace-mcp"]
}
}
}
```
> The image installs mempalace into an isolated `uv tool` venv at `/opt/uv-tools/mempalace/`. `uv tool install` places `mempalace-mcp` on `PATH` as a shim whose shebang points at the venv's Python, so MCP clients can invoke it as a normal binary without worrying about the venv. Do **not** use `["python3", "-m", "mempalace.mcp_server"]` — the system Python cannot import from the uv-managed venv and you'll get `ModuleNotFoundError` / `MCP error -32000: connection closed`.
This gives opencode access to 29 MCP tools for searching memory, querying the knowledge graph, managing wings/rooms/drawers, and agent diaries.
### Basic usage
```bash
# Mine project files into the palace
mempalace mine /workspace
# Mine conversation transcripts
mempalace mine ~/.local/share/opencode/ --mode convos
# Search memory
mempalace search "why did we switch to eno1"
# Load context for a new session
mempalace wake-up
```
Each workspace gets its own isolated "wing" — memories never leak between projects.
### Storage
Two separate named volumes keep different data classes apart:
- **Palace data** (`~/.mempalace/`): ChromaDB vectors, SQLite knowledge graph, drawers. This is your memory — back it up, treat it as precious. Persists via the `devbox-palace` named volume.
- **Embedding model cache** (`~/.cache/chroma/`): ONNX model (~79 MB), downloaded automatically on first search. Disposable — blow it away and it re-downloads in ~4 seconds. Persists via the `devbox-chroma-cache` named volume so you don't re-download on every container recreation.
- **No API keys required** for core functionality (local embeddings via ONNX).
Both volumes are commented out by default in `docker-compose.yml` — uncomment to enable:
```yaml
- devbox-palace:/home/developer/.mempalace
- devbox-chroma-cache:/home/developer/.cache/chroma
```
**Air-gapped environments:** pre-populate the `devbox-chroma-cache` volume with the `all-MiniLM-L6-v2/` model contents. The palace volume needs no pre-population.
## Gitea MCP server
The image includes the [official Gitea MCP server](https://gitea.com/gitea/gitea-mcp) (`gitea-mcp`), providing 50+ MCP tools for interacting with self-hosted Gitea instances — repositories, issues, pull requests, releases, branches, wiki, and Actions.
### Setup
1. Create a Personal Access Token on your Gitea instance (Settings → Applications → Generate Token, scopes: `repo`, `read:user`).
2. Add to your `.env`:
```env
GITEA_HOST=https://your-gitea-instance.example.com
GITEA_ACCESS_TOKEN=your_token_here
```
3. Enable the gitea MCP server in your `opencode.json`:
```json
{
"mcp": {
"gitea": {
"type": "local",
"command": ["gitea-mcp", "-t", "stdio", "--host", "{env:GITEA_HOST}"],
"environment": {
"GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}"
},
"enabled": true
}
}
}
```
The server is installed but disabled by default — it requires authentication to be useful.
## Shell defaults
The image ships a baked `.bash_aliases` and `.inputrc` with quality-of-life defaults. On first container start they are copied from `/etc/skel-devbox/` into `/home/developer/` **only if the target file does not already exist** — so host bind-mounts and any version you've customized inside the container are never overwritten on upgrade.
Defaults you get out of the box:
- **Prefix history search** on Up/Down arrows (type `git `, press Up, walk back through prior `git ...` commands only). Ctrl-Up / Ctrl-Down still step through full history.
- **Persistent history** — `$HISTFILE` points at `~/.cache/bash/history`, backed by the `devbox-shell-history` named volume so history survives container recreation. Timestamps, 100 000 entries, dedup.
- **Case-insensitive tab completion**, coloured completion lists, `show-all-if-ambiguous`.
- **Aliases** — `ls`/`ll`/`la` use `eza`, `cat` uses `bat`, `gs`/`gd`/`gl` for git, safe `rm`/`mv`/`cp`.
- **Integrations** — `zoxide` (`z <fragment>` to jump), `fzf` Ctrl-R / Ctrl-T key bindings.
- **Prompt marker** — `[devbox]` prefix so it's always obvious you're inside the container.
### Overriding the defaults
**Option A — bind-mount host files.** Uncomment the bind-mount lines in `docker-compose.yml`:
```yaml
- ~/.bash_aliases:/home/developer/.bash_aliases:ro
- ~/.inputrc:/home/developer/.inputrc:ro
```
> **Single-file bind-mount caveat (all platforms):** Docker bind-mounts the file's **inode**, not its path. When editors like vim, nvim, VS Code, or `sed -i` save a file, they write to a temp file and `rename()` it over the original — creating a new inode. The container stays pinned to the old (now unlinked) inode and never sees the update. This is a kernel limitation ([Docker #15793](https://github.com/moby/moby/issues/15793)), not fixable by Docker. Append-only writes (`echo "alias foo=bar" >> file`) are safe because they modify the same inode. **Workaround:** mount the parent directory instead of the single file (e.g. `~/.config/devbox-shell:/home/developer/.config/devbox-shell:ro`) and source files from there.
**Option B — customize inside the container.** Just edit `~/.bash_aliases` or `~/.inputrc` as normal. Pair this with a bind-mount or named volume on the home dir if you want the edits to survive container recreation.
### Restoring or diffing defaults
The skel files remain available inside every container at `/etc/skel-devbox/`. Useful commands:
```bash
# See what the image currently ships
cat /etc/skel-devbox/.bash_aliases
# Diff your current config against the upstream defaults
diff ~/.bash_aliases /etc/skel-devbox/.bash_aliases
# Reset to the baked defaults
cp /etc/skel-devbox/.bash_aliases ~/.bash_aliases
# …or delete the file and recreate the container — the entrypoint
# copies from /etc/skel-devbox/ on next start if the target is absent
rm ~/.bash_aliases
```
## Secret Scanning
A [gitleaks](https://github.com/gitleaks/gitleaks) pre-commit hook prevents accidentally committing API keys, passwords, or other secrets.
@@ -477,7 +637,7 @@ 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
├── neovim 0.12, tmux, htop, bat, eza, zoxide, uv, rustup, make, gcc, g++, rsync
├── git, git-crypt, age, ssh, ripgrep, fd, fzf, jq, curl, tree
├── Node.js (for MCP servers)
├── Bun (optional — included with oh-my-opencode-slim)
@@ -493,11 +653,15 @@ Container (Debian trixie)
| `/home/developer/.ssh` | Host bind mount (ro) | ✅ Yes | SSH keys |
| `/home/developer/.aws` | Host bind mount (if configured) | ✅ Yes | AWS credentials/SSO cache |
| `/home/developer/.local/share/opencode` | Named volume `devbox-data` | ✅ Yes | Session history, memory |
| `/home/developer/.local/state/opencode` | Named volume `devbox-state` | ✅ Yes | TUI settings (theme, toggles) |
| `/home/developer/.cache/bash` | Named volume `devbox-shell-history` | ✅ Yes | Bash history (`$HISTFILE`), survives container recreate |
| `/home/developer/.local/share/zoxide` | Named volume `devbox-zoxide` | ✅ Yes | Zoxide directory history (`z <fragment>` jump targets) |
| `/home/developer/.local/share/nvim` | Named volume `devbox-nvim-data` | ✅ Yes | Neovim plugins, Mason LSP installs, Lazy plugin cache |
| `/home/developer/.local/share/uv` | Named volume `devbox-uv` (if configured) | ✅ Yes | Python installs, uv tool installs |
| `/home/developer/.rustup` | Named volume `devbox-rustup` (if configured) | ✅ Yes | Rust toolchains |
| `/home/developer/.cargo` | Named volume `devbox-cargo` (if configured) | ✅ Yes | Cargo binaries, registry cache |
| `/home/developer/.vscode-server` | Named volume `devbox-vscode` (if configured) | ✅ Yes | VS Code server and extensions |
| `/home/developer/.config/opencode` | Host bind mount (if configured) | ✅ Yes | opencode.json, oh-my-opencode-slim.json, skills |
| `/home/developer/.config/opencode` | Host bind mount (if configured) | ✅ Yes | opencode.json, skills, plus `oh-my-opencode-slim.json` on the OMOS variant |
**opencode config** (`opencode.json`) is auto-generated from `OPENCODE_PROVIDER` on each start. It sets provider and model only — no MCP servers. To persist config changes and use custom settings, mount the config directory from the host (see Custom opencode config above).
+318
View File
@@ -0,0 +1,318 @@
# Deploy — Host VM setup
Scripts for setting up a fresh Linux VM to host opencode-devbox.
## Files
- **`cloud-init.yml`** — cloud-init user-data template for automated VM provisioning on OpenStack, Proxmox, or any cloud with cloud-init support
- **`setup-host.sh`** — interactive post-install script for VMs that weren't provisioned with cloud-init
- **`setup-openstack-secgroup.sh`** — creates an OpenStack security group with the right rules (SSH, mosh, ICMP)
- **`sync-to-vm.sh`** — syncs local config directories (`~/.aws`, `~/.config/opencode`, etc.) to a remote VM based on which bind mounts are active in its `docker-compose.yml`
## Supported distributions
- **Debian 13 (Trixie)** — recommended (matches opencode-devbox base image)
- **Ubuntu 24.04 LTS** — also works
Other distributions will need manual adaptation.
## Quick start
### Option 1: Cloud-init (automated)
Customize `cloud-init.yml` — replace the SSH public key and optionally the hostname/timezone. Then use it during VM creation:
- **Proxmox**: attach as cloud-init user-data
- **OpenStack**: pass via `--user-data` flag (see full example below)
- **AWS/DigitalOcean/etc**: paste into the "user data" field
#### Full OpenStack example
Cloud-init only handles guest configuration — flavor, image, network, and security group must be specified explicitly at creation time.
> **Note:** Do not use `--key-name` — the SSH key is configured in `cloud-init.yml` under `ssh_authorized_keys` for the `devbox` user. The `--key-name` flag injects into the image's default user (e.g. `debian`), not the `devbox` user created by cloud-init.
```bash
# List available flavors to choose appropriate sizing
openstack flavor list
# Create the security group first (one-time, see below)
./setup-openstack-secgroup.sh
# Basic — boot from default storage
openstack server create \
--flavor c4m8 \
--image Debian-13-Trixie \
--network my-network \
--security-group opencode-devbox \
--user-data cloud-init.yml \
devbox-vm
```
If your cloud offers NVMe-backed (performance) volumes, boot from one for faster Docker and build I/O:
```bash
# Performance — boot from NVMe volume (40GB, preserved on instance deletion)
openstack server create \
--flavor c4m8 \
--network my-network \
--security-group opencode-devbox \
--user-data cloud-init.yml \
--block-device source_type=image,uuid=$(openstack image show Debian-13-Trixie -f value -c id),destination_type=volume,volume_size=40,delete_on_termination=false,boot_index=0,volume_type=performance \
devbox-vm
```
> **Note:** The inline `volume_type` parameter requires API microversion 2.67+. If the server goes to ERROR state, check your volume quota (`openstack quota show`) and try creating the volume separately:
> ```bash
> openstack volume create --image Debian-13-Trixie --size 40 --type performance --bootable devbox-boot-volume
> openstack server create --flavor c4m8 --volume devbox-boot-volume --network my-network --security-group opencode-devbox --user-data cloud-init.yml devbox-vm
> ```
#### Floating IP
OpenStack doesn't support assigning a floating IP at instance creation time — it's a separate step after the VM is active:
```bash
# Allocate a new floating IP from the external network
openstack floating ip create <external-network>
# Assign it to the VM
openstack server add floating ip devbox-vm <floating-ip>
```
To find your external network name: `openstack network list --external`. If you already have an unassigned floating IP, skip the create step.
The VM boots with Docker installed, firewall configured (or skipped on OpenStack), and your SSH key authorized. Log in as the `devbox` user.
### Console password (optional)
The cloud-init template uses SSH key authentication only — no password is set by default. This is sufficient for normal use since the `devbox` user has passwordless `sudo`.
A password is only needed for:
- **Emergency console access** — logging in via OpenStack Horizon console (noVNC) or Proxmox VNC when SSH is unreachable
- **`su - devbox`** — switching to the devbox user from another account
To enable console access, uncomment the `chpasswd` block in `cloud-init.yml` before deploying:
```yaml
chpasswd:
expire: false
users:
- name: devbox
password: your-password-here
type: text
```
For an already-running VM, set a password via SSH:
```bash
sudo passwd devbox
```
### Option 2: Post-install script (manual)
On a fresh Debian/Ubuntu VM:
```bash
curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/deploy/setup-host.sh | bash
```
Or clone and run:
```bash
git clone https://gitea.jordbo.se/joakimp/opencode-devbox
cd opencode-devbox/deploy
./setup-host.sh
```
## What gets installed
- Docker Engine (from Docker's official apt repo, not distro's `docker.io`)
- Docker Compose plugin (v2)
- `tmux`, `mosh`, `git`
- `ufw` firewall with SSH (22) and mosh (UDP 60000-61000) allowed — **skipped on OpenStack** (detected automatically; use security groups instead)
- IPv4 DNS preference (works around Docker Hub IPv6 connectivity issues)
## OpenStack security groups
On OpenStack, firewalling is handled by security groups rather than ufw. The `setup-host.sh` script detects OpenStack automatically and skips ufw configuration.
To create the required security group:
```bash
./setup-openstack-secgroup.sh
```
This creates a security group named `opencode-devbox` with rules for SSH (TCP 22), mosh (UDP 60000-61000), and ICMP. Apply it to your instance:
```bash
# New instance
openstack server create --security-group opencode-devbox ...
# Existing instance
openstack server add security group <instance-name> opencode-devbox
```
## VM sizing recommendations
| Use case | vCPU | RAM | Disk |
|---|---|---|---|
| Minimum | 2 | 4 GB | 20 GB |
| Recommended | 4 | 8 GB | 40 GB |
| Heavy use (Rust/Python builds, multi-project) | 8 | 16 GB | 80 GB |
## After VM setup
If you uncomment any bind mounts in `docker-compose.yml` (e.g. `~/.aws`, `~/.config/opencode`), create the directories first — Docker creates missing bind mount paths as root-owned, which causes permission issues:
```bash
# Only create directories for mounts you uncomment
mkdir -p ~/.aws # AWS Bedrock SSO
mkdir -p ~/.config/opencode # persistent opencode config
mkdir -p ~/.config/nvim # custom neovim config
mkdir -p ~/.agents/skills # opencode agent skills
```
Named volumes (`devbox-data`, `devbox-uv`, etc.) are managed by Docker and need no pre-creation.
```bash
mkdir -p ~/opencode-devbox && cd ~/opencode-devbox
curl -sL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/docker-compose.yml -o docker-compose.yml
curl -sL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.example -o .env
vim .env # configure provider and keys
vim docker-compose.yml # uncomment optional volume mounts
docker compose up -d
docker compose exec -u developer devbox opencode
```
> **AWS Bedrock users:** Uncomment the `~/.aws` volume mount in `docker-compose.yml` before starting. You'll also need to copy your `~/.aws/config` from a machine where SSO is already configured, then authenticate inside the container with `aws sso login`.
### Syncing local config to the VM
After editing `docker-compose.yml` on the VM to uncomment the bind mounts you need, run `sync-to-vm.sh` from your local machine to copy the corresponding directories:
```bash
./deploy/sync-to-vm.sh devbox-affection
```
The script reads `docker-compose.yml` on the remote VM, detects which bind mounts are active, and syncs only those directories from your local machine. It also creates the remote directories if they don't exist.
### Upgrading an existing VM to a new release
Each tagged release may add new named volumes or bind-mount lines to `docker-compose.yml`. Pulling a new image via `docker compose pull` grabs the new container behaviour, but compose files on the VM are user-owned and never touched by the image — you have to reconcile them yourself when upgrading across versions.
**Symptom of a missed reconcile:** a new feature quietly doesn't work even though the image is correct. Example from v1.14.19c → v1.14.20: bash history persistence requires the `devbox-shell-history` named volume mounted at `/home/developer/.cache/bash`. The v1.14.20 image writes history to that path either way, but without the volume mount on the VM, writes land in the container's writable layer and vanish on every `--force-recreate`.
**Upgrade ritual:**
```bash
# On the VM, before recreating the container:
cd ~/opencode-devbox
cp docker-compose.yml docker-compose.yml.bak-$(date +%Y%m%d-%H%M%S)
# Compare against the repo version to see what's new:
# (from your local checkout)
scp devbox-affection:~/opencode-devbox/docker-compose.yml /tmp/vm-compose.yml
diff -u /tmp/vm-compose.yml ~/src/src_local/opencode-devbox/docker-compose.yml
```
For each new `volumes:` entry or mount line in the repo version that isn't in your VM's file, add it manually — preserving any local customizations you've made (image variant, read/write flags on bind mounts, etc.). Then:
```bash
docker compose config >/dev/null # verify YAML still parses
docker compose up -d --force-recreate
```
If you maintain the VM's compose file with no local changes, `scp` the repo version over wholesale. If you have customizations (the common case), do the diff-and-merge by hand.
### Shell defaults inside the container
The image ships baked `.bash_aliases` and `.inputrc` in `/etc/skel-devbox/` — quality-of-life defaults (prefix history search on Up/Down arrows, persistent history across container recreates via the `devbox-shell-history` named volume, `[devbox]` prompt marker, sensible aliases). On first container start the entrypoint copies them to `/home/developer/` **only if the target file does not already exist**.
This means:
- Fresh containers get the defaults automatically.
- If you bind-mount your host's `~/.bash_aliases` / `~/.inputrc` (see the commented lines in `docker-compose.yml`), your host versions win.
- If you edit the files inside a running container and store them via a home-dir bind-mount or equivalent, subsequent upgrades never overwrite them.
- To restore the baked defaults any time: `cp /etc/skel-devbox/.bash_aliases ~/` (or delete the file and recreate the container).
- To diff your current config against what the image ships: `diff ~/.bash_aliases /etc/skel-devbox/.bash_aliases`.
### CI runner maintenance: automatic Docker pruning
Gitea Actions runners accumulate Docker build cache, stale buildkit containers, and unused images over time. Without periodic cleanup, the runner's disk fills up and builds stall during the image-push phase (symptom: `#61 exporting to image` / `pushing layers` hangs indefinitely while buildkit repeatedly re-authenticates with Docker Hub).
Set up two layers of automatic cleanup on the runner host:
**1. Daily cron job** — prunes images, containers, and build cache older than 72 hours:
```bash
sudo tee /etc/cron.daily/docker-prune <<'EOF'
#!/bin/sh
docker system prune -af --filter "until=72h" > /var/log/docker-prune.log 2>&1
docker builder prune -af --filter "until=72h" >> /var/log/docker-prune.log 2>&1
EOF
sudo chmod +x /etc/cron.daily/docker-prune
```
**2. Docker daemon builder GC** — caps buildkit cache at 10 GB (Docker 23.0+):
Add to `/etc/docker/daemon.json` (create if absent):
```json
{
"builder": {
"gc": {
"enabled": true,
"defaultKeepStorage": "10GB"
}
}
}
```
Then `sudo systemctl restart docker`.
Both are safe to run on a machine that also hosts long-running containers (like opencode-devbox) — `docker system prune` only removes *unused* images and *stopped* containers, never running ones.
### Troubleshooting: SSH hangs or "banner exchange" timeouts
If SSH to the VM intermittently fails with `Connection timed out during banner exchange` or pure TCP connect timeouts — especially after the first few successful connects in a short window — the cause is almost certainly your ISP's CGNAT (Carrier-Grade NAT), not the VM.
**Symptoms**
- First 34 SSH connects succeed, then subsequent ones fail hard for 2030 minutes
- `ping` to the VM works perfectly throughout (ICMP isn't tracked the same way)
- `mosh` sessions stay stable once established (UDP, different flow table)
- Happens on residential ISPs (Tele2, Comhem, Telia, most European consumer broadband)
- VM-side logs show SSH is idle — the SYNs never reach it
**Cause**
Residential CGNAT boxes keep a per-subscriber TCP flow table with a small concurrent-flow cap (~4) per destination IP. Once exhausted, new SYNs to that destination are silently dropped until old flows age out (typically 2030 min after TCP close).
**Fix**
Add SSH connection multiplexing on your client so all SSH sessions (interactive, `scp`, `rsync`, scripts) share a single TCP connection to the VM:
```ssh-config
# ~/.ssh/config
Host <vm-alias>
HostName <vm-ip>
User devbox
IdentityFile ~/.ssh/id_ed25519
ControlMaster auto
ControlPath ~/.ssh/cm/%r@%h:%p
ControlPersist 4h
ServerAliveInterval 30
ServerAliveCountMax 6
```
Then create the socket directory:
```bash
mkdir -p ~/.ssh/cm && chmod 700 ~/.ssh/cm
```
All SSH to the VM now multiplexes over a single flow slot, regardless of how many parallel sessions you open. `sync-to-vm.sh` already does this internally for its own rsync/scp calls.
For a more robust long-term fix (especially if you access the VM from multiple hosts), run a WireGuard tunnel on the VM and route SSH through that — UDP bypasses the TCP flow table entirely.
+110
View File
@@ -0,0 +1,110 @@
#cloud-config
# cloud-init template for opencode-devbox host VM
# Tested on Debian 13 (Trixie) and Ubuntu 24.04
#
# Usage:
# - Proxmox: attach this file as cloud-init user-data in VM config
# - OpenStack: pass as --user-data when creating the instance
# - Cloud providers: paste into "user data" field
#
# Customize the marked sections before use.
# ── Hostname ─────────────────────────────────────────────────────────
hostname: devbox
manage_etc_hosts: true
# ── User ─────────────────────────────────────────────────────────────
users:
- name: devbox
groups: sudo, docker
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
# CUSTOMIZE: replace with your public SSH key.
# This is the only SSH key config needed — do NOT use --key-name with
# openstack server create, as that injects into the image's default
# user (e.g. debian), not the devbox user defined here.
- ssh-ed25519 AAAA... your-key-here
# ── Optional: console password ───────────────────────────────────────
# Uncomment to set a password for the devbox user. Only needed for
# emergency access via the OpenStack/Proxmox console (VNC/noVNC).
# SSH key authentication is used for normal access.
#
# chpasswd:
# expire: false
# users:
# - name: devbox
# password: your-password-here
# type: text
# ── Locale and timezone ──────────────────────────────────────────────
# en_US.UTF-8 is pre-generated on Debian/Ubuntu and works out of the box.
# To use a different locale (e.g. sv_SE.UTF-8), add it to the runcmd
# section before the locale is applied:
# - locale-gen sv_SE.UTF-8
# Then change the locale line below to match.
locale: en_US.UTF-8
timezone: Europe/Stockholm
# ── Package installation ─────────────────────────────────────────────
package_update: true
package_upgrade: true
packages:
- ca-certificates
- curl
- gnupg
- git
- tmux
- mosh
- rsync
- fzf
- ripgrep
- ufw
# ── Commands to run at first boot ────────────────────────────────────
runcmd:
# Install Docker from official repository
- install -m 0755 -d /etc/apt/keyrings
- curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
- chmod a+r /etc/apt/keyrings/docker.asc
- echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo \"$ID\") $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" > /etc/apt/sources.list.d/docker.list
- apt-get update
- apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
- usermod -aG docker devbox
# Firewall — skip on OpenStack (use security groups instead)
- |
if curl -s --connect-timeout 2 http://169.254.169.254/openstack/ >/dev/null 2>&1; then
echo "OpenStack detected — skipping ufw (use security groups instead)"
else
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 60000:61000/udp
ufw --force enable
fi
# Disable IPv6 preference for Docker (avoids intermittent Docker Hub connectivity issues)
- echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
# Create projects directory for the user
- mkdir -p /home/devbox/projects
- chown devbox:devbox /home/devbox/projects
# ── Final message ───────────────────────────────────────────────────
final_message: |
opencode-devbox host VM ready.
Next steps:
1. SSH in: ssh devbox@<this-host>
2. Clone your opencode-devbox compose config, or:
mkdir -p ~/opencode-devbox && cd ~/opencode-devbox
curl -sL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/docker-compose.yml -o docker-compose.yml
curl -sL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.example -o .env
3. Edit .env with your provider and keys
4. Edit docker-compose.yml to uncomment optional mounts (e.g. ~/.aws for Bedrock)
5. docker compose up -d
6. docker compose exec -u developer devbox opencode
Cloud-init run completed in $UPTIME seconds.
+145
View File
@@ -0,0 +1,145 @@
#!/bin/bash
# setup-host.sh — Post-install script for opencode-devbox host VM
#
# Run this on a fresh Debian 13 or Ubuntu 24.04 VM to set up everything
# needed to run opencode-devbox containers.
#
# Usage:
# curl -fsSL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/deploy/setup-host.sh | bash
#
# Or clone and run:
# git clone https://gitea.jordbo.se/joakimp/opencode-devbox
# cd opencode-devbox/deploy
# ./setup-host.sh
set -euo pipefail
# ── Colors ──────────────────────────────────────────────────────────
BOLD="\033[1m"; GREEN="\033[32m"; YELLOW="\033[33m"; RED="\033[31m"; RESET="\033[0m"
info() { echo -e "${BOLD}==>${RESET} $*"; }
ok() { echo -e "${GREEN}${BOLD}${RESET} $*"; }
warn() { echo -e "${YELLOW}${BOLD}!${RESET} $*"; }
err() { echo -e "${RED}${BOLD}${RESET} $*" >&2; }
# ── Detect distro ──────────────────────────────────────────────────
if [[ ! -f /etc/os-release ]]; then
err "Cannot detect Linux distribution — /etc/os-release missing"
exit 1
fi
. /etc/os-release
case "$ID" in
debian|ubuntu)
info "Detected $PRETTY_NAME"
;;
*)
err "Unsupported distribution: $ID — this script only supports Debian and Ubuntu"
exit 1
;;
esac
# ── Require sudo ────────────────────────────────────────────────────
if [[ $EUID -eq 0 ]]; then
err "Do not run as root — use a regular user with sudo"
exit 1
fi
if ! sudo -n true 2>/dev/null; then
warn "This script needs sudo access. You may be prompted for your password."
fi
# ── Update packages ─────────────────────────────────────────────────
info "Updating package index..."
sudo apt-get update -qq
info "Installing base packages..."
sudo apt-get install -y --no-install-recommends \
ca-certificates curl gnupg git tmux mosh rsync fzf ripgrep ufw
# ── Docker ──────────────────────────────────────────────────────────
if command -v docker &>/dev/null; then
ok "Docker already installed ($(docker --version))"
else
info "Installing Docker from official repository..."
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL "https://download.docker.com/linux/${ID}/gpg" -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update -qq
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
ok "Docker installed: $(docker --version)"
fi
# ── Add user to docker group ────────────────────────────────────────
if groups | grep -q docker; then
ok "User already in docker group"
else
info "Adding $USER to docker group..."
sudo usermod -aG docker "$USER"
warn "You must log out and back in for docker group to take effect"
warn "Or run: newgrp docker"
fi
# ── Firewall ────────────────────────────────────────────────────────
# Detect OpenStack — if running on OpenStack, skip ufw (security groups handle firewalling)
SKIP_UFW=false
if curl -s --connect-timeout 2 http://169.254.169.254/openstack/ &>/dev/null; then
SKIP_UFW=true
warn "OpenStack detected — skipping ufw (use security groups instead)"
warn "Ensure your security group allows: SSH (22/tcp), mosh (60000-61000/udp)"
fi
if [[ "$SKIP_UFW" == "false" ]]; then
info "Configuring firewall (ufw)..."
sudo ufw default deny incoming >/dev/null
sudo ufw default allow outgoing >/dev/null
sudo ufw allow ssh >/dev/null
sudo ufw allow 60000:61000/udp comment 'mosh' >/dev/null
if ! sudo ufw status | grep -q "Status: active"; then
sudo ufw --force enable
fi
ok "Firewall active — SSH and mosh allowed"
fi
# ── IPv4 preference for Docker Hub ──────────────────────────────────
if ! grep -q 'precedence ::ffff:0:0/96' /etc/gai.conf 2>/dev/null; then
info "Setting IPv4 preference in /etc/gai.conf..."
echo 'precedence ::ffff:0:0/96 100' | sudo tee -a /etc/gai.conf > /dev/null
ok "IPv4 preferred for DNS resolution"
fi
# ── Create projects directory ───────────────────────────────────────
if [[ ! -d "$HOME/projects" ]]; then
mkdir -p "$HOME/projects"
ok "Created ~/projects"
fi
# ── Done ────────────────────────────────────────────────────────────
echo ""
ok "Host setup complete"
echo ""
cat <<EOF
${BOLD}Next steps:${RESET}
1. If you weren't already in the docker group, log out and back in:
exit
ssh <your-user>@<this-host>
2. Set up opencode-devbox:
mkdir -p ~/opencode-devbox && cd ~/opencode-devbox
curl -sL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/docker-compose.yml -o docker-compose.yml
curl -sL https://gitea.jordbo.se/joakimp/opencode-devbox/raw/branch/main/.env.example -o .env
3. Edit .env with your provider and API keys:
vim .env
4. Start and connect:
docker compose up -d
docker compose exec -u developer devbox opencode
EOF
+63
View File
@@ -0,0 +1,63 @@
#!/bin/bash
# setup-openstack-secgroup.sh — Create an OpenStack security group for opencode-devbox
#
# Prerequisites:
# - OpenStack CLI installed (pip install python-openstackclient)
# - Authenticated (source your openrc.sh or clouds.yaml configured)
#
# Usage:
# ./setup-openstack-secgroup.sh [group-name]
#
# Default group name: opencode-devbox
set -euo pipefail
GROUP_NAME="${1:-opencode-devbox}"
BOLD="\033[1m"; GREEN="\033[32m"; YELLOW="\033[33m"; RESET="\033[0m"
info() { echo -e "${BOLD}==>${RESET} $*"; }
ok() { echo -e "${GREEN}${BOLD}${RESET} $*"; }
warn() { echo -e "${YELLOW}${BOLD}!${RESET} $*"; }
if ! command -v openstack &>/dev/null; then
echo "Error: openstack CLI not found. Install with: pip install python-openstackclient"
exit 1
fi
# Check if group already exists
if openstack security group show "$GROUP_NAME" &>/dev/null; then
warn "Security group '$GROUP_NAME' already exists — updating rules"
else
info "Creating security group '$GROUP_NAME'..."
openstack security group create "$GROUP_NAME" \
--description "opencode-devbox: SSH, mosh, HTTPS"
ok "Security group created"
fi
# Add rules (idempotent — OpenStack ignores duplicates)
info "Adding rules..."
# SSH (TCP 22)
openstack security group rule create "$GROUP_NAME" \
--protocol tcp --dst-port 22 --remote-ip 0.0.0.0/0 \
--description "SSH" 2>/dev/null && ok "SSH (TCP 22)" || warn "SSH rule already exists"
# Mosh (UDP 60000-61000)
openstack security group rule create "$GROUP_NAME" \
--protocol udp --dst-port 60000:61000 --remote-ip 0.0.0.0/0 \
--description "mosh" 2>/dev/null && ok "mosh (UDP 60000-61000)" || warn "mosh rule already exists"
# ICMP (ping — useful for diagnostics)
openstack security group rule create "$GROUP_NAME" \
--protocol icmp --remote-ip 0.0.0.0/0 \
--description "ICMP ping" 2>/dev/null && ok "ICMP ping" || warn "ICMP rule already exists"
echo ""
ok "Security group '$GROUP_NAME' ready"
echo ""
echo -e "${BOLD}Apply to a new instance:${RESET}"
echo " openstack server create --security-group $GROUP_NAME ..."
echo ""
echo -e "${BOLD}Apply to an existing instance:${RESET}"
echo " openstack server add security group <instance-name> $GROUP_NAME"
echo ""
+146
View File
@@ -0,0 +1,146 @@
#!/bin/bash
# sync-to-vm.sh — Copy local config to an opencode-devbox VM
#
# Reads docker-compose.yml on the remote VM to detect which bind mounts
# are active, then syncs the corresponding directories from this machine.
#
# Usage:
# ./sync-to-vm.sh <ssh-host>
#
# Examples:
# ./sync-to-vm.sh devbox-affection
# ./sync-to-vm.sh devbox@129.192.68.184
set -euo pipefail
# ── Colors ──────────────────────────────────────────────────────────
BOLD="\033[1m"; GREEN="\033[32m"; YELLOW="\033[33m"; RED="\033[31m"; RESET="\033[0m"
info() { echo -e "${BOLD}==>${RESET} $*"; }
ok() { echo -e "${GREEN}${BOLD}${RESET} $*"; }
warn() { echo -e "${YELLOW}${BOLD}!${RESET} $*"; }
err() { echo -e "${RED}${BOLD}${RESET} $*" >&2; }
# ── Args ────────────────────────────────────────────────────────────
if [[ $# -lt 1 ]]; then
err "Usage: $0 <ssh-host>"
echo " Example: $0 devbox-affection"
exit 1
fi
SSH_HOST="$1"
REMOTE_COMPOSE="~/opencode-devbox/docker-compose.yml"
# ── SSH multiplexing (reuse one connection for all operations) ──────
CTRL_SOCKET=$(mktemp -u /tmp/sync-to-vm-XXXXXX)
SSH_OPTS="-o ControlMaster=auto -o ControlPath=${CTRL_SOCKET} -o ControlPersist=120 -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3"
cleanup() {
ssh ${SSH_OPTS} -O exit "$SSH_HOST" 2>/dev/null || true
rm -f "$CTRL_SOCKET"
}
trap cleanup EXIT
ssh_cmd() {
ssh ${SSH_OPTS} "$SSH_HOST" "$@"
}
# ── Bind mount patterns to detect ──────────────────────────────────
# Maps: grep pattern → local source → remote destination
declare -a MOUNT_PATTERNS=(
"~/.aws:/home/developer/.aws|$HOME/.aws|~/.aws"
"~/.config/opencode:/home/developer/.config/opencode|$HOME/.config/opencode|~/.config/opencode"
"~/.config/nvim:/home/developer/.config/nvim|$HOME/.config/nvim|~/.config/nvim"
"~/.agents/skills:/home/developer/.agents/skills|$HOME/.agents/skills|~/.agents/skills"
)
# ── Establish persistent SSH connection ─────────────────────────────
info "Connecting to ${SSH_HOST}..."
if ! ssh_cmd true 2>/dev/null; then
err "Cannot connect to ${SSH_HOST}"
exit 1
fi
ok "Connected to ${SSH_HOST}"
# ── Fetch remote docker-compose.yml ─────────────────────────────────
info "Reading docker-compose.yml from ${SSH_HOST}..."
REMOTE_COMPOSE_CONTENT=$(ssh_cmd "cat $REMOTE_COMPOSE 2>/dev/null") || {
err "Could not read ${REMOTE_COMPOSE} on ${SSH_HOST}"
err "Has the VM been set up? Run the post-setup steps first."
exit 1
}
# ── Ensure workspace directory exists on remote ─────────────────────
REMOTE_ENV="~/opencode-devbox/.env"
WORKSPACE_PATH=$(ssh_cmd "grep -E '^\s*WORKSPACE_PATH=' $REMOTE_ENV 2>/dev/null | cut -d= -f2- | tr -d '\"'" 2>/dev/null || true)
if [[ -n "$WORKSPACE_PATH" ]]; then
info "Ensuring WORKSPACE_PATH (${WORKSPACE_PATH}) exists on ${SSH_HOST}..."
ssh_cmd "mkdir -p ${WORKSPACE_PATH}"
ok "Workspace directory ready"
else
# Default from docker-compose.yml is ~/projects or current dir
WORKSPACE_PATH=$(echo "$REMOTE_COMPOSE_CONTENT" | grep -oP 'WORKSPACE_PATH:-[^}]+' | sed 's/WORKSPACE_PATH:-//' || true)
if [[ -n "$WORKSPACE_PATH" && "$WORKSPACE_PATH" != "." ]]; then
info "Ensuring default WORKSPACE_PATH (${WORKSPACE_PATH}) exists on ${SSH_HOST}..."
ssh_cmd "mkdir -p ${WORKSPACE_PATH}"
ok "Workspace directory ready"
fi
fi
# ── Detect active bind mounts ──────────────────────────────────────
SYNCED=0
for entry in "${MOUNT_PATTERNS[@]}"; do
IFS='|' read -r pattern local_path remote_path <<< "$entry"
# Check if the mount is uncommented (active) in docker-compose.yml
# Match lines that start with optional whitespace and a dash, NOT preceded by #
if echo "$REMOTE_COMPOSE_CONTENT" | grep -qE "^\s*-\s+['\"]?${pattern}" 2>/dev/null; then
# Mount is active — check if local source exists
if [[ ! -d "$local_path" ]]; then
warn "Mount active for ${pattern} but ${local_path} does not exist locally — skipping"
continue
fi
# Check if directory has content
if [[ -z "$(ls -A "$local_path" 2>/dev/null)" ]]; then
warn "${local_path} is empty — skipping"
continue
fi
info "Syncing ${local_path}${SSH_HOST}:${remote_path}"
# Ensure remote directory exists
ssh_cmd "mkdir -p ${remote_path}"
# Sync with rsync (fall back to scp if rsync unavailable)
# Exclude generated/cached content that gets recreated on the remote.
# Use -rlptD (archive minus -o -g) so ownership on the remote is set
# by the receiving user (devbox). Preserving host UID/GID with -a
# tagged files with the pusher's numeric GID, which leaked through
# whenever the VM happened to have a matching group (see #group-1001).
if command -v rsync &>/dev/null; then
rsync -rlptDz --progress \
--exclude='node_modules' \
--exclude='__pycache__' \
--exclude='.venv' \
--exclude='*.pyc' \
--exclude='cli/cache' \
--exclude='sso/cache' \
-e "ssh ${SSH_OPTS}" "${local_path}/" "${SSH_HOST}:${remote_path}/"
else
scp -o "ControlPath=${CTRL_SOCKET}" -r "${local_path}/." "${SSH_HOST}:${remote_path}/"
fi
ok "Synced ${local_path}"
SYNCED=$((SYNCED + 1))
fi
done
# ── Summary ─────────────────────────────────────────────────────────
echo ""
if [[ $SYNCED -eq 0 ]]; then
warn "No active bind mounts detected in remote docker-compose.yml"
warn "Uncomment the mounts you need in ${REMOTE_COMPOSE} on the VM, then re-run this script"
else
ok "Synced ${SYNCED} director$([ $SYNCED -eq 1 ] && echo 'y' || echo 'ies') to ${SSH_HOST}"
fi
+36 -4
View File
@@ -12,20 +12,32 @@
# 5. mkdir -p ~/<signum>/.config/opencode
# 6. docker compose up -d
#
# Named volumes are automatically isolated per user because Docker Compose
# prefixes them with the project directory name (e.g. opencode-devbox_devbox-data).
# Since each user runs from ~/<signum>/opencode-devbox/, volumes don't collide.
# Volume isolation: the top-level 'name:' field derives a unique project
# name per user, which Docker Compose uses as the prefix for all named
# volumes. Without this, two users whose compose file lives in a directory
# with the same basename would share volumes — the Docker daemon is
# system-wide and doesn't scope by OS user.
#
# Two modes:
# Own-account mode (each user has their own OS login):
# Leave SIGNUM unset in .env — it defaults to $USER automatically.
# Shared-account mode (everyone logs in as the same OS user):
# Set SIGNUM=<unique-id> in .env so each person gets isolated volumes.
name: devbox-${SIGNUM:-${USER}}
services:
devbox:
image: joakimp/opencode-devbox:latest
container_name: devbox-${SIGNUM:?Set SIGNUM in .env}
container_name: devbox-${SIGNUM:-${USER}}
stdin_open: true
tty: true
env_file:
- .env
environment:
- TERM=xterm-256color
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
- GITEA_HOST=${GITEA_HOST:-}
volumes:
# Host workspace — user's project directory
- ${WORKSPACE_PATH:-~/src}:/workspace
@@ -39,12 +51,32 @@ services:
# Persist opencode data (auth, memory, session history)
- devbox-data:/home/developer/.local/share/opencode
# Persist bash history across container recreations
- devbox-shell-history:/home/developer/.cache/bash
# Persist zoxide directory history ('z <fragment>' to jump)
- devbox-zoxide:/home/developer/.local/share/zoxide
# Persist neovim plugin/Mason data (avoids re-downloading on every recreate)
- devbox-nvim-data:/home/developer/.local/share/nvim
# Persist uv data (Python installs)
- devbox-uv:/home/developer/.local/share/uv
# Optional: persist MemPalace data (conversation memory, knowledge graph)
# - devbox-palace:/home/developer/.mempalace
# Optional: persist ChromaDB embedding model cache (~79 MB)
# - devbox-chroma-cache:/home/developer/.cache/chroma
# Optional: AWS credentials (per-user if available)
# - ${HOME}/${SIGNUM}/.aws:/home/developer/.aws
volumes:
devbox-data:
devbox-shell-history:
devbox-zoxide:
devbox-nvim-data:
devbox-uv:
# devbox-palace:
# devbox-chroma-cache:
+60 -7
View File
@@ -8,15 +8,23 @@
# Or for interactive one-shot:
# docker compose run --rm devbox
# Pin the project name so named volumes survive directory renames.
# Without this, Docker Compose derives the project name from the
# directory basename — renaming the dir orphans all existing volumes.
name: opencode-devbox
services:
devbox:
build:
context: .
args:
INSTALL_PYTHON: "false"
INSTALL_GO: "false"
INSTALL_OMOS: "false"
image: opencode-devbox:latest
image: joakimp/opencode-devbox:latest
# For multi-agent orchestration, use the omos variant instead:
# image: joakimp/opencode-devbox:latest-omos
#
# To build from source instead of pulling from Docker Hub, uncomment:
# build:
# context: .
# args:
# INSTALL_GO: "false"
# INSTALL_OMOS: "false"
container_name: opencode-devbox
stdin_open: true
tty: true
@@ -24,6 +32,9 @@ services:
- .env
environment:
- TERM=xterm-256color
- GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN:-}
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
- GITEA_HOST=${GITEA_HOST:-}
volumes:
# Host workspace — mount your project here
- ${WORKSPACE_PATH:-.}:/workspace
@@ -45,6 +56,29 @@ services:
# Optional: persist opencode data (auth, memory, etc.)
- devbox-data:/home/developer/.local/share/opencode
# Optional: persist opencode TUI settings (theme, toggles, etc.)
- devbox-state:/home/developer/.local/state/opencode
# Persist bash history across container recreations.
# Without this, ~/.bash_history is lost on 'docker compose up --force-recreate'.
- devbox-shell-history:/home/developer/.cache/bash
# Persist zoxide directory history ('z <fragment>' to jump).
- devbox-zoxide:/home/developer/.local/share/zoxide
# Optional: override baked shell defaults with your host's rc files.
# The image ships sensible defaults (history tuning, prefix-search on
# Up/Down arrows, fzf/zoxide integration). Uncomment to use your own:
#
# NOTE: Single-file bind-mounts break when editors use atomic save
# (vim, VS Code, sed -i write a temp file then rename() over the
# original, creating a new inode the container never sees). This is a
# kernel limitation, not Docker-specific. If host edits stop appearing
# in the container, mount the parent directory instead — see the
# "Shell defaults" section in README.md.
# - ~/.bash_aliases:/home/developer/.bash_aliases:ro
# - ~/.inputrc:/home/developer/.inputrc:ro
# Optional: persist uv data (Python installs, tool installs)
# Without this, 'uv python install' must be re-run after container removal.
- devbox-uv:/home/developer/.local/share/uv
@@ -57,12 +91,31 @@ services:
# Optional: persist VS Code server and extensions across container recreations
# - devbox-vscode:/home/developer/.vscode-server
# Persist neovim plugin/Mason data (avoids re-downloading on every recreate)
- 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.
# - devbox-palace:/home/developer/.mempalace
# Optional: persist ChromaDB embedding model cache (~79 MB, downloaded on
# first mempalace search). Without this, the model re-downloads on every
# container recreation. Separate from palace data — model cache is
# disposable, palace data is precious.
# - devbox-chroma-cache:/home/developer/.cache/chroma
# Optional: AWS credentials/SSO config (not read-only — SSO writes token cache)
# - ~/.aws:/home/developer/.aws
volumes:
devbox-data:
devbox-state:
devbox-shell-history:
devbox-zoxide:
devbox-nvim-data:
devbox-uv:
# devbox-palace:
# devbox-chroma-cache:
# devbox-rustup:
# devbox-cargo:
# devbox-vscode:
+38 -60
View File
@@ -1,6 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
# ── Shell defaults: copy baked files from /etc/skel-devbox/ if absent
# Respects host bind-mounts and user customizations — existing files
# are never overwritten. To restore defaults: rm ~/.bash_aliases (or
# .inputrc) and recreate the container, or cp from /etc/skel-devbox/
# directly.
SKEL_DIR="/etc/skel-devbox"
if [ -d "$SKEL_DIR" ]; then
for f in .bash_aliases .inputrc; do
if [ -f "$SKEL_DIR/$f" ] && [ ! -e "$HOME/$f" ]; then
cp "$SKEL_DIR/$f" "$HOME/$f"
fi
done
fi
# ── MemPalace: initialize palace for the workspace if mempalace is installed
# Creates the palace directory structure on first run. Idempotent — skips
# if palace already exists, so upgrades from older versions preserve
# existing data. `--yes` auto-accepts detected entities so the init is
# non-interactive — the container entrypoint has no usable stdin for
# prompts anyway.
if command -v mempalace &>/dev/null && [ -d /workspace ]; then
PALACE_DIR="${HOME}/.mempalace"
if [ ! -d "$PALACE_DIR/palace" ]; then
echo "Initializing MemPalace for workspace (non-interactive)..."
mempalace init --yes /workspace >/dev/null 2>&1 || true
fi
fi
# ── Git config defaults ──────────────────────────────────────────────
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
git config --global user.name "$GIT_USER_NAME"
@@ -10,64 +38,14 @@ if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null;
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.
python3 /usr/local/lib/opencode-devbox/generate-config.py
CONFIG_DIR="$HOME/.config/opencode"
CONFIG_FILE="$CONFIG_DIR/opencode.json"
if [ ! -f "$CONFIG_FILE" ] && [ -n "${OPENCODE_PROVIDER:-}" ]; then
echo "Generating opencode config for provider: $OPENCODE_PROVIDER"
mkdir -p "$CONFIG_DIR"
case "$OPENCODE_PROVIDER" in
anthropic)
cat > "$CONFIG_FILE" <<EOF
{
"\$schema": "https://opencode.ai/config.json",
"model": "${OPENCODE_MODEL:-anthropic/claude-sonnet-4-5}",
"share": "disabled",
"autoupdate": false
}
EOF
;;
openai)
cat > "$CONFIG_FILE" <<EOF
{
"\$schema": "https://opencode.ai/config.json",
"model": "${OPENCODE_MODEL:-openai/gpt-4o}",
"share": "disabled",
"autoupdate": false
}
EOF
;;
amazon-bedrock)
cat > "$CONFIG_FILE" <<EOF
{
"\$schema": "https://opencode.ai/config.json",
"model": "${OPENCODE_MODEL:-amazon-bedrock/anthropic.claude-sonnet-4-5-v1}",
"share": "disabled",
"autoupdate": false,
"provider": {
"amazon-bedrock": {
"options": {
"region": "${AWS_REGION:-us-east-1}",
"profile": "${AWS_PROFILE:-default}"
}
}
}
}
EOF
;;
*)
cat > "$CONFIG_FILE" <<EOF
{
"\$schema": "https://opencode.ai/config.json",
"model": "${OPENCODE_MODEL:-anthropic/claude-sonnet-4-5}",
"share": "disabled",
"autoupdate": false
}
EOF
;;
esac
fi
OMOS_CONFIG="$CONFIG_DIR/oh-my-opencode-slim.json"
# ── oh-my-opencode-slim setup (multi-agent orchestration) ────────────
# Activated by ENABLE_OMOS=true. Requires the image to be built with
@@ -75,7 +53,7 @@ fi
OMOS_CONFIG="$CONFIG_DIR/oh-my-opencode-slim.json"
if [ "${ENABLE_OMOS:-false}" = "true" ]; then
if ! command -v bunx &>/dev/null; then
if ! command -v bun &>/dev/null; then
echo "WARNING: ENABLE_OMOS=true but bun is not installed."
echo "Rebuild with: docker compose build --build-arg INSTALL_OMOS=true"
elif [ ! -f "$OMOS_CONFIG" ]; then
@@ -92,7 +70,7 @@ if [ "${ENABLE_OMOS:-false}" = "true" ]; then
OMOS_SKILLS_FLAG="no"
fi
bunx oh-my-opencode-slim@latest install \
bun x oh-my-opencode-slim@latest install \
--no-tui \
--tmux="${OMOS_TMUX_FLAG}" \
--skills="${OMOS_SKILLS_FLAG}"
@@ -109,7 +87,7 @@ if [ "${ENABLE_OMOS:-false}" = "true" ]; then
OMOS_SKILLS_FLAG="yes"
[ "${OMOS_SKILLS:-true}" = "false" ] && OMOS_SKILLS_FLAG="no"
bunx oh-my-opencode-slim@latest install \
bun x oh-my-opencode-slim@latest install \
--no-tui \
--tmux="${OMOS_TMUX_FLAG}" \
--skills="${OMOS_SKILLS_FLAG}" \
+61 -10
View File
@@ -6,18 +6,22 @@ CURRENT_UID=$(id -u "$USER_NAME")
CURRENT_GID=$(id -g "$USER_NAME")
# ── UID/GID adjustment ───────────────────────────────────────────────
# Priority: env vars > auto-detect from /workspace > default (1000)
# Priority per dimension: env var > auto-detect from /workspace > no-op
# UID and GID are detected independently so a GID-only mismatch (e.g. host
# user has UID 1000 but primary group at GID 1001) is still corrected.
TARGET_UID="${USER_UID:-}"
TARGET_GID="${USER_GID:-}"
# Auto-detect from /workspace owner if env vars not set
if [ -z "$TARGET_UID" ] && [ -d /workspace ]; then
WORKSPACE_UID=$(stat -c '%u' /workspace 2>/dev/null || stat -f '%u' /workspace 2>/dev/null)
WORKSPACE_GID=$(stat -c '%g' /workspace 2>/dev/null || stat -f '%g' /workspace 2>/dev/null)
# Only adjust if workspace is owned by a non-root user
if [ "$WORKSPACE_UID" != "0" ] && [ "$WORKSPACE_UID" != "$CURRENT_UID" ]; then
if [ -d /workspace ]; then
WORKSPACE_UID=$(stat -c '%u' /workspace 2>/dev/null || stat -f '%u' /workspace 2>/dev/null || echo "")
WORKSPACE_GID=$(stat -c '%g' /workspace 2>/dev/null || stat -f '%g' /workspace 2>/dev/null || echo "")
# Adopt workspace UID if env var not set and workspace is non-root-owned
if [ -z "$TARGET_UID" ] && [ -n "$WORKSPACE_UID" ] && [ "$WORKSPACE_UID" != "0" ] && [ "$WORKSPACE_UID" != "$CURRENT_UID" ]; then
TARGET_UID="$WORKSPACE_UID"
TARGET_GID="${TARGET_GID:-$WORKSPACE_GID}"
fi
# Adopt workspace GID if env var not set and workspace group differs
if [ -z "$TARGET_GID" ] && [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "0" ] && [ "$WORKSPACE_GID" != "$CURRENT_GID" ]; then
TARGET_GID="$WORKSPACE_GID"
fi
fi
@@ -25,12 +29,13 @@ fi
if [ -n "$TARGET_GID" ] && [ "$TARGET_GID" != "$CURRENT_GID" ]; then
groupmod -g "$TARGET_GID" "$USER_NAME" 2>/dev/null || true
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -group "$CURRENT_GID" -exec chgrp "$TARGET_GID" {} + 2>/dev/null || true
echo "Adjusted developer GID to $TARGET_GID"
fi
if [ -n "$TARGET_UID" ] && [ "$TARGET_UID" != "$CURRENT_UID" ]; then
usermod -u "$TARGET_UID" "$USER_NAME" 2>/dev/null || true
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -user "$CURRENT_UID" -exec chown "$TARGET_UID" {} + 2>/dev/null || true
echo "Adjusted developer UID:GID to $TARGET_UID:${TARGET_GID:-$CURRENT_GID}"
echo "Adjusted developer UID to $TARGET_UID"
fi
# ── SSH key permissions ──────────────────────────────────────────────
@@ -51,18 +56,64 @@ fi
# developer user can write to them.
FINAL_UID="${TARGET_UID:-$CURRENT_UID}"
FINAL_GID="${TARGET_GID:-$CURRENT_GID}"
# First, fix parent dirs that Docker auto-creates as root:root when it
# materializes nested mount points (e.g. mounting a volume at
# .local/state/opencode creates .local/state as root). Non-recursive —
# we only need the dir node itself; children are handled below or were
# created by the user.
for parent in \
/home/"$USER_NAME"/.local \
/home/"$USER_NAME"/.local/share \
/home/"$USER_NAME"/.local/state \
/home/"$USER_NAME"/.cache \
/home/"$USER_NAME"/.config; do
if [ -d "$parent" ] && [ "$(stat -c '%u' "$parent" 2>/dev/null)" != "$FINAL_UID" ]; then
chown "$FINAL_UID":"$FINAL_GID" "$parent" 2>/dev/null || true
fi
done
for dir in \
/home/"$USER_NAME"/.local/share/opencode \
/home/"$USER_NAME"/.local/state/opencode \
/home/"$USER_NAME"/.local/share/uv \
/home/"$USER_NAME"/.local/share/zoxide \
/home/"$USER_NAME"/.local/share/nvim \
/home/"$USER_NAME"/.mempalace \
/home/"$USER_NAME"/.cache/bash \
/home/"$USER_NAME"/.cache/chroma \
/home/"$USER_NAME"/.rustup \
/home/"$USER_NAME"/.cargo \
/home/"$USER_NAME"/.vscode-server \
/home/"$USER_NAME"/.config/opencode \
/home/"$USER_NAME"/.config/nvim \
/home/"$USER_NAME"/.agents/skills; do
if [ -d "$dir" ] && [ "$(stat -c '%u' "$dir" 2>/dev/null)" != "$FINAL_UID" ]; then
[ -d "$dir" ] || continue
# Sentinel-file fast path: on volumes with thousands of files (nvim
# plugins, palace data) the recursive chown used to cost multiple
# seconds on every container start even when ownership was already
# correct. Now we write a sentinel after a successful chown and skip
# the walk when the sentinel matches the target UID:GID.
#
# If USER_UID changes between runs (user switches hosts, different
# workspace owner), the sentinel won't match and the full chown runs.
sentinel="$dir/.devbox-owner"
expected="$FINAL_UID:$FINAL_GID"
if [ -f "$sentinel" ] && [ "$(cat "$sentinel" 2>/dev/null)" = "$expected" ]; then
continue
fi
# Recursive chown needed. Only do it when the top-level differs too
# (covers the common case of fresh root-owned named volumes).
if [ "$(stat -c '%u' "$dir" 2>/dev/null)" != "$FINAL_UID" ]; then
chown -R "$FINAL_UID":"$FINAL_GID" "$dir" 2>/dev/null || true
fi
# Write sentinel so subsequent starts skip the recursive walk.
# Suppress errors — a read-only mount would fail here, but that would
# already have failed above on the chown itself.
echo "$expected" > "$sentinel" 2>/dev/null || true
done
# ── Drop to developer user for remaining setup ──────────────────────
+94
View File
@@ -0,0 +1,94 @@
# opencode-devbox bash aliases and customizations
# Sourced by the Debian-default ~/.bashrc on shell startup.
# To override, bind-mount your host's ~/.bash_aliases over this file
# via docker-compose.yml.
# ── Host-shared shell customizations (devbox-shell bridge) ───────────
# If the host bind-mounts a directory at ~/.config/devbox-shell/ (the
# recommended pattern for sharing aliases/PATH/utilities between host
# and container), source the bash_aliases file from it. This survives
# --force-recreate because it's baked into the image's skel, not the
# container's writable layer. Hosts that don't use this pattern are
# unaffected — the test silently skips if the file doesn't exist.
[ -r "$HOME/.config/devbox-shell/bash_aliases" ] && . "$HOME/.config/devbox-shell/bash_aliases"
# ── History persistence and quality ──────────────────────────────────
# The named volume devbox-shell-history is mounted at ~/.cache/bash
# so history survives container recreation.
export HISTFILE="${HOME}/.cache/bash/history"
mkdir -p "$(dirname "$HISTFILE")" 2>/dev/null || true
# Large, time-stamped, deduplicated history. Append rather than overwrite.
export HISTSIZE=100000
export HISTFILESIZE=200000
export HISTCONTROL=ignoreboth:erasedups
export HISTTIMEFORMAT='%F %T '
shopt -s histappend 2>/dev/null
shopt -s cmdhist 2>/dev/null
# Note: PROMPT_COMMAND="history -a" is installed LATER in this file,
# after zoxide's init runs. Installing it here would create a
# "history -a;;__zoxide_hook" chain because zoxide's init uses ';'
# as its separator and prepends itself; two adjacent ';' breaks the
# parser. See https://github.com/ajeetdsouza/zoxide/issues/722.
# ── Common aliases ───────────────────────────────────────────────────
# Prefer eza (modern ls) when available
if command -v eza >/dev/null 2>&1; then
alias ls='eza --group-directories-first'
alias ll='eza -lh --group-directories-first --git'
alias la='eza -lha --group-directories-first --git'
alias tree='eza --tree'
else
alias ll='ls -lh'
alias la='ls -lha'
fi
# Prefer bat (syntax-highlighted cat) when available
if command -v bat >/dev/null 2>&1; then
alias cat='bat --style=plain --paging=never'
alias less='bat --paging=always'
fi
# Git shortcuts
alias gs='git status'
alias gd='git diff'
alias gl='git log --oneline --graph --decorate -20'
# Safety: confirm before destructive ops
alias rm='rm -i'
alias mv='mv -i'
alias cp='cp -i'
# ── Shell integrations ───────────────────────────────────────────────
# zoxide — smarter cd. Use 'z <fragment>' to jump to previously-visited dirs.
if command -v zoxide >/dev/null 2>&1; then
eval "$(zoxide init bash)"
fi
# fzf — fuzzy finder key bindings (Ctrl-R for history, Ctrl-T for files).
# We install fzf from GitHub releases (not apt), so sourcing from the
# apt-path /usr/share/doc/fzf/examples/* would find nothing. Use the
# binary's own --bash flag (available since fzf 0.48) for setup.
if command -v fzf >/dev/null 2>&1; then
eval "$(fzf --bash)" 2>/dev/null || true
fi
# ── PROMPT_COMMAND: flush history every prompt ───────────────────────
# Installed AFTER zoxide init so zoxide's hook is already in place;
# 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.
if [ -z "${DEVBOX_HIST_SET:-}" ]; then
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a"
export DEVBOX_HIST_SET=1
fi
# ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container
# Preserves the default Debian PS1 logic but prefixes with a container marker.
# We check for the literal '[devbox]' substring in PS1 rather than relying on
# an exported guard variable — otherwise `exec bash` inherits the guard but
# gets a fresh (prefix-less) PS1 from .bashrc, and the prefix would never be
# re-added in the new shell.
if [ -n "${PS1:-}" ] && [[ "$PS1" != *"[devbox]"* ]]; then
PS1='\[\e[38;5;39m\][devbox]\[\e[0m\] '"${PS1}"
fi
+27
View File
@@ -0,0 +1,27 @@
# opencode-devbox readline defaults
# To override, bind-mount your host's ~/.inputrc over this file
# via docker-compose.yml.
# Inherit system-wide defaults (colour, 8-bit input, …) if present
$include /etc/inputrc
# ── History search on Up/Down ────────────────────────────────────────
# Type a prefix, press Up, and walk through previous commands starting
# with that prefix. Ctrl-Up / Ctrl-Down keep the unconditional stepper.
"\e[A": history-search-backward
"\e[B": history-search-forward
"\e[1;5A": previous-history
"\e[1;5B": next-history
# ── Completion quality ───────────────────────────────────────────────
set show-all-if-ambiguous on # single Tab shows matches on ambiguity
set completion-ignore-case on # case-insensitive file/dir completion
set colored-stats on # colour ls-style completion list entries
set colored-completion-prefix on # highlight the matched prefix
set visible-stats on # append /*@ type indicators in completion
set mark-symlinked-directories on # add trailing / to symlinks to dirs
set skip-completed-text on # don't re-insert already-typed text
# Treat hyphens and underscores as equivalent when completing (e.g.
# typing `foo-` matches both `foo-bar` and `foo_bar`).
set completion-map-case on
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""
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.
- Requires OPENCODE_PROVIDER to be set. Without it, no file is written.
Environment variables:
OPENCODE_PROVIDER Required. One of: anthropic, openai, amazon-bedrock.
OPENCODE_MODEL Optional. Overrides the provider default model.
AWS_REGION Bedrock only. Default: us-east-1.
AWS_PROFILE Bedrock only. Default: default.
MCP servers are auto-registered for tools detected on PATH:
- mempalace (if installed) — enabled
- gitea-mcp (if installed) — registered but disabled by default
Output path: $HOME/.config/opencode/opencode.json
"""
from __future__ import annotations
import json
import os
import shutil
import sys
from pathlib import Path
# Default model per provider. Update here when upstream changes.
DEFAULT_MODELS: dict[str, str] = {
"anthropic": "anthropic/claude-sonnet-4-6",
"openai": "openai/gpt-5.4",
"amazon-bedrock": (
"amazon-bedrock/global.anthropic.claude-sonnet-4-5-20250929-v1:0"
),
}
# Fallback when OPENCODE_PROVIDER is set but not recognized.
FALLBACK_MODEL = DEFAULT_MODELS["anthropic"]
SCHEMA_URL = "https://opencode.ai/config.json"
def build_config(provider: str, model: str) -> dict:
"""Build the base opencode.json structure for a provider."""
config: dict = {
"$schema": SCHEMA_URL,
"model": model,
"share": "disabled",
"autoupdate": False,
}
if provider == "amazon-bedrock":
config["provider"] = {
"amazon-bedrock": {
"options": {
"region": os.environ.get("AWS_REGION", "us-east-1"),
"profile": os.environ.get("AWS_PROFILE", "default"),
}
}
}
return config
def register_mcp_servers(config: dict) -> list[str]:
"""Auto-register MCP servers for tools detected on PATH.
Returns the list of server names that were added. The "mcp" key
is only added to the config when at least one server is registered.
"""
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"):
servers["mempalace"] = {
"type": "local",
"command": ["mempalace-mcp"],
}
# Gitea — self-hosted Git forge API (if installed).
# Disabled by default; user must set GITEA_ACCESS_TOKEN + GITEA_HOST
# and flip enabled=true in their config.
if shutil.which("gitea-mcp"):
servers["gitea"] = {
"type": "local",
"command": ["gitea-mcp", "-t", "stdio"],
"enabled": False,
}
if servers:
config["mcp"] = servers
return list(servers.keys())
def main() -> int:
provider = os.environ.get("OPENCODE_PROVIDER", "").strip()
if not provider:
# No provider set — nothing to do. Not an error.
return 0
home = Path(os.environ.get("HOME", "/home/developer"))
config_dir = home / ".config" / "opencode"
config_file = 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.
if config_file.exists():
print(
f"Existing opencode.json found at {config_file}"
"skipping generation.",
file=sys.stderr,
)
return 0
if provider not in DEFAULT_MODELS:
print(
f"WARNING: unknown OPENCODE_PROVIDER={provider!r}, "
f"falling back to default model {FALLBACK_MODEL!r}.",
file=sys.stderr,
)
model = os.environ.get("OPENCODE_MODEL", "").strip() or DEFAULT_MODELS.get(
provider, FALLBACK_MODEL
)
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)
with config_file.open("w") as f:
json.dump(config, f, indent=2)
f.write("\n")
if added:
print(
f"MCP servers registered in opencode config: {', '.join(added)}.",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""
Generate DOCKER_HUB.md from README.md.
Rationale
---------
README.md is the authoritative source. DOCKER_HUB.md is a subset
intended for users pulling the pre-built image from Docker Hub — so
build-from-source instructions, developer setup (git hooks, gitleaks),
and CI/contribution content are dropped.
Docker Hub enforces a 25 kB limit on the full description field.
Usage
-----
Regenerate in place:
python3 scripts/generate-dockerhub-md.py
Fail if DOCKER_HUB.md is out of sync with what this script would emit
(run this in CI):
python3 scripts/generate-dockerhub-md.py --check
Design
------
Sections are selected and in some cases rewritten via `SECTION_RULES`
below. This keeps the transformation explicit and easy to audit — if
a new section is added to README.md that should also appear on Docker
Hub, extend SECTION_RULES rather than inventing implicit heuristics.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
README = REPO_ROOT / "README.md"
DOCKER_HUB = REPO_ROOT / "DOCKER_HUB.md"
# Max size for Docker Hub full_description (bytes, UTF-8).
MAX_SIZE_BYTES = 25_000
# Per-section transformation.
#
# Each key is a top-level section title as it appears in README.md
# (without the leading "## ").
#
# The value is one of:
# "keep" — include verbatim.
# "drop" — exclude entirely.
# "replace" — substitute a custom body (see REPLACEMENTS).
# "trim" — keep but drop selected level-3 sub-sections listed
# in TRIM_SUBSECTIONS[title].
#
# Unknown sections default to "drop" with a warning — forcing an
# explicit decision whenever README gains a new section.
SECTION_RULES: dict[str, str] = {
"Why?": "drop", # build-motivation, not user-facing
"Quick Start": "replace", # swap docker compose clone flow for docker run
"Features": "keep",
"Usage": "keep",
"Configuration": "trim", # drop dev-build sub-sections
"oh-my-opencode-slim (Multi-Agent Orchestration)": "keep",
"AWS Bedrock Authentication": "keep",
"MemPalace — persistent AI memory": "keep",
"Gitea MCP server": "keep",
"Shell defaults": "keep",
"Secret Scanning": "drop", # dev-only — gitleaks is for committers
"Architecture": "keep",
"License": "replace", # point at source repo instead
}
# Level-3 sub-section titles (without the leading "### ") to drop from
# sections flagged as "trim". These are dev/build-oriented — Docker Hub
# users already have the image and don't need rebuild or multi-user
# compose instructions.
TRIM_SUBSECTIONS: dict[str, set[str]] = {
"Configuration": {
"Multi-user setup",
"Rebuilding the Image",
"Build Args",
},
}
# Replacement bodies. Keys match SECTION_RULES entries marked "replace".
# Each value is the full section including the "## Title" heading.
REPLACEMENTS: dict[str, str] = {
"Quick Start": """## Quick Start
```bash
docker run -it --rm \\
-e ANTHROPIC_API_KEY=your-key \\
-e OPENCODE_PROVIDER=anthropic \\
-e GIT_USER_NAME="Your Name" \\
-e GIT_USER_EMAIL="you@example.com" \\
-v ~/projects:/workspace \\
-v ~/.ssh:/home/developer/.ssh:ro \\
joakimp/opencode-devbox:latest
```
This drops you straight into opencode with your project mounted at `/workspace`.
For an interactive shell first (useful for AWS SSO login):
```bash
docker run -it --rm \\
-e ANTHROPIC_API_KEY=your-key \\
-e OPENCODE_PROVIDER=anthropic \\
-v ~/projects:/workspace \\
-v ~/.ssh:/home/developer/.ssh:ro \\
joakimp/opencode-devbox:latest bash
```
Then run `opencode` when ready.
For docker-compose users, see the source repo for `docker-compose.yml` and `.env.example` templates.
""",
"License": """## Source
MIT licensed. Source, issues, and `docker-compose.yml` templates: <https://gitea.jordbo.se/joakimp/opencode-devbox>
""",
}
# Prepended to the generated file.
HEADER = """# opencode-devbox — Docker Hub
Portable AI developer environment for [opencode](https://opencode.ai). Debian-based, with git, SSH, Node.js, AWS CLI v2, and common dev tools pre-installed.
## Image Variants
Two image variants are published for each release:
| Tag | Description |
|---|---|
| `latest` / `vX.Y.Z` | Base image — opencode, Node.js, AWS CLI, dev tools |
| `latest-omos` / `vX.Y.Z-omos` | Base + [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) multi-agent orchestration and Bun |
Both variants support `linux/amd64` and `linux/arm64`.
> **NOTE:** This file is auto-generated from `README.md` by `scripts/generate-dockerhub-md.py`. Edit README.md and regenerate rather than editing this file directly.
"""
def split_sections(md: str) -> list[tuple[str, str]]:
"""Split markdown on level-2 headings, returning (title, body) pairs.
The body includes the heading line and everything up to (but not
including) the next level-2 heading or EOF. Content before the first
``## `` is returned with an empty title (the document preamble).
"""
pattern = re.compile(r"^## ", re.MULTILINE)
parts = pattern.split(md)
preamble, *rest = parts
sections: list[tuple[str, str]] = []
if preamble.strip():
sections.append(("", preamble))
for part in rest:
line, _, body = part.partition("\n")
sections.append((line.strip(), f"## {line}\n{body}"))
return sections
def trim_subsections(body: str, drop: set[str]) -> str:
"""Remove level-3 sub-sections whose title is in `drop`.
A sub-section starts at a line beginning with "### " and ends at
the next "### " or "## " (or EOF).
"""
if not drop:
return body
# Split on level-3 headings while preserving the level-2 header
# block. First piece is everything up to the first "### ".
parts = re.split(r"(^### .+\n)", body, flags=re.MULTILINE)
# parts alternates: [before_first_h3, "### Title\n", body, "### Title\n", body, ...]
kept: list[str] = [parts[0]] if parts else []
i = 1
while i < len(parts):
heading = parts[i]
content = parts[i + 1] if i + 1 < len(parts) else ""
title = heading[4:].strip()
if title not in drop:
kept.append(heading)
kept.append(content)
i += 2
return "".join(kept)
def generate() -> str:
"""Produce the DOCKER_HUB.md content string."""
readme = README.read_text(encoding="utf-8")
sections = split_sections(readme)
out: list[str] = [HEADER]
unknown: list[str] = []
for title, body in sections:
if title == "":
# README preamble is replaced by our HEADER; skip.
continue
rule = SECTION_RULES.get(title)
if rule is None:
unknown.append(title)
continue
if rule == "drop":
continue
if rule == "keep":
out.append(body.rstrip() + "\n\n")
elif rule == "trim":
trimmed = trim_subsections(body, TRIM_SUBSECTIONS.get(title, set()))
out.append(trimmed.rstrip() + "\n\n")
elif rule == "replace":
out.append(REPLACEMENTS[title].rstrip() + "\n\n")
else: # pragma: no cover — programmer error
raise AssertionError(f"unknown rule {rule!r} for section {title!r}")
if unknown:
print(
"ERROR: README.md contains sections not classified in "
"SECTION_RULES:\n - "
+ "\n - ".join(unknown)
+ "\n\nAdd each to SECTION_RULES in "
"scripts/generate-dockerhub-md.py (choose keep/drop/replace).",
file=sys.stderr,
)
raise SystemExit(2)
return "".join(out).rstrip() + "\n"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check",
action="store_true",
help="Fail if DOCKER_HUB.md differs from generated content.",
)
args = parser.parse_args()
content = generate()
size = len(content.encode("utf-8"))
if size > MAX_SIZE_BYTES:
print(
f"ERROR: generated DOCKER_HUB.md is {size} bytes, exceeding the "
f"Docker Hub limit of {MAX_SIZE_BYTES} bytes.",
file=sys.stderr,
)
return 1
if args.check:
existing = DOCKER_HUB.read_text(encoding="utf-8") if DOCKER_HUB.exists() else ""
if existing != content:
print(
"ERROR: DOCKER_HUB.md is out of sync with README.md.\n"
"Run: python3 scripts/generate-dockerhub-md.py",
file=sys.stderr,
)
# Show a small diff hint.
import difflib
diff = difflib.unified_diff(
existing.splitlines(keepends=True),
content.splitlines(keepends=True),
fromfile="DOCKER_HUB.md (committed)",
tofile="DOCKER_HUB.md (generated)",
n=2,
)
sys.stderr.writelines(list(diff)[:80])
return 1
print(
f"OK: DOCKER_HUB.md is in sync with README.md "
f"({size} bytes, {MAX_SIZE_BYTES} limit).",
)
return 0
DOCKER_HUB.write_text(content, encoding="utf-8")
print(
f"Wrote {DOCKER_HUB} ({size} bytes, {MAX_SIZE_BYTES} limit).",
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env bash
# Smoke-test a freshly-built opencode-devbox image.
#
# Verifies:
# - Core binaries are on PATH and runnable
# - opencode itself starts and prints a version
# - Entrypoint runs cleanly as non-root after UID adjustment
# - Generated opencode.json has the expected shape
# - MCP wrapper works (when mempalace is installed)
#
# Usage: ./scripts/smoke-test.sh <image> [--variant base|omos]
#
# Exit codes:
# 0 all checks passed
# 1 one or more checks failed
set -euo pipefail
IMAGE="${1:-}"
VARIANT="base"
if [ "${2:-}" = "--variant" ]; then
VARIANT="${3:-base}"
fi
if [ -z "$IMAGE" ]; then
echo "usage: $0 <image> [--variant base|omos]" >&2
exit 2
fi
FAILED=0
pass() { echo "$1"; }
fail() { echo "$1" >&2; FAILED=$((FAILED + 1)); }
run() {
# Run a command inside the image and capture its output.
# First arg is a label, rest is the shell command.
local label="$1"; shift
local out
if out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$*" 2>&1); then
pass "$label ($(echo "$out" | head -1))"
else
fail "$label: $out"
fi
}
echo "=== Smoke test: $IMAGE (variant: $VARIANT) ==="
echo
echo "-- Resolved component versions --"
# Prints the actual version of every floating component so CI logs
# always record what got baked into this image, even when Dockerfile
# ARGs default to "latest".
docker run --rm --entrypoint="" "$IMAGE" sh -c '
printf " %-15s %s\n" "opencode" "$(opencode --version 2>&1 | head -1)"
printf " %-15s %s\n" "node" "$(node --version)"
printf " %-15s %s\n" "npm" "$(npm --version)"
printf " %-15s %s\n" "nvim" "$(nvim --version | head -1)"
printf " %-15s %s\n" "bat" "$(bat --version)"
printf " %-15s %s\n" "eza" "$(eza --version | head -2 | tail -1)"
printf " %-15s %s\n" "zoxide" "$(zoxide --version)"
printf " %-15s %s\n" "uv" "$(uv --version)"
printf " %-15s %s\n" "fzf" "$(fzf --version)"
printf " %-15s %s\n" "fd" "$(fd --version)"
printf " %-15s %s\n" "rg" "$(rg --version | head -1)"
printf " %-15s %s\n" "gosu" "$(gosu --version)"
printf " %-15s %s\n" "git-lfs" "$(git-lfs --version)"
printf " %-15s %s\n" "gitea-mcp" "$(gitea-mcp --version 2>&1 | head -1)"
printf " %-15s %s\n" "aws" "$(aws --version 2>&1)"
if command -v bun >/dev/null 2>&1; then
printf " %-15s %s\n" "bun" "$(bun --version)"
fi
if command -v mempalace >/dev/null 2>&1; then
printf " %-15s %s\n" "mempalace" "$(mempalace --version 2>&1 | head -1 || echo installed)"
fi
'
echo
echo "-- Core binaries --"
run "opencode" "opencode --version"
run "node" "node --version"
run "npm" "npm --version"
run "git" "git --version"
run "nvim" "nvim --version | head -1"
run "bat" "bat --version"
run "eza" "eza --version | head -1"
run "zoxide" "zoxide --version"
run "uv" "uv --version"
run "uvx" "uvx --version"
run "rustup-init" "rustup-init --version"
run "fzf" "fzf --version"
run "fd" "fd --version"
run "rg" "rg --version | head -1"
run "jq" "jq --version"
run "aws" "aws --version"
run "gitea-mcp" "gitea-mcp --version"
run "gosu" "gosu --version"
run "tmux" "tmux -V"
echo
echo "-- Optional / variant-gated --"
# mempalace: present unless built with INSTALL_MEMPALACE=false
if docker run --rm --entrypoint="" "$IMAGE" sh -c "command -v mempalace" >/dev/null 2>&1; then
run "mempalace" "mempalace --help | head -1"
run "mempalace-mcp" "test -x /usr/local/bin/mempalace-mcp && readlink /usr/local/bin/mempalace-mcp"
else
echo " - mempalace not installed (INSTALL_MEMPALACE=false)"
fi
# bun: only in the omos variant
if [ "$VARIANT" = "omos" ]; then
run "bun (omos)" "bun --version"
run "bunx symlink (omos)" "test -L /usr/local/bin/bunx && readlink /usr/local/bin/bunx"
# oh-my-opencode-slim is npm-installed globally (not a bun install);
# verify it shows up in the global module list.
run "oh-my-opencode-slim" "npm ls -g --depth=0 2>/dev/null | grep oh-my-opencode-slim"
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"
else
pass "bun correctly absent from base image"
fi
fi
echo
echo "-- Entrypoint behaviour --"
# Generate-config script exists and has valid syntax.
run "generate-config.py exists" \
"test -x /usr/local/lib/opencode-devbox/generate-config.py && python3 -m py_compile /usr/local/lib/opencode-devbox/generate-config.py && echo ok"
# Entrypoint drops to developer user and runs a trivial command.
# Writes the result to a file inside the container so we don't have to
# disentangle entrypoint log output from command stdout on the host.
label="entrypoint drops to developer"
tmpout=$(mktemp)
if docker run --rm -e OPENCODE_PROVIDER= "$IMAGE" \
sh -c 'whoami > /tmp/who && cat /tmp/who' > "$tmpout" 2>/dev/null; then
# The last line of stdout is the whoami output. Entrypoint log lines
# (MemPalace init, "Adjusted developer UID", etc.) go to stderr or
# get printed before our sh command runs.
actual=$(tail -1 "$tmpout" | tr -d '[:space:]')
if [ "$actual" = "developer" ]; then
pass "$label"
else
fail "$label: expected 'developer', got '$actual' (full output: $(cat "$tmpout"))"
fi
else
fail "$label: container failed"
fi
rm -f "$tmpout"
# Config generation with anthropic provider writes valid JSON with the
# expected shape. The script's log message goes to stderr (line 1 of
# generate-config.py uses file=sys.stderr) so capturing only stdout
# gives us clean JSON.
label="generate-config produces valid opencode.json"
tmp=$(mktemp -d)
if docker run --rm \
-e OPENCODE_PROVIDER=anthropic \
-e HOME=/tmp/home \
--entrypoint="" \
"$IMAGE" sh -c '
mkdir -p /tmp/home
python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null
cat /tmp/home/.config/opencode/opencode.json
' > "$tmp/out.json" 2>/dev/null; then
if python3 -c "
import json, sys
c = json.load(open('$tmp/out.json'))
assert c['model'].startswith('anthropic/'), c
assert c['autoupdate'] is False
assert c['share'] == 'disabled'
" 2>&1; then
pass "$label"
else
fail "$label: output doesn't match expected shape: $(cat "$tmp/out.json")"
fi
else
fail "$label: container failed: $(cat "$tmp/out.json")"
fi
# Config generation is idempotent — running twice must not overwrite.
label="generate-config never overwrites existing config"
if docker run --rm \
-e OPENCODE_PROVIDER=anthropic \
-e HOME=/tmp/home \
--entrypoint="" \
"$IMAGE" sh -c '
mkdir -p /tmp/home/.config/opencode
echo "{\"sentinel\": \"user-config\"}" > /tmp/home/.config/opencode/opencode.json
python3 /usr/local/lib/opencode-devbox/generate-config.py 2>/dev/null
cat /tmp/home/.config/opencode/opencode.json
' 2>/dev/null | grep -q '"sentinel": "user-config"'; then
pass "$label"
else
fail "$label: existing config was modified!"
fi
rm -rf "$tmp"
echo
echo "-- Image size --"
SIZE_BYTES=$(docker image inspect --format='{{.Size}}' "$IMAGE")
SIZE_MB=$((SIZE_BYTES / 1024 / 1024))
echo " Uncompressed size: ${SIZE_MB} MB"
# Thresholds (uncompressed): base 2500 MB, omos 3000 MB. Adjust as image content evolves.
THRESHOLD=2500
[ "$VARIANT" = "omos" ] && THRESHOLD=3000
if [ "$SIZE_MB" -gt "$THRESHOLD" ]; then
fail "image size ${SIZE_MB} MB exceeds threshold ${THRESHOLD} MB for variant=$VARIANT"
else
pass "image size ${SIZE_MB} MB within threshold ${THRESHOLD} MB"
fi
echo
if [ "$FAILED" -gt 0 ]; then
echo "=== FAILED: $FAILED check(s) ===" >&2
exit 1
fi
echo "=== PASSED ==="