7d8ee4cea1
pi-studio binds the container's 127.0.0.1, which a published Docker port can't reach. Add a robust, portable bridge rather than a doc-only one-liner: - Dockerfile.base: add socat (~1 MB, generally useful TCP relay). - rootfs/usr/local/bin/studio-expose: socat TCP relay listening on the container's egress IPv4 (not 0.0.0.0 — that would EADDRINUSE against Studio's loopback listener) forwarding to 127.0.0.1:PORT on the SAME port, so Studio's printed token URL works verbatim. Robust egress-IP detection (hostname -I, loopback-filtered; ip route get fallback), --help, port validation, foreground. - entrypoint-user.sh: opt-in STUDIO_EXPOSE=1 auto-starts the bridge in the background (studio variant only). Default OFF — Studio stays loopback-only (its secure default) unless explicitly opted in. - README: 'Using pi-studio' now documents host-networking (A) and the studio-expose/STUDIO_EXPOSE bridge (B) with a security note; ssh -L for remote, mosh caveat retained. - smoke-test: assert socat + studio-expose present (base-level). - CHANGELOG/AGENTS updated. No tag — stopping for review.
450 lines
23 KiB
Docker
450 lines
23 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-06-09 (v1.0.0 — decoupled from opencode-devbox)
|
|
#
|
|
# ── Lineage note ─────────────────────────────────────────────────────
|
|
# Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2).
|
|
# pi-devbox was previously a thin re-brand of opencode-devbox's pi-only
|
|
# variant; this file is the start of an independent build chain. The
|
|
# opencode-devbox install logic (INSTALL_OPENCODE, INSTALL_OMOS) does
|
|
# not appear here. The base is otherwise broadly equivalent so generic
|
|
# upstream improvements (CVE updates, new dev tooling) can be cherry-
|
|
# picked between repos.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
ARG DEBIAN_VERSION=trixie-slim
|
|
FROM debian:${DEBIAN_VERSION} AS base
|
|
|
|
ARG TARGETARCH
|
|
|
|
LABEL maintainer="joakimp"
|
|
LABEL description="pi-devbox — base image (variant-independent)"
|
|
LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/pi-devbox"
|
|
|
|
# Avoid interactive prompts during build
|
|
ENV DEBIAN_FRONTEND=noninteractive
|
|
|
|
# ── Core system packages ─────────────────────────────────────────────
|
|
# apt-get upgrade picks up any security/CVE fixes published between
|
|
# debian:trixie-slim base-image rebuilds. Paired with the index update
|
|
# and the install in the same layer so we don't bloat image history.
|
|
#
|
|
# Additions vs the upstream opencode-devbox base (2026-06-09):
|
|
# pandoc — Markdown↔HTML/PDF/etc. conversion. Required by pi-studio
|
|
# preview/export pipelines and broadly useful for any
|
|
# agent-driven document workflow. ~200 MB.
|
|
# graphviz — `dot` rendering for many diagram tools. ~10 MB.
|
|
# imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB.
|
|
# yq — YAML-aware companion to jq.
|
|
# socat — TCP relay. Powers `studio-expose`, which bridges
|
|
# pi-studio's container-loopback server to the container's
|
|
# external interface so a published port can reach it.
|
|
# ~1 MB; generally useful for any port-forwarding need.
|
|
RUN apt-get update && \
|
|
apt-get upgrade -y --no-install-recommends && \
|
|
apt-get install -y --no-install-recommends \
|
|
ca-certificates \
|
|
curl \
|
|
wget \
|
|
git \
|
|
openssh-client \
|
|
gnupg \
|
|
jq \
|
|
yq \
|
|
ripgrep \
|
|
fd-find \
|
|
tree \
|
|
less \
|
|
htop \
|
|
tmux \
|
|
make \
|
|
patch \
|
|
diffutils \
|
|
git-crypt \
|
|
age \
|
|
file \
|
|
sudo \
|
|
locales \
|
|
procps \
|
|
unzip \
|
|
gcc \
|
|
g++ \
|
|
rsync \
|
|
python3-pip \
|
|
python3-venv \
|
|
pandoc \
|
|
graphviz \
|
|
imagemagick \
|
|
socat \
|
|
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
|
&& apt-get clean \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# ── tmux defaults: 0-indexed windows and panes ───────────────────────
|
|
# pi-studio (omaclaren/pi-studio) hard-codes its tmux send target to
|
|
# `<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.
|
|
#
|
|
# ControlPersist=10m means the master socket sticks around 10 min after
|
|
# the last session closes, so consecutive ssh calls in a workflow reuse
|
|
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
|
|
# (mode 700) on each container start.
|
|
RUN mkdir -p /etc/ssh/ssh_config.d && \
|
|
printf '%s\n' \
|
|
'# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \
|
|
'# Override per-host in ~/.ssh/config if the master socket location' \
|
|
'# needs to differ.' \
|
|
'Host *' \
|
|
' ControlMaster auto' \
|
|
' ControlPath /tmp/sshcm/%r@%h:%p' \
|
|
' ControlPersist 10m' \
|
|
' ServerAliveInterval 30' \
|
|
' ServerAliveCountMax 6' \
|
|
> /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf && \
|
|
chmod 644 /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf
|
|
|
|
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
|
|
#
|
|
# Version policy: default is `latest` — resolved at build time by
|
|
# following the /releases/latest redirect and reading the tag from the
|
|
# Location header. Every base rebuild picks up the newest upstream
|
|
# release. Explicit pins still work via build-args (e.g.
|
|
# --build-arg GOSU_VERSION=1.19).
|
|
|
|
# gosu — privilege de-escalation
|
|
ARG GOSU_VERSION=latest
|
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
|
V="${GOSU_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
|
fi && \
|
|
V="${V#v}" && [ -n "$V" ] && \
|
|
echo "Installing gosu ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/download/${V}/gosu-${ARCH}" -o /usr/local/bin/gosu && \
|
|
chmod +x /usr/local/bin/gosu && \
|
|
gosu --version
|
|
|
|
# fzf — fuzzy finder
|
|
ARG FZF_VERSION=latest
|
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
|
V="${FZF_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
|
fi && \
|
|
V="${V#v}" && [ -n "$V" ] && \
|
|
echo "Installing fzf ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/download/v${V}/fzf-${V}-linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin fzf && \
|
|
fzf --version
|
|
|
|
# git-lfs
|
|
ARG GIT_LFS_VERSION=latest
|
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
|
V="${GIT_LFS_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
|
fi && \
|
|
V="${V#v}" && [ -n "$V" ] && \
|
|
echo "Installing git-lfs ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/download/v${V}/git-lfs-linux-${ARCH}-v${V}.tar.gz" | tar -xz -C /tmp && \
|
|
install /tmp/git-lfs-${V}/git-lfs /usr/local/bin/git-lfs && \
|
|
rm -rf /tmp/git-lfs-${V} && \
|
|
git lfs install --system && \
|
|
git-lfs --version
|
|
|
|
# gitleaks
|
|
ARG GITLEAKS_VERSION=latest
|
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x64" ;; arm64) echo "arm64" ;; *) echo "x64" ;; esac) && \
|
|
V="${GITLEAKS_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
|
fi && \
|
|
V="${V#v}" && [ -n "$V" ] && \
|
|
echo "Installing gitleaks ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/download/v${V}/gitleaks_${V}_linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin gitleaks && \
|
|
chmod +x /usr/local/bin/gitleaks && \
|
|
gitleaks version
|
|
|
|
# neovim
|
|
ARG NVIM_VERSION=latest
|
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
|
V="${NVIM_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
|
fi && \
|
|
V="${V#v}" && [ -n "$V" ] && \
|
|
echo "Installing neovim ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/download/v${V}/nvim-linux-${ARCH}.tar.gz" | tar -xz -C /opt && \
|
|
ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \
|
|
nvim --version | head -1
|
|
|
|
# bat
|
|
ARG BAT_VERSION=latest
|
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
|
V="${BAT_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
|
fi && \
|
|
V="${V#v}" && [ -n "$V" ] && \
|
|
echo "Installing bat ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/download/v${V}/bat-v${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
|
install /tmp/bat-v${V}-${ARCH}-unknown-linux-musl/bat /usr/local/bin/bat && \
|
|
rm -rf /tmp/bat-v${V}-* && \
|
|
bat --version
|
|
|
|
# eza
|
|
ARG EZA_VERSION=latest
|
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
|
V="${EZA_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
|
fi && \
|
|
V="${V#v}" && [ -n "$V" ] && \
|
|
echo "Installing eza ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/download/v${V}/eza_${ARCH}-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin && \
|
|
eza --version | head -1
|
|
|
|
# zoxide
|
|
ARG ZOXIDE_VERSION=latest
|
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
|
V="${ZOXIDE_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
|
fi && \
|
|
V="${V#v}" && [ -n "$V" ] && \
|
|
echo "Installing zoxide ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/download/v${V}/zoxide-${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /usr/local/bin zoxide && \
|
|
zoxide --version
|
|
|
|
# uv — fast Python package manager. Note: uv tags don't prefix with "v".
|
|
ARG UV_VERSION=latest
|
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
|
V="${UV_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
|
fi && \
|
|
V="${V#v}" && [ -n "$V" ] && \
|
|
echo "Installing uv ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/download/${V}/uv-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
|
install /tmp/uv-${ARCH}-unknown-linux-musl/uv /usr/local/bin/uv && \
|
|
install /tmp/uv-${ARCH}-unknown-linux-musl/uvx /usr/local/bin/uvx && \
|
|
rm -rf /tmp/uv-* && \
|
|
uv --version
|
|
|
|
# ── MemPalace — local-first AI memory system ─────────────────────────
|
|
# Provides semantic search over conversation history via 29 MCP tools.
|
|
# Always installed in the base. Set INSTALL_MEMPALACE=false at base-build
|
|
# time to shave ~300 MB.
|
|
ARG INSTALL_MEMPALACE=true
|
|
# Pin to a known-good version. Bump deliberately, not implicitly: an
|
|
# unpinned install silently swept in mempalace 3.3.x/3.4.0 with a broken
|
|
# diary_write schema (see workaround RUN below + issue #1728). Pinning
|
|
# makes mempalace upgrades a reviewable diff rather than a surprise.
|
|
ARG MEMPALACE_VERSION=3.4.0
|
|
ENV UV_TOOL_DIR=/opt/uv-tools
|
|
ENV UV_TOOL_BIN_DIR=/usr/local/bin
|
|
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
|
mkdir -p /opt/uv-tools && \
|
|
uv tool install --no-cache "mempalace==${MEMPALACE_VERSION}" && \
|
|
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
|
|
fi
|
|
|
|
# ── workaround: strip top-level anyOf from mempalace_diary_write schema ──
|
|
# Mempalace 3.3.x/3.4.0 advertise diary_write's input_schema with a
|
|
# top-level `anyOf: [{required:[entry]}, {required:[content]}]` to express
|
|
# "either entry or content must be supplied". Anthropic's tools API rejects
|
|
# top-level anyOf/oneOf/allOf, so pi/Claude fail at session start with
|
|
# `tools.<n>.custom.input_schema: input_schema does not support oneOf,
|
|
# allOf, or anyOf at the top level`.
|
|
#
|
|
# Patch the advertised schema to require ["agent_name", "entry"] and remove
|
|
# the anyOf block. The handler keeps accepting `content` server-side as a
|
|
# kwarg alias so existing callers still work.
|
|
#
|
|
# Idempotent and self-deactivating: once upstream releases the fix the
|
|
# regex no longer matches and this RUN is a silent no-op.
|
|
# Upstream tracking:
|
|
# https://github.com/MemPalace/mempalace/issues/1728
|
|
# https://github.com/MemPalace/mempalace/pull/1735
|
|
# TODO: remove this RUN once a mempalace release containing PR #1735 is on
|
|
# PyPI and installed by the line above.
|
|
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
|
MP_FILE="$(find /opt/uv-tools/mempalace -path '*/mempalace/mcp_server.py' | head -n1)" && \
|
|
if [ -z "$MP_FILE" ]; then echo "mempalace mcp_server.py not found" >&2; exit 1; fi && \
|
|
perl -0777 -i -pe 's/(?:[ \t]*\#[^\n]*\n)*[ \t]*"required":\s*\[\s*"agent_name"\s*\]\s*,\s*\n[ \t]*"anyOf":\s*\[\s*\n[ \t]*\{\s*"required":\s*\[\s*"entry"\s*\]\s*\}\s*,\s*\n[ \t]*\{\s*"required":\s*\[\s*"content"\s*\]\s*\}\s*,?\s*\n[ \t]*\]\s*,\s*\n/ "required": ["agent_name", "entry"],\n/s' "$MP_FILE" && \
|
|
if grep -q '"required": \["agent_name", "entry"\]' "$MP_FILE"; then \
|
|
echo "mempalace diary_write anyOf workaround: applied (or already clean)"; \
|
|
else \
|
|
echo "WARN: mempalace diary_write anyOf workaround did not match expected schema — upstream may have changed shape" >&2; \
|
|
fi ; \
|
|
fi
|
|
|
|
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
|
|
ARG INSTALL_MEMPALACE_TOOLKIT=true
|
|
ARG MEMPALACE_TOOLKIT_REF=main
|
|
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
|
|
git clone --depth 1 --branch "${MEMPALACE_TOOLKIT_REF}" \
|
|
https://gitea.jordbo.se/joakimp/mempalace-toolkit.git /opt/mempalace-toolkit && \
|
|
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
|
|
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
|
|
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
|
|
mempalace-session --help >/dev/null && \
|
|
mempalace-docs --help >/dev/null && \
|
|
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
|
|
fi
|
|
|
|
# rustup — Rust toolchain manager (init binary only; toolchains installed at runtime)
|
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://static.rust-lang.org/rustup/dist/${ARCH}-unknown-linux-gnu/rustup-init" -o /usr/local/bin/rustup-init && \
|
|
chmod +x /usr/local/bin/rustup-init
|
|
|
|
# gitea-mcp — MCP server for Gitea API
|
|
ARG GITEA_MCP_VERSION=latest
|
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
|
V="${GITEA_MCP_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
|
fi && \
|
|
V="${V#v}" && [ -n "$V" ] && \
|
|
echo "Installing gitea-mcp ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/download/v${V}/gitea-mcp_Linux_${ARCH}.tar.gz" \
|
|
| tar -xz -C /usr/local/bin/ gitea-mcp && \
|
|
chmod +x /usr/local/bin/gitea-mcp && \
|
|
gitea-mcp --version
|
|
|
|
# Locales
|
|
RUN sed -i -E '/(en_US|en_GB|sv_SE|da_DK|nb_NO|fi_FI|de_DE|fr_FR|es_ES|it_IT|pt_BR|nl_NL|pl_PL|ja_JP|ko_KR|zh_CN)\.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
|
ENV LANG=en_US.UTF-8
|
|
ENV LANGUAGE=en_US:en
|
|
ENV LC_ALL=en_US.UTF-8
|
|
ENV EDITOR=nvim
|
|
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
|
|
|
|
# ── Node.js (required for pi + MCP servers + tldr) ──
|
|
ARG NODE_VERSION=22
|
|
RUN curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
|
|
apt-get install -y --no-install-recommends nodejs && \
|
|
rm -rf /var/lib/apt/lists/*
|
|
|
|
# ── tldr (tealdeer) — community-maintained command examples ──────────
|
|
# Tealdeer is a Rust port of the tldr-pages client; ~5 MB static binary,
|
|
# ~135 MB smaller than the Node tldr global. Same `tldr` command, same UX.
|
|
ARG TEALDEER_VERSION=latest
|
|
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
|
V="${TEALDEER_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
|
fi && \
|
|
V="${V#v}" && [ -n "$V" ] && \
|
|
echo "Installing tealdeer ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/download/v${V}/tealdeer-linux-${ARCH}-musl" -o /usr/local/bin/tldr && \
|
|
chmod +x /usr/local/bin/tldr && \
|
|
tldr --version
|
|
|
|
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
|
|
RUN ARCH=$(case "${TARGETARCH}" in \
|
|
amd64) echo "x86_64" ;; \
|
|
arm64) echo "aarch64" ;; \
|
|
*) echo "x86_64" ;; \
|
|
esac) && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \
|
|
unzip -q /tmp/awscli.zip -d /tmp && \
|
|
/tmp/aws/install && \
|
|
rm -rf /tmp/aws /tmp/awscli.zip && \
|
|
aws --version
|
|
|
|
# ── Non-root user ────────────────────────────────────────────────────
|
|
ARG USER_NAME=developer
|
|
ARG USER_UID=1000
|
|
ARG USER_GID=1000
|
|
|
|
RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
|
|
useradd --uid ${USER_UID} --gid ${USER_GID} -m -s /bin/bash ${USER_NAME} && \
|
|
echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/${USER_NAME}
|
|
|
|
# Standard directories
|
|
RUN mkdir -p /workspace \
|
|
/home/${USER_NAME}/.pi/agent/extensions \
|
|
/home/${USER_NAME}/.agents/skills \
|
|
/home/${USER_NAME}/.cache/bash \
|
|
/home/${USER_NAME}/.ssh && \
|
|
chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME}
|
|
|
|
# ── Pre-warm chromadb embedding model ──────────────────────────────
|
|
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
|
gosu ${USER_NAME} /opt/uv-tools/mempalace/bin/python -c "\
|
|
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2; \
|
|
ef = ONNXMiniLM_L6_V2(); \
|
|
_ = ef(['warmup']); \
|
|
print('chromadb embedding model warmed: all-MiniLM-L6-v2')" && \
|
|
ls -lh /home/${USER_NAME}/.cache/chroma/onnx_models/all-MiniLM-L6-v2/ ; \
|
|
fi
|
|
|
|
# ── User-writable npm global prefix on the devbox-pi-config volume ──
|
|
# Build-time installs use NPM_CONFIG_PREFIX=/usr (see Dockerfile.variant).
|
|
# Runtime npm/pi installs use this prefix → land on the named volume.
|
|
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global
|
|
ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
|
|
|
|
# ── Shell defaults (bash history, aliases, readline) ─────────────────
|
|
RUN mkdir -p /etc/skel-devbox
|
|
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
|
|
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
|
|
|
# ── Entrypoint ────────────────────────────────────────────────────────
|
|
COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
|
|
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
|
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
|
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
|
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
|
|
/usr/local/bin/studio-expose \
|
|
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
|
|
|
|
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
|
WORKDIR /workspace
|
|
|
|
ENTRYPOINT ["entrypoint.sh"]
|
|
CMD ["bash", "-l"]
|