diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8a7b72a --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7bb712a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +Dockerfile text eol=lf +.dockerignore text eol=lf +docker-compose.yml text eol=lf +.env.example text eol=lf diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b1bccfd --- /dev/null +++ b/Dockerfile @@ -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 .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"] diff --git a/docs/superpowers/specs/2026-09-26-docker-deployment-design.md b/docs/superpowers/specs/2026-09-26-docker-deployment-design.md index 03b712e..f92b6c9 100644 --- a/docs/superpowers/specs/2026-09-26-docker-deployment-design.md +++ b/docs/superpowers/specs/2026-09-26-docker-deployment-design.md @@ -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` | | 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 .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` | | 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 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` | | 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) | @@ -42,8 +42,7 @@ the image build. 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, Telegram, plus the Scheduler and GoogleCalendar - extensions once the host references them), then + 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`. @@ -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 `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 && chown -R app:app /data`. 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`, `USER $APP_UID`. +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 `` works in `/data/workspaces/` | | `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 | @@ -153,11 +154,12 @@ volumes: `*_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 (PID 1 in the container) makes itself - non-dumpable at startup, so the agent can't read `/proc/1/environ` or attach to it, 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. + 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//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, @@ -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. 4. Later expiries use the existing owner re-login flow; nothing Docker-specific. -Fallback without the bot: `docker exec -it claude auth login --claudeai` (runs as `app`, same -`CLAUDE_CONFIG_DIR`). Copying an existing `.credentials.json` into the `claude` volume also works but is not the -documented path. +Fallback without the bot: `docker exec -u app -it 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 diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs index b025bd5..73d3b8d 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs @@ -88,4 +88,24 @@ public class PlaywrightPresetTests Assert.Contains("browser_close", 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=(?\S+)\s*$", RegexOptions.Multiline); + + var match = Assert.Single(matches); + Assert.Equal(PlaywrightPreset.PackageVersion, match.Groups["version"].Value); + } + + /// A file in the repository root, found by walking up from the test output to VolcanicAgenticCore.slnx. + 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); + } }