3d46c6615e
Lint workflows / actionlint (push) Successful in 21s
pandoc's bundled typst template defaults the document font to an empty tuple (font: ()), so a naked `pandoc --pdf-engine=typst` fails with "font fallback list must not be empty". Patch the template default to Libertinus Serif (typst's own bundled default) at build time so PDF export works out of the box. Document usage in README and note the fix in CHANGELOG (Unreleased). Base-affecting.
582 lines
32 KiB
Docker
582 lines
32 KiB
Docker
# pi-devbox — base image (variant-independent layers)
|
|
#
|
|
# This Dockerfile produces an image tagged base-<hash>, 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-07-11 (Unreleased — typst PDF engine + xz-utils + pandoc typst-template default-font patch)
|
|
#
|
|
# ── 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. NOTE: pandoc is
|
|
# only the front-end — PDF output needs a back-end engine.
|
|
# We ship `typst` (installed further down) as the
|
|
# lightweight default engine (`pandoc --pdf-engine=typst`)
|
|
# instead of a ~600 MB TeX Live install.
|
|
# xz-utils — `xz` decompressor. tar shells out to it for `.tar.xz`
|
|
# assets (typst ships .tar.xz). ~0.5 MB. Also generally
|
|
# useful for extracting xz-compressed archives.
|
|
# graphviz — `dot` rendering for many diagram tools. ~10 MB.
|
|
# See the bundled `dot-watch` helper for live .dot -> PNG
|
|
# re-render (handy with pi-studio's image preview).
|
|
# imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB.
|
|
# (yq is NOT apt-installed: Debian's `yq` is the unrelated Python tool;
|
|
# mikefarah's Go yq is installed as a pinned binary further down.)
|
|
# 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.
|
|
# nano — small, non-modal terminal editor for users who don't want
|
|
# a vi-based editor. ~2.8 MB installed; its deps (libc6,
|
|
# libncursesw6, libtinfo6) are already pulled in by nvim/less/
|
|
# htop/tmux, so it adds no extra packages. Companion to nvim
|
|
# and the `micro` binary installed further down. EDITOR stays
|
|
# nvim; users opt in via `export EDITOR=nano`.
|
|
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 \
|
|
pandoc \
|
|
xz-utils \
|
|
graphviz \
|
|
imagemagick \
|
|
socat \
|
|
nano \
|
|
&& 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
|
|
# `<session>: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.
|
|
#
|
|
# CAVEAT (and why it is handled elsewhere): a user per-host override that
|
|
# points ControlPath BACK under the read-only ~/.ssh (e.g. the common CGNAT
|
|
# idiom `ControlPath ~/.ssh/cm/%r@%h:%p`) re-introduces the unwritable-socket
|
|
# failure — a system drop-in here can never override a user's per-host value.
|
|
# For `pi --ssh`, the ssh-controlmaster extension handles this by detecting an
|
|
# unwritable system ControlPath and falling back to its own /tmp master; for
|
|
# `ssh -F ~/.ssh-local/config` (dssh/dscp), setup-lan-access.sh redirects
|
|
# ControlPath into the writable ~/.ssh-local. See CHANGELOG "Unreleased".
|
|
#
|
|
# 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
|
|
|
|
# micro — modern, non-modal terminal editor. Ships alongside nvim so users
|
|
# who aren't comfortable with vi-style modal editing have a friendly option:
|
|
# desktop-style keybindings (Ctrl+S save, Ctrl+Q quit, Ctrl+C/V/X, Ctrl+Z
|
|
# undo), mouse support, and syntax highlighting out of the box. A single
|
|
# static Go binary (~12 MB) installed from GitHub releases, exactly like
|
|
# bat/eza/zoxide below. EDITOR stays nvim (see below); users opt in with
|
|
# `export EDITOR=micro` or `git config --global core.editor micro`.
|
|
#
|
|
# NOTE: upstream moved zyedidia/micro -> micro-editor/micro. The old org URL
|
|
# still 302s, but its /releases/latest redirect lands on ANOTHER /latest URL
|
|
# (the org rename), so the tag-parsing idiom below would resolve "latest"
|
|
# instead of a version. Use the canonical micro-editor/micro URL.
|
|
# Arch asset naming differs from the others: amd64 -> linux64, arm64 ->
|
|
# linux-arm64. The tarball extracts to micro-<version>/micro.
|
|
ARG MICRO_VERSION=latest
|
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "linux64" ;; arm64) echo "linux-arm64" ;; *) echo "linux64" ;; esac) && \
|
|
V="${MICRO_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/micro-editor/micro/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
|
fi && \
|
|
V="${V#v}" && [ -n "$V" ] && \
|
|
echo "Installing micro ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/micro-editor/micro/releases/download/v${V}/micro-${V}-${ARCH}.tar.gz" | tar -xz -C /tmp && \
|
|
install /tmp/micro-${V}/micro /usr/local/bin/micro && \
|
|
rm -rf /tmp/micro-${V} && \
|
|
micro --version
|
|
|
|
# bat
|
|
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.
|
|
#
|
|
# Stall protection (fixed 2026-06-13; self-heal added 2026-06-25):
|
|
# mempalace-mcp is launched by the `mempalace.ts` pi extension from
|
|
# mempalace-toolkit (cloned below). That extension applies a per-REQUEST
|
|
# timeout in its JSON-RPC client and kills the child on stall, so a virtiofs
|
|
# cold-open of chroma.sqlite3 / HNSW load can no longer hang the pi TUI
|
|
# uninterruptibly. A stall-kill is no longer a permanent latch either: the
|
|
# next tool call respawns the server with capped exponential backoff (the
|
|
# budget resets on any successful response). Tunables:
|
|
# MEMPALACE_MCP_TIMEOUT_MS (default 60000), MEMPALACE_MCP_INIT_TIMEOUT_MS
|
|
# (default 300000 — generous so a genuine first cold-open isn't killed),
|
|
# MEMPALACE_MCP_MAX_RESPAWNS (default 2; 0 disables self-heal),
|
|
# MEMPALACE_MCP_RESPAWN_BACKOFF_MS (default 1000); timeouts of 0 disable.
|
|
# Defaults live in the extension, so no ENV is needed here. A standalone
|
|
# stdio-watchdog shim is NOT needed — the extension already owns
|
|
# request/response correlation. See CHANGELOG.md "Unreleased > Fixed".
|
|
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. Pinning makes mempalace upgrades a reviewable diff
|
|
# rather than a surprise.
|
|
#
|
|
# 3.5.0 (2026-06) ships the upstream fix for the top-level-anyOf diary_write
|
|
# schema (issue #1728 / PR #1717, merged 2026-06-14): the advertised schema
|
|
# is now `"required": ["agent_name"]` with entry/content enforced at dispatch,
|
|
# which Anthropic's tools API accepts — so the old mcp_server.py perl
|
|
# workaround that used to live below is gone. Keep in lockstep with
|
|
# opencode-devbox when bumping.
|
|
ARG MEMPALACE_VERSION=3.5.0
|
|
ENV UV_TOOL_DIR=/opt/uv-tools
|
|
ENV UV_TOOL_BIN_DIR=/usr/local/bin
|
|
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
|
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
|
|
|
|
# (The mempalace diary_write top-level-anyOf workaround that patched
|
|
# mcp_server.py here was removed in v1.2.2 — fixed upstream in mempalace
|
|
# 3.5.0 via issue #1728 / PR #1717 (merged 2026-06-14). See CHANGELOG.md.)
|
|
|
|
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
|
|
ARG INSTALL_MEMPALACE_TOOLKIT=true
|
|
ARG MEMPALACE_TOOLKIT_REF=main
|
|
# MEMPALACE_TOOLKIT_REPO defaults to the canonical gitea origin but is
|
|
# overridable so a relocated/forked build can clone from a mirror or a
|
|
# different host without editing this Dockerfile (mirrors the
|
|
# PI_FORK_REPO / PI_OBSMEM_REPO / PI_STUDIO_REPO pattern in the variant).
|
|
ARG MEMPALACE_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/mempalace-toolkit.git
|
|
# MEMPALACE_TOOLKIT_REF accepts EITHER a branch name OR a commit SHA. CI
|
|
# resolves it to a SHA (resolve-versions job) and folds that SHA into the
|
|
# base-decide hash so the base rebuilds when the toolkit moves. `git clone
|
|
# --branch <40-char-SHA>` fails ("Remote branch not found") — the same
|
|
# footgun fixed in Dockerfile.variant (v1.0.0-rerun, run 374) — so use
|
|
# `git fetch <ref> + checkout FETCH_HEAD`, which works for name and SHA.
|
|
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
|
|
rm -rf /opt/mempalace-toolkit && mkdir -p /opt/mempalace-toolkit && \
|
|
git -C /opt/mempalace-toolkit init -q && \
|
|
git -C /opt/mempalace-toolkit remote add origin "${MEMPALACE_TOOLKIT_REPO}" && \
|
|
ok=0; for i in 1 2 3 4 5; do \
|
|
if git -C /opt/mempalace-toolkit fetch --depth 1 origin "${MEMPALACE_TOOLKIT_REF}" && \
|
|
git -C /opt/mempalace-toolkit checkout -q FETCH_HEAD; then ok=1; break; fi; \
|
|
echo "git fetch mempalace-toolkit@${MEMPALACE_TOOLKIT_REF} failed (attempt $i/5), retrying in $((i*5))s..."; \
|
|
sleep $((i*5)); \
|
|
done; \
|
|
[ "$ok" = "1" ] && \
|
|
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
|
|
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
|
|
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
|
|
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
|
|
|
|
# ── typst — lightweight PDF engine for pandoc (Markdown→PDF) ─────────
|
|
# pandoc (apt-installed above) is only a front-end; rendering PDF needs a
|
|
# back-end engine. Rather than a ~600 MB TeX Live install, we ship typst:
|
|
# a single ~30 MB static Rust binary with no LaTeX dependency. pi-studio's
|
|
# PDF export (studio_export_pdf) and pandoc invocations use it via
|
|
# `pandoc --pdf-engine=typst`. A fuller TeX Live remains the higher-
|
|
# fidelity fallback for anyone who needs LaTeX-exact output (not shipped
|
|
# here — install on demand or in a future variant).
|
|
#
|
|
# Follows the `latest` GitHub-release convention (like tealdeer/uv/bat).
|
|
# typst ships a `.tar.xz` asset (hence xz-utils in the apt layer above)
|
|
# that extracts to typst-<arch>-unknown-linux-musl/typst. Pin a specific
|
|
# tag with --build-arg TYPST_VERSION=vX.Y.Z.
|
|
#
|
|
# We also patch pandoc's bundled typst template
|
|
# (/usr/share/pandoc/data/templates/template.typst): its conf() defaults the
|
|
# document font to an empty tuple (`font: ()`), so a naked
|
|
# `pandoc --pdf-engine=typst` fails with "font fallback list must not be empty"
|
|
# unless the caller passes `-V mainfont=...`. We default it to Libertinus Serif
|
|
# (typst's own bundled default font) so PDF export works out-of-the-box.
|
|
ARG TYPST_VERSION=latest
|
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
|
V="${TYPST_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/typst/typst/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
|
fi && \
|
|
V="${V#v}" && [ -n "$V" ] && \
|
|
echo "Installing typst ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/typst/typst/releases/download/v${V}/typst-${ARCH}-unknown-linux-musl.tar.xz" | tar -xJ -C /tmp && \
|
|
install /tmp/typst-${ARCH}-unknown-linux-musl/typst /usr/local/bin/typst && \
|
|
rm -rf /tmp/typst-${ARCH}-unknown-linux-musl && \
|
|
typst --version && \
|
|
sed -i 's/^ font: (),$/ font: ("Libertinus Serif",),/' /usr/share/pandoc/data/templates/template.typst && \
|
|
grep -q 'font: ("Libertinus Serif",),' /usr/share/pandoc/data/templates/template.typst
|
|
|
|
# ── yq (mikefarah) — YAML processor, jq's companion for YAML ─────────
|
|
# Installed as the mikefarah Go binary — NOT Debian's `yq` apt package, which
|
|
# is the unrelated Python kislyuk/yq (a jq wrapper with different syntax and
|
|
# version line, e.g. 3.x). The cloud-init repo's deploy.sh/provision.sh
|
|
# require mikefarah yq v4 (the unrelated Debian python yq is v3.x). Follows
|
|
# the repo's `latest` convention (like tealdeer/uv/etc.); the smoke test pins
|
|
# the contract to major v4, so a future yq v5 fails CI instead of silently
|
|
# breaking provision.sh. Pin a specific tag with --build-arg YQ_VERSION=vX.Y.Z.
|
|
ARG YQ_VERSION=latest
|
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
|
V="${YQ_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/mikefarah/yq/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
|
fi && \
|
|
[ -n "$V" ] && \
|
|
echo "Installing mikefarah yq ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/mikefarah/yq/releases/download/${V}/yq_linux_${ARCH}" -o /usr/local/bin/yq && \
|
|
chmod +x /usr/local/bin/yq && \
|
|
yq --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
|
|
COPY rootfs/home/developer/.gitignore_global /etc/skel-devbox/.gitignore_global
|
|
|
|
# ── Entrypoint ────────────────────────────────────────────────────────
|
|
COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
|
|
# Image-baked skills + the global-AGENTS append snippet. Under /usr/local so a
|
|
# named volume over a home dir can't shadow them; linked into ~/.agents/skills
|
|
# by entrypoint-user.sh, and the snippet is concatenated onto the global
|
|
# AGENTS.md in Dockerfile.variant (after pi-toolkit, which owns that file).
|
|
COPY rootfs/usr/local/share/pi-devbox/ /usr/local/share/pi-devbox/
|
|
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
|
|
COPY rootfs/usr/local/bin/dot-watch /usr/local/bin/dot-watch
|
|
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/bin/dot-watch \
|
|
/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"]
|