# pi-devbox — base image (variant-independent layers) # # This Dockerfile produces an image tagged base-, used as the parent # for all published variants of pi-devbox. It contains everything that does # not depend on variant-specific build-args (the pi install moves to # Dockerfile.variant). # # The base is rebuilt only when this file or anything it COPYs in changes # (rootfs/, entrypoint*.sh). Version bumps to 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-06-09 (v1.0.0 — decoupled from opencode-devbox) # # ── Lineage note ───────────────────────────────────────────────────── # Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2). # pi-devbox was previously a thin re-brand of opencode-devbox's pi-only # variant; this file is the start of an independent build chain. The # opencode-devbox install logic (INSTALL_OPENCODE, INSTALL_OMOS) does # not appear here. The base is otherwise broadly equivalent so generic # upstream improvements (CVE updates, new dev tooling) can be cherry- # picked between repos. # ───────────────────────────────────────────────────────────────────── ARG DEBIAN_VERSION=trixie-slim FROM debian:${DEBIAN_VERSION} AS base ARG TARGETARCH LABEL maintainer="joakimp" LABEL description="pi-devbox — base image (variant-independent)" LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/pi-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. # # Additions vs the upstream opencode-devbox base (2026-06-09): # pandoc — Markdown↔HTML/PDF/etc. conversion. Required by pi-studio # preview/export pipelines and broadly useful for any # agent-driven document workflow. ~200 MB. # graphviz — `dot` rendering for many diagram tools. ~10 MB. # imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB. # yq — YAML-aware companion to jq. # socat — TCP relay. Powers `studio-expose`, which bridges # pi-studio's container-loopback server to the container's # external interface so a published port can reach it. # ~1 MB; generally useful for any port-forwarding need. 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 \ yq \ 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 \ pandoc \ graphviz \ imagemagick \ socat \ && ln -s /usr/bin/fdfind /usr/local/bin/fd \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # ── tmux defaults: 0-indexed windows and panes ─────────────────────── # pi-studio (omaclaren/pi-studio) hard-codes its tmux send target to # `:0.0`. Containers that ship tmux with default options are # already 0-indexed; this file makes the assumption explicit so future # /etc/tmux.conf consumers can read it. Users can override per-user # in ~/.tmux.conf if they want 1-indexing — pi-studio will then fail # to find its REPL session. RUN printf '%s\n' \ '# pi-devbox baked default — see Dockerfile.base.' \ '# pi-studio targets tmux session :0.0; do not change these here.' \ 'set -g base-index 0' \ 'set -g pane-base-index 0' \ > /etc/tmux.conf # ── 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: default is `latest` — resolved at build time by # following the /releases/latest redirect and reading the tag from the # Location header. Every base rebuild picks up the newest upstream # release. Explicit pins still work via build-args (e.g. # --build-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) && \ 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 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 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 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 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 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 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. Note: uv tags don't prefix with "v". 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. Set INSTALL_MEMPALACE=false at base-build # time to shave ~300 MB. ARG INSTALL_MEMPALACE=true # Pin to a known-good version. Bump deliberately, not implicitly: an # unpinned install silently swept in mempalace 3.3.x/3.4.0 with a broken # diary_write schema (see workaround RUN below + issue #1728). Pinning # makes mempalace upgrades a reviewable diff rather than a surprise. 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..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 # Locales 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 pi + MCP servers + tldr) ── 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/* # ── tldr (tealdeer) — community-maintained command examples ────────── # Tealdeer is a Rust port of the tldr-pages client; ~5 MB static binary, # ~135 MB smaller than the Node tldr global. Same `tldr` command, same UX. ARG TEALDEER_VERSION=latest RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \ V="${TEALDEER_VERSION}" && \ if [ "$V" = "latest" ]; then \ V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \ fi && \ V="${V#v}" && [ -n "$V" ] && \ echo "Installing tealdeer ${V}" && \ curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/download/v${V}/tealdeer-linux-${ARCH}-musl" -o /usr/local/bin/tldr && \ chmod +x /usr/local/bin/tldr && \ tldr --version # ── 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} # Standard directories RUN mkdir -p /workspace \ /home/${USER_NAME}/.pi/agent/extensions \ /home/${USER_NAME}/.agents/skills \ /home/${USER_NAME}/.cache/bash \ /home/${USER_NAME}/.ssh && \ chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME} # ── Pre-warm chromadb embedding model ────────────────────────────── 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 ── # Build-time installs use NPM_CONFIG_PREFIX=/usr (see Dockerfile.variant). # Runtime npm/pi installs use this prefix → land on the named volume. 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/pi-devbox/ /usr/local/lib/pi-devbox/ COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \ /usr/local/bin/studio-expose \ /usr/local/lib/pi-devbox/*.sh 2>/dev/null || true # Start as root — entrypoint adjusts UID/GID then drops to developer WORKDIR /workspace ENTRYPOINT ["entrypoint.sh"] CMD ["bash", "-l"]