Files
Playa/docs/superpowers/specs/2026-09-26-docker-deployment-design.md
EugeneTes 4ed3758f9c feat(docker): Dockerfile for the Telegram host
Two-stage build, agent tools, Node 24, claude CLI, data dirs owned by app, DOTNET_EnableDiagnostics=0. Adds the MCP spec's deferred test that the Dockerfile's PLAYWRIGHT_MCP_VERSION equals PlaywrightPreset.DefaultPackage.

Spec change: the image keeps root as its user and the entrypoint drops to app with setpriv. With USER app, compose's docker-init (PID 1) runs as app and its /proc/1/environ exposes every secret to the agent's bash. The spec also lists the csprojs the restore layer copies (GoogleCalendar, Mcp, Scheduler included) and sets HOME=/home/app in the image.
2026-09-27 12:04:42 +02:00

240 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Docker deployment (Coolify) — design
Date: 2026-09-26. Status: draft.
Depends on: `2026-09-26-process-environment-hook-design.md` (the secrets below stay out of agent-spawned processes),
`2026-09-26-scheduler-design.md` §7 (the default time zone).
## Goal
Run the Telegram host (`hosts/Volcanic.AgenticCore.Telegram`) as a single container on Coolify, deployed from
this repo's `docker-compose.yml`. The container carries everything the agent's `bash` tool and the Claude Code
provider need, runs as a non-root user, and keeps workspaces, sessions and the Claude login across redeploys.
`docker compose up -d --build` on any Docker host runs the same thing locally.
Reference: VolcanicSecretary (`Dockerfile`, `docker-compose.yml`, `docker-entrypoint.sh`), adapted to a .NET
build and to the Claude Code provider's subscription-only rules.
## Decisions
| Topic | Decision |
|---|---|
| Files | `Dockerfile`, `.dockerignore`, `docker-compose.yml`, `.env.example` at the repo root; user doc `docs/docker.md` |
| Build | Multi-stage: `mcr.microsoft.com/dotnet/sdk:10.0-noble` → `mcr.microsoft.com/dotnet/runtime:10.0-noble` |
| Publish | Framework-dependent `dotnet publish -c Release` of the Telegram host only. No self-contained, no AOT, no single-file: `claude` launches the bridge as `dotnet <Bridge>.dll`, so the `dotnet` muxer and a loose `Bridge.dll` + `.runtimeconfig.json` must exist |
| Entry | `ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]`: `setpriv` (util-linux, already in the base image) drops to `app` and execs `dotnet`, so `Environment.ProcessPath` is still the muxer `BridgeLocator.FindDotnet` wants. No entrypoint script |
| Agent tools | `bash`, `git`, `ripgrep`, `python3` (+ `python-is-python3`, `python3-venv`), `curl`, `ca-certificates`, `tzdata`, `jq`, `less`, `procps`, `openssh-client` |
| Node | Node.js 24 from NodeSource (`ARG NODE_MAJOR=24`); Ubuntu noble's `nodejs` is too old |
| Claude CLI | `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}`, `ARG CLAUDE_CODE_VERSION=2.1` (newest 2.1.x at build time; set an exact version to pin). Updated by rebuilding, never in place |
| Playwright | `ARG INSTALL_PLAYWRIGHT=false` in the Dockerfile; compose passes `true`. Installs `@playwright/mcp` globally at the version pinned by the MCP spec, and Chromium + OS deps under `/opt/ms-playwright`. Compose points the preset at the preinstalled `playwright-mcp` (no npx download) and adds `--no-sandbox` |
| User | The image's default user stays root so that PID 1 (compose's `init: true`, i.e. `docker-init`) is root-owned; the bot and everything it spawns run as the base image's non-root `app` user (1654, `$APP_UID`), `HOME=/home/app`. With `USER app`, `docker-init` runs as `app` and its `/proc/1/environ` (the whole container environment, secrets included) is readable by the agent's `bash` (checked on Docker 29) |
| Data | Three named volumes under `/data`: `workspaces`, `agent`, `claude` |
| Config | Environment only, set in Coolify. Required: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USER_IDS`. Optional: `OPENAI_API_KEY`, `STT_MODEL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `DEFAULT_TIMEZONE`, `PROCESS_ENV_ALLOW`, `TZ`, `PLAYWRIGHT_ENABLED`, `PLAYWRIGHT_HEADLESS`, `PLAYWRIGHT_BROWSER`, `PLAYWRIGHT_EXTRA_ARGS`, `PLAYWRIGHT_COMMAND` |
| Healthcheck | None (see below) |
| Coolify | No `ports`, no `expose`, no domain, no `SERVICE_FQDN_*`; `restart: unless-stopped`; `init: true` |
| Local script | None. `cp .env.example .env && docker compose up -d --build` is the local run; the `dotnet run` path in `docs/telegram-bot.md` stays for development |
Out of scope: webhooks/HTTP endpoints, a .NET SDK inside the runtime image for the agent to use, CI image publishing
to a registry, multi-arch builds (the image is built on the Coolify server, whatever its arch), running tests in
the image build.
## Dockerfile
Build stage (`sdk:10.0-noble`):
1. `WORKDIR /src`. Copy `Directory.Build.props`, `VolcanicAgenticCore.slnx` and only the `*.csproj` files the host
needs (Abstractions, core, ClaudeCode, ClaudeCode.Bridge, GoogleCalendar, Mcp, Scheduler, Telegram), then
`dotnet restore hosts/Volcanic.AgenticCore.Telegram` — a cached layer until a project file changes.
2. Copy `src/`, `extensions/`, `hosts/` and `dotnet publish hosts/Volcanic.AgenticCore.Telegram -c Release
--no-restore -o /out`. `TreatWarningsAsErrors` applies, so a warning fails the image build as it fails `dotnet build`.
3. Guard: `test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll && test -f
/out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json`. The bridge reaches the output through
a `ReferenceOutputAssembly="false"` project reference; if `publish` drops the runtimeconfig (it does not in
`build`), fix the csproj, not the Dockerfile.
Runtime stage (`runtime:10.0-noble`), as root:
1. `apt-get install --no-install-recommends` the agent tools above, then NodeSource `nodejs`; clean apt lists.
2. `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} && claude --version` (fails the build if the
CLI does not run). The global prefix `/usr/lib/node_modules` stays root-owned, so the CLI's auto-updater cannot
replace the binary at runtime: a Claude Code upgrade changes what `system/init` validation sees and invalidates
the prompt cache, so it should only arrive with a rebuild.
3. If `INSTALL_PLAYWRIGHT=true`: `ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright`,
`npm install -g @playwright/mcp@${PLAYWRIGHT_MCP_VERSION}`, then run the
`playwright` CLI **from that package's own dependency** with `install --with-deps chromium`, so browser build and
library version match. `chmod -R a+rX /opt/ms-playwright`. Roughly +400 MB. `PLAYWRIGHT_BROWSERS_PATH` is set
unconditionally so the MCP spec (`2026-09-26-mcp-client-playwright-design.md`) can rely on it: its server
process inherits the environment. `ARG PLAYWRIGHT_MCP_VERSION` defaults to the exact version in the MCP spec's
`PlaywrightPreset.DefaultPackage`, which is the single source of truth. A unit test in that spec fails when the two
differ, so bumping the version means changing both in one commit. Compose does not override this arg.
4. `mkdir -p /data/workspaces /data/agent /data/claude /home/app && chown -R app:app /data /home/app`. Named volumes
copy this ownership on first mount, which is what lets a non-root process write to them.
5. `COPY --from=build /out /app`, `WORKDIR /app`. No `USER` (see the User row); an earlier step checks that `app` is
uid/gid 1654, which the entrypoint hard-codes.
6. Environment baked into the image (compose and Coolify may override):
| Variable | Value | Why |
|---|---|---|
| `Telegram__WorkspaceRoot` | `/data/workspaces` | Chat `<id>` works in `/data/workspaces/<id>` |
| `VOLCANIC_AGENT_DIR` | `/data/agent` | Sessions, `extensions/*.dll`, `settings.json`, `auth.json` |
| `HOME` | `/home/app` | The image user is root, so Docker would otherwise set `/root` |
| `CLAUDE_CONFIG_DIR` | `/data/claude` | Moves both `~/.claude/` and `~/.claude.json` (credentials and onboarding state) into one volume. Already on the provider's env allow-list, so every `claude` child and `claude auth login` see it |
| `DOTNET_CLI_TELEMETRY_OPTOUT`, `DOTNET_NOLOGO` | `1` | Quiet |
| `TZ` | `UTC` | Overridable |
| `DOTNET_EnableDiagnostics` | `0` | Turns off the runtime's diagnostic IPC socket, which would hand the bot's environment to the agent's same-uid processes and bypass the host's non-dumpable hardening (process-environment-hook spec §8). Unset it (with `ProcessEnvironment__HardenHostProcess=false`) only to attach `dotnet-dump`/`dotnet-trace` while debugging |
`VOLUME` is not declared in the Dockerfile; the compose file owns volumes (Coolify treats anonymous volumes badly).
## docker-compose.yml
```yaml
services:
bot:
build:
context: .
args:
INSTALL_PLAYWRIGHT: ${INSTALL_PLAYWRIGHT:-true}
CLAUDE_CODE_VERSION: ${CLAUDE_CODE_VERSION:-2.1}
restart: unless-stopped
# dotnet does not reap orphans; killed `claude` trees and bash tool children would pile up as zombies.
init: true
# Chromium crashes with Docker's 64 MB /dev/shm.
shm_size: 1gb
stop_grace_period: 30s
environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- TELEGRAM_ALLOWED_USER_IDS=${TELEGRAM_ALLOWED_USER_IDS}
- Telegram__Model=${TELEGRAM_MODEL:-claude-code/claude-opus-5-5}
- TZ=${TZ:-UTC}
- Logging__LogLevel__Default=${LOG_LEVEL:-Information}
# Optional features; empty means off / default.
- OPENAI_API_KEY=${OPENAI_API_KEY:-} # speech to text (attachments spec)
- STT_MODEL=${STT_MODEL:-}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} # Google Calendar (calendar spec)
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
- DEFAULT_TIMEZONE=${DEFAULT_TIMEZONE:-} # chats' default zone (scheduler spec §7); empty → TZ
- PROCESS_ENV_ALLOW=${PROCESS_ENV_ALLOW:-} # deny-listed variables the agent may see, e.g. GH_TOKEN
# Playwright MCP preset (MCP spec). Enabled when the image has it; the preinstalled binary, no npx download.
- PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-${INSTALL_PLAYWRIGHT:-true}}
- PLAYWRIGHT_HEADLESS=${PLAYWRIGHT_HEADLESS:-true} # no display in the container
- PLAYWRIGHT_BROWSER=${PLAYWRIGHT_BROWSER:-}
- PLAYWRIGHT_EXTRA_ARGS=${PLAYWRIGHT_EXTRA_ARGS:---no-sandbox}
- PLAYWRIGHT_COMMAND=${PLAYWRIGHT_COMMAND:-playwright-mcp}
volumes:
- workspaces:/data/workspaces
- agent:/data/agent
- claude:/data/claude
volumes:
workspaces:
agent:
claude:
```
- No `ports`/`expose`: long polling only makes outbound connections. No domain is configured in Coolify, so no
Traefik labels are generated.
- **No `ANTHROPIC_*`, `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CODE_USE_*` entries**, ever: the provider refuses to run
with them. `docs/docker.md` says so next to the Coolify env setup, because shared/team variables in Coolify
are the likely way one sneaks in.
- Env mapping: the two required settings use the existing shortcuts (`TELEGRAM_BOT_TOKEN`,
`TELEGRAM_ALLOWED_USER_IDS`, comma separated) because a list in `Telegram__AllowedUserIds__0..n` form is awkward
in Coolify. Everything else in `TelegramOptions` is reachable as `Telegram__<Name>` (e.g.
`Telegram__ReconnectMaxDelay=00:02:00`) without a compose change, by adding it in Coolify and to `environment`.
The Claude provider's `VOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS` / `..._TOTAL_TIMEOUT_MS` work the same way, as do
the `Scheduler__*`, `GoogleCalendar__*`, `ProcessEnvironment__*` and `Telegram__Speech__*` /
`Telegram__InputDebounce` settings of the sibling specs.
- Optional features use the env shortcuts their specs define: `OPENAI_API_KEY` (+ `STT_MODEL`) turns on speech to
text; `GOOGLE_CLIENT_ID` + `GOOGLE_CLIENT_SECRET` turn on the calendar, and each chat then connects its own Google
account. Empty values mean off, so they're in `environment` and `.env.example` from the start. `OPENAI_API_KEY` is
not on the Claude Code provider's denied list and never reaches `claude`.
- Time zones: each chat has its own (`/timezone`). A chat that never set one uses `DEFAULT_TIMEZONE`, else the
container's `TZ` (default `UTC`), so a deployment whose users are mostly in one zone should set `DEFAULT_TIMEZONE`.
The chat's zone is also passed as `TZ` to the agent's `bash` and watcher scripts (the image has `tzdata`). Calendar
times use the calendar's own zone, else the chat's.
- Secrets set here (`TELEGRAM_BOT_TOKEN`, `OPENAI_API_KEY`, `GOOGLE_CLIENT_SECRET` and anything matching
`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD` or their `Section__Key` forms) are removed from the environment of
the agent's `bash`, scheduled scripts and MCP servers by the process environment hook. To give the agent a
credential on purpose (say `GH_TOKEN` for `gh`), add it in Coolify and list its name in `PROCESS_ENV_ALLOW`; an MCP
server gets one through its own `env` entry instead. On top of that, the bot (the only child of the root-owned
PID 1) makes itself non-dumpable at startup, so the agent can't read its `/proc/<pid>/environ` or attach to it,
PID 1's own `/proc/1/environ` is root-only, and the configured secrets' values are replaced by `[REDACTED]` in
tool output (process-environment-hook spec §8–§9). The side effect is no core dumps and no
`dotnet-dump`/`dotnet-trace` attach; `ProcessEnvironment__HardenHostProcess=false` turns it off for a debugging
session. The container stays the real isolation boundary: none of this is a sandbox.
- Playwright: `PLAYWRIGHT_COMMAND=playwright-mcp` runs the globally installed, version-pinned server, so the first
browser call in a chat doesn't wait on an npx download. `PLAYWRIGHT_EXTRA_ARGS` defaults to `--no-sandbox`:
Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies to the non-root `app` user,
so the container is the isolation boundary. Overriding `PLAYWRIGHT_EXTRA_ARGS` must keep `--no-sandbox`.
`PLAYWRIGHT_ENABLED` follows `INSTALL_PLAYWRIGHT` (nested default, supported by Compose v2), so an image built
without Playwright doesn't advertise tools it can't run.
- The owner (first id in `TELEGRAM_ALLOWED_USER_IDS`) gets the `🟢 Online.` message and the Claude Code re-login
prompts. The calendar has no owner special case: every chat connects its own account.
- Coolify prefixes named volumes with the resource uuid; they survive redeploys and are only removed when the
resource is deleted with its volumes. Bind mounts (as in VolcanicSecretary) also work but the host directories
must then be owned by uid 1654; the doc mentions this in one line.
- Deploys: Coolify stops the old container before starting the new one for compose resources. If they ever overlap,
the second poller gets `409 Conflict`, which the polling service already backs off from.
## Healthcheck
None. The bot serves no HTTP, and the failure modes a probe could detect are already handled in-process: lost
connectivity self-heals with backoff (`ReconnectPolicy`), a dead polling loop is restarted, and the unrecoverable
cases exit the process (`401` → exit 1, invalid config → exit 2), which `restart: unless-stopped` turns into a
visible restart loop in Coolify's logs. A file-heartbeat probe would add a moving part for no new signal. Coolify
shows the service as running without health status; that is acceptable.
## First run and Claude login
1. In Coolify: new resource from this repo, build pack *Docker Compose*, set `TELEGRAM_BOT_TOKEN` and
`TELEGRAM_ALLOWED_USER_IDS` (owner first), deploy.
2. The start log says Claude Code was found (image-provided) and lists allowed users.
3. The owner opens the bot, sends `/start`, then `/claude-code-login`: link + code prompt arrive as one message, the
pasted code is fed to `claude auth login --claudeai` inside the container, and credentials land in
`/data/claude`. `/claude-code-doctor` should then report logged in, `claude.ai`, bridge ok.
4. Later expiries use the existing owner re-login flow; nothing Docker-specific.
Fallback without the bot: `docker exec -u app -it <container> claude auth login --claudeai` (same `CLAUDE_CONFIG_DIR`;
`docker exec` defaults to root, whose credentials the bot couldn't read). Copying an existing `.credentials.json` into
the `claude` volume also works but is not the documented path.
## Extensions and project context
- Extra extension assemblies go to `/data/agent/extensions/*.dll` (user directory) or
`/data/workspaces/<chatId>/.volcanic/extensions/` (project directory); discovery is unchanged. Getting a DLL there
is `docker cp` + restart; they must be built against the same `Volcanic.AgenticCore.Abstractions` version as the image.
- `AGENTS.md` / `CLAUDE.md`, `PERSONA.md`, `MEMORY.md` live in each chat's workspace directory in the `workspaces`
volume, edited by the agent or by `docker exec`.
## Docs
- New `docs/docker.md`: build args, env table (with which variables the agent can't see, and `PROCESS_ENV_ALLOW`), volumes, Coolify steps, first login, no-API-key warning, updating
Claude Code (redeploy; set `CLAUDE_CODE_VERSION` to pin), troubleshooting (`[executable_missing]`, `[auth]`,
`[mcp_startup]` → bridge files, permission denied on bind mounts → chown 1654, `409` → another instance).
- `README.md`: one line under the Telegram quick start linking `docs/docker.md`.
- `docs/telegram-bot.md`: "Security" points at the container as the recommended way to run it.
- Update the "Out of scope: … Docker/Coolify" line in `2026-09-25-telegram-host-design.md` to reference this spec.
## Verification
No unit tests (nothing in C# changes). Manual checklist in the implementation plan:
1. `docker compose build` succeeds with `INSTALL_PLAYWRIGHT=false` and `true`; the bridge guard and `claude --version` pass.
2. `docker compose up` with a real bot: allowed user gets answers; `/claude-code-login` then `/claude-code-doctor`
all green; `id` via the agent's bash reports uid 1654; `env` via the agent's bash shows neither
`TELEGRAM_BOT_TOKEN` nor `OPENAI_API_KEY`, and `date` shows the chat's zone after `/timezone Europe/Berlin`;
`cat /proc/1/environ` via the agent's bash fails with permission denied, and no `dotnet-diagnostic-*` socket
exists under `/tmp`.
3. Agent can run `git`, `rg`, `python3`, `node`, `npx`; with Playwright, `playwright-mcp --version` prints the pinned version offline, and "Open https://example.com and tell me the H1" works in a chat on the first try (no npx download, no sandbox error).
4. `docker compose down && docker compose up -d --build`: previous chat resumes, no re-login needed, workspace files remain.
5. Setting `ANTHROPIC_API_KEY` in compose makes the provider refuse with its documented error (then remove it).
## Open questions
1. Persist `HOME` too? `~/.gitconfig`, `~/.ssh`, `gh` auth and the npm/npx cache are lost on redeploy with this
design. A fourth volume at `/home/app` would keep them; left out until there is a need.
2. Playwright default: compose builds it in (`true`) on the assumption that the MCP spec ships; flip the compose
default to `false` if that spec lands later. (Sandboxing and which binary is launched are settled above.)
3. `CLAUDE_CODE_VERSION=2.1` tracks the newest 2.1.x at build time. Pin an exact, doctor-verified version instead,
at the cost of manual bumps?
4. Should the agent get a .NET SDK in the image (`INSTALL_DOTNET_SDK` build arg, +~700 MB) so it can build .NET
projects in its workspace?