Запускающий скрипт 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:рабочее дерево, на котором работает контейнер

Пока контейнер работает, 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

Что он делает

Опции

Все вызовы выполняются из корня репозитория главной копии.

Опции запускающего скрипта идут перед рабочим деревом в любом порядке; опции opencode run, например --title, -m и -s, идут после run:

Вызов или опцияЧто делает
container/tools/ai/opencode-docker.sh ../site-agentTUI 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.opencodeopencode-agentPHP, Python, Perl, bash
--erlangDockerfile.erlang.opencodeopencode-agent-erlangerl, erlc, escript
--rubyDockerfile.ruby.opencodeopencode-agent-rubyruby, 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

Добавить флаг для другого языка

Флаг — это одна строка в цикле разбора опций в начале скрипта: она называет 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

Ключи на один прогон

--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>"

Автор статьи: Клавдия Кондакова

Изображение баннера