Files
opencode-devbox/Dockerfile.base
T
pi ff6e17b732
Validate / base-change-warning (push) Successful in 7s
Validate / docs-check (push) Failing after 9s
Validate / validate-omos (push) Successful in 4m4s
Validate / validate-with-pi (push) Successful in 7m14s
Validate / validate-omos-with-pi (push) Successful in 5m46s
Publish Docker Image / base-decide (push) Successful in 9s
Publish Docker Image / resolve-versions (push) Successful in 4s
Validate / validate-pi-only (push) Successful in 6m27s
Validate / validate-base (push) Successful in 14m39s
Publish Docker Image / build-base (push) Successful in 31m9s
Publish Docker Image / smoke-base (push) Successful in 5m3s
Publish Docker Image / smoke-with-pi (push) Successful in 5m2s
Publish Docker Image / smoke-omos-with-pi (push) Successful in 5m59s
Publish Docker Image / smoke-pi-only (push) Successful in 6m48s
Publish Docker Image / smoke-omos (push) Successful in 12m8s
Publish Docker Image / build-variant-base (push) Successful in 13m37s
Publish Docker Image / build-variant-with-pi (push) Successful in 17m8s
Publish Docker Image / build-variant-pi-only (push) Successful in 22m57s
Publish Docker Image / build-variant-omos (push) Successful in 19m4s
Publish Docker Image / build-variant-omos-with-pi (push) Successful in 28m5s
Publish Docker Image / promote-base-latest (push) Successful in 10s
Publish Docker Image / update-description (push) Successful in 12s
v1.17.2: bump opencode 1.16.2->1.17.2, deprecate pi, pin+patch mempalace
opencode-ai 1.16.2 -> 1.17.2 (OPENCODE_VERSION).

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

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

Fold prior Unreleased smoke-test pi-extensions readiness fix into v1.17.2.
2026-06-10 19:31:49 +02:00

430 lines
22 KiB
Docker

# opencode-devbox — base image (variant-independent layers)
#
# This Dockerfile produces an image tagged base-<hash>, used as the parent
# for all four published variants (base, omos, with-pi, omos-with-pi).
# It contains everything that does not depend on variant-specific
# build-args (INSTALL_OPENCODE, INSTALL_OMOS, INSTALL_PI). The variant
# Dockerfile (Dockerfile.variant) FROMs the base and adds only those
# deltas.
#
# The base is rebuilt only when this file or anything it COPYs in
# changes (rootfs/, entrypoint*.sh). Version bumps to OPENCODE_VERSION,
# OMOS_VERSION, PI_VERSION, etc. do NOT trigger a base rebuild.
#
# To force a base rebuild for fresh apt packages without other code
# changes, bump the BASE_REBUILD_DATE comment below. The hash is
# content-addressed over this file, so any byte change invalidates the
# cache. Recommended cadence: once per release for security updates.
#
# BASE_REBUILD_DATE: 2026-05-14 (v1.14.50b — fresh apt + first promote-base-latest)
#
# See the project README's "Build pipeline" section for the rationale.
ARG DEBIAN_VERSION=trixie-slim
FROM debian:${DEBIAN_VERSION} AS base
ARG TARGETARCH
LABEL maintainer="joakimp"
LABEL description="opencode-devbox — base image (variant-independent)"
LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/opencode-devbox"
# Avoid interactive prompts during build
ENV DEBIAN_FRONTEND=noninteractive
# ── Core system packages ─────────────────────────────────────────────
# 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 \
git \
openssh-client \
gnupg \
jq \
ripgrep \
fd-find \
tree \
less \
htop \
tmux \
make \
patch \
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/*
# ── SSH client defaults: ControlMaster on a writable socket path ──────
# Why this exists: the devbox typically mounts ~/.ssh from the host as
# read-only (security: keys are readable, but agents can't tamper with
# config / known_hosts / authorized_keys / plant a malicious ProxyCommand).
# OpenSSH's default ControlPath is ~/.ssh/cm/... which is unwritable on
# such mounts, so any attempt to use ControlMaster fails. Symptoms:
# unix_listener: cannot bind to path /home/.../.ssh/cm/...: Read-only file system
# kex_exchange_identification: Connection closed by remote host
# The latter manifests downstream of CGNAT per-destination flow caps
# (~4 concurrent flows on most European residential ISPs) which silently
# drop further SYNs once exceeded — making fresh ssh attempts fail with
# banner-exchange timeouts that look like a remote problem.
#
# Fix: set a system-wide default ControlPath in /tmp (per-container,
# tmpfs-friendly, always writable) so multiplexing Just Works without
# touching the read-only ~/.ssh mount. Per-host overrides in user's
# ~/.ssh/config still win — Debian's default /etc/ssh/ssh_config has
# `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block,
# so user config can override these defaults if desired.
#
# ControlPersist=10m means the master socket sticks around 10 min after
# the last session closes, so consecutive ssh calls in a workflow reuse
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
# (mode 700) on each container start.
RUN mkdir -p /etc/ssh/ssh_config.d && \
printf '%s\n' \
'# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \
'# Override per-host in ~/.ssh/config if the master socket location' \
'# needs to differ.' \
'Host *' \
' ControlMaster auto' \
' ControlPath /tmp/sshcm/%r@%h:%p' \
' ControlPersist 10m' \
' ServerAliveInterval 30' \
' ServerAliveCountMax 6' \
> /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf && \
chmod 644 /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
#
# Version policy for the binaries below:
# • 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 base rebuild 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.
# • Resolved versions are printed during build and re-checked by the
# smoke test (scripts/smoke-test.sh), so drift is visible in CI logs.
# gosu — privilege de-escalation
ARG GOSU_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
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
ARG FZF_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
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
ARG GIT_LFS_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
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
# gitleaks — secret scanner (used as a pre-commit hook in several of the
# repos this devbox is meant to operate on; pairs with git-crypt below).
# Distributed as a Go-compiled tarball; arch suffix is `x64` (not `x86_64`
# or `amd64`) on this project — mind the deviation from the surrounding
# tools' naming.
ARG GITLEAKS_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x64" ;; arm64) echo "arm64" ;; *) echo "x64" ;; esac) && \
V="${GITLEAKS_VERSION}" && \
if [ "$V" = "latest" ]; then \
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
fi && \
V="${V#v}" && \
[ -n "$V" ] && \
echo "Installing gitleaks ${V}" && \
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/download/v${V}/gitleaks_${V}_linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin gitleaks && \
chmod +x /usr/local/bin/gitleaks && \
gitleaks version
# neovim — modern text editor
ARG NVIM_VERSION=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
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=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
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=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
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=latest
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
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)
# 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) && \
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
# ── MemPalace — local-first AI memory system ─────────────────────────
# Provides semantic search over conversation history via 29 MCP tools.
# Always installed in the base (variant-independent). Set
# INSTALL_MEMPALACE=false at base-build time to shave ~300 MB.
ARG INSTALL_MEMPALACE=true
# Pin mempalace explicitly (mirrors pi-devbox). An unpinned
# `uv tool install mempalace` is what silently swept in the broken
# diary_write top-level-anyOf schema (3.3.x/3.4.0) that breaks the
# Anthropic tools API; pinning makes every bump a deliberate, reviewable
# diff. Bump this in lockstep with pi-devbox's MEMPALACE_VERSION.
ARG MEMPALACE_VERSION=3.4.0
ENV UV_TOOL_DIR=/opt/uv-tools
ENV UV_TOOL_BIN_DIR=/usr/local/bin
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
mkdir -p /opt/uv-tools && \
uv tool install --no-cache "mempalace==${MEMPALACE_VERSION}" && \
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
fi
# ── workaround: strip top-level anyOf from mempalace_diary_write schema ──
# Mempalace 3.3.x/3.4.0 advertise diary_write's input_schema with a
# top-level `anyOf: [{required:[entry]}, {required:[content]}]` to express
# "either entry or content must be supplied". Anthropic's tools API rejects
# top-level anyOf/oneOf/allOf, so pi/Claude fail at session start with
# `tools.<n>.custom.input_schema: input_schema does not support oneOf,
# allOf, or anyOf at the top level`.
#
# Patch the advertised schema to require ["agent_name", "entry"] and remove
# the anyOf block. The handler keeps accepting `content` server-side as a
# kwarg alias so existing callers still work.
#
# Idempotent and self-deactivating: once upstream releases the fix the
# regex no longer matches and this RUN is a silent no-op.
# Upstream tracking:
# https://github.com/MemPalace/mempalace/issues/1728
# https://github.com/MemPalace/mempalace/pull/1735
# TODO: remove this RUN once a mempalace release containing PR #1735 is on
# PyPI and installed by the line above.
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
MP_FILE="$(find /opt/uv-tools/mempalace -path '*/mempalace/mcp_server.py' | head -n1)" && \
if [ -z "$MP_FILE" ]; then echo "mempalace mcp_server.py not found" >&2; exit 1; fi && \
perl -0777 -i -pe 's/(?:[ \t]*\#[^\n]*\n)*[ \t]*"required":\s*\[\s*"agent_name"\s*\]\s*,\s*\n[ \t]*"anyOf":\s*\[\s*\n[ \t]*\{\s*"required":\s*\[\s*"entry"\s*\]\s*\}\s*,\s*\n[ \t]*\{\s*"required":\s*\[\s*"content"\s*\]\s*\}\s*,?\s*\n[ \t]*\]\s*,\s*\n/ "required": ["agent_name", "entry"],\n/s' "$MP_FILE" && \
if grep -q '"required": \["agent_name", "entry"\]' "$MP_FILE"; then \
echo "mempalace diary_write anyOf workaround: applied (or already clean)"; \
else \
echo "WARN: mempalace diary_write anyOf workaround did not match expected schema — upstream may have changed shape" >&2; \
fi ; \
fi
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
ARG INSTALL_MEMPALACE_TOOLKIT=true
ARG MEMPALACE_TOOLKIT_REF=main
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
git clone --depth 1 --branch "${MEMPALACE_TOOLKIT_REF}" \
https://gitea.jordbo.se/joakimp/mempalace-toolkit.git /opt/mempalace-toolkit && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
mempalace-session --help >/dev/null && \
mempalace-docs --help >/dev/null && \
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
fi
# rustup — Rust toolchain manager (init binary only; toolchains installed at runtime)
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
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
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)
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
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
ENV EDITOR=nvim
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
# ── Node.js (required for opencode/pi/omos at variant build + MCP servers) ──
ARG NODE_VERSION=22
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/*
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
RUN ARCH=$(case "${TARGETARCH}" in \
amd64) echo "x86_64" ;; \
arm64) echo "aarch64" ;; \
*) echo "x86_64" ;; \
esac) && \
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
# ── Non-root user ────────────────────────────────────────────────────
ARG USER_NAME=developer
ARG USER_UID=1000
ARG USER_GID=1000
RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
useradd --uid ${USER_UID} --gid ${USER_GID} -m -s /bin/bash ${USER_NAME} && \
echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/${USER_NAME}
# Create standard directories
RUN mkdir -p /workspace \
/home/${USER_NAME}/.config/opencode/skills \
/home/${USER_NAME}/.pi/agent/extensions \
/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}
# ── Pre-warm chromadb embedding model ──────────────────────────────
# Runs as gosu developer so Path.home() resolves correctly. Uses
# the mempalace venv's python, which is the only one that has
# chromadb importable (system python3 cannot reach the isolated venv).
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
gosu ${USER_NAME} /opt/uv-tools/mempalace/bin/python -c "\
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2; \
ef = ONNXMiniLM_L6_V2(); \
_ = ef(['warmup']); \
print('chromadb embedding model warmed: all-MiniLM-L6-v2')" && \
ls -lh /home/${USER_NAME}/.cache/chroma/onnx_models/all-MiniLM-L6-v2/ ; \
fi
# ── User-writable npm global prefix on the devbox-pi-config volume ──
# By default npm's global prefix is /usr (writable only by root) so any
# `pi install npm:<pkg>` or `npm install -g <pkg>` invoked by the
# developer user would EACCES. Pointing the prefix into ~/.pi places
# user-installed packages on the named volume, which means they survive
# container recreation AND image rebuilds.
#
# IMPORTANT: in this split-build layout the variant Dockerfile inherits
# this prefix at build time. To keep the baked binaries on /usr (so the
# ~/.pi volume mount doesn't shadow them), the variant Dockerfile MUST
# run each `npm install -g` with NPM_CONFIG_PREFIX=/usr in the per-RUN
# environment. See Dockerfile.variant.
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global
ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
# ── Shell defaults (bash history, aliases, readline) ─────────────────
RUN mkdir -p /etc/skel-devbox
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
# ── 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 \
/usr/local/lib/opencode-devbox/*.py
# Start as root — entrypoint adjusts UID/GID then drops to developer
WORKDIR /workspace
ENTRYPOINT ["entrypoint.sh"]
CMD ["bash", "-l"]