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