Files
pi-devbox/entrypoint-user.sh
T
Joakim Persson 7c00dd6001
Lint / actionlint (push) Successful in 21s
Lint / hadolint (push) Successful in 29s
mempalace: drop the stage ENV pin, fix the shared-server compose
The feeder now defaults to <palace-root>/pi-stage upstream, so pinning
MEMPALACE_PI_STAGE into ~/.pi here is unnecessary -- and was actively wrong. It
created a second convention that could still diverge from the palace: keep the
devbox-palace volume, drop devbox-pi-config, and a scoped `mempalace sync`
prunes every conversation drawer, because dedup keys on the staged path. Both
the ENV and the entrypoint export are gone; a comment explains why adding one
back re-introduces the split it was meant to fix.

docker-compose.mempalace.yml was broken on mempalace 3.6.0 in both directions:
  - `--host 0.0.0.0` with no token in the environment makes the server refuse
    to start, crash-looping under `restart: unless-stopped`.
  - Supply a token and the healthcheck's unauthenticated `tools/list` POST 401s,
    marking a perfectly healthy server unhealthy forever.
Now the token is required via ${MEMPALACE_REMOTE_TOKEN:?...} so it fails fast at
`docker compose up` with a readable message, and the healthcheck probes the
deliberately token-free /healthz. The "no authentication of its own" security
note has been stale since 3.6.0 and is replaced with the actual posture
(bearer token + Host pin + Origin allowlist), including why browser-shaped auth
must not be put in front of it.

Dockerfile.base: mempalace-pi-session symlinked onto PATH, with a build-time
`--help` check so a broken feeder fails the image build rather than the first
session.

smoke-test: assert the stage resolves beside the palace (default, and following
$MEMPALACE_PALACE_PATH) instead of asserting the removed ENV pin. The two
behavioural guards -- a synthetic session that must be captured, an abandoned
one that must not be -- are unchanged.

.env.example: recommend `mempalace serve` on the docker0 gateway rather than
`mempalace-mcp --transport http --host 0.0.0.0`, with the two binds to avoid.
2026-08-12 17:04:14 +02:00

366 lines
19 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# ── Startup banner: which pi-devbox build is this? ─────────────────
# Printed FIRST, before the setup noise below, so it's the first thing
# visible when the container starts (CMD is `bash -l`, tty:true in compose,
# so this reaches the same stream as the interactive shell the user lands
# in). Reads the ground-truth manifest baked in Dockerfile.variant; a no-op
# with a short stderr notice on images built before it existed.
command -v pi-devbox-version >/dev/null 2>&1 && pi-devbox-version || true
# ── SSH ControlMaster socket dir ────────────────────────────────
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
# base image — that file declares ControlPath=/tmp/sshcm/%r@%h:%p; this
# creates the directory with the right permissions on every container
# start. /tmp is per-container so the dir doesn't survive recreation;
# baking it into a Dockerfile layer would be wrong.
# Mode 700 is required — OpenSSH refuses to use a ControlPath dir that
# others can write to.
mkdir -p /tmp/sshcm
chmod 700 /tmp/sshcm
# ── LAN access + writable SSH sidecar: host-OS-agnostic helper ──────
# Generates the writable ~/.ssh-local/config on EVERY host OS: a `Host *`
# ControlPath redirect into ~/.ssh-local/cm (so `ssh -F` / dssh / dscp work
# even when ~/.ssh is bind-mounted read-only) plus `Include ~/.ssh/config`. On
# VM-backed hosts (macOS OrbStack / Docker Desktop) it ALSO adds an
# SSH-jump-via-host block so the container can reach the host's
# directly-attached LAN peers; on native Linux (LAN reachable directly) the
# jump block is omitted but the sidecar is still rendered. Controlled by
# DEVBOX_LAN_ACCESS (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the
# script header.
if [ -r /usr/local/lib/pi-devbox/setup-lan-access.sh ]; then
bash /usr/local/lib/pi-devbox/setup-lan-access.sh || true
fi
# ── Shell defaults: copy baked files from /etc/skel-devbox/ if absent
# Respects host bind-mounts and user customizations — existing files
# are never overwritten. To restore defaults: rm ~/.bash_aliases (or
# .inputrc) and recreate the container, or cp from /etc/skel-devbox/
# directly.
SKEL_DIR="/etc/skel-devbox"
if [ -d "$SKEL_DIR" ]; then
for f in .bash_aliases .inputrc .gitignore_global; do
if [ -f "$SKEL_DIR/$f" ] && [ ! -e "$HOME/$f" ]; then
cp "$SKEL_DIR/$f" "$HOME/$f"
fi
done
fi
# ── Image-baked skills: link into ~/.agents/skills ───────────────────
# Skills shipped IN the image (under /usr/local/share/pi-devbox/skills/) are
# made available regardless of whether a skillset repo is mounted. Done EARLY
# — before the pi-toolkit/extensions deploy below — so the symlinks exist by
# the time anything gates on "container ready": the smoke-test readiness probe
# waits on pi-deploy markers (keybindings.json, mempalace.ts) that only land
# AFTER this point, so linking here closes a sample-too-early race that failed
# the runtime skill-link assertion. Pointing at the image path (/usr/local/...)
# keeps the skill fresh from the image and surviving volume recreate (unlike
# anything baked under a home dir, which a named volume would shadow). Created
# only when absent, so a same-named skillset skill (deployed later, at the end
# of this script) or a user override is never clobbered; the skillset deploy
# classifies these as foreign-links and its --prune-stale pass leaves them
# alone (only dangling symlinks are pruned).
DEVBOX_SKILLS_SRC=/usr/local/share/pi-devbox/skills
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
mkdir -p "$HOME/.agents/skills"
for _sk in "$DEVBOX_SKILLS_SRC"/*/; do
[ -d "$_sk" ] || continue
_skname=$(basename "$_sk")
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
ln -s "${_sk%/}" "$HOME/.agents/skills/$_skname"
fi
done
fi
# ── MemPalace: initialize palace for the workspace if mempalace is installed
# Creates the palace directory structure on first run. Idempotent — skips
# if palace already exists, so upgrades from older versions preserve
# existing data. `--yes` auto-accepts detected entities so the init is
# non-interactive.
if command -v mempalace &>/dev/null && [ -d /workspace ]; then
PALACE_DIR="${HOME}/.mempalace"
if [ ! -d "$PALACE_DIR/palace" ]; then
echo "Initializing MemPalace for workspace (non-interactive)..."
# </dev/null: mempalace init has an interactive "Mine this directory
# now? [Y/n]" prompt that --yes does not auto-answer in all paths.
# Without redirected stdin, the process blocks here forever when run
# from `docker run -it` (the TTY keeps stdin open). EOF on stdin
# makes the prompt fall through to its default (skip).
mempalace init --yes /workspace </dev/null >/dev/null 2>&1 || true
fi
fi
# ── MemPalace: pi transcript feeder ─────────────────────────────────
# mempalace-toolkit ships `mempalace-pi-session`, which mines pi's own JSONL
# session transcripts into the palace. pi's mempalace extension drives it on
# session_shutdown and on a debounced agent_settled; this is the catch-up for
# the one case no handler can cover — a hard kill (docker kill, OOM, host
# reboot) runs nothing at all, so without this the previous life's transcripts
# are never mined.
#
# No MEMPALACE_PI_STAGE override here on purpose: the feeder stages next to the
# palace it feeds (<palace-root>/pi-stage), so the stage and the dedup keys
# referencing it share one lifetime — whatever persistence the palace has, the
# stage inherits. Pinning it elsewhere (e.g. into the ~/.pi volume) would
# re-introduce the very split that design prevents: palace volume kept, stage
# volume dropped, and `mempalace sync` then prunes every conversation drawer.
#
# Backgrounded: a cold mine can take tens of seconds and must never delay the
# shell. Contention with a live session is handled by the tool itself (it exits
# 0 and lets the palace holder do the mine).
# Self-heal onto PATH for images whose base predates the toolkit symlink.
# ~/.local/bin is already ahead of /usr/local/bin on PATH (Dockerfile.base sets
# it in ENV PATH) and is writable by this (non-root) user, unlike /usr/local/bin.
if [ -x /opt/mempalace-toolkit/bin/mempalace-pi-session ] && \
! command -v mempalace-pi-session >/dev/null 2>&1; then
mkdir -p "$HOME/.local/bin"
ln -sf /opt/mempalace-toolkit/bin/mempalace-pi-session "$HOME/.local/bin/mempalace-pi-session"
fi
# Resolve the feeder explicitly rather than trusting PATH: this runs before any
# login shell, and a silently-skipped catch-up is exactly the failure we are
# here to prevent.
MEMPALACE_FEEDER=""
if command -v mempalace-pi-session >/dev/null 2>&1; then
MEMPALACE_FEEDER="mempalace-pi-session"
elif [ -x /opt/mempalace-toolkit/bin/mempalace-pi-session ]; then
MEMPALACE_FEEDER="/opt/mempalace-toolkit/bin/mempalace-pi-session"
fi
if [ "${MEMPALACE_FEED:-1}" != "0" ] && [ -n "$MEMPALACE_FEEDER" ]; then
if [ -n "${MEMPALACE_REMOTE_URL:-}" ] && [ -z "${MEMPALACE_PI_SSH_TARGET:-}" ]; then
: # remote palace but no inbox configured — nothing we can ship to; skip quietly
else
mkdir -p "$HOME/.pi/agent"
(
"$MEMPALACE_FEEDER" --reason container-start \
>"$HOME/.pi/agent/mempalace-catchup.log" 2>&1 || true
) &
fi
fi
# ── Git config defaults ──────────────────────────────────────────────
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
git config --global user.name "$GIT_USER_NAME"
fi
if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null; then
git config --global user.email "$GIT_USER_EMAIL"
fi
# Global gitignore for personal/tooling artifacts (*.bak, *~, *.orig, ...).
# Seeded above into $HOME/.gitignore_global from /etc/skel-devbox. Point git at
# it only if the user has not already set their own core.excludesFile.
if [ -f "$HOME/.gitignore_global" ] && ! git config --global core.excludesFile &>/dev/null; then
git config --global core.excludesFile "$HOME/.gitignore_global"
fi
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
# pi is always installed in pi-devbox; no INSTALL_PI guard needed.
# Each install.sh is idempotent and backs up real files before linking,
# so re-running across container restarts is safe.
#
# Order: pi-toolkit first (creates ~/.pi/agent/keybindings.json symlink
# and writes the AWS env loader), then pi-extensions (symlinks our
# extensions), then settings.json bootstrap from the toolkit template,
# then the mempalace bridge symlink (one-liner; mempalace-toolkit's
# install_skill is intentionally skipped to avoid racing with skillset
# auto-deploy below).
if command -v pi &>/dev/null; then
if [ -d /opt/pi-toolkit ]; then
(cd /opt/pi-toolkit && ./install.sh --yes) || \
echo "WARN: pi-toolkit install.sh failed (continuing)"
fi
if [ -d /opt/pi-extensions ]; then
(cd /opt/pi-extensions && ./install.sh --yes) || \
echo "WARN: pi-extensions install.sh failed (continuing)"
fi
# Bootstrap settings.json from template if absent (pi rewrites this
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
_pi_settings="$HOME/.pi/agent/settings.json"
_pi_template=/opt/pi-toolkit/settings.example.json
if [ ! -f "$_pi_settings" ] && [ -f "$_pi_template" ]; then
cp "$_pi_template" "$_pi_settings"
echo "pi settings.json bootstrapped from template"
elif [ -f "$_pi_settings" ] && [ -f "$_pi_template" ] && \
[ "${PI_SETTINGS_MERGE:-1}" != "0" ] && command -v jq >/dev/null 2>&1; then
# Non-destructive merge: a settings.json on a PRESERVED volume never
# otherwise sees new template keys (the bootstrap above only fires when
# the file is absent), so config added in an image upgrade — e.g. the
# observational-memory / pi-fork blocks or a newly-enabled model — never
# reaches existing users. Deep-merge with the template FIRST and the
# live file SECOND ('.[0] * .[1]') so the user's values always win and
# only keys MISSING from the live file are filled in from the template.
# Arrays are treated as leaves (the user's array is kept verbatim, so a
# model they deliberately removed is not re-added). Only rewrite when the
# merge actually changes something, and back up the original first.
# Set PI_SETTINGS_MERGE=0 to disable. Invalid JSON on either side → skip,
# never clobber.
if _pi_merged=$(jq -s '.[0] * .[1]' "$_pi_template" "$_pi_settings" 2>/dev/null); then
if [ -n "$_pi_merged" ] && \
! printf '%s' "$_pi_merged" | jq -e --slurpfile cur "$_pi_settings" '. == $cur[0]' >/dev/null 2>&1; then
cp "$_pi_settings" "${_pi_settings}.bak.$(date +%Y%m%d-%H%M%S)"
printf '%s\n' "$_pi_merged" > "$_pi_settings"
echo "pi settings.json: merged new template keys from settings.example.json (backup saved)"
fi
else
echo "WARN: pi settings.json merge skipped (jq could not parse template or live file; left untouched)"
fi
fi
# pi↔mempalace MCP bridge — single extension symlink.
if [ -f /opt/mempalace-toolkit/extensions/pi/mempalace.ts ] && \
command -v mempalace &>/dev/null && \
[ ! -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
"$HOME/.pi/agent/extensions/mempalace.ts"
fi
# pi-fork (fork tool) + pi-observational-memory (recall tool) + pi-atelier
# (TUI sidebar panels/split-pane) + (in the :latest-studio variant only)
# pi-studio (/studio command + studio_* tools + theme). These are pi packages (not symlink-style extensions):
# they're cloned to /opt with node_modules baked at BUILD time, then
# registered here via `pi install <local-path>`. A local-path install is
# instant + in-place (pi loads the extension directly from /opt) +
# idempotent (no duplicate package entry on re-run), and stores a relative
# path that resolves into the image-layer /opt so it survives volume
# recreate. The tools/command register on the NEXT pi start (extensions
# bind at startup) or on `/reload`. Guard on settings.json so we only
# install once per volume. /opt/pi-studio is present only in the studio
# variant; the `[ -d ]` test makes this a no-op everywhere else.
#
# The guard MUST inspect the `packages` ARRAY, not merely grep the whole
# file for the package name. settings.example.json ships a top-level
# "pi-fork" CONFIG block (the fork effort profiles, pi-toolkit adb6907,
# 2026-06-17), so a whole-file substring grep matches on any settings.json
# that was bootstrapped from — or template-merged with — that template.
# Worse, the merge above runs FIRST, so it plants the matching string in the
# same startup that the loop then reads: `pi install /opt/pi-fork` was
# skipped forever and the `fork` tool never registered (v1.0.0 → v1.6.3).
# Its siblings escaped only by luck — the template key is
# "observational-memory" (no pi- prefix) and there is no studio block.
# jq reads the array; the grep fallback matches the stored relative-path
# form ("…/opt/<name>\""), which a config KEY can never produce.
_pi_pkg_registered() {
_pi_reg_settings="$HOME/.pi/agent/settings.json"
[ -f "$_pi_reg_settings" ] || return 1
if command -v jq >/dev/null 2>&1; then
jq -e --arg n "$1" \
'(.packages // []) | any((type == "string") and (. == "npm:" + $n or endswith("/" + $n)))' \
"$_pi_reg_settings" >/dev/null 2>&1
else
grep -q "opt/$1\"" "$_pi_reg_settings"
fi
}
# ── pi-atelier: retire a stale `npm:pi-atelier`, plus an opt-out ──────
# The image now vendors pi-atelier at a pinned, audited tag (PI_ATELIER_REF
# in Dockerfile.variant). A leftover `npm:pi-atelier` entry from a
# hand-install resolves through ~/.pi/npm-global, which lives on the
# devbox-pi-config VOLUME — so it survives image upgrades and keeps whatever
# version was installed by hand, unpinned and unaudited. That is not
# academic: pi-atelier < 0.7.1 makes pi >= 0.84 hang at startup with
# sustained CPU, so leaving it in place turns a pi bump into a TUI that will
# not start. And `_pi_pkg_registered` deliberately counts `npm:<name>` as
# registered (it respects a user's own npm install), so the loop below would
# never replace it.
#
# We only DELETE the exact `npm:pi-atelier` string; the loop then registers
# /opt/pi-atelier in pi's own canonical serialization, so this code never has
# to guess the stored relative-path form. Idempotent — after the rewrite
# there is no npm entry left to match.
#
# DEVBOX_ATELIER=0 goes further and removes pi-atelier from `packages`
# altogether. That escape hatch lives HERE, in the entrypoint, precisely
# because this component's known failure mode is "pi will not start" — which
# you cannot repair with `pi uninstall`.
_pi_atelier_drop() {
# $1 = jq predicate over one `packages` entry, selecting what to REMOVE.
# Returns 0 only when the file was actually rewritten (caller logs), 1 for
# "nothing to do" — including missing jq or unparseable JSON, which must
# never clobber user settings. Backs up first, same convention as the
# template merge above.
_ad_settings="$HOME/.pi/agent/settings.json"
[ -f "$_ad_settings" ] || return 1
command -v jq >/dev/null 2>&1 || return 1
_ad_new=$(jq "(.packages // []) |= map(select(($1) | not))" "$_ad_settings" 2>/dev/null) || return 1
[ -n "$_ad_new" ] || return 1
if printf '%s' "$_ad_new" | jq -e --slurpfile cur "$_ad_settings" '. == $cur[0]' >/dev/null 2>&1; then
return 1
fi
# `.bak.atelier.` rather than the merge's plain `.bak.` prefix: both can
# fire in the same startup, and a bare seconds-resolution timestamp would
# make the second cp overwrite the first one's backup.
cp "$_ad_settings" "${_ad_settings}.bak.atelier.$(date +%Y%m%d-%H%M%S)"
printf '%s\n' "$_ad_new" > "$_ad_settings"
return 0
}
if [ "${DEVBOX_ATELIER:-1}" = "0" ]; then
if _pi_atelier_drop '(. == "npm:pi-atelier") or ((type == "string") and endswith("/pi-atelier"))'; then
echo "pi-atelier: unregistered per DEVBOX_ATELIER=0 (settings backup saved)"
fi
elif [ -d /opt/pi-atelier ]; then
if _pi_atelier_drop '. == "npm:pi-atelier"'; then
echo "pi-atelier: dropped stale npm: registration — the pinned /opt copy takes over (settings backup saved)"
fi
fi
for _pkg in /opt/pi-fork /opt/pi-observational-memory /opt/pi-studio /opt/pi-atelier; do
[ -d "$_pkg" ] || continue
_name=$(basename "$_pkg")
# DEVBOX_ATELIER=0 → leave pi-atelier unregistered (handled just above).
if [ "$_name" = "pi-atelier" ] && [ "${DEVBOX_ATELIER:-1}" = "0" ]; then continue; fi
if ! _pi_pkg_registered "$_name"; then
pi install "$_pkg" >/dev/null 2>&1 || \
echo "WARN: pi install $_name failed (continuing)"
fi
done
fi
# ── pi-studio: optional loopback bridge (opt-in) ──────────────────────
# pi-studio binds its server to 127.0.0.1 inside the container, which a
# published Docker port cannot reach. When STUDIO_EXPOSE is truthy (set in
# compose), start the `studio-expose` socat bridge in the background so a
# published port + `ssh -L` tunnel can reach Studio once the user runs
# `/studio --port "$STUDIO_PORT"`. Default OFF — Studio stays loopback-only
# (its secure default) unless explicitly opted in. Guarded on the studio
# variant (/opt/pi-studio) so it is a no-op in the plain image.
case "${STUDIO_EXPOSE:-}" in
1|true|TRUE|yes|on)
if [ -d /opt/pi-studio ] && command -v studio-expose &>/dev/null && command -v socat &>/dev/null; then
echo "STUDIO_EXPOSE set — starting studio-expose bridge on port ${STUDIO_PORT:-8765} (background)"
nohup studio-expose "${STUDIO_PORT:-8765}" >/tmp/studio-expose.log 2>&1 &
else
echo "STUDIO_EXPOSE set but studio-expose/socat/pi-studio unavailable — skipping bridge"
fi
;;
esac
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
# run the deploy script to create relative symlinks for skills and instructions.
# This ensures skills resolve correctly inside the container regardless of
# where the repo lives on the host. Idempotent — second run is a no-op.
#
# Detection order:
# 1. SKILLSET_CONTAINER_PATH env var (explicit, for non-standard layouts)
# 2. $HOME/skillset (dedicated volume mount via SKILLSET_PATH in compose)
# 3. /workspace/skillset (skillset is directly inside workspace root)
SKILLSET_DEPLOY=""
if [ -n "${SKILLSET_CONTAINER_PATH:-}" ] && [ -x "${SKILLSET_CONTAINER_PATH}/deploy-skills.sh" ]; then
SKILLSET_DEPLOY="${SKILLSET_CONTAINER_PATH}/deploy-skills.sh"
elif [ -x "$HOME/skillset/deploy-skills.sh" ]; then
SKILLSET_DEPLOY="$HOME/skillset/deploy-skills.sh"
elif [ -x /workspace/skillset/deploy-skills.sh ]; then
SKILLSET_DEPLOY="/workspace/skillset/deploy-skills.sh"
fi
if [ -n "$SKILLSET_DEPLOY" ]; then
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
fi
# ── Execute command ──────────────────────────────────────────────────
exec "$@"