cd /news/ai-agents/lightweight-sandboxing-bwrap-wrapper… · home › topics › ai-agents › article
[ARTICLE · art-139695] src=gist.github.com ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

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

A developer released pi-box, a Bash wrapper that runs the pi coding agent inside a bubblewrap (bwrap) sandbox. The project mounts the working directory read-write at /work, keeps host home directories hidden unless explicitly mounted read-only via -H, and stores the agent's profile and sessions in a project-local .pi-box directory. It also supports attaching to running sandboxes, one-shot prompts, and optional X11/GPU passthrough.

by read27 min views1 publishedSep 25, 2026

| | #!/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}" |
── more in #ai-agents 4 stories · sorted by recency
── more on @pi-box 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/lightweight-sandboxi…] indexed:0 read:27min 2026-09-25 · —