# 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 .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 # Playwright MCP at the pinned version, and Chromium installed by that package's own playwright dependency, so browser # build and library version match. Roughly +400 MB. RUN if [ "$INSTALL_PLAYWRIGHT" = "true" ]; then \ npm install -g "@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}" \ && pw="$(npm root -g)/@playwright/mcp/node_modules/playwright/cli.js" \ && test -f "$pw" \ && node "$pw" install --with-deps chromium \ && chmod -R a+rX "$PLAYWRIGHT_BROWSERS_PATH" \ && playwright-mcp --version \ && npm cache clean --force \ && rm -rf /var/lib/apt/lists/*; \ fi # The entrypoint hard-codes uid/gid 1654. Named volumes copy this ownership on first mount; bind-mounted roots are # chowned by the entrypoint (not recursively), since Docker creates missing host directories as root. 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. # An empty bounding set and no_new_privs keep the agent's processes from regaining root through setuid binaries. # The shell only chowns the mount roots and execs setpriv, so the process tree is unchanged. ENTRYPOINT ["/bin/sh", "-c", "chown 1654:1654 /data/workspaces /data/agent /data/claude; exec setpriv --reuid=1654 --regid=1654 --init-groups --inh-caps=-all --bounding-set=-all --no-new-privs -- dotnet Volcanic.AgenticCore.Telegram.dll"]