Pi в Docker: безопасный стенд для сравнения локальных и облачных агентов

Pi — минималистичный coding-агент для терминала: у него небольшой системный промпт, короткий набор инструментов (чтение и запись файлов, правка, shell) и поддержка разных провайдеров моделей. Поэтому его удобно использовать как стенд для исследований: обвязки мало, и разница в поведении сильнее зависит от модели.
Но у такого агента есть shell. Если запустить его в рабочем каталоге хоста, он сможет прочитать ~/.ssh, .env соседних проектов, токены в ~/.config и всё, что доступно вашему пользователю. При сравнении нескольких моделей, особенно слабых локальных, вероятность случайной разрушительной команды только растёт.
Ниже — стенд, который решает обе задачи сразу: Pi работает в контейнере без root, видит только одноразовую копию тестового репозитория и получает ровно один ключ API за запуск. Одна и та же задача прогоняется на нескольких моделях, результаты собираются в отдельный каталог.
Что получится в итоге
- Docker-образ с закреплённой версией Pi и непривилегированным пользователем.
- Запуск с
--cap-drop=ALL,no-new-privileges, файловой системой только для чтения и лимитами на CPU, память и число процессов. - Тестовый репозиторий-фикстура с падающим тестом и фиксированный промпт.
- Скрипт, который для каждой модели создаёт чистую копию фикстуры, запускает Pi, а затем сохраняет diff, вывод тестов и лог.
Модель угроз: от чего защищаемся
Чтобы не переоценить защиту, сразу зафиксируем рамки:
- Защищаем: файлы и секреты хоста, другие проекты, Docker-сокет, привилегии root внутри контейнера, ресурсы машины (fork-бомбы, бесконечные циклы).
- Частично защищаем: сеть. Облачной модели нужен выход в интернет, значит у агента внутри контейнера он тоже будет. Строгий контроль исходящего трафика требует egress-прокси с allowlist, его мы разберём в разделе об ограничениях.
- Не защищаем: ключ API, переданный в контейнер. Агент может его прочитать. Поэтому используйте отдельный ключ с лимитом расходов, выпущенный только для стенда.
Шаг 1. Структура каталога
pi-lab/
├── Dockerfile
├── fixture/ # эталонная задача, только для чтения
│ ├── package.json
│ ├── src/slugify.js
│ └── test/slugify.test.js
├── prompts/task.txt
├── config/models.json # описание локального провайдера
├── secrets/ # .env-файлы с ключами, в .gitignore
├── runs/ # результаты прогонов
└── run-one.sh
mkdir -p pi-lab/{fixture/src,fixture/test,prompts,config,secrets,runs}
cd pi-lab
printf 'secrets/\nruns/\n' > .gitignore
chmod 700 secrets
Шаг 2. Образ с закреплённой версией
Закрепляйте и базовый образ, и версию Pi. Иначе через неделю вы сравните уже другой агент.
# Dockerfile
FROM node:22-bookworm-slim
# Минимум инструментов, которые агенту обычно нужны для работы с кодом
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates ripgrep \
&& rm -rf /var/lib/apt/lists/*
# Подставьте конкретную версию вместо <VERSION>; проверьте: npm view @mariozechner/pi-coding-agent versions
ARG PI_VERSION=<VERSION>
RUN npm install -g @mariozechner/pi-coding-agent@${PI_VERSION}
# Непривилегированный пользователь
RUN useradd --create-home --uid 10001 agent
USER agent
WORKDIR /work
ENTRYPOINT ["pi"]
docker build --build-arg PI_VERSION=<VERSION> -t pi-lab:pinned .
docker run --rm pi-lab:pinned --help
Последняя команда сразу показывает, какие флаги поддерживает ваша версия: неинтерактивный режим, выбор провайдера и модели. Дальше в статье используются -p, --provider и --model. Если в вашей версии они называются иначе, поправьте скрипт.
Шаг 3. Фикстура: одна проверяемая задача
Задача должна быть маленькой, детерминированной и иметь объективный критерий успеха: тесты проходят или нет. Пример ниже учебный. Замените его своей задачей, ближе к реальной работе.
// fixture/src/slugify.js — намеренно неполная реализация
function slugify(input) {
return input.toLowerCase().replace(/ /g, '-');
}
module.exports = { slugify };
// fixture/test/slugify.test.js
const test = require('node:test');
const assert = require('node:assert');
const { slugify } = require('../src/slugify');
test('схлопывает пробелы и обрезает края', () => {
assert.strictEqual(slugify(' Hello World '), 'hello-world');
});
test('удаляет пунктуацию', () => {
assert.strictEqual(slugify('Agents, Docker & Pi!'), 'agents-docker-pi');
});
// fixture/package.json
{ "name": "fixture", "private": true, "scripts": { "test": "node --test" } }
# prompts/task.txt
В репозитории падают тесты в test/slugify.test.js.
Исправь src/slugify.js так, чтобы все тесты проходили.
Не меняй файлы тестов. Не устанавливай зависимости.
Когда закончишь, запусти npm test и кратко опиши изменения.
Тестам не нужны npm-зависимости: агенту незачем ходить в реестр пакетов, а результат не зависит от сети.
Шаг 4. Подключение локальной модели
Для локальных моделей удобно использовать Ollama или другой сервер с OpenAI-совместимым API на хосте. Pi описывает собственные провайдеры в файле models.json в каталоге конфигурации агента (обычно ~/.pi/agent/). Примерная структура — точные поля сверьте с README своей версии:
{
"providers": {
"ollama": {
"baseUrl": "http://host.docker.internal:11434/v1",
"api": "openai-completions",
"apiKey": "ollama",
"models": [
{ "id": "qwen2.5-coder:14b" }
]
}
}
}
Имя модели здесь условное. Укажите ту, что действительно скачана: ollama list.
Шаг 5. Секреты: один ключ на запуск
Для каждого облачного провайдера заведите отдельный файл с одной переменной. Никаких общих .env с десятком ключей.
# secrets/anthropic.env
ANTHROPIC_API_KEY=...
# secrets/openai.env
OPENAI_API_KEY=...
chmod 600 secrets/*.env
Используйте отдельные ключи с лимитом бюджета в консоли провайдера. Если агент выведет ключ в лог или передаст его куда-нибудь, ущерб будет ограничен, а ключ легко отозвать.
Шаг 6. Скрипт одного прогона
Главное правило воспроизводимости: каждый запуск начинается с чистой копии фикстуры. Агент никогда не работает в оригинале.
#!/usr/bin/env bash
# run-one.sh <label> <provider> <model> [env-file]
set -euo pipefail
LABEL="$1"; PROVIDER="$2"; MODEL="$3"; ENV_FILE="${4:-}"
STAMP="$(date +%Y%m%d-%H%M%S)"
OUT="runs/${STAMP}-${LABEL}"
mkdir -p "$OUT/work" "$OUT/home"
# Чистая копия задачи + git для точного diff
cp -a fixture/. "$OUT/work/"
git -C "$OUT/work" init -q
git -C "$OUT/work" add -A
git -C "$OUT/work" -c user.email=lab@local -c user.name=lab commit -qm baseline
# Конфигурация агента: только models.json, без ваших реальных настроек
mkdir -p "$OUT/home/.pi/agent"
cp config/models.json "$OUT/home/.pi/agent/models.json"
ENV_ARGS=()
[ -n "$ENV_FILE" ] && ENV_ARGS=(--env-file "$ENV_FILE")
set +e
timeout 15m docker run --rm \
--user 10001:10001 \
--cap-drop=ALL \
--security-opt no-new-privileges \
--read-only \
--tmpfs /tmp:rw,size=256m \
--memory 2g --cpus 2 --pids-limit 256 \
--add-host=host.docker.internal:host-gateway \
-e HOME=/home/agent \
-v "$PWD/$OUT/home:/home/agent" \
-v "$PWD/$OUT/work:/work" \
"${ENV_ARGS[@]}" \
pi-lab:pinned \
-p "$(cat prompts/task.txt)" --provider "$PROVIDER" --model "$MODEL" \
> "$OUT/agent.log" 2>&1
echo "exit=$?" > "$OUT/agent.exit"
set -e
# Проверка результата — отдельным контейнером, без агента и без ключей
git -C "$OUT/work" diff > "$OUT/changes.diff"
git -C "$OUT/work" diff --stat -- test/ > "$OUT/tests-touched.txt"
docker run --rm --network none --read-only --user 10001:10001 \
-v "$PWD/$OUT/work:/work:ro" -w /work --entrypoint node \
pi-lab:pinned --test > "$OUT/verify.log" 2>&1 \
&& echo PASS > "$OUT/verdict" || echo FAIL > "$OUT/verdict"
echo "$OUT: $(cat "$OUT/verdict")"
chmod +x run-one.sh
Что делают ключевые флаги:
--cap-drop=ALLиno-new-privileges— у процесса нет Linux-capabilities, и он не сможет повысить права через setuid-бинарники.--read-only+--tmpfs /tmp— писать можно только в/work, в home-каталог прогона и во временный/tmp.--pids-limit,--memory,--cpusи внешнийtimeout— защита от зависаний и неконтролируемого расхода ресурсов.- Ни
/var/run/docker.sock, ни ваш$HOMEв контейнер не монтируются.
Шаг 7. Прогон на нескольких моделях
# Локальная модель: ключ не нужен
./run-one.sh local-qwen ollama qwen2.5-coder:14b
# Облачные модели: подставьте идентификаторы, которые поддерживает ваша версия Pi
./run-one.sh cloud-a anthropic <model-id> secrets/anthropic.env
./run-one.sh cloud-b openai <model-id> secrets/openai.env
Ответ модели недетерминирован, поэтому один прогон ничего не доказывает. Запустите каждую конфигурацию несколько раз (например, 5) и смотрите на долю успешных:
for i in 1 2 3 4 5; do ./run-one.sh local-qwen ollama qwen2.5-coder:14b; done
# Сводка по меткам
for d in runs/*/; do
label="$(basename "$d" | cut -d- -f3-)"
echo "$label $(cat "$d/verdict")"
done | sort | uniq -c
Проверка результата
По каждому прогону у вас есть пять артефактов. Смотрите на все, а не только на PASS/FAIL:
verdict— проходят ли тесты при независимой проверке без агента и без сети.tests-touched.txt— должен быть пустым. Если агент правил тесты, прогон считается проваленным, даже с PASS.changes.diff— размер и качество правки: исправил ли агент функцию или переписал полрепозитория.agent.log— какие команды агент запускал, пытался ли выйти за пределы/work, читал ли переменные окружения.agent.exit— код 124 означает, что сработал таймаут.
Простой санитарный тест изоляции перед серьёзными экспериментами: попросите агента в отдельном прогоне вывести содержимое /home/agent, /root и список смонтированных файловых систем. Убедитесь, что ничего лишнего он не видит.
Типовые ошибки
- Ошибка соединения с локальной моделью. Ollama слушает только
127.0.0.1, или забыт--add-host=host.docker.internal:host-gateway(на Linux он нужен явно). - Permission denied при записи в /work. Каталог прогона принадлежит вашему UID, а контейнер работает под 10001. Решение: собрать образ с UID вашего пользователя (
id -u) или выполнитьchownкаталогов прогона. - Агент падает из-за read-only FS. Какой-то инструмент пишет в каталог вне
/tmpи home. Найдите путь в логе и добавьте точечный--tmpfs, а не отключайте--read-only. - Сравнение «яблок с апельсинами». Разные версии Pi, разные промпты, грязная копия фикстуры. Скрипт закрепляет всё это. Не правьте файлы руками между прогонами.
- Монтирование всего проекта «для удобства». Вместе с ним в контейнер попадают
.env,.gitс историей и чужие секреты. Монтируйте только копию. - Слишком короткий контекст локальной модели. Неудача может объясняться обрезанным контекстом сервера, а не «глупостью» модели. Проверьте настройки контекста в Ollama и укажите их в отчёте.
Ограничения стенда
- Сеть открыта. Для облачных моделей контейнеру нужен интернет, и агент может отправлять запросы куда угодно. Чтобы это закрыть, поместите контейнер во внутреннюю Docker-сеть (
docker network create --internal) и выпускайте трафик через прокси с allowlist доменов API провайдера. Для локальных моделей сеть можно ограничить сильнее. - Контейнер — не виртуальная машина. Ядро общее с хостом. Для недоверенных сценариев повышенного риска используйте rootless Docker, gVisor или отдельную VM.
- Ключ внутри контейнера доступен агенту. Защита здесь организационная: отдельный ключ, лимит бюджета, быстрая ротация.
- Одна задача — узкая выборка. Стенд даёт воспроизводимость, а не репрезентативность. Для выводов о моделях нужен набор задач разных типов.
- Время и стоимость не сравниваются напрямую. Локальная модель упирается в ваше железо, облачная — в тариф и сетевые задержки. Если меряете время, фиксируйте окружение.
Что дальше
Когда базовый стенд заработает, добавьте вторую и третью задачи (рефакторинг, работа с несколькими файлами, задача без тестов с ревью diff) и egress-прокси. Другие практические разборы собраны в разделе гайдов, а термины из статьи — в глоссарии.