31f36182bf
sqlite3 is the only one of the four that was genuinely missing rather than merely absent. MemPalace keeps both the palace and the logstream as SQLite files (palace/chroma.sqlite3, logstream.sqlite3) and mempalace_status reports a sqlite_integrity block, so every integrity or forensics check on this fleet has so far gone through a python3 -c one-liner because the CLI was not in the image. bc and dc are low value on their own and the CHANGELOG says so plainly: awk, python3 and perl are all already baked and each is strictly more capable. They are here because copy-pasted shell snippets assume bc exists. What actually went wrong on 2026-09-28 was NOT bc's absence: printf '%6.2f' was handed the empty output of the missing bc and rendered it as a confident "0.00 days" for a figure that was really 4.81 days. No package fixes that failure mode -- only not taking a formatted number on trust does. bsdextrautils ships /usr/bin/column. Cost MEASURED rather than estimated: 1311 KB total (587 + 236 + 149 + 339) and ZERO transitive packages. libsqlite3-0, libreadline8t64, zlib1g, libsmartcols1 and libtinfo6 each already report "install ok installed", so nothing new is pulled under --no-install-recommends. Package names were verified with apt-cache and dpkg -S on a real trixie host instead of being inferred, which caught two traps that would each have produced either a build failure or a silently missing binary: dc is a SEPARATE binary package from bc on Debian, and column ships in bsdextrautils, not in the pre-bullseye bsdmainutils where it used to live. Declined at the same time, recorded so the omission reads as a decision rather than an oversight: datamash and xsv/csvkit. Python's stdlib csv module handled a real 7-file Excel-export concatenation that day -- UTF-8 BOM, no trailing newlines, and bare CR/LF inside quoted fields -- correctly and without them. Dockerfile.base is in the base hash, so the next tag rebuilds the base (~64 min) regardless of what else it carries.
983 lines
59 KiB
Docker
983 lines
59 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-09-22 (v1.9.4 — mempalace 3.9.0 -> 3.10.0 with ENV MEMPALACE_CONFIG_DIR pinning the layout, mempalace-toolkit 2167a1b explicit event_list order; previous marker 2026-09-19 / v1.9.3)
|
|
#
|
|
# ── 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`.
|
|
# kitty-terminfo — terminfo entry for the kitty terminal (TERM=xterm-kitty).
|
|
# ~77 KB, terminfo file only (no kitty binary). Without it,
|
|
# ncurses apps fall back and Neovim can't reliably detect
|
|
# true-colour from kitty over ssh; installing it makes
|
|
# TERM=xterm-kitty understood. Pairs with the system-wide
|
|
# Neovim termguicolors default (etc/xdg/nvim/sysinit.vim).
|
|
# ncurses-term — broad terminfo bundle (wezterm, alacritty, foot, st, the
|
|
# base `ghostty` entry, and many more) so SSHing in from a
|
|
# modern emulator resolves its TERM instead of degrading to a
|
|
# dumb fallback. xterm-kitty is NOT in it (hence kitty-terminfo
|
|
# above); TERM=xterm-ghostty is compiled from an alias further
|
|
# down (ncurses ships `ghostty`, not `xterm-ghostty`). iTerm2
|
|
# defaults to xterm-256color (ncurses-base), so needs nothing.
|
|
# iproute2 — `ss` (socket statistics) and `ip`. Measured 2026-08-30 on
|
|
# v1.8.11: NEITHER was present, so the container could not
|
|
# answer "what is listening in here" by any means, and
|
|
# cli_utils' `portcheck` was a hard stub — it prints
|
|
# "portcheck requires at least one of: ss, lsof, netstat" and
|
|
# all three were absent. `ss` satisfies its preferred branch
|
|
# (`ss -tlnp`), which is also the branch that reports the
|
|
# owning PID, so nothing further is needed: net-tools is
|
|
# deliberately NOT added (`netstat` is deprecated and only a
|
|
# fallback branch) and neither is lsof (~500 KB for a third
|
|
# path to the same answer). ~5.5 MB total: iproute2 itself is
|
|
# 4.2 MB and pulls 6 libs under --no-install-recommends
|
|
# (libbpf1, libmnl0, libtirpc-common, libtirpc3t64,
|
|
# libxtables12, libcap2-bin — libpam-cap is a Recommends and
|
|
# is correctly dropped). Verified end-to-end in a live
|
|
# container: `ss` lands at /usr/bin/ss, `ip` at /usr/sbin/ip
|
|
# (both already on the developer PATH), and `portcheck --all`
|
|
# then correctly identifies the socat listener on 8765.
|
|
# shellcheck — shell linter. Added 2026-09-09 to close a CAPABILITY gap, not
|
|
# a style preference. `scripts/lint-shell.sh` is the release
|
|
# GATE (the `lint-gate` job that `resolve-versions` depends
|
|
# on), and it correctly refuses to pass when shellcheck is
|
|
# missing — "a gate that cannot run must not pass". Measured on
|
|
# v1.8.14: shellcheck was absent from this image by all three
|
|
# routes (PATH, dpkg, filesystem), so `bash
|
|
# scripts/lint-shell.sh` exited 2 in EVERY devbox container and
|
|
# no developer could run the release gate locally at all. The
|
|
# loop was therefore write-shell → push → wait for CI → discover,
|
|
# which is the loop the gate was added to shorten: v1.8.14's
|
|
# first attempt burned ~46 min on a tree whose lint had already
|
|
# been red for 24 h. This is also what makes a client-side
|
|
# pre-push hook possible (see hooks/pre-push); without the
|
|
# binary that hook would refuse every push. ~39 MB installed
|
|
# (Installed-Size 40112 KB, shellcheck 0.10.0-1) and measured
|
|
# to pull ZERO additional packages under
|
|
# --no-install-recommends: its deps (libc6, libffi8, libgmp10)
|
|
# are already present. NOTE this file feeds the base-decide
|
|
# hash (Dockerfile.base + rootfs/), so adding it forces one
|
|
# full base rebuild.
|
|
# bind9-dnsutils — `dig` and `nslookup`. Added 2026-09-10 to close a
|
|
# DIAGNOSTIC gap measured during the gitea.egl.lan/FreeIPA
|
|
# work: the container could resolve names but had NO way to
|
|
# ask a SPECIFIC nameserver anything. `getent hosts` only
|
|
# follows the resolver's default path, so the whole "gateway
|
|
# 172.16.88.1 returns NXDOMAIN for the egl.lan zone while
|
|
# 10.20.253.1 is authoritative for it" diagnosis had to be
|
|
# hand-rolled in python3 — dig, host AND nslookup were all
|
|
# absent. `dig @10.20.253.1 freeipa-4.egl.lan` is the
|
|
# one-liner that replaces it, and split-horizon DNS is a
|
|
# recurring class of bug on this fleet, not a one-off. NOTE
|
|
# the package to name is bind9-dnsutils: plain `dnsutils` is
|
|
# a transitional package in trixie. ~6.1 MB total (6210 KB
|
|
# measured): bind9-dnsutils 721 KB + bind9-host 161 KB +
|
|
# bind9-libs 3804 KB plus 7 small libs (libfstrm0,
|
|
# libjson-c5, liblmdb0, libmaxminddb0, libprotobuf-c1,
|
|
# liburcu8t64, libuv1t64) under --no-install-recommends.
|
|
# ldap-utils — `ldapsearch`/`ldapmodify`. Added 2026-09-10. This fleet
|
|
# authenticates against FreeIPA (EGL.LAN), and every LDAP
|
|
# probe during the Gitea auth work had to be run by SSHing to
|
|
# an already-enrolled host because the container had no LDAP
|
|
# client at all. 1244 KB and pulls NOTHING extra under
|
|
# --no-install-recommends — its deps (libldap, libsasl2) are
|
|
# already present. CAVEAT: this gives SIMPLE binds only,
|
|
# which is what Gitea itself uses and what most probes need.
|
|
# GSSAPI binds (`ldapsearch -Y GSSAPI`) additionally require
|
|
# krb5-user + libsasl2-modules-gssapi-mit, deliberately NOT
|
|
# added here — that is a Kerberos-client decision with
|
|
# /etc/krb5.conf implications, not just a tool.
|
|
# xxd — hex dump. 198 KB, no extra deps. Convenience, and honestly
|
|
# marginal: `od -c` from coreutils is always present and does
|
|
# the same job. Earned its place because verifying that
|
|
# git-crypt actually encrypted a staged blob (the \0GITCRYPT\0
|
|
# magic) is a recurring check in myconfigs and xxd is the
|
|
# muscle-memory command for it.
|
|
# NOT added — netcat-openbsd (133 KB): measured redundant on
|
|
# 2026-09-10, because socat is already baked above AND bash's
|
|
# /dev/tcp does reachability checks with zero packages
|
|
# (verified against gitea.egl.lan:3000). Recorded here so the
|
|
# omission reads as a decision rather than an oversight.
|
|
# sqlite3 — the `sqlite3` CLI. Added 2026-09-28 at ALC's request. 587 KB.
|
|
# This is the one that was genuinely missing rather than merely
|
|
# absent: MemPalace keeps BOTH the palace and the logstream as
|
|
# SQLite files (palace/chroma.sqlite3, logstream.sqlite3),
|
|
# mempalace_status reports a sqlite_integrity block, and every
|
|
# integrity or forensics check on this fleet has so far been
|
|
# done through a python3 -c one-liner because the CLI did not
|
|
# exist in the image.
|
|
# bc, dc — arbitrary-precision calculators. 236 KB + 149 KB. SEPARATE
|
|
# binary packages on Debian (dc split out of bc before
|
|
# bookworm), so both must be named — verified with apt-cache on
|
|
# a trixie host, not assumed. Added 2026-09-28. HONEST
|
|
# RATIONALE: low value on their own, because awk, python3 and
|
|
# perl are all already baked and each is strictly more capable.
|
|
# They are here because copy-pasted shell snippets assume bc
|
|
# exists. Note what actually went wrong on 2026-09-28, since it
|
|
# was NOT bc's absence: `printf '%6.2f'` was handed the empty
|
|
# output of the missing bc and rendered it as a confident
|
|
# "0.00 days" for a figure that was really 4.81 days. A missing
|
|
# tool that formats as a plausible number is worse than one
|
|
# that fails loudly, and no package fixes that — only not
|
|
# taking a formatted value on trust does.
|
|
# bsdextrautils— ships /usr/bin/column. 339 KB. Added 2026-09-28. Confirmed
|
|
# with `dpkg -S /usr/bin/column` on trixie rather than inferred
|
|
# from the pre-bullseye bsdmainutils name, which is where column
|
|
# used to live and is the obvious way to get this wrong.
|
|
# MEASURED COST OF ALL FOUR: 1311 KB total and ZERO transitive
|
|
# packages — libsqlite3-0, libreadline8t64, zlib1g,
|
|
# libsmartcols1 and libtinfo6 are each already present in the
|
|
# image (checked with dpkg-query, all five report
|
|
# "install ok installed"), so under --no-install-recommends
|
|
# nothing new is pulled.
|
|
# NOT added — datamash, xsv/csvkit: considered 2026-09-28 and
|
|
# declined. Python's stdlib csv module handled a real 7-file
|
|
# Excel-export concatenation that day (BOM, CRLF, embedded
|
|
# newlines inside quoted fields) correctly and without them.
|
|
# python3-yaml — PyYAML. Added 2026-09-10 for precisely the same reason as
|
|
# shellcheck above: a gate this repo ALREADY OWNS could not be
|
|
# run locally by anyone. scripts/check-workflow-shell.sh — the
|
|
# guard that catches the "bash-only syntax under Gitea's default
|
|
# sh/dash shell" footgun that broke resolve-versions (ed49b8d)
|
|
# and promote-base-latest (b7197e8) — hard-exits with "ERROR:
|
|
# python3 yaml module missing" without it. lint.yml installs it
|
|
# explicitly in CI (`shellcheck python3-yaml`), which is itself
|
|
# the evidence that the image lacked it. Measured 2026-09-10
|
|
# while wiring the skill-floor job: the guard could not be run
|
|
# before pushing — the same write → push → wait-for-CI loop that
|
|
# shellcheck was baked to shorten. 552 KB, and pulls ZERO extra
|
|
# packages under --no-install-recommends.
|
|
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 \
|
|
shellcheck \
|
|
git-crypt \
|
|
age \
|
|
file \
|
|
sudo \
|
|
locales \
|
|
procps \
|
|
unzip \
|
|
gcc \
|
|
g++ \
|
|
rsync \
|
|
python3-pip \
|
|
python3-venv \
|
|
pandoc \
|
|
xz-utils \
|
|
graphviz \
|
|
imagemagick \
|
|
socat \
|
|
nano \
|
|
kitty-terminfo \
|
|
ncurses-term \
|
|
iproute2 \
|
|
bind9-dnsutils \
|
|
ldap-utils \
|
|
xxd \
|
|
python3-yaml \
|
|
sqlite3 \
|
|
bc \
|
|
dc \
|
|
bsdextrautils \
|
|
&& 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; the feed's `mempalace_mine` carries
|
|
# its own longer MEMPALACE_FEED_MINE_TIMEOUT_MS, default 300000, since toolkit
|
|
# 817b3a8 — before that the 60 s deadline cut every honest mine off),
|
|
# 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) shipped 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.
|
|
#
|
|
# 3.6.0 (2026-07-17, PyPI latest) is additive/reliability only — secure
|
|
# `mempalace serve` remote mode, optional Milvus backend, atomic KG
|
|
# supersede(), conversation chronology, mining exclusions, plus recovery and
|
|
# locking fixes. Reviewed for MCP tool-schema changes before bumping (that
|
|
# being the exact regression class this pin exists to catch): there are NONE,
|
|
# and nothing touches diary_write. Two fixes matter for how this image uses
|
|
# mempalace: read-only mode now covers checkpoint + delete_by_source in
|
|
# _MUTATING_TOOLS (#1930), and agent attribution is preserved in
|
|
# mempalace_checkpoint (#2023/#2034).
|
|
#
|
|
# Keep in lockstep with opencode-devbox when bumping.
|
|
#
|
|
# 3.7.1 (from 3.6.0) is safe for anyone with an EXISTING LOCAL palace: verified
|
|
# against the 3.7.1 source, not the changelog. Legacy drawers lack the new
|
|
# `chunk_total` marker and both decision sites trust them ("trust the match as
|
|
# before"), NORMALIZE_VERSION is 2 in both, chromadb stays <2 (no index-format
|
|
# migration), there is no auto-migration ("We do NOT auto-migrate"), and the one
|
|
# new palace file (logstream.sqlite3) is created lazily on first logstream use.
|
|
# Two behaviour changes to know: MEMPALACE_MCP_ALLOW_PEER_WRITER no longer works
|
|
# on local/chroma palaces, and writer-lock setup failures now fail CLOSED
|
|
# (refuse the write) rather than fail open. Neither affects the container's
|
|
# normal MCP-server-plus-CLI-feeder pattern, which already serialised on the
|
|
# same lock under 3.6.0.
|
|
#
|
|
# 3.8.0 (2026-08-23, PyPI, released hours after this project's own v1.8.5 tag
|
|
# the same day) is additive/reliability only — reviewed for MCP tool-schema
|
|
# changes before bumping, as always: there are NONE. Two PRs matter:
|
|
# - PR #2320/#2322: `sync --apply` no longer deletes a drawer solely because
|
|
# its source_file was unreachable AT THAT MOMENT — it now asks for
|
|
# corroboration first. This fixes losing a whole mined project to one
|
|
# `sync --apply` while its volume happened to be unmounted.
|
|
# IMPORTANT — do not over-read this fix: it addresses TRANSIENT
|
|
# unreachability, not the standing landmine (documented in the operator's
|
|
# global AGENTS.md) against running `mempalace_sync` / `mempalace_delete_by_source`
|
|
# beyond dry-run on the SHARED central palace. On that palace most
|
|
# source_file paths are PERMANENTLY absent from whichever host runs the
|
|
# sync — a different machine's paths simply do not exist here, ever, not
|
|
# merely "right now". That is a different failure shape than #2320/#2322
|
|
# fixes. The landmine still stands; this bump does not relax it.
|
|
# - PR #2307: long-running Chroma servers no longer invalidate their own
|
|
# HNSW cache on their own writes (server-side perf fix). This does NOT
|
|
# make `mempalace_reconnect` unnecessary — that tool exists for EXTERNAL
|
|
# writes bypassing the in-process client (e.g. direct sqlite backfills,
|
|
# CLI commands against a running server), a different scenario #2307
|
|
# does not touch.
|
|
#
|
|
# CI-side audit (added after v1.8.6, closing that release's "Still open" item):
|
|
# resolve-versions now treats this pin exactly as it treats PI_VERSION — it
|
|
# reads the ARG from THIS file, refuses a non-concrete value, verifies the
|
|
# version is published on PyPI, refuses a YANKED release (an exact pin installs
|
|
# one silently under PEP 592), and WARNS — never silently adopts — when PyPI has
|
|
# a newer release. smoke-test.sh then asserts the installed core equals that
|
|
# audited pin, which catches a stale cached base layer that no manifest-internal
|
|
# check can see. So a bump here is now gated end to end; what remains manual is
|
|
# the JUDGEMENT above (MCP schema review, server/client sequencing), which is
|
|
# the part that should stay manual.
|
|
#
|
|
# Deployment sequencing note for whoever ships this bump: synlig (the shared
|
|
# central palace host) serves mempalace SERVER-SIDE as a `uv tool` install run
|
|
# by the systemd unit `mempalace-serve.service` (`python -m mempalace.mcp_server
|
|
# --transport http`), NOT via docker-compose.mempalace.yml — that compose file
|
|
# exists in this repo but is not what runs there. (Measured 2026-09-22 over
|
|
# ssh: `uv tool list` -> mempalace v3.9.0, python 3.12.13, chromadb 1.5.9;
|
|
# `docker ps` matched no palace container. This comment previously said the
|
|
# compose stack served 3.8.0, which was stale on both counts.) Bumping this ARG
|
|
# changes only the CLIENT version baked into pi-devbox images: it introduces
|
|
# client/server skew until synlig's tool is upgraded (`uv tool upgrade
|
|
# mempalace` + restart the unit). Not something to code around here — just
|
|
# sequence the upgrade. And note which side OWNS what: MCP tool semantics
|
|
# (event_list ordering, kg_timeline pagination, search result fields) come
|
|
# from the SERVER the extension talks to over MEMPALACE_REMOTE_URL, so they
|
|
# change when synlig upgrades; only the local CLI (`mempalace init` at first
|
|
# run, the mempalace-pi-session feeder) and the on-disk layout under
|
|
# ~/.mempalace change when THIS pin does.
|
|
#
|
|
# v1.8.13: 3.8.0 -> 3.9.0. Audited: no Breaking/Removed changelog headings.
|
|
# Adopted mainly for #2281 (`mempalace_mine` accepts a single conversation
|
|
# file again) — though note that does NOT unblock this image's own feeder,
|
|
# which was measured to mine DIRECTORIES, not files, so it was never hitting
|
|
# that bug. Four behaviour changes ride along and are skew-relevant while
|
|
# synlig stays on 3.8.0: hub-forward escaping, an HTTP lock split, similarity
|
|
# score semantics, and parsed-output compatibility. 3.9.0-only features
|
|
# (release awareness, `task create`/`task launch` MCP tools) are SERVER-side,
|
|
# so they stay dark until synlig is redeployed — a client bump alone cannot
|
|
# light them up.
|
|
#
|
|
# v1.9.4: 3.9.0 -> 3.10.0 (PyPI 2026-09-15). Deferred at v1.9.3 for two
|
|
# "Upgrade notes" items; both re-measured against the 3.10.0 wheel, one needed
|
|
# an adaptation:
|
|
# - "New installs keep config and palace under ~/.config/mempalace". The
|
|
# resolution order is $MEMPALACE_CONFIG_DIR, then ~/.mempalace IF it holds
|
|
# config.json / people_map.json / palace/chroma.sqlite3, then XDG. An
|
|
# EMPTY ~/.mempalace does not count — and an empty ~/.mempalace is exactly
|
|
# what a freshly mounted devbox-palace volume (or entrypoint.sh's mkdir on
|
|
# a volume-less container) looks like at first boot. Measured with a fresh
|
|
# $HOME: `mempalace init` wrote to ~/.config/mempalace, outside the
|
|
# persisted path, and entrypoint-user.sh's first-run test
|
|
# `[ ! -d ~/.mempalace/palace ]` would stay true on every start. With
|
|
# MEMPALACE_CONFIG_DIR set, everything landed in ~/.mempalace. Hence the
|
|
# ENV MEMPALACE_CONFIG_DIR below (in the non-root-user section, where
|
|
# ${USER_NAME} is in scope): first in the resolution order, so the
|
|
# heuristic never runs and the image's layout contract no longer depends
|
|
# on it. Existing volumes were safe either way (config.json is a legacy
|
|
# marker); the ENV is for first boots. palace_path still defaults to
|
|
# <config_dir>/palace and MEMPALACE_PALACE_PATH is still honoured (config.py
|
|
# :927), so scripts/smoke-test.sh's stage-path test keeps its meaning.
|
|
# - "MCP event listing returns the newest events first when no cursor is
|
|
# given". SERVER-side (see above), so it lands when synlig upgrades, not
|
|
# here. mempalace-toolkit 2167a1b made every cursor-less event_list call
|
|
# in the pi extension say `order: "desc"` explicitly, so the mailbox reads
|
|
# the same window against either server version.
|
|
# Also in the notes, neither reaching this image: `mempalace rules` dropped
|
|
# `--agent` (no caller in pi-devbox, mempalace-toolkit, skillset or myconfigs);
|
|
# `get_collection()` refuses unknown collection names (library callers only).
|
|
# MCP tool-schema review, as always: no tool removed or renamed; additive
|
|
# fields on search results (filed_at / content_date provenance), `limit` /
|
|
# `offset` on kg_timeline, `last_modified` on drawers. Skew while synlig stays
|
|
# on 3.9.0 is narrower than it looks: the pi extension speaks HTTP to the hub
|
|
# (no local mempalace-mcp is spawned), and the feeder in remote mode stages
|
|
# locally in python, rsyncs, and calls the hub's own `mempalace_mine` tool.
|
|
# Measured 2026-09-22 with a PATH shim in front of `mempalace`: a 47-session
|
|
# `mempalace-pi-session --dry-run` made ZERO local CLI calls (the shim's
|
|
# positive control logged one). So 3.10.0's new CLI write-routing policy never
|
|
# runs against the hub from this image; the client pin touches first-run
|
|
# `mempalace init` and the on-disk layout, nothing else in remote mode.
|
|
ARG MEMPALACE_VERSION=3.10.0
|
|
# Recorded as a label HERE, not in Dockerfile.variant, for three reasons: the
|
|
# value lives next to the ARG that defines it (a second copy in the variant
|
|
# would be one more pin able to drift, which is the class check-doc-drift.sh
|
|
# exists to catch); labels are inherited by every image built FROM this one, so
|
|
# both variants carry it with no build-arg to plumb through four call sites;
|
|
# and inheritance means the label states the pin of the base the variant
|
|
# ACTUALLY built on — which is the question when base-decide cache-hits an
|
|
# older base. Like every se.jordbo.pi-devbox.* label this records INTENT; the
|
|
# ground truth is /etc/pi-devbox/build-manifest.json's mempalace_version, read
|
|
# from the installed binary, and scripts/smoke-test.sh asserts the two agree.
|
|
# check-doc-drift.sh check 9 reads this off the last published image so that a
|
|
# pin bump must be named in the CHANGELOG — until this label ships, that
|
|
# component reports SKIP (label absent on the published release), not OK.
|
|
LABEL se.jordbo.pi-devbox.mempalace-version="${MEMPALACE_VERSION}"
|
|
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 && \
|
|
ln -sf /opt/mempalace-toolkit/bin/mempalace-pi-session /usr/local/bin/mempalace-pi-session && \
|
|
ln -sf /opt/mempalace-toolkit/bin/mempalace-census /usr/local/bin/mempalace-census && \
|
|
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs \
|
|
/opt/mempalace-toolkit/bin/mempalace-pi-session \
|
|
/opt/mempalace-toolkit/bin/mempalace-census && \
|
|
mempalace-session --help >/dev/null && \
|
|
mempalace-docs --help >/dev/null && \
|
|
mempalace-pi-session --help >/dev/null && \
|
|
mempalace-census --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
|
|
# Advertise 24-bit colour so colour-aware tools (Neovim's own auto-detect, bat,
|
|
# delta, ...) use true colour instead of a 256-colour fallback. Safe for the
|
|
# modern terminals this devbox targets; override by exporting `COLORTERM=`
|
|
# (empty) from a terminal that lacks true-colour support.
|
|
ENV COLORTERM=truecolor
|
|
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
|
|
|
|
# ── Node.js (required for pi + MCP servers + tldr) ──
|
|
# 24 (LTS "Krypton"), raised from 22 on 2026-09-10 because the image was BELOW a
|
|
# DECLARED requirement, not merely behind the newest release: `agent-browser`
|
|
# publishes engines.node ">=24.0.0", so every build on 22 installed it with an npm
|
|
# EBADENGINE warning and then ran it outside its supported range — measured on
|
|
# v1.8.14, which shipped node 22.23.2 with agent-browser 0.37.1. The other two npm
|
|
# consumers are satisfied either way: pi declares ">=22.19.0" and playwright
|
|
# ">=20". Verified before bumping, because a missing NodeSource suite would break
|
|
# the build for every arch at once: deb.nodesource.com/setup_24.x returns HTTP 200
|
|
# and the node_24.x suite advertises `Architectures: amd64 arm64 armhf x86_64`, so
|
|
# both the arm64 fleet and the amd64 CI runners resolve.
|
|
ARG NODE_VERSION=24
|
|
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/*
|
|
|
|
# ── agent-browser — headless browser automation for the agent ────────
|
|
# Gives the agent a real browser it can drive (open/click/fill/eval/
|
|
# screenshot) so front-end work involving live DOM or WebGL can be VERIFIED
|
|
# rather than guessed at. The `agent-browser` skill (shipped from the
|
|
# skillset repo, not this image) documents the CLI; without this block that
|
|
# skill is a no-op because the binary isn't present. Verified end-to-end
|
|
# 2026-07-13: drives the baked Chromium headless (open + screenshot + eval
|
|
# into a WebGL SPA) — doctor's launch test passes in ~0.5s.
|
|
#
|
|
# TWO pieces, because agent-browser is a standalone Rust CLI that ships NO
|
|
# browser of its own — it only drives one you provide:
|
|
# 1. the CLI itself (npm; ~70 MB of prebuilt native binaries), and
|
|
# 2. a Chromium, which we fetch via Playwright.
|
|
#
|
|
# Why Playwright fetches the browser (and NOT `agent-browser install`):
|
|
# agent-browser's own installer drops Chrome under ~/.agent-browser/browsers
|
|
# — inside /home/${USER_NAME}, which is a NAMED VOLUME at runtime, so a
|
|
# build-time download would be SHADOWED (invisible) once the volume mounts.
|
|
# Playwright honours PLAYWRIGHT_BROWSERS_PATH, so we place the browser under
|
|
# /usr/local/share (never shadowed) and hand agent-browser a STABLE symlink
|
|
# via AGENT_BROWSER_EXECUTABLE_PATH — the symlink insulates the ENV from
|
|
# Playwright's per-version, per-ARCH browser directory (`chrome-linux` on arm64,
|
|
# `chrome-linux64` on amd64 — Chrome-for-Testing), so we `find` the `chrome`
|
|
# binary rather than hardcode the path; the headless-shell binary is named
|
|
# `chrome-headless-shell`, so `-name chrome` skips it.
|
|
#
|
|
# `playwright install --with-deps chromium` also apt-installs Chromium's
|
|
# runtime libs; verified to resolve correctly on Debian trixie (exit 0 — the
|
|
# t64 library renames are handled by Playwright's dep list). Build runs as
|
|
# root, so the apt step works. NPM_CONFIG_PREFIX=/usr keeps both CLIs on /usr
|
|
# so they survive the ~/.pi/npm-global volume mount (same trick the variant
|
|
# uses for pi). After fetching, we DROP Playwright's `chromium_headless_shell-*`
|
|
# build — agent-browser drives the full chrome (verified, incl. headless), so the
|
|
# headless shell is dead weight — and clean the apt/npm caches, trimming the
|
|
# layer to ~625 MB (Chromium) from ~960 MB. Still the bulk of the base's size,
|
|
# and the one real tradeoff of shipping this to every variant.
|
|
ARG AGENT_BROWSER_VERSION=latest
|
|
ARG PLAYWRIGHT_VERSION=latest
|
|
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/ms-playwright
|
|
RUN NPM_CONFIG_PREFIX=/usr npm install -g \
|
|
"agent-browser@${AGENT_BROWSER_VERSION}" \
|
|
"playwright@${PLAYWRIGHT_VERSION}" && \
|
|
playwright install --with-deps chromium && \
|
|
CHROME="$(find "${PLAYWRIGHT_BROWSERS_PATH}" -type f -name chrome -path '*/chromium-*/*' | head -n1)" && \
|
|
[ -n "$CHROME" ] && ln -sf "$CHROME" /usr/local/bin/agent-chrome && \
|
|
agent-browser --version && \
|
|
test -x "$(readlink -f /usr/local/bin/agent-chrome)" && \
|
|
rm -rf "${PLAYWRIGHT_BROWSERS_PATH}"/chromium_headless_shell-* && \
|
|
npm cache clean --force && \
|
|
rm -rf /var/lib/apt/lists/* /root/.npm /tmp/*
|
|
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/local/bin/agent-chrome
|
|
|
|
# ── 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}"
|
|
|
|
# ── MemPalace config/palace root: pin it, do not let a heuristic pick it ──
|
|
# mempalace >= 3.10.0 resolves its config dir as $MEMPALACE_CONFIG_DIR, then
|
|
# ~/.mempalace ONLY if it already holds a config/palace, else ~/.config/mempalace
|
|
# (XDG). An empty ~/.mempalace — a fresh devbox-palace volume, or entrypoint.sh's
|
|
# mkdir on a volume-less container — fails that test, so a first boot would put
|
|
# the palace outside the persisted path and re-run first-run init forever. This
|
|
# ENV is first in the order, so the layout is what entrypoint.sh (mkdir),
|
|
# entrypoint-user.sh (first-run test), the feeder's <palace-root>/pi-stage and
|
|
# scripts/recreate-sanity-check.sh all already assume. Rationale and the
|
|
# measurement live with ARG MEMPALACE_VERSION above; keep the two in step.
|
|
ENV MEMPALACE_CONFIG_DIR=/home/${USER_NAME}/.mempalace
|
|
|
|
# ── 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
|
|
|
|
# ── Editor defaults: system-wide Neovim true-colour ──────────────────
|
|
# /etc/xdg/nvim/sysinit.vim is Neovim's system vimrc: it loads for every user
|
|
# (before any personal ~/.config/nvim) and can still be overridden per-user.
|
|
# Enables termguicolors so the default theme renders in 24-bit colour instead
|
|
# of a muddy 256-colour fallback. Pairs with kitty-terminfo (installed above).
|
|
COPY rootfs/etc/xdg/nvim/sysinit.vim /etc/xdg/nvim/sysinit.vim
|
|
|
|
# ── Terminal support: xterm-ghostty terminfo alias ──────────────────
|
|
# ncurses-term (installed above) covers wezterm/alacritty/foot/st and the base
|
|
# `ghostty` entry, but Ghostty connects with TERM=xterm-ghostty, for which no
|
|
# distro packages an entry. Ship a thin alias (use=ghostty) and compile it into
|
|
# the system terminfo db with `tic -x`, so it inherits the maintained ghostty
|
|
# capability set. The `infocmp` check fails the build if the entry didn't land.
|
|
COPY rootfs/usr/local/share/terminfo-src/ghostty.terminfo /usr/local/share/terminfo-src/ghostty.terminfo
|
|
RUN tic -x -o /usr/share/terminfo /usr/local/share/terminfo-src/ghostty.terminfo && \
|
|
infocmp -x xterm-ghostty >/dev/null
|
|
|
|
# ── 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 rootfs/usr/local/bin/pi-devbox-version /usr/local/bin/pi-devbox-version
|
|
COPY rootfs/usr/local/bin/devbox-skill-reconcile /usr/local/bin/devbox-skill-reconcile
|
|
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/bin/pi-devbox-version \
|
|
/usr/local/bin/devbox-skill-reconcile \
|
|
/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"]
|