# Lightweight sandboxing(bwrap) wrapper for the pi coding agent.

> Source: <https://gist.github.com/Jipok/d4f7744b7e5cc1c6668ba2ed2fa74b8c>
> Published: 2026-09-25 13:59:54+00:00

|  | #!/usr/bin/env bash | 
|  | # pi-box — запуск pi в изолированной bwrap-песочнице. | 
|  | # | 
|  | # Проект монтируется в /work целиком; всё, что нужно инструменту, лежит в | 
|  | # <проект>/.pi-box. Наружу ничего не пишется, домашние каталоги хоста | 
|  | # (и ключи в них) внутри не видны — кроме явного -H, и то только для чтения. | 
|  | # | 
|  | # Внутри: /work (проект, rw), /box/profile, /box/sessions, /pi (ro), /home пусто. | 
|  | # | 
|  | # Полная справка: pi-box --help | 
|  | set -uo pipefail | 
|  | # ── цвета ──────────────────────────────────────────────────────────────────── | 
|  | # Только для терминала: в конвейере лишние escape-последовательности мешают. | 
|  | if [ -t 1 ]; then | 
|  | C_RST=$'\033[0m'; C_B=$'\033[1m'; C_DIM=$'\033[2m' | 
|  | C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m'; C_YEL=$'\033[33m'; C_RED=$'\033[31m' | 
|  | else | 
|  | C_RST=""; C_B=""; C_DIM=""; C_CYAN=""; C_GREEN=""; C_YEL=""; C_RED="" | 
|  | fi | 
|  | # ── справка ──────────────────────────────────────────────────────────────── | 
|  | # Текст целиком внутри функции, а не вырезан из шапки по номерам строк: | 
|  | # правка шапки однажды уже обрезала help посреди предложения. | 
|  | usage() { | 
|  | cat <<EOF | 
|  | ${C_B}pi-box${C_RST} — ${C_CYAN}pi${C_RST} в изолированной песочнице (bwrap). | 
|  | Основной режим — запуск pi в текущем каталоге. Проект монтируется в /work и | 
|  | доступен на запись; домашние каталоги хоста внутри не видны. | 
|  | Повторный вызов подключается к уже запущенной песочнице. | 
|  | ${C_B}ЗАПУСК${C_RST} | 
|  | ${C_GREEN}pi-box${C_RST} [PATH] запустить (по умолчанию текущий каталог) | 
|  | ${C_GREEN}pi-box${C_RST} -p "промпт" [PATH] один запрос, без TUI | 
|  | ${C_GREEN}pi-box${C_RST} -a [ИМЯ\|PATH] подключиться к запущенной | 
|  | ${C_GREEN}pi-box${C_RST} -m меню песочниц (даже если есть своя) | 
|  | ${C_GREEN}pi-box${C_RST} -x + X11 и GPU: рендер 3D, окна | 
|  | ${C_GREEN}pi-box${C_RST} -H + домашний каталог, только чтение | 
|  | ${C_DIM}Без аргументов открывается меню песочниц.${C_RST} | 
|  | ${C_B}ФАЙЛЫ${C_RST} | 
|  | ${C_GREEN}pi-box${C_RST} -l список песочниц | 
|  | ${C_GREEN}pi-box${C_RST} --rm [ИМЯ\|PATH] удалить .pi-box проекта | 
|  | ${C_GREEN}pi-box${C_RST} -f не спрашивать подтверждения при удалении | 
|  | ${C_GREEN}pi-box${C_RST} --new пересоздать (сносит профиль и сессии) | 
|  | ${C_GREEN}pi-box${C_RST} -v SRC:DST[:ro\|rw] смонтировать каталог (можно несколько) | 
|  | ${C_B}ВНУТРИ ПЕСОЧНИЦЫ${C_RST} | 
|  | ${C_CYAN}/work${C_RST} проект, чтение и запись | 
|  | ${C_CYAN}\$HOME${C_RST} весь дом, только чтение (с -H) | 
|  | ${C_CYAN}/box/profile${C_RST} профиль pi: auth, settings, модели, плагины | 
|  | ${C_CYAN}/box/sessions${C_RST} сессии | 
|  | ${C_CYAN}/pi${C_RST} установленный pi (только чтение) | 
|  | ${C_DIM}Состояние инструмента — <проект>/.pi-box (профиль, сессии); сам каталог | 
|  | агенту не виден. /home внутри пуст, корень, /etc и /var — только чтение.${C_RST} | 
|  | ${C_B}-H: ДОМАШНИЙ КАТАЛОГ${C_RST} | 
|  | Дом монтируется рядом с /work по своему пути, ${C_B}только для чтения${C_RST}. | 
|  | Агент видит конфиги и может искать файлы, но ничего там не меняет. | 
|  | От чтения скрыты известные хранилища секретов: | 
|  | ${C_DIM}.ssh .gnupg .aws .kube .docker .mozilla .netrc .git-credentials | 
|  | .my.cnf .pgpass .password-store .config/gh .config/gcloud .local/share/keyrings${C_RST} | 
|  | ${C_B}ПЕРЕМЕННЫЕ${C_RST} | 
|  | ${C_CYAN}PI_BOX_HOME${C_RST} реестр песочниц (по умолчанию ~/.local/share/pi-box) | 
|  | ${C_CYAN}PI_BOX_PI${C_RST} каталог @earendil-works с установленным pi | 
|  | ${C_CYAN}PI_BOX_RUNTIME${C_RST} откуда брать auth, модели и плагины (по умолчанию ~/.pi/agent) | 
|  | ${C_DIM}Нужны bwrap, tmux и установленный pi.${C_RST} | 
|  | EOF | 
|  | } | 
|  | # ── настройки ──────────────────────────────────────────────────────────────── | 
|  | PI_BOX_HOME="${PI_BOX_HOME:-${XDG_DATA_HOME:-$HOME/.local/share}/pi-box}" | 
|  | PI_SRC="${PI_BOX_PI:-$HOME/.local/lib/node_modules/@earendil-works}" | 
|  | PI_BASENAME="pi-coding-agent" | 
|  | RUNTIME="${PI_BOX_RUNTIME:-$HOME/.pi/agent}" # источник auth/models/плагинов | 
|  | SESSION_ID="${PI_BOX_SESSION_ID:-main}" | 
|  | BOX_DIRNAME=".pi-box" | 
|  | TMUX_NAME="${PI_BOX_TMUX:-pi-box}" | 
|  | # XAUTHORITY нужен для -x/--x11: без него X11 отказывает в доступе. Если | 
|  | # переменная не задана, ищем файл в обычных местах. | 
|  | if [ -z "${XAUTHORITY:-}" ]; then | 
|  | for c in "$HOME/.Xauthority" "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/xauth" \ | 
|  | "$HOME/.local/share/sx/xauthority"; do | 
|  | [ -f "$c" ] && { XAUTHORITY="$c"; break; } | 
|  | done | 
|  | fi | 
|  | require_pi() { | 
|  | if [ ! -f "$PI_SRC/$PI_BASENAME/dist/bundle/cli.js" ]; then | 
|  | echo "${C_RED}Не найден pi${C_RST}: $PI_SRC/$PI_BASENAME" >&2 | 
|  | echo "Укажи установку через ${C_CYAN}PI_BOX_PI${C_RST} или поставь pi обычным способом." >&2 | 
|  | exit 1 | 
|  | fi | 
|  | } | 
|  | # Проверяем зависимости до работы: иначе падение будет невнятным (или, хуже, | 
|  | # скрипт напишет «запущена», не запустив ничего). | 
|  | require_tools() { | 
|  | local miss=() t | 
|  | for t in bwrap tmux; do | 
|  | command -v "$t" >/dev/null 2>&1 \|\| miss+=("$t") | 
|  | done | 
|  | # node ищем один раз и запоминаем путь: запускать будем именно его, а не | 
|  | # жёсткий /usr/bin/node — иначе с nvm проверка проходит, а запуск падает. | 
|  | NODE_BIN=$(command -v node 2>/dev/null) | 
|  | [ -n "$NODE_BIN" ] \|\| miss+=("node") | 
|  | [ ${#miss[@]} -eq 0 ] && return 0 | 
|  | echo "${C_RED}Нет программ${C_RST}: ${miss[*]}" >&2 | 
|  | echo "Нужны: bwrap (bubblewrap), tmux и node." >&2 | 
|  | echo "Пример установки (Debian/Ubuntu): sudo apt install bubblewrap tmux nodejs" >&2 | 
|  | exit 1 | 
|  | } | 
|  | # Реестр песочниц: без общего каталога перечислять иначе нечего. | 
|  | register() { | 
|  | mkdir -p "$PI_BOX_HOME" | 
|  | grep -qxF "$1" "$PI_BOX_HOME/boxes" 2>/dev/null \|\| echo "$1" >> "$PI_BOX_HOME/boxes" | 
|  | } | 
|  | # Сокет в /tmp: путь внутри проекта может быть длинным, а sun_path — 107 байт. | 
|  | box_socket() { echo "/tmp/pi-box-$(id -u)/$(printf %s "$1" \| md5sum \| cut -c1-12).sock"; } | 
|  | # ── создание .pi-box ───────────────────────────────────────────────────────── | 
|  | box_init() { | 
|  | local project="$1" | 
|  | local d="$project/$BOX_DIRNAME" | 
|  | mkdir -p "$d"/{profile/extensions,sessions,tmp} | 
|  | # Ключи не должны попасть в git: внутри .pi-box игнорируем всё. | 
|  | [ -f "$d/.gitignore" ] \|\| printf '*\n' > "$d/.gitignore" | 
|  | [ -f "$RUNTIME/auth.json" ] && install -m 600 "$RUNTIME/auth.json" "$d/profile/auth.json" | 
|  | [ -f "$RUNTIME/models-store.json" ] && install -m 600 "$RUNTIME/models-store.json" "$d/profile/models-store.json" | 
|  | # Плагины не копируются: они берутся из $RUNTIME/extensions при запуске. | 
|  | # Если нужны свои — положи их сюда, и они получат приоритет. | 
|  | if [ ! -f "$d/profile/settings.json" ]; then | 
|  | local provider model | 
|  | provider=$(sed -n 's/.*"defaultProvider" *: *"$[^"]*$".*/\1/p' "$RUNTIME/settings.json" 2>/dev/null \| head -1) | 
|  | model=$(sed -n 's/.*"defaultModel" *: *"$[^"]*$".*/\1/p' "$RUNTIME/settings.json" 2>/dev/null \| head -1) | 
|  | cat > "$d/profile/settings.json" <<EOF | 
|  | { | 
|  | "packages": [], | 
|  | "defaultProvider": "${provider:-ez-ai}", | 
|  | "defaultModel": "${model:-deepseek/deepseek-v4.1-flash}", | 
|  | "theme": "dark" | 
|  | } | 
|  | EOF | 
|  | fi | 
|  | cat > "$d/tmp/tmux.conf" <<'EOF' | 
|  | set -g default-terminal "tmux-256color" | 
|  | set -as terminal-features ",*:RGB" | 
|  | set -g extended-keys on | 
|  | set -g extended-keys-format csi-u | 
|  | set -g mouse on | 
|  | set -g history-limit 50000 | 
|  | EOF | 
|  | } | 
|  | # ── argv для bwrap ───────────────────────────────────────────────────────────── | 
|  | # /work монтируется как есть, но поверх .pi-box кладётся пустой tmpfs. Профиль | 
|  | # rw (pi пишет lock-файлы), но auth.json и models-store.json — ro поверх, чтобы | 
|  | # случайная запись или rm не сломали ключ. | 
|  | box_bwrap() { | 
|  | local project="$1" | 
|  | local p="$project/$BOX_DIRNAME/profile" | 
|  | BW=( | 
|  | bwrap | 
|  | --ro-bind /usr /usr | 
|  | --ro-bind /etc /etc | 
|  | --ro-bind /var /var | 
|  | --proc /proc | 
|  | --dev /dev | 
|  | --tmpfs /tmp | 
|  | # /home не пробрасываем: там чужие каталоги, ключи и конфиги. Внутри | 
|  | # остаётся пустой tmpfs, а нужное (pi, проект) смонтировано точечно. | 
|  | --tmpfs /home | 
|  | --bind "$project" /work | 
|  | --dir /box | 
|  | --bind "$p" /box/profile | 
|  | --bind "$project/$BOX_DIRNAME/sessions" /box/sessions | 
|  | # pi берём из установки на хосте, только чтение. | 
|  | --ro-bind "$PI_SRC" /pi | 
|  | # Окружение и рабочий каталог — самим bwrap, без shell-обвязки: | 
|  | # так значения с кавычками и $(...) не попадут в интерпретатор. | 
|  | --setenv HOME /box | 
|  | --setenv PI_CODING_AGENT_DIR /box/profile | 
|  | --chdir /work | 
|  | --unshare-pid | 
|  | --die-with-parent | 
|  | ) | 
|  | # Ctrl-H: домашний каталог рядом с /work, только чтение. Именно ro, а не | 
|  | # «дом как проект»: агент может читать конфиги и искать файлы, но ничего | 
|  | # там не меняет. Секреты маскируем даже от чтения — они не нужны для работы, | 
|  | # а утекут в контекст модели при первом же ls. | 
|  | if [ "$HOMERO" = 1 ]; then | 
|  | local hp="$HOME" | 
|  | BW+=(--ro-bind "$hp" "$hp") | 
|  | # Каталог — пустым tmpfs, файл — /dev/null: --tmpfs на файл bwrap | 
|  | # отвергает («Destination is not a directory», проверено). | 
|  | local sec | 
|  | for sec in .ssh .gnupg .password-store .aws .kube .docker .mozilla \ | 
|  | .config/gh .config/gcloud .local/share/keyrings \ | 
|  | .netrc .git-credentials .my.cnf .pgpass; do | 
|  | [ -e "$hp/$sec" ] \|\| continue | 
|  | if [ -d "$hp/$sec" ]; then | 
|  | BW+=(--tmpfs "$hp/$sec") | 
|  | else | 
|  | BW+=(--ro-bind /dev/null "$hp/$sec") | 
|  | fi | 
|  | done | 
|  | fi | 
|  | # Маскируем .pi-box внутри /work — свой и чужие (вложенные песочницы). | 
|  | # | 
|  | # Именно --tmpfs на существующий каталог, а не безусловный --tmpfs на путь: | 
|  | # bwrap создаёт точку монтирования в реальной файловой системе, если её нет | 
|  | # (проверено). Иначе на хосте появлялся пустой каталог-ловушка, а find_root | 
|  | # затем находил его и подменял им проект. | 
|  | # | 
|  | # Пути вложенных берём из реестра, а не find: обход всего $HOME занимал | 
|  | # десятки секунд, а известных песочниц единицы. | 
|  | local nested rel | 
|  | [ -d "$project/$BOX_DIRNAME" ] && BW+=(--tmpfs "/work/$BOX_DIRNAME") | 
|  | while IFS= read -r nested; do | 
|  | [ -n "$nested" ] \|\| continue | 
|  | [ -d "$nested/$BOX_DIRNAME" ] \|\| continue | 
|  | case "$nested" in | 
|  | "$project"/*) rel="${nested#$project}" ;; | 
|  | *) continue ;; | 
|  | esac | 
|  | BW+=(--tmpfs "/work$rel/$BOX_DIRNAME") | 
|  | done < "$PI_BOX_HOME/boxes" | 
|  | # Раскладку /bin, /lib, /lib64, /sbin повторяем с хоста, а не угадываем: на | 
|  | # merged-usr это симлинки в usr, но на Fedora /lib64 -> usr/lib64, и жёсткий | 
|  | # usr/lib оставил бы /lib64 битым. Симлинк — копируем цель, настоящий | 
|  | # каталог — монтируем. | 
|  | local l tgt | 
|  | for l in /bin /lib /lib64 /sbin; do | 
|  | tgt=$(readlink "$l" 2>/dev/null) | 
|  | if [ -n "$tgt" ]; then | 
|  | BW+=(--symlink "$tgt" "$l") | 
|  | elif [ -d "$l" ]; then | 
|  | BW+=(--ro-bind "$l" "$l") | 
|  | fi | 
|  | done | 
|  | # node бывает вне /usr (nvm, /opt, /usr/local), а этих каталогов внутри нет: | 
|  | # запуск упал бы, хотя проверка command -v прошла. Пробрасываем тот самый | 
|  | # бинарник, которым запускаем, чтобы проверка и запуск не расходились. | 
|  | case "$NODE_BIN" in | 
|  | /usr/*\|/etc/*\|/var/*) ;; | 
|  | *) [ -n "$NODE_BIN" ] && BW+=(--ro-bind "$NODE_BIN" "$NODE_BIN") ;; | 
|  | esac | 
|  | # Плагины: нет своих — подключаем каталог из $RUNTIME read-only, чтобы | 
|  | # правка плагина была видна во всех песочницах сразу. Есть свои — ничего не | 
|  | # монтируем, чтобы не перекрыть намеренную настройку. | 
|  | if [ -z "$(ls -A "$p/extensions" 2>/dev/null)" ] && [ -d "$RUNTIME/extensions" ]; then | 
|  | BW+=(--ro-bind "$RUNTIME/extensions" /box/profile/extensions) | 
|  | fi | 
|  | # Ключи — только чтение, поверх rw-профиля. | 
|  | [ -f "$p/auth.json" ] && BW+=(--ro-bind "$p/auth.json" /box/profile/auth.json) | 
|  | [ -f "$p/models-store.json" ] && BW+=(--ro-bind "$p/models-store.json" /box/profile/models-store.json) | 
|  | # -v src:dst[:ro\|rw] — как в docker. Целевую точку bwrap создаёт сам, включая | 
|  | # вложенную. | 
|  | local v src dst mode rest | 
|  | for v in ${VOLUMES[@]+"${VOLUMES[@]}"}; do | 
|  | [ -n "$v" ] \|\| continue | 
|  | src="${v%%:*}"; rest="${v#*:}" | 
|  | [ "$rest" = "$v" ] && { echo "-v: нужен вид <источник>:<куда>[:ro\|rw], получил '$v'" >&2; exit 1; } | 
|  | dst="${rest%%:*}" | 
|  | mode="${rest#*:}"; [ "$mode" = "$rest" ] && mode="rw" | 
|  | src="${src/#\~/$HOME}" | 
|  | [ -e "$src" ] \|\| { echo "-v: нет источника '$src'" >&2; exit 1; } | 
|  | case "$mode" in | 
|  | ro\|readonly) BW+=(--ro-bind "$src" "$dst") ;; | 
|  | rw\|readwrite) BW+=(--bind "$src" "$dst") ;; | 
|  | *) echo "-v: режим '$mode' неизвестен (ro или rw)" >&2; exit 1 ;; | 
|  | esac | 
|  | done | 
|  | # -x/--x11: проброс X11 и GPU. /sys нужен Mesa, иначе рендер уходит на llvmpipe. | 
|  | if [ "$X11" = 1 ]; then | 
|  | [ -d /dev/dri ] && BW+=(--dev-bind /dev/dri /dev/dri) | 
|  | [ -d /sys ] && BW+=(--ro-bind /sys /sys) | 
|  | [ -d /tmp/.X11-unix ] && BW+=(--ro-bind /tmp/.X11-unix /tmp/.X11-unix) | 
|  | [ -n "${XAUTHORITY:-}" ] && [ -f "$XAUTHORITY" ] && BW+=(--ro-bind "$XAUTHORITY" /box/.Xauthority) | 
|  | BW+=(--setenv DISPLAY "${DISPLAY:-:0}") | 
|  | [ -n "${XAUTHORITY:-}" ] && BW+=(--setenv XAUTHORITY /box/.Xauthority) | 
|  | BW+=(--setenv LIBGL_ALWAYS_SOFTWARE 0) | 
|  | # Звук и сессия — только если есть. | 
|  | [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -d "$XDG_RUNTIME_DIR" ] && \ | 
|  | BW+=(--ro-bind "$XDG_RUNTIME_DIR" "$XDG_RUNTIME_DIR" --setenv XDG_RUNTIME_DIR "$XDG_RUNTIME_DIR") | 
|  | fi | 
|  | } | 
|  | # ── поиск песочниц ─────────────────────────────────────────────────────────── | 
|  | # Из реестра: "<путь>" по имени каталога, точному пути или части имени. | 
|  | find_box() { | 
|  | local want="$1" | 
|  | [ -f "$PI_BOX_HOME/boxes" ] \|\| return 1 | 
|  | local line hit="" | 
|  | while IFS= read -r line; do | 
|  | [ -n "$line" ] \|\| continue | 
|  | [ -d "$line" ] \|\| continue | 
|  | if [ "$line" = "$want" ] \|\| [ "$(basename "$line")" = "$want" ]; then | 
|  | echo "$line"; return 0 | 
|  | fi | 
|  | case "$(basename "$line")" in *"$want"*) hit="$line" ;; esac | 
|  | done < "$PI_BOX_HOME/boxes" | 
|  | [ -n "$hit" ] && { echo "$hit"; return 0; } | 
|  | return 1 | 
|  | } | 
|  | is_running() { | 
|  | local s; s=$(box_socket "$1") | 
|  | [ -S "$s" ] && tmux -S "$s" has-session -t "$TMUX_NAME" 2>/dev/null | 
|  | } | 
|  | # Режимы запущенной песочницы читаем из argv живого bwrap: скрипт их нигде не | 
|  | # хранит, а процесс о них знает. Так статус честный и после перезапуска | 
|  | # инструмента. Вывод: " X11 ~ro" или пусто. | 
|  | box_modes() { | 
|  | local s pid argv | 
|  | s=$(box_socket "$1") | 
|  | [ -S "$s" ] \|\| return 0 | 
|  | pid=$(tmux -S "$s" list-panes -a -F '#{pane_pid}' 2>/dev/null \| head -1) | 
|  | [ -n "$pid" ] && [ -r "/proc/$pid/cmdline" ] \|\| return 0 | 
|  | argv=$(tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null) | 
|  | grep -qx '/dev/dri' <<< "$argv" && printf ' X11' | 
|  | # Дом виден как ro-bind самого $HOME — ищем именно такую пару аргументов. | 
|  | grep -A1 -x -- '--ro-bind' <<< "$argv" \| grep -qx -- "$HOME" && printf ' ~ro' | 
|  | } | 
|  | running_boxes() { | 
|  | [ -f "$PI_BOX_HOME/boxes" ] \|\| return 0 | 
|  | local line | 
|  | while IFS= read -r line; do | 
|  | [ -n "$line" ] && [ -d "$line" ] && is_running "$line" && echo "$line" | 
|  | done < "$PI_BOX_HOME/boxes" | 
|  | } | 
|  | cmd_list() { | 
|  | [ -s "$PI_BOX_HOME/boxes" ] \|\| { echo "песочниц нет"; return; } | 
|  | local line n sz sess run | 
|  | while IFS= read -r line; do | 
|  | [ -n "$line" ] \|\| continue | 
|  | if [ ! -d "$line/$BOX_DIRNAME" ]; then | 
|  | printf " %-24s (удалена)\n" "$(basename "$line")" | 
|  | continue | 
|  | fi | 
|  | n=$(basename "$line"); sz=$(du -sh "$line/$BOX_DIRNAME" 2>/dev/null \| cut -f1) | 
|  | sess=$(find "$line/$BOX_DIRNAME/sessions" -maxdepth 1 -name '*.jsonl' 2>/dev/null \| wc -l) | 
|  | run=""; is_running "$line" && run=" [запущена]" | 
|  | printf " %-24s %6s сессий:%s%s\n" "$n" "$sz" "$sess" "${C_GREEN}$run${C_RST}" | 
|  | done < "$PI_BOX_HOME/boxes" | 
|  | echo | 
|  | echo "удалить: pi-box --rm <имя>" | 
|  | } | 
|  | cmd_rm() { | 
|  | local target="${1:-}" line sock pid | 
|  | [ -n "$target" ] \|\| { echo "укажи песочницу (pi-box -l)"; exit 1; } | 
|  | line=$(find_box "$target") \|\| { echo "нет песочницы '$target' (pi-box -l)" >&2; exit 1; } | 
|  | confirm_rm "$line" \|\| return 1 | 
|  | # Гасим ровно процессы песочницы: PID панелей tmux, а потом сам сервер. | 
|  | # pkill по строке argv тут опасен — под шаблон попадает и наш собственный | 
|  | # shell, если в его аргументах есть тот же путь. | 
|  | sock=$(box_socket "$line") | 
|  | for pid in $(tmux -S "$sock" list-panes -a -F '#{pane_pid}' 2>/dev/null); do | 
|  | kill "$pid" 2>/dev/null | 
|  | done | 
|  | tmux -S "$sock" kill-server 2>/dev/null | 
|  | sleep 1 | 
|  | rm -rf "$line/$BOX_DIRNAME" && echo "${C_GREEN}удалено${C_RST}: $line/$BOX_DIRNAME" | 
|  | # Убираем из реестра. | 
|  | if [ -f "$PI_BOX_HOME/boxes" ]; then | 
|  | grep -vxF "$line" "$PI_BOX_HOME/boxes" > "$PI_BOX_HOME/boxes.tmp" 2>/dev/null \|\| true | 
|  | mv "$PI_BOX_HOME/boxes.tmp" "$PI_BOX_HOME/boxes" | 
|  | fi | 
|  | } | 
|  | # Удаление запущенной песочницы прерывает работу и сносит профиль с историей — | 
|  | # отменить это нельзя. Спрашиваем только у активной: у остановленной терять | 
|  | # нечего, и подтверждение там было бы лишним шумом. | 
|  | confirm_rm() { | 
|  | local line="$1" ans="" | 
|  | is_running "$line" \|\| return 0 | 
|  | [ "$FORCE" = 1 ] && return 0 | 
|  | printf '%s' "${C_YEL}«$(basename "$line")» запущена${C_RST} — работа прервётся, профиль и история сессий удалятся. Удалить? [y/N] " >&2 | 
|  | # Читаем с терминала, а не со stdin: в режиме --rm stdin бывает занят | 
|  | # конвейером, а в меню его уже прочитал fzf. Нет терминала (cron, скрипт) — | 
|  | # отвечать некому, поэтому не удаляем: пусть решает --force. | 
|  | { read -r ans < /dev/tty; } 2>/dev/null \|\| { | 
|  | echo "нет терминала для подтверждения — используй --force" >&2 | 
|  | return 1 | 
|  | } | 
|  | case "$ans" in | 
|  | [yYдД]*) return 0 ;; | 
|  | esac | 
|  | echo "отменено" >&2 | 
|  | return 1 | 
|  | } | 
|  | # ── интерактивное меню ──────────────────────────────────────────────────────── | 
|  | # ── интерактивное меню ──────────────────────────────────────────────────────── | 
|  | # Живёт в текущем экране (fzf --height), история остаётся видна. Цикл: | 
|  | # после удаления или отказа показываем список снова, пока не выбрана песочница | 
|  | # или пользователь не нажал Esc. | 
|  | # | 
|  | # Enter открыть (создать, если выбран текущий каталог) | 
|  | # Ctrl-D удалить выбранную | 
|  | # Esc выйти | 
|  | # | 
|  | # Цель — одной командой сделать всё: завести, открыть, снести. | 
|  | cmd_menu() { | 
|  | command -v fzf >/dev/null 2>&1 \|\| { | 
|  | echo "${C_RED}Нужен fzf${C_RST} для интерактивного выбора." >&2 | 
|  | echo "Установи fzf или используй аргументы (pi-box -l, pi-box PATH)." >&2 | 
|  | exit 1 | 
|  | } | 
|  | local here; here=$(pwd -P) | 
|  | # Из домашнего каталога песочницу не создаём (см. проверку ниже), поэтому | 
|  | # и строки «создать» для него нет. | 
|  | [ "$here" = "$HOME" ] && here="" | 
|  | while :; do | 
|  | # Однострочные строки: fzf 0.74 разворачивает \n внутри элемента | 
|  | # нестабильно — при единственной строке рисует «␊» вместо переноса, | 
|  | # поэтому многострочность не используем. Разделитель полей — \x1f, | 
|  | # а не таб: таб может встретиться в пути. | 
|  | local -a rows=() | 
|  | # Создание — отдельная заметная строка, а не серый путь: раньше её было | 
|  | # не отличить от «открыть существующую». | 
|  | [ -n "$here" ] && rows+=("${C_CYAN}+ создать здесь${C_RST} ${C_DIM}$here${C_RST}\x1fHERE") | 
|  | if [ -f "$PI_BOX_HOME/boxes" ]; then | 
|  | local line name run st m | 
|  | while IFS= read -r line; do | 
|  | [ -n "$line" ] \|\| continue | 
|  | [ -d "$line/$BOX_DIRNAME" ] \|\| continue | 
|  | [ "$line" = "$here" ] && continue | 
|  | name=$(basename "$line") | 
|  | # Статус и режимы — в первой колонке: её видно. Режимы берём у живого | 
|  | # процесса, чтобы после перезапуска инструмента они не пропадали. | 
|  | if is_running "$line"; then | 
|  | m=$(box_modes "$line") | 
|  | st="${C_GREEN}●${C_RST}${C_YEL}${m}${C_RST}" | 
|  | else | 
|  | st="${C_DIM}·${C_RST}" | 
|  | fi | 
|  | sz=$(du -sh "$line/$BOX_DIRNAME" 2>/dev/null \| cut -f1) | 
|  | rows+=("$(printf '%-16s %s' "$name" "$st") ${C_DIM}${sz} $line${C_RST}\x1f$line") | 
|  | done < "$PI_BOX_HOME/boxes" | 
|  | fi | 
|  | # Активные режимы — в приглашении: видно, с чем откроется выбранная. | 
|  | local modes="" | 
|  | [ "$X11" = 1 ] && modes=" · ${C_YEL}X11${C_RST}" | 
|  | [ "$HOMERO" = 1 ] && modes="$modes · ${C_YEL}\$HOME ro${C_RST}" | 
|  | local out="" | 
|  | out=$(printf '%b\0' "${rows[@]}" \| fzf --ansi --read0 \ | 
|  | --height="${FZF_HEIGHT:-~45%}" \ | 
|  | --reverse --border --no-multi \ | 
|  | --expect=ctrl-d,ctrl-x,ctrl-h \ | 
|  | --prompt="песочница${modes}> " \ | 
|  | --header='Enter — открыть · Ctrl-D — удалить · Ctrl-X — X11 · Ctrl-H — $HOME ro · Esc — выход' \ | 
|  | --delimiter='\x1f' --with-nth=1 --accept-nth=2) \|\| exit 0 | 
|  | local -a kl=() | 
|  | readarray -t kl < <(printf '%s\n' "$out") | 
|  | # ${kl[1]:-}: при set -u обращение к отсутствующему элементу — ошибка, | 
|  | # а при Enter fzf отдаёт одну строку (без имени клавиши). | 
|  | local pressed="${kl[0]:-}" want="${kl[1]:-}" | 
|  | # При Enter строки с клавишей нет — выбор оказывается первым. | 
|  | [ "${#kl[@]}" -lt 2 ] && { pressed=""; want="${kl[0]:-}"; } | 
|  | case "$pressed" in | 
|  | ctrl-x) X11=$((1 - X11)); continue ;; | 
|  | ctrl-h) HOMERO=$((1 - HOMERO)); continue ;; | 
|  | esac | 
|  | [ -n "$want" ] \|\| continue | 
|  | if [ "$pressed" = ctrl-d ]; then | 
|  | if [ "$want" = HERE ]; then | 
|  | echo "${C_YEL}Здесь ещё нет песочницы — удалять нечего${C_RST}" >&2 | 
|  | continue | 
|  | fi | 
|  | cmd_rm "$want" | 
|  | continue | 
|  | fi | 
|  | [ "$want" = HERE ] && MENU_PROJECT="$here" \|\| MENU_PROJECT="$want" | 
|  | return 0 | 
|  | done | 
|  | } | 
|  | cmd_attach() { | 
|  | local want="${1:-}" line | 
|  | if [ -z "$want" ]; then | 
|  | local run=(); while IFS= read -r l; do run+=("$l"); done < <(running_boxes) | 
|  | case "${#run[@]}" in | 
|  | 0) echo "нет запущенных песочниц (pi-box -l)" >&2; exit 1 ;; | 
|  | 1) line="${run[0]}" ;; | 
|  | *) echo "запущено несколько — укажи имя:" >&2 | 
|  | for l in "${run[@]}"; do echo " $(basename "$l")" >&2; done; exit 1 ;; | 
|  | esac | 
|  | else | 
|  | line=$(find_box "$want") \|\| { echo "нет песочницы '$want' (pi-box -l)" >&2; exit 1; } | 
|  | fi | 
|  | is_running "$line" \|\| { echo "песочница '$(basename "$line")' не запущена" >&2; exit 1; } | 
|  | exec tmux -S "$(box_socket "$line")" attach -t "$TMUX_NAME" | 
|  | } | 
|  | # ── разбор аргументов ──────────────────────────────────────────────────────── | 
|  | NEW=0; PROMPT=""; ATTACH=""; X11=0; HOMERO=0; VOLUMES=(); PROJECT=""; MENU=0; FORCE=0 | 
|  | while [ $# -gt 0 ]; do | 
|  | case "$1" in | 
|  | -h\|--help) usage; exit 0 ;; | 
|  | -l\|--list) cmd_list; exit 0 ;; | 
|  | --rm) shift; cmd_rm "${1:-}"; exit $? ;; | 
|  | -f\|--force) FORCE=1; shift ;; | 
|  | --new) NEW=1; shift ;; | 
|  | -m\|--menu) MENU=1; shift ;; | 
|  | -x\|--x11) X11=1; shift ;; | 
|  | -H\|--home) HOMERO=1; shift ;; | 
|  | -v\|--volume) VOLUMES+=("${2:-}"); shift 2 ;; | 
|  | -a\|--attach) ATTACH="1"; shift | 
|  | case "${1:-}" in ''\|-*) ;; *) ATTACH="$1"; shift ;; esac ;; | 
|  | -p\|--print) PROMPT="${2:-}"; shift 2 ;; | 
|  | -*) echo "неизвестный флаг: $1" >&2; exit 1 ;; | 
|  | *) PROJECT="$1"; shift ;; | 
|  | esac | 
|  | done | 
|  | # Зависимости проверяем сразу после разбора аргументов: --help и -l должны | 
|  | # работать без них, а всё остальное — нет. | 
|  | require_tools | 
|  | [ -n "$ATTACH" ] && { [ "$ATTACH" = 1 ] && cmd_attach "" \|\| cmd_attach "$ATTACH"; } | 
|  | # Ищем .pi-box вверх по дереву — как git. Запуск из подкаталога подхватывает | 
|  | # уже существующую песочницу, а не плодит вложенную. | 
|  | find_root() { | 
|  | local dir="$1" | 
|  | while :; do | 
|  | [ -d "$dir/$BOX_DIRNAME" ] && { echo "$dir"; return 0; } | 
|  | [ "$dir" = "/" ] && break | 
|  | dir=$(dirname "$dir") | 
|  | done | 
|  | return 1 | 
|  | } | 
|  | PROJECT="${PROJECT:-$PWD}" | 
|  | [ -d "$PROJECT" ] \|\| { echo "${C_RED}нет каталога${C_RST}: $PROJECT" >&2; exit 1; } | 
|  | # realpath -P: без него один проект попадёт в реестр под разными путями | 
|  | # (симлинки, .. , относительный путь), и песочница «раздвоится». | 
|  | PROJECT=$(cd "$PROJECT" 2>/dev/null && pwd -P) | 
|  | [ -n "$PROJECT" ] \|\| { echo "${C_RED}не удалось определить каталог${C_RST}: $PWD" >&2; exit 1; } | 
|  | ROOT=$(find_root "$PROJECT") \|\| ROOT="" | 
|  | # Меню — только когда песочницы нет ни здесь, ни выше. Если мы уже в проекте | 
|  | # с .pi-box, запуск/подключение ожидается сразу, без списка. | 
|  | if [ "$MENU" = 1 ] \|\| { [ -z "$ROOT" ] && [ "$NEW" = 0 ] && [ -z "$PROMPT" ] \ | 
|  | && [ "$X11" = 0 ] && [ ${#VOLUMES[@]} = 0 ] && [ -z "$ATTACH" ]; }; then | 
|  | cmd_menu | 
|  | PROJECT="${MENU_PROJECT:-$PROJECT}" | 
|  | ROOT=$(find_root "$PROJECT") \|\| ROOT="" | 
|  | fi | 
|  | [ -n "$ROOT" ] \|\| ROOT="$PROJECT" | 
|  | # Защита: корень и дом — это не проект. Иначе /work получил бы весь $HOME | 
|  | # (включая .ssh, .pi, чужие проекты), и агент мог бы их править. Дом для чтения | 
|  | # даёт Ctrl-H — он монтирует $HOME рядом с /work, не делая его проектом. | 
|  | case "$ROOT" in | 
|  | "$HOME") echo "${C_RED}Нельзя${C_RST} запускать в домашнем каталоге: /work получил бы весь $HOME." >&2 | 
|  | echo "Зайди в каталог проекта: ${C_CYAN}cd ~/code/myproject && pi-box${C_RST}" >&2 | 
|  | echo "Весь дом только для чтения — ${C_CYAN}pi-box -m${C_RST} и Ctrl-H." >&2 | 
|  | exit 1 ;; | 
|  | "/") echo "${C_RED}Нельзя${C_RST} использовать корень / как проект." >&2; exit 1 ;; | 
|  | esac | 
|  | # .pi-box не должен быть симлинком. Проект монтируется в /work rw, поэтому | 
|  | # симлинк в .pi-box позволял бы писать за пределы песочницы (в ~/.ssh, /etc) | 
|  | # и увёл бы --rm на чужой каталог. Не тюрьма — страховка от случайности. | 
|  | reject_link() { | 
|  | [ ! -L "$1" ] \|\| { | 
|  | echo "${C_RED}опасный симлинк${C_RST}: $1" >&2 | 
|  | echo ".pi-box и всё внутри должны быть настоящими каталогами и файлами." >&2 | 
|  | exit 1 | 
|  | } | 
|  | } | 
|  | reject_link "$ROOT/$BOX_DIRNAME" | 
|  | for sub in profile profile/extensions sessions tmp profile/auth.json \ | 
|  | profile/models-store.json profile/settings.json tmp/tmux.conf; do | 
|  | reject_link "$ROOT/$BOX_DIRNAME/$sub" | 
|  | done | 
|  | register "$ROOT" | 
|  | # --new на работающей песочнице сносит профиль из-под живой сессии: она | 
|  | # продолжит держать старые файлы (bind), а новая запустится с чистым профилем — | 
|  | # и два процесса будут писать в одно место. Отказываемся. | 
|  | if [ "$NEW" = 1 ] && is_running "$ROOT"; then | 
|  | echo "${C_RED}Песочница запущена${C_RST} — сначала останови её:" >&2 | 
|  | echo " ${C_CYAN}pi-box --rm $(basename "$ROOT")${C_RST} (или подключись: pi-box -a)" >&2 | 
|  | exit 1 | 
|  | fi | 
|  | require_pi | 
|  | [ "$NEW" = 1 ] && rm -rf "$ROOT/$BOX_DIRNAME" | 
|  | box_init "$ROOT" | 
|  | # pi монтируется в /pi целиком как каталог @earendil-works, поэтому путь к | 
|  | # cli.js повторяет его структуру. | 
|  | PI="/pi/$PI_BASENAME/dist/bundle/cli.js" | 
|  | box_bwrap "$ROOT" | 
|  | # ── один запрос ────────────────────────────────────────────────────────────── | 
|  | # Без bash -c: переменные и cwd ставятся самим bwrap. Через bash -c значение | 
|  | # с кавычкой или $(...) попадало в shell — проверено, PI_BOX_SESSION_ID мог | 
|  | # выполнить произвольную команду. Теперь значения идут аргументами, не строкой. | 
|  | if [ -n "$PROMPT" ]; then | 
|  | exec "${BW[@]}" "$NODE_BIN" "$PI" -p \ | 
|  | --session-dir /box/sessions --session-id "$SESSION_ID" "$PROMPT" | 
|  | fi | 
|  | # ── интерактив ─────────────────────────────────────────────────────────────── | 
|  | # Через tmux: опции терминала надо выставить до создания панели, иначе панель | 
|  | # унаследует screen-256color и потеряет truecolor, а Shift+Enter — Enter. | 
|  | # | 
|  | # -c "$ROOT": каталог сессии, из него открываются новые окна (prefix+c). | 
|  | # tmux-сервер живёт СНАРУЖИ bwrap, поэтому новые окна — обычные шеллы хоста, | 
|  | # и путь должен быть настоящим: /work существует только внутри песочницы. | 
|  | SOCK=$(box_socket "$ROOT") | 
|  | mkdir -p "$(dirname "$SOCK")" | 
|  | if is_running "$ROOT"; then | 
|  | [ -t 0 ] && exec tmux -S "$SOCK" attach -t "$TMUX_NAME" | 
|  | echo "${C_GREEN}песочница${C_RST} '${C_B}$(basename "$ROOT")${C_RST}' уже запущена — ${C_CYAN}pi-box -a${C_RST}" | 
|  | exit 0 | 
|  | fi | 
|  | # tmux new-session возвращает 0 даже когда панель не поднялась (проверено), | 
|  | # поэтому успех подтверждаем отдельной проверкой, а не кодом возврата. | 
|  | tmux -S "$SOCK" -f "$ROOT/$BOX_DIRNAME/tmp/tmux.conf" new-session -d -s "$TMUX_NAME" -c "$ROOT" \ | 
|  | "${BW[@]}" "$NODE_BIN" "$PI" \ | 
|  | --session-dir /box/sessions --session-id "$SESSION_ID" | 
|  | if ! is_running "$ROOT"; then | 
|  | echo "${C_RED}не удалось запустить песочницу${C_RST}" >&2 | 
|  | echo "Проверь, что проект лежит в доступном каталоге; запусти без tmux:" >&2 | 
|  | echo " ${C_CYAN}pi-box -p \"echo test\"${C_RST}" >&2 | 
|  | exit 1 | 
|  | fi | 
|  | [ -t 0 ] && exec tmux -S "$SOCK" attach -t "$TMUX_NAME" | 
|  | echo "${C_GREEN}песочница${C_RST} '${C_B}$(basename "$ROOT")${C_RST}' запущена — ${C_CYAN}pi-box -a${C_RST}" |
