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.
This commit is contained in:
EugeneTes
2026-09-27 12:04:42 +02:00
parent 80358784ac
commit 4ed3758f9c
5 changed files with 134 additions and 15 deletions

17
.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
**/bin
**/obj
**/TestResults
**/*.user
**/Properties/launchSettings.json
.git
.vs
.idea
.claude
.volcanic
.env
.env.*
tests
samples
docs
*.md
docker-compose.yml

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
Dockerfile text eol=lf
.dockerignore text eol=lf
docker-compose.yml text eol=lf
.env.example text eol=lf

76
Dockerfile Normal file
View File

@@ -0,0 +1,76 @@
# The Telegram host as one container. User guide: docs/docker.md.
# Spec: docs/superpowers/specs/2026-09-26-docker-deployment-design.md
FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build
WORKDIR /src
# Restore layer: only the project files the host needs, cached until one of them changes.
COPY Directory.Build.props VolcanicAgenticCore.slnx ./
COPY src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj src/Volcanic.AgenticCore.Abstractions/
COPY src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj src/Volcanic.AgenticCore/
COPY extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/
COPY extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/
COPY extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/
COPY extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj extensions/Volcanic.AgenticCore.Extensions.Mcp/
COPY extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj extensions/Volcanic.AgenticCore.Extensions.Scheduler/
COPY hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj hosts/Volcanic.AgenticCore.Telegram/
RUN dotnet restore hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj
COPY src/ src/
COPY extensions/ extensions/
COPY hosts/ hosts/
# Framework-dependent: claude starts the bridge as `dotnet <Bridge>.dll`, so the loose dll and its runtimeconfig must exist.
RUN dotnet publish hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj -c Release --no-restore -o /out \
&& test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll \
&& test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json
FROM mcr.microsoft.com/dotnet/runtime:10.0-noble
ARG DEBIAN_FRONTEND=noninteractive
ARG NODE_MAJOR=24
ARG CLAUDE_CODE_VERSION=2.1
ARG INSTALL_PLAYWRIGHT=false
# Must equal the version in PlaywrightPreset.DefaultPackage (the single source of truth);
# PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage fails when they differ.
ARG PLAYWRIGHT_MCP_VERSION=0.0.82
# What the agent's bash tool can run, then Node from NodeSource (Ubuntu noble's nodejs is too old).
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash git ripgrep python3 python-is-python3 python3-venv curl ca-certificates tzdata jq less procps openssh-client gnupg \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# The global prefix stays root-owned, so claude's auto-updater can't replace it at runtime: an upgrade changes what
# system/init validation sees and invalidates the prompt cache, so it only arrives with a rebuild.
RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
&& claude --version \
&& npm cache clean --force
# Set even without Playwright, so an MCP server started with the host environment finds browsers here.
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
# INSTALL_PLAYWRIGHT block (T2)
# The entrypoint hard-codes uid/gid 1654. Named volumes copy this ownership on first mount.
RUN test "$(id -u app)" = 1654 && test "$(id -g app)" = 1654 \
&& mkdir -p /data/workspaces /data/agent /data/claude /home/app \
&& chown -R app:app /data /home/app
COPY --from=build /out /app
WORKDIR /app
ENV Telegram__WorkspaceRoot=/data/workspaces \
VOLCANIC_AGENT_DIR=/data/agent \
CLAUDE_CONFIG_DIR=/data/claude \
HOME=/home/app \
DOTNET_CLI_TELEMETRY_OPTOUT=1 \
DOTNET_NOLOGO=1 \
TZ=UTC \
DOTNET_EnableDiagnostics=0
# No USER: PID 1 (docker-init with compose's init: true) must stay root, or the agent could read every secret from
# /proc/1/environ. setpriv drops to app and execs dotnet, so Environment.ProcessPath is the muxer the bridge needs.
ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]

View File

@@ -21,12 +21,12 @@ build and to the Claude Code provider's subscription-only rules.
| Files | `Dockerfile`, `.dockerignore`, `docker-compose.yml`, `.env.example` at the repo root; user doc `docs/docker.md` | | 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` | | 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 | | 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 ["dotnet", "Volcanic.AgenticCore.Telegram.dll"]`, so `Environment.ProcessPath` is the muxer `BridgeLocator.FindDotnet` wants. No entrypoint script | | 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` | | 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 | | 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 | | 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` | | 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 runtime image's built-in non-root `app` user (`$APP_UID`, 1654), `HOME=/home/app` | | 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` | | 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` | | 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) | | Healthcheck | None (see below) |
@@ -42,8 +42,7 @@ the image build.
Build stage (`sdk:10.0-noble`): Build stage (`sdk:10.0-noble`):
1. `WORKDIR /src`. Copy `Directory.Build.props`, `VolcanicAgenticCore.slnx` and only the `*.csproj` files the host 1. `WORKDIR /src`. Copy `Directory.Build.props`, `VolcanicAgenticCore.slnx` and only the `*.csproj` files the host
needs (Abstractions, core, ClaudeCode, ClaudeCode.Bridge, Telegram, plus the Scheduler and GoogleCalendar needs (Abstractions, core, ClaudeCode, ClaudeCode.Bridge, GoogleCalendar, Mcp, Scheduler, Telegram), then
extensions once the host references them), then
`dotnet restore hosts/Volcanic.AgenticCore.Telegram` — a cached layer until a project file changes. `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 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`. --no-restore -o /out`. `TreatWarningsAsErrors` applies, so a warning fails the image build as it fails `dotnet build`.
@@ -67,15 +66,17 @@ Runtime stage (`runtime:10.0-noble`), as root:
process inherits the environment. `ARG PLAYWRIGHT_MCP_VERSION` defaults to the exact version in the MCP spec's 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 `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. 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 && chown -R app:app /data`. Named volumes copy this 4. `mkdir -p /data/workspaces /data/agent /data/claude /home/app && chown -R app:app /data /home/app`. Named volumes
ownership on first mount, which is what lets a non-root process write to them. 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`, `USER $APP_UID`. 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): 6. Environment baked into the image (compose and Coolify may override):
| Variable | Value | Why | | Variable | Value | Why |
|---|---|---| |---|---|---|
| `Telegram__WorkspaceRoot` | `/data/workspaces` | Chat `<id>` works in `/data/workspaces/<id>` | | `Telegram__WorkspaceRoot` | `/data/workspaces` | Chat `<id>` works in `/data/workspaces/<id>` |
| `VOLCANIC_AGENT_DIR` | `/data/agent` | Sessions, `extensions/*.dll`, `settings.json`, `auth.json` | | `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 | | `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 | | `DOTNET_CLI_TELEMETRY_OPTOUT`, `DOTNET_NOLOGO` | `1` | Quiet |
| `TZ` | `UTC` | Overridable | | `TZ` | `UTC` | Overridable |
@@ -153,11 +154,12 @@ volumes:
`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD` or their `Section__Key` forms) are removed from the environment of `*_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 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 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 (PID 1 in the container) makes itself server gets one through its own `env` entry instead. On top of that, the bot (the only child of the root-owned
non-dumpable at startup, so the agent can't read `/proc/1/environ` or attach to it, and the configured secrets' PID 1) makes itself non-dumpable at startup, so the agent can't read its `/proc/<pid>/environ` or attach to it,
values are replaced by `[REDACTED]` in tool output (process-environment-hook spec §8–§9). The side effect is no PID 1's own `/proc/1/environ` is root-only, and the configured secrets' values are replaced by `[REDACTED]` in
core dumps and no `dotnet-dump`/`dotnet-trace` attach; `ProcessEnvironment__HardenHostProcess=false` turns it off tool output (process-environment-hook spec §8–§9). The side effect is no core dumps and no
for a debugging session. The container stays the real isolation boundary: none of this is a sandbox. `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 - 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`: 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, Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies to the non-root `app` user,
@@ -190,9 +192,9 @@ shows the service as running without health status; that is acceptable.
`/data/claude`. `/claude-code-doctor` should then report logged in, `claude.ai`, bridge ok. `/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. 4. Later expiries use the existing owner re-login flow; nothing Docker-specific.
Fallback without the bot: `docker exec -it <container> claude auth login --claudeai` (runs as `app`, same Fallback without the bot: `docker exec -u app -it <container> claude auth login --claudeai` (same `CLAUDE_CONFIG_DIR`;
`CLAUDE_CONFIG_DIR`). Copying an existing `.credentials.json` into the `claude` volume also works but is not the `docker exec` defaults to root, whose credentials the bot couldn't read). Copying an existing `.credentials.json` into
documented path. the `claude` volume also works but is not the documented path.
## Extensions and project context ## Extensions and project context

View File

@@ -88,4 +88,24 @@ public class PlaywrightPresetTests
Assert.Contains("browser_close", without, StringComparison.Ordinal); Assert.Contains("browser_close", without, StringComparison.Ordinal);
Assert.Contains("passwords", without, StringComparison.Ordinal); Assert.Contains("passwords", without, StringComparison.Ordinal);
} }
[Fact]
public void Dockerfile_version_matches_DefaultPackage()
{
var dockerfile = File.ReadAllText(RepoFile("Dockerfile"));
var matches = Regex.Matches(dockerfile, @"^ARG PLAYWRIGHT_MCP_VERSION=(?<version>\S+)\s*$", RegexOptions.Multiline);
var match = Assert.Single(matches);
Assert.Equal(PlaywrightPreset.PackageVersion, match.Groups["version"].Value);
}
/// <summary>A file in the repository root, found by walking up from the test output to <c>VolcanicAgenticCore.slnx</c>.</summary>
private static string RepoFile(string name)
{
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
{
if (File.Exists(Path.Combine(dir.FullName, "VolcanicAgenticCore.slnx"))) return Path.Combine(dir.FullName, name);
}
throw new InvalidOperationException("VolcanicAgenticCore.slnx not found above " + AppContext.BaseDirectory);
}
} }