Запускающий скрипт OpenCode в Docker: opencode-docker.sh, флаги образов и ключи
Введение
Эта статья — часть большого руководства
OpenCode в Docker на Ubuntu 24.04.
Руководство показывает, как запускать OpenCode
с параметром --auto внутри контейнера
Docker.
С --auto агент не спрашивает вас перед каждой
командой, поэтому контейнер ограничивает то, до чего он может добраться:
агент работает только в одном рабочем дереве
git, отдельной копии проекта.
Эта статья полностью раскрывает шаг 5
(запускающий скрипт) руководства.
Контейнер агента вы никогда не запускаете своей командой
docker run.
Каждая команда в руководстве, которая запускает агента, использует один
скрипт — container/tools/ai/opencode-docker.sh.
Скрипт собирает образ и отказывается от папки с игнорируемыми файлами.
Затем он монтирует рабочее дерево, заменяет секретные файлы пустыми,
отбирает все права root и запускает OpenCode, оболочку или TUI. Скрипт
написан на Bash.
Скрипт
Весь скрипт в его нынешнем виде. Часть, которая выбирает образ, — это строки case для каждого флага, ближе к началу:
vi container/tools/ai/opencode-docker.sh
#!/usr/bin/env bash # version 0.6.0 # Run OpenCode with --auto in a container that sees container/src of an # agent worktree read-write, and the instructions, docs and tools read-only. # # Usage (repo root of the main checkout): # git worktree add ../site-agent -b agent/<task> # container/tools/ai/opencode-docker.sh ../site-agent # TUI # container/tools/ai/opencode-docker.sh ../site-agent run "<task>" # one task # container/tools/ai/opencode-docker.sh ../site-agent shell # a shell # container/tools/ai/opencode-docker.sh --rw container/tools/deploy ../site-agent run "<task>" # --rw <path>: writable for this run, a path inside the worktree; repeat # it for several paths. Masked files stay empty even under it. # container/tools/ai/opencode-docker.sh --openrouter-key ~/.config/opencode-agent/openrouter.key ../site-agent run -m openrouter/<model> "<task>" # --openrouter-key <file>: an OpenRouter key for this run only, from a # file outside the repo; the container gets it as OPENROUTER_API_KEY. # container/tools/ai/opencode-docker.sh --opencode-key ~/.config/opencode-agent/opencode.key ../site-agent run -m opencode-go/<model> "<task>" # --opencode-key <file>: an OpenCode key (Zen or Go) for this run only, # from a file outside the repo; the container gets it as OPENCODE_API_KEY. # container/tools/ai/opencode-docker.sh --erlang ../site-agent run "<task>" # --erlang: the image of Dockerfile.erlang.opencode, which adds Erlang/OTP # (erl, erlc, escript) for the examples of the Erlang pages. # container/tools/ai/opencode-docker.sh --ruby ../site-agent run "<task>" # --ruby: the image of Dockerfile.ruby.opencode, which adds Ruby (ruby, # irb, gem, erb) for the examples of the Ruby pages. # At start it prints to stderr the model and the file or option it comes # from, the image and the name of the container, between empty lines. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" rw=(); keys=(); dockerfile=Dockerfile.opencode; image=opencode-agent while :; do case "${1:-}" in --rw) rw+=("${2:?--rw needs a path inside the worktree}"); shift 2 ;; --openrouter-key) keys+=(OPENROUTER_API_KEY "${2:?--openrouter-key needs a file}"); shift 2 ;; --opencode-key) keys+=(OPENCODE_API_KEY "${2:?--opencode-key needs a file}"); shift 2 ;; --erlang) dockerfile=Dockerfile.erlang.opencode; image=opencode-agent-erlang; shift ;; --ruby) dockerfile=Dockerfile.ruby.opencode; image=opencode-agent-ruby; shift ;; *) break ;; esac done WT="$(cd "${1:?usage: opencode-docker.sh [--rw <path>]... [--openrouter-key <file>] [--opencode-key <file>] [--erlang | --ruby] <worktree> [run <task> | shell]}" && pwd)" shift [ -d "$WT/container/src" ] || { echo "ERROR: $WT/container/src not found." >&2; exit 1; } # A key goes in through the environment of this run only, never as a # file: one in the repo or the worktree would be mounted, or committed. env=() for ((i = 0; i < ${#keys[@]}; i += 2)); do var="${keys[i]}"; key_file="${keys[i + 1]}" [ -r "$key_file" ] || { echo "ERROR: cannot read $key_file." >&2; exit 1; } case "$(realpath "$key_file")" in "$(realpath "$REPO_ROOT")"/*|"$(realpath "$WT")"/*) echo "ERROR: keep the key file outside the repo and the worktree." >&2; exit 1 ;; esac key="$(tr -d '[:space:]' < "$key_file")" [ -n "$key" ] || { echo "ERROR: $key_file is empty." >&2; exit 1; } export "$var=$key" env+=(-e "$var") done ro=(AGENTS.md .claude/skills skill.md _internal_ai_skill_for_files.md container/docs container/tools container/tests) for p in "${rw[@]}"; do case "$p" in /*|*..*) echo "ERROR: --rw $p: use a path inside the worktree." >&2; exit 1 ;; esac [ -e "$WT/$p" ] || { echo "ERROR: --rw $p: not found in $WT." >&2; exit 1; } done # Only tracked files go in: the ignored ones are logs, visitor data, backups. # Python caches are ignored too, but they hold nothing but compiled tools. ignored="$(git -C "$WT" status --short --ignored -- container "${ro[@]}" "${rw[@]}" \ | grep '^!!' | grep -v '__pycache__/$' || true)" if [ -n "$ignored" ]; then echo "ERROR: $WT holds ignored files; use a fresh worktree:" >&2 echo "$ignored" | head -5 >&2 exit 1 fi in_list() { local x="$1" y; shift; for y in "$@"; do [ "$x" = "$y" ] && return 0; done; return 1; } # What this run uses: the model, where it comes from, the image and the # name of the container. To stderr, so stdout holds only what OpenCode says. config="$SCRIPT_DIR/docker/opencode.jsonc" case "${1:-}" in run) what="one task" ;; shell) what="a shell; OpenCode is not started, the model is for opencode commands in it" ;; *) what="the TUI" ;; esac model=; model_from= if [ "${1:-}" != shell ]; then prev= for a in "$@"; do case "$prev" in -m|--model) model="$a"; model_from="$prev on the command line" ;; esac case "$a" in --model=*) model="${a#--model=}"; model_from="--model on the command line" ;; esac prev="$a" done fi if [ -z "$model" ]; then model="$(sed -n 's/^[[:space:]]*"model"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$config" | head -n 1)" model_from="$config" [ -n "$model" ] || model="the default of OpenCode: the file has no model line" fi container="$image-$(printf '%s' "${WT##*/}" | tr -c 'A-Za-z0-9_.-' '-')-$$" { echo echo "OpenCode in Docker: $what" echo " model: $model" echo " from: $model_from" echo " image: $image, built from container/containerfiles/$dockerfile" echo " container: $container" echo " worktree: $WT" echo } >&2 docker build -q -t "$image" \ --build-arg HOST_UID="$(id -u)" --build-arg HOST_GID="$(id -g)" \ -f "$REPO_ROOT/container/containerfiles/$dockerfile" \ "$REPO_ROOT/container/containerfiles" >/dev/null # Read-write: the docroot. Read-only: what the agent reads and runs, unless # the task named it with --rw. mounts=(-v "$WT/container/src:/work/container/src") for p in "${ro[@]}"; do [ -e "$WT/$p" ] || continue if in_list "$p" "${rw[@]}"; then mounts+=(-v "$WT/$p:/work/$p") else mounts+=(-v "$WT/$p:/work/$p:ro"); fi done # --rw inside a read-only folder: a writable mount on top of it. for p in "${rw[@]}"; do in_list "$p" container/src "${ro[@]}" || mounts+=(-v "$WT/$p:/work/$p") done # Tracked files that the deny rules of the host protect: an empty read-only # file in their place, even under --rw. Edit them by hand on the host. while IFS= read -r f; do mounts+=(-v "$SCRIPT_DIR/docker/masked:/work/$f:ro") done < <(git -C "$WT" ls-files -- container | grep -E '\.env$|creds' || true) mounts+=(-v "$SCRIPT_DIR/docker/opencode.jsonc:/work/opencode.jsonc:ro" -v "$SCRIPT_DIR/docker/CONTAINER.md:/work/CONTAINER.md:ro" -v opencode-agent-data:/home/agent/.local/share/opencode) entry=() case "${1:-}" in run) shift; set -- run --auto "$@" ;; shell) shift; entry=(--entrypoint sh) ;; *) set -- --auto "$@" ;; esac tty=(); [ -t 0 ] && tty=(-t) exec docker run --rm -i "${tty[@]}" --name "$container" \ --cap-drop ALL --security-opt no-new-privileges \ "${env[@]}" "${mounts[@]}" "${entry[@]}" "$image" "$@"
chmod +x container/tools/ai/opencode-docker.sh
Пустой файл, который встаёт на место замаскированных:
touch container/tools/ai/docker/masked
Что он печатает при старте
Прежде чем собрать образ и запустить контейнер, запускающий скрипт
печатает, что использует этот прогон, с пустой строкой до и после.
Задача с конфигурацией по умолчанию (имя пользователя и названия папок
заменены):
container/tools/ai/opencode-docker.sh ../site-agent run "<task>"
OpenCode in Docker: one task model: opencode/deepseek-v4.1-flash-free from: /home/me/github/site/container/tools/ai/docker/opencode.jsonc image: opencode-agent, built from container/containerfiles/Dockerfile.opencode container: opencode-agent-site-agent-3532388 worktree: /home/me/github/site-agent
Задача в образе с Erlang на модели, названной через -m:
container/tools/ai/opencode-docker.sh --erlang ../site-agent run -m openrouter/deepseek/deepseek-v4.1-flash "<task>"
OpenCode in Docker: one task model: openrouter/deepseek/deepseek-v4.1-flash from: -m on the command line image: opencode-agent-erlang, built from container/containerfiles/Dockerfile.erlang.opencode container: opencode-agent-erlang-site-agent-3530010 worktree: /home/me/github/site-agent
| Строка | О чём она говорит |
|---|---|
| OpenCode in Docker: | что запускается: one task для run, the TUI без него или оболочка, где OpenCode не запускается |
| model: | модель прогона |
| from: | откуда эта модель: файл конфигурации с полным путём или -m либо --model в командной строке |
| image: | образ и Dockerfile, из которого он собран: по умолчанию или из --erlang либо --ruby |
| container: | имя контейнера |
| worktree: | рабочее дерево, на котором работает контейнер |
- Строка модели — это то, что читает запускающий скрипт: значение после -m или --model, если оно есть в вызове, иначе первая строка "model" файла конфигурации. Другая конфигурация не монтируется, поэтому именно эту модель и использует OpenCode. Раздел Откуда берётся модель статьи «Выбор модели» рассказывает, как проверить, что прочитал сам OpenCode.
- Строки идут в stderr, поэтому перенаправление stdout получает только то, что печатает OpenCode. Проверено в оболочке: container/tools/ai/opencode-docker.sh ../site-agent shell -c 'echo "this is stdout"' 2>/dev/null печатает this is stdout и больше ничего.
- Имя контейнера — это образ, папка рабочего дерева и номер процесса запускающего скрипта, поэтому два прогона никогда не получат одно имя. Используйте его в docker exec и docker stop вместо id контейнера.
Пока контейнер работает, docker ps показывает его
под этим именем.
Проверено с оболочкой в образе Ruby на папке рабочего дерева с именем
site-agent-demo:
docker ps --filter name=opencode-agent-ruby-site-agent-demo --format '{{.Names}}'
opencode-agent-ruby-site-agent-demo-3530656
Что он делает
- Проверка игнорируемых файлов отклоняет главную копию и любое рабочее дерево, в котором с тех пор появились игнорируемые файлы. Кеши Python проходят.
- Никаких прав root. --cap-drop ALL и no-new-privileges: у пользователя agent нет возможностей Linux и нет способа их получить.
- Только чтение по умолчанию. Агент читает и запускает инструкции, навыки, документацию и инструменты, но не может изменить свои правила или скрипты, которые проверяют его работу. --rw снимает это для одного пути и одного прогона.
- Замаскированные файлы. Каждый отслеживаемый файл с именем *.env или *creds* — те имена, которые защищают правила запрета на хосте, — читается пустым и отказывает в записи.
- Сессии в томе. opencode-agent-data хранит их между прогонами, поэтому --continue работает. Том общий для всех рабочих деревьев, поэтому --continue берёт самую новую сессию любого агента (новый прогон не знает о прошлом). docker volume rm opencode-agent-data очищает его.
- Он говорит, что запускает. Модель и откуда она, образ и имя контейнера — в stderr (что он печатает при старте).
- Ключ только по запросу. --openrouter-key и --opencode-key передают ключ в один прогон (ключи на один прогон). Без них контейнер не получает ключа.
- Один образ на прогон. Без флага — образ по умолчанию; --erlang или --ruby — образ с этим языком (образ на каждый прогон: --erlang и --ruby).
- Больше ничего. Ни сокета Docker, ни сети хоста, ни опубликованных портов, ничего из вашей домашней папки, кроме этого ключа.
Опции
Все вызовы выполняются из корня репозитория главной копии.
Опции запускающего скрипта идут перед рабочим деревом в любом порядке;
опции opencode run, например
--title, -m и
-s, идут после run:
| Вызов или опция | Что делает |
|---|---|
| container/tools/ai/opencode-docker.sh ../site-agent | TUI OpenCode |
| container/tools/ai/opencode-docker.sh ../site-agent run "<task>" | одна задача с --auto, затем контейнер завершается |
| container/tools/ai/opencode-docker.sh ../site-agent shell | оболочка в том же контейнере, как в шаге 6 (проверить, что видит контейнер) |
| --rw <path> | ещё один путь на запись для этого прогона (когда задача в container/tools: --rw) |
| --openrouter-key <file> | ключ OpenRouter на этот прогон (платная модель через OpenRouter) |
| --opencode-key <file> | ключ OpenCode Zen или Go на этот прогон (ключи на один прогон) |
| --erlang, --ruby | образ с Erlang или Ruby (образ на каждый прогон: --erlang и --ruby) |
Образ на каждый прогон: --erlang и --ruby
Без флага прогон использует образ по умолчанию,
opencode-agent из
Dockerfile.opencode.
Флаг выбирает другой Dockerfile
и другое имя образа; монтирования, маски, ключи и том сессий остаются
прежними:
| Флаг | Dockerfile | Образ | Добавляет |
|---|---|---|---|
| нет | Dockerfile.opencode | opencode-agent | PHP, Python, Perl, bash |
| --erlang | Dockerfile.erlang. | opencode-agent-erlang | erl, erlc, escript |
| --ruby | Dockerfile.ruby. | opencode-agent-ruby | ruby, irb, gem, erb |
Задача по страницам Erlang и оболочка в образе Ruby:
container/tools/ai/opencode-docker.sh --erlang ../site-agent run "<task>"
container/tools/ai/opencode-docker.sh --ruby ../site-agent shell
- Собирается при первом использовании. Первый прогон с флагом собирает свой образ: здесь 56 секунд для Ruby. Следующие берут его из кеша. Dockerfile описаны на странице Образ.
- Один образ на прогон. С двумя флагами побеждает последний. Проверено: --erlang --ruby дал оболочку с ruby и без erl.
- Одни и те же сессии в каждом образе. Все образы монтируют один том сессий, поэтому -s продолжает сессию в другом образе (повседневное использование: какой образ).
- Проверка сборки при каждом старте. Даже из кеша здесь это занимало до 20 секунд до запуска контейнера.
Добавить флаг для другого языка
Флаг — это одна строка в цикле разбора опций в начале скрипта: она
называет Dockerfile и образ.
Проверено с Go в одноразовом рабочем дереве.
Сначала Dockerfile языка
(страница об образе, пример:
Go), затем строка, после строки --ruby:
vi container/tools/ai/opencode-docker.sh
--go) dockerfile=Dockerfile.go.opencode; image=opencode-agent-go; shift ;;
Этой строки скрипту достаточно. В тесте она и была всем изменением:
git diff --stat
container/tools/ai/opencode-docker.sh | 1 + 1 file changed, 1 insertion(+)
Дальше флаг работает как остальные:
container/tools/ai/opencode-docker.sh --go ../site-agent shell
go version
go version go1.26.10 linux/amd64
- Назовите его и в строках использования: в комментарии в начале скрипта и в сообщении после usage:, чтобы [--erlang | --ruby] перечислял новый флаг. Меняя скрипт, поднимайте строку версии.
- Имя образа — в нижнем регистре, как требует Docker.
- Команды, которые ищут контейнеры, фильтруют по образу: добавьте в них --filter ancestor=opencode-agent-go (повседневное использование: какой образ).
Ключи на один прогон
--openrouter-key и
--opencode-key читают ключ из файла вне
репозитория и передают его этому прогону как переменную окружения:
OPENROUTER_API_KEY для
OpenRouter,
OPENCODE_API_KEY для OpenCode Zen и OpenCode
Go.
Ключ OpenCode берётся в
консоли
OpenCode; сохраните его так же, как ключ OpenRouter
(ключ для
контейнера), в
~/.config/opencode-agent/opencode.key. Обе
опции вместе работают. Проверено с фиктивными ключами, в оболочке:
container/tools/ai/opencode-docker.sh --openrouter-key ~/.config/opencode-agent/openrouter.key --opencode-key ~/.config/opencode-agent/opencode.key ../site-agent shell
В приглашении /work $:
opencode auth list
OpenCode Go OPENCODE_API_KEY environment OpenCode Console OPENCODE_API_KEY environment OpenRouter OPENROUTER_API_KEY environment
opencode models | grep '^opencode-go/deepseek'
opencode-go/deepseek-v4-pro opencode-go/deepseek-v4.1-flash
Прогон на DeepSeek V4.1 Flash через OpenCode Go называет модель через -m:
container/tools/ai/opencode-docker.sh --opencode-key ~/.config/opencode-agent/opencode.key ../site-agent run -m opencode-go/deepseek-v4.1-flash "<task>"
- Пустой файл ключа останавливает запускающий скрипт до старта Docker: ERROR: <file> is empty.
- Файл ключа в репозитории или рабочем дереве отклоняется для обеих опций: ERROR: keep the key file outside the repo and the worktree.
- Вход, набранный внутри контейнера, — это другое. opencode auth login в оболочке сохраняет ключ в томе сессий, и каждый следующий прогон его видит — с опцией или без. opencode auth list показывает такой вход как stored, а не environment (сохранённый вход).
- Каждый прогон с ключом может его прочитать, через env. Защищает вас лимит расходов на ключе.
Автор статьи: Клавдия Кондакова