#!/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. # `--no-skills`: this runs FIRST, before the baked skill links are created # below and long before the skillset deploy + devbox-skill-reconcile run at the # end of this script, so the skill-source section would report a pre-reconcile # state that is about to change. Wrong-but-plausible is worse than absent. command -v pi-devbox-version >/dev/null 2>&1 && pi-devbox-version --no-skills || 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 user override is never clobbered. # # NB: "created only when absent" does NOT hand a same-named skillset skill # priority — the opposite. The skillset deploy runs at the end of this script # and classifies these links as foreign, so through v1.8.4 the BAKED copy # always won and an edit pushed to a skillset-owned skill was invisible until # the next image build. The links below are therefore the FALLBACK only; # devbox-skill-reconcile (invoked right after the skillset deploy) hands the # skillset-OWNED skills back to the live clone. Ownership is per-skill, listed # in skills/skillset-owned.txt — see VENDORED.md for why pi-extensions must # keep losing to the baked copy. 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 # -sfn, not -s: `[ ! -e ]` is TRUE for a DANGLING symlink (-e follows the # link), and since v1.8.5 these links can point into /workspace/skillset # (see devbox-skill-reconcile, invoked after the skillset deploy). If that # mount vanishes while the writable layer survives — a `docker restart` or # a host reboot under restart: unless-stopped, as opposed to a recreate — # plain `ln -s` fails with "File exists" and, under `set -e`, aborts # container start before `exec "$@"`. With -f the broken link heals back to # the baked fallback, and the reconciler re-points it in the same boot if # the clone is back. ln -sfn "${_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 # Read the root from the same variable mempalace itself reads (set as an # image ENV in Dockerfile.base since mempalace 3.10.0 started resolving # ~/.config/mempalace for an EMPTY ~/.mempalace). The fallback keeps the # historical location for anyone running this script with the ENV unset; # the point of naming the variable here is that this test and mempalace's # own resolution can no longer disagree about where the palace lives — a # disagreement that would make this branch fire on every start. PALACE_DIR="${MEMPALACE_CONFIG_DIR:-${HOME}/.mempalace}" # Sentinel = config.json, because that is what `mempalace init` writes. # It does NOT create palace/ — mining does — so the earlier test on palace/ # re-fired on every start of a container that had never mined locally # (v1.9.4 acceptance: 1 "Initializing" line after one boot, 2 after a # restart). Harmless (init is idempotent) but the log lied. Populated # volumes (config.json present) skip either way. if [ ! -f "$PALACE_DIR/config.json" ]; then echo "Initializing MemPalace for workspace (non-interactive)..." # /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 (/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 to ship transcripts to — the feeder genuinely # cannot do anything here, so skipping is right. Saying so is the point: # this branch used to be a bare `:`, and the skip happens *before* the # subshell below that writes mempalace-catchup.log, so a container in this # state contributed nothing to the palace and left no artifact at all — not # even an empty log — to explain why. That is indistinguishable from a # healthy run that simply had nothing to file. `tee` puts the notice both in # the container's start output (docker logs) and at the path anyone # debugging "why is nothing from this container in the palace?" looks first. # This is an entrypoint: a notice must never be able to stop a container # from starting. An unwritable ~/.pi (root-owned volume — a classic Docker # permission accident) makes `mkdir -p` fail, and under `set -e` that would # abort startup entirely: a brand-new failure mode in precisely the branch # that used to do nothing at all. Degrade to stdout-only instead. _mp_log="$HOME/.pi/agent/mempalace-catchup.log" mkdir -p "$HOME/.pi/agent" 2>/dev/null || _mp_log=/dev/null { echo "MemPalace catch-up skipped: remote palace with no transcript inbox." echo " MEMPALACE_REMOTE_URL is set (${MEMPALACE_REMOTE_URL})" echo " but MEMPALACE_PI_SSH_TARGET is not, so there is nowhere to ship this" echo " container's staged sessions. MCP tools still read and write the shared" echo " palace — but this container's own conversations are mined nowhere." echo " Fix: set MEMPALACE_PI_SSH_TARGET (and MEMPALACE_PI_DEVICE) in .env," echo " or unset MEMPALACE_REMOTE_URL to keep the palace local." echo " Deliberate? MEMPALACE_FEED=0 turns the feed off and silences this." } | tee "$_mp_log" 2>/dev/null || true unset _mp_log else mkdir -p "$HOME/.pi/agent" ( "$MEMPALACE_FEEDER" --reason container-start \ >"$HOME/.pi/agent/mempalace-catchup.log" 2>&1 || true ) & fi fi # ── cli_utils: link workspace bin/ commands onto PATH ──────────────── # Standalone commands from a mounted cli_utils checkout (git-status-all, # git-pull-all, devbox-sanity, pi-session-repair, ...) live in /bin. On a # host they reach PATH via cli_utils' own install.sh, whose install_bin step # symlinks them into ~/.local/bin — but that home is on the container's WRITABLE # LAYER, so every recreate loses them and the human is back to typing # /workspace/cli_utils/bin/git-status-all. This is the container equivalent of # that install step, re-run at every start. # # WHY SYMLINKS RATHER THAN A PATH EDIT IN AN rc FILE: ~/.local/bin is already # ahead of /usr/local/bin in ENV PATH (Dockerfile.base), so links here resolve in # NON-interactive shells too — `docker exec git-status-all`, agent tool # shells, scripts. An rc-file PATH edit cannot reach those, because ~/.bashrc # returns early when the shell is not interactive. Measured 2026-08-27 on # tor-ms22: `command -v git-status-all` failed in a non-interactive shell while # working in an interactive one, from exactly that asymmetry. # # Detection order (first hit wins): # 1. CLI_UTILS_CONTAINER_PATH explicit, for non-standard layouts # 2. /workspace/cli_utils repo directly in the workspace root # 3. $HOME/cli_utils dedicated mount # 4. /workspace/*/cli_utils workspace root holds several repo groups # CLI_UTILS_LINK=0 disables. Absent repo = silent no-op, which is the common # case for anyone who does not use cli_utils. if [ "${CLI_UTILS_LINK:-1}" != "0" ]; then CLI_UTILS_BIN="" if [ -n "${CLI_UTILS_CONTAINER_PATH:-}" ] && [ -d "${CLI_UTILS_CONTAINER_PATH}/bin" ]; then CLI_UTILS_BIN="${CLI_UTILS_CONTAINER_PATH}/bin" elif [ -d /workspace/cli_utils/bin ]; then CLI_UTILS_BIN=/workspace/cli_utils/bin elif [ -d "$HOME/cli_utils/bin" ]; then CLI_UTILS_BIN="$HOME/cli_utils/bin" else # `if` bodies, not `&&` chains: under `set -e` a loop whose LAST command is a # false test exits non-zero and would abort the entrypoint. With no match the # glob stays literal, so that is the normal case on any machine without this # repo — i.e. the bug would have been "container will not start", not "links # missing". for _cu in /workspace/*/cli_utils/bin; do if [ -d "$_cu" ]; then CLI_UTILS_BIN="$_cu" break fi done unset _cu fi if [ -n "$CLI_UTILS_BIN" ]; then mkdir -p "$HOME/.local/bin" 2>/dev/null || true # Never clobber a real file, and never steal a link that points elsewhere: a # deliberate user override in ~/.local/bin must win, and silently shadowing # an image-provided command is worse than the missing command. for _f in "$CLI_UTILS_BIN"/*; do if [ ! -f "$_f" ] || [ ! -x "$_f" ]; then continue fi _link="$HOME/.local/bin/$(basename "$_f")" if [ -e "$_link" ] && [ ! -L "$_link" ]; then continue fi if [ -L "$_link" ]; then case "$(readlink "$_link")" in "$CLI_UTILS_BIN"/*) ;; *) continue ;; esac fi ln -sf "$_f" "$_link" 2>/dev/null || true done # Prune links we own whose target vanished (command renamed, repo moved), # mirroring the skillset deploy's --prune-stale. A dangling link on PATH # reports "No such file or directory" for a command that simply no longer # exists, which reads as a broken container rather than a removed script. for _link in "$HOME/.local/bin"/*; do [ -L "$_link" ] || continue case "$(readlink "$_link")" in */cli_utils/bin/*) [ -e "$_link" ] || rm -f "$_link" ;; esac done unset _f _link fi unset CLI_UTILS_BIN fi # ── Per-device boot hook ───────────────────────────────────────────── # Runs ~/.config/devbox-shell/init.sh if the host provides one. That directory is # the host-owned, bind-mounted shell-sharing dir (see "Volumes and persistence"), # so a hook placed there survives every recreate WITHOUT an image change — the # boot-time twin of the interactive bridge in /etc/skel-devbox/.bash_aliases, # which sources ~/.config/devbox-shell/bash_aliases for every interactive shell. # # NO NEW TRUST BOUNDARY: that same directory is already sourced into every # interactive shell, i.e. it is already arbitrary code from the same owner. What # is new is only WHEN it runs — once at start, before any shell — which is what # non-interactive fixups (symlinks, dirs, one-off migrations) need. # # Deliberately `bash `, not `.` — a hook must not be able to mutate this # entrypoint's own shell state, and its exit status must not matter. Output goes # to a log rather than the container's start output, so a chatty hook cannot # masquerade as a startup error. if [ -r "$HOME/.config/devbox-shell/init.sh" ]; then mkdir -p "$HOME/.pi/agent" 2>/dev/null || true bash "$HOME/.config/devbox-shell/init.sh" \ >"$HOME/.pi/agent/devbox-init.log" 2>&1 || true 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 # Route git-over-ssh through the WRITABLE ssh sidecar. ~/.ssh is commonly # bind-mounted read-only from the host, and a per-host # ControlPath ~/.ssh/cm/%r@%h:%p # inherited from that config (the standard CGNAT multiplexing recipe) kills every # push with # unix_listener: cannot bind to path ~/.ssh/cm/...: Read-only file system # hidden behind git's misleading "Please make sure you have the correct access # rights", which sends the reader hunting for a key problem that does not exist. # setup-lan-access.sh (run near the top of this script) already wrote # ~/.ssh-local/config, whose leading `Host *` block overrides ControlPath into # the writable ~/.ssh-local/cm and only THEN `Include`s the user's own config — # so -F repairs the socket path while keeping every per-host User/Port/ # IdentityFile. Wiring it here means no caller has to know any of that. # # WHY THIS IS NOT LEFT TO DOCUMENTATION: measured 2026-09-22 on tor-ms22, an # agent with the remedy in its system prompt, in a loaded skill, in 24 palace # drawers, AND printed verbatim by recreate-sanity-check.sh two hours earlier # still hit this and reinvented a /tmp/sshcm workaround. The knowledge was # available four times over, so a fifth copy is not the fix — removing the need # to know is. # # The [ -r ] guard is load-bearing, not decoration: setup-lan-access.sh only # writes the sidecar on VM-backed hosts (OrbStack / Docker Desktop). On native # Linux Docker there is none, and pointing -F at a missing file would break EVERY # git-over-ssh operation instead of fixing one. Respect a value the user already # set — same first-wins convention as the three settings above. if [ -r "$HOME/.ssh-local/config" ] && ! git config --global core.sshCommand &>/dev/null; then git config --global core.sshCommand "ssh -F $HOME/.ssh-local/config" 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 `. 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/\""), 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:` 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 # ── agent-browser: retire a stale volume copy that shadows the image ─── # Same hazard class as the pi-atelier retirement above, different delivery # path — and this block exists because that guard did not generalise. # ~/.pi/npm-global lives on the devbox-pi-config VOLUME, so anything ever # installed there with `npm i -g` survives every image upgrade, and PATH puts # it AHEAD of /usr/bin (position 2 vs 8). # # Measured on mbp-m1-2020, 2026-09-06: a 2026-07-17 hand-install pinned # agent-browser 0.27.0 in the volume while the image shipped 0.35.2, so every # session for ~7 weeks ran a stale CLI. The damaging part was not the binary # but its BUNDLED SKILL, which is what the agent actually reads: 3 skillsets / # 17.6 KB core in 0.27.0 vs 8 skillsets / 31.5 KB core in 0.35.2, with ten # subcommands present in the image and undocumented to the agent (a11y, # browser, data, mcp, page, plugin, read, selectors, to, webmcp). A stale tool # announces itself; a stale skill quietly teaches the wrong commands. # # MOVE rather than delete (reversible, same instinct as the settings backups # above), and only when the image ships its own copy — a machine that # deliberately hand-installs agent-browser on an image WITHOUT one keeps it. _ab_vol="$HOME/.pi/npm-global/lib/node_modules/agent-browser" if [ -d "$_ab_vol" ] && [ -d /usr/lib/node_modules/agent-browser ]; then _ab_park="$HOME/.pi/npm-global/.retired-agent-browser-$(date +%Y%m%d-%H%M%S)" if mkdir -p "$_ab_park" 2>/dev/null && mv "$_ab_vol" "$_ab_park/" 2>/dev/null; then # The bin shim is what PATH actually hits; leaving it behind would give a # dangling symlink, which is a worse failure than a stale version. rm -f "$HOME/.pi/npm-global/bin/agent-browser" 2>/dev/null || true echo "agent-browser: retired stale volume copy -> ${_ab_park} (image copy now wins; delete the parked dir when satisfied)" else echo "WARN: agent-browser: stale volume copy at $_ab_vol shadows the image copy and could not be moved; retire it by hand" fi 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 # The deploy leaves the early baked links (above) in place as foreign links, # which silently shadows the live clone for skills the skillset OWNS. Repoint # just those; baked stays the fallback, user overrides still win. `|| true`: # a skill-link refinement must never break container start. if command -v devbox-skill-reconcile >/dev/null 2>&1; then devbox-skill-reconcile "$(dirname "$SKILLSET_DEPLOY")" || true fi fi # ── Execute command ────────────────────────────────────────────────── exec "$@"