From 80358784ac3cf7ff1f7bfc4f7e3e35f4507a6764 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sun, 27 Sep 2026 11:58:42 +0200 Subject: [PATCH 1/8] docs(plan): docker-deployment implementation plan --- .../plans/2026-09-26-docker-deployment.md | 596 ++++++++++++++++++ 1 file changed, 596 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-26-docker-deployment.md diff --git a/docs/superpowers/plans/2026-09-26-docker-deployment.md b/docs/superpowers/plans/2026-09-26-docker-deployment.md new file mode 100644 index 0000000..a43bb6d --- /dev/null +++ b/docs/superpowers/plans/2026-09-26-docker-deployment.md @@ -0,0 +1,596 @@ +# Docker Deployment (Coolify) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Run the Telegram host as one non-root container, built from this repo's `Dockerfile` and started by `docker-compose.yml` on Coolify or any Docker host. The container ships the agent's tools, Node 24, the `claude` CLI and optionally Playwright MCP + Chromium, and keeps workspaces, sessions and the Claude login in three named volumes. + +**Architecture:** A two-stage `Dockerfile` (`sdk:10.0-noble` publishes the host framework-dependent; `runtime:10.0-noble` adds apt tools, NodeSource Node, `@anthropic-ai/claude-code`, optional `@playwright/mcp` + Chromium). The image's default user stays root so that PID 1 (compose's `init: true`, i.e. `docker-init`) is root-owned; the entrypoint `setpriv` drops to `app` (uid 1654) and execs `dotnet Volcanic.AgenticCore.Telegram.dll`. `docker-compose.yml` passes configuration through environment variables only. No C# behaviour changes; the one new unit test pins the Dockerfile's `PLAYWRIGHT_MCP_VERSION` to `PlaywrightPreset.DefaultPackage`. + +**Tech Stack:** Docker (BuildKit, Compose v2), `mcr.microsoft.com/dotnet/sdk:10.0-noble` and `runtime:10.0-noble`, NodeSource Node.js 24, `@anthropic-ai/claude-code@2.1`, `@playwright/mcp@0.0.82`, `util-linux` `setpriv`, xunit 2. Spec: `docs/superpowers/specs/2026-09-26-docker-deployment-design.md`. + +## Global Constraints + +- Work only in the worktree `D:\Projects\Names\wt-docker-deployment` (branch `feat/docker-deployment`). Every Bash command starts with `cd /d/Projects/Names/wt-docker-deployment && ...`; Read/Edit/Write use absolute paths under `D:\Projects\Names\wt-docker-deployment`. Never switch branches, never `git worktree`/`checkout`/`reset --hard`/`push`. Never touch `D:\Projects\Names\VolcanicAgenticCore` or another `wt-*` directory. +- Wave E runs alone, but keep edits to shared host files minimal. This plan changes **no** file under `src/`, `extensions/` or `hosts/`; if a task finds it has to, it edits the spec in the same commit and says so in the commit body. +- `Directory.Build.props` sets `TreatWarningsAsErrors=true` for non-test projects; the image build runs `dotnet publish -c Release`, so a Release-only warning fails the image build too. +- Tests: xunit 2. No test calls a paid API or any network endpoint, and no test runs Docker. Known baseline on this Windows machine: 3 tests in `Volcanic.AgenticCore.Tests` fail on `main` before any change (`SystemPromptBuilderTests.Loads_AGENTS_md_in_preference_to_CLAUDE_md_then_persona_and_memory`, `ExtensionLoaderTests.Discover_loads_a_real_assembly_from_the_user_directory`, `BuiltInToolTests.Ls_find_and_grep`). "Green" = no other failures. +- Docker on this machine: Docker Desktop 29, Linux containers. Run docker commands from Git Bash with `MSYS_NO_PATHCONV=1` in front (otherwise Git Bash rewrites container paths such as `/data/...` into Windows paths), or from PowerShell. Foreground `sleep` is blocked in the Bash tool; wait with PowerShell `Start-Sleep -Seconds 20` or the Monitor tool. +- Docker hygiene: never use real secrets (only `TELEGRAM_BOT_TOKEN=123:fake`, `TELEGRAM_ALLOWED_USER_IDS=1`); never push images; never `docker system prune`, `docker volume prune` or remove volumes/images you didn't create. Image tags this plan creates: `volcanic-agentic-core:test` (no Playwright), `volcanic-agentic-core:test-pw` (Playwright). Compose project name for test runs: `volcanic-test` (its volumes are `volcanic-test_workspaces`, `volcanic-test_agent`, `volcanic-test_claude`; its image `volcanic-test-bot`). Container names: `volcanic-probe`, `volcanic-probe-pw`. T6 removes all of them. +- Never commit `.env` (a dummy one is created for `docker compose config`/`up`), `bin/`, `obj/`, `launchSettings.json`, `hosts/Volcanic.AgenticCore.Telegram/Properties/`. +- Exact values from the spec and the code (copy verbatim): + - base images `mcr.microsoft.com/dotnet/sdk:10.0-noble`, `mcr.microsoft.com/dotnet/runtime:10.0-noble`; + - build args: `NODE_MAJOR=24`, `CLAUDE_CODE_VERSION=2.1`, `INSTALL_PLAYWRIGHT=false` (Dockerfile) / `true` (compose default), `PLAYWRIGHT_MCP_VERSION=0.0.82` (must equal `PlaywrightPreset.PackageVersion`); + - apt tools: `bash git ripgrep python3 python-is-python3 python3-venv curl ca-certificates tzdata jq less procps openssh-client` (+ `gnupg` for the NodeSource key); + - user `app`, uid/gid **1654**, `HOME=/home/app`; volumes `/data/workspaces`, `/data/agent`, `/data/claude`; `PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright`; + - image 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`; + - environment variables the host reads (verified in code): `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USER_IDS` (`TelegramOptions.ApplyEnvironment`), `OPENAI_API_KEY`, `STT_MODEL`, `DEFAULT_TIMEZONE` (same), `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` (`GoogleCalendarSetup.Options`), `PROCESS_ENV_ALLOW` (`TelegramProcessEnvironment.AllowVariable`), `PLAYWRIGHT_ENABLED`, `PLAYWRIGHT_HEADLESS`, `PLAYWRIGHT_BROWSER`, `PLAYWRIGHT_EXTRA_ARGS`, `PLAYWRIGHT_COMMAND` (`McpOptions.ApplyEnvironment`), `VOLCANIC_AGENT_DIR` (`VolcanicPaths.DefaultAgentDir`, `GoogleCalendarOptions`), plus every `Section__Key` form through `Host.CreateApplicationBuilder`'s unprefixed environment provider (so `Telegram__WorkspaceRoot` and `Telegram__Model` bind to `TelegramOptions` exactly as the spec's compose assumes; `TELEGRAM_MODEL`, `LOG_LEVEL`, `INSTALL_PLAYWRIGHT`, `CLAUDE_CODE_VERSION` are compose-level variables only); + - the `@playwright/mcp@0.0.82` package's binary is `playwright-mcp` (`npm view @playwright/mcp@0.0.82 bin` → `{ 'playwright-mcp': 'cli.js' }`); it depends on `playwright` `1.64.0-alpha-1789764292000`; + - startup log lines to expect (from `Program.cs`): `Process environment: 15 deny rules, allow: none`, `MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true)` / `MCP: playwright enabled (headless, chromium)`, `Speech to text: off (no OPENAI_API_KEY)`, `Using Claude Code at /usr/bin/claude, model claude-code/claude-opus-5-5`, `Scheduler: /data/agent/scheduler/jobs.json`, `Default time zone: ...`, `Allowed users: 1. Workspaces under /data/workspaces`, and **no** `The .NET diagnostic IPC socket is on ...` warning. +- **Deviation from the spec found while planning (T1 edits the spec):** the spec runs the image as `USER $APP_UID` with compose's `init: true`. Checked on Docker 29: `docker run --rm --init -u 1654 -e SECRET_PROBE=x mcr.microsoft.com/dotnet/runtime:10.0-noble sh -c 'tr "\0" "\n" \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); + } +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `cd /d/Projects/Names/wt-docker-deployment && dotnet test tests/Volcanic.AgenticCore.Extensions.Tests --filter "FullyQualifiedName~PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage"` +Expected: FAIL with `FileNotFoundException` for `...\Dockerfile`. + +- [ ] **Step 3: Create `.gitattributes`, `.dockerignore` and `Dockerfile`** + +`.gitattributes` (the machine has `core.autocrlf=true`; keep these LF everywhere): + +``` +Dockerfile text eol=lf +.dockerignore text eol=lf +docker-compose.yml text eol=lf +.env.example text eol=lf +``` + +`.dockerignore` (Windows `obj/project.assets.json` in the context would break the Linux restore; `.env` must never reach the build): + +``` +**/bin +**/obj +**/TestResults +**/*.user +**/Properties/launchSettings.json +.git +.vs +.idea +.claude +.volcanic +.env +.env.* +tests +samples +docs +*.md +docker-compose.yml +``` + +`Dockerfile`: + +```dockerfile +# 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"] +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd /d/Projects/Names/wt-docker-deployment && dotnet test tests/Volcanic.AgenticCore.Extensions.Tests --filter "FullyQualifiedName~PlaywrightPresetTests"` +Expected: all `PlaywrightPresetTests` PASS. Sanity-check the test bites: temporarily change the ARG to `0.0.81`, re-run, expect FAIL (`Expected: 0.0.82 Actual: 0.0.81`), change it back. + +- [ ] **Step 5: Build the image without Playwright** + +Run (allow ~10 min; network needed for base images, apt, NodeSource and npm): +`cd /d/Projects/Names/wt-docker-deployment && MSYS_NO_PATHCONV=1 docker build --build-arg INSTALL_PLAYWRIGHT=false -t volcanic-agentic-core:test .` +Expected: success; the log shows the bridge `test -f` step and `claude --version` (2.1.x) passing. If `publish` drops the bridge runtimeconfig, fix the ClaudeCode csproj, not the Dockerfile (a local `dotnet publish` on Windows does produce `Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json`). If `dotnet restore` misses a project, check every `ProjectReference` again (host → core, ClaudeCode, GoogleCalendar, Mcp, Scheduler; all → Abstractions; ClaudeCode → Bridge). + +- [ ] **Step 6: Image sanity checks (no secrets)** + +Run the orchestrator's check as given (the entrypoint shell runs as root now; that is expected): +`MSYS_NO_PATHCONV=1 docker run --rm --entrypoint sh volcanic-agentic-core:test -c 'id; claude --version; test -f /app/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json && echo bridge-ok; echo $DOTNET_EnableDiagnostics'` +Expected: `uid=0(root)`, a `2.1.x` version, `bridge-ok`, `0`. + +Run the same as the bot's user, plus the agent tools: +`MSYS_NO_PATHCONV=1 docker run --rm --entrypoint setpriv volcanic-agentic-core:test --reuid=1654 --regid=1654 --init-groups --inh-caps=-all -- sh -c 'id; echo HOME=$HOME; claude --version; git --version; rg --version | head -1; python --version; python3 -m venv /tmp/v && echo venv-ok; node --version; npx --version; jq --version; ssh -V; ps --version; less --version | head -1; date; ls -ld /home/app /data /data/*; touch /data/workspaces/x /data/agent/x /data/claude/x && echo data-writable'` +Expected: `uid=1654(app) gid=1654(app)`, `HOME=/home/app`, node `v24.*`, `venv-ok`, `/home/app` and `/data/*` owned by `app`, `data-writable`. + +PID 1 check (the deviation above): +`MSYS_NO_PATHCONV=1 docker run --rm --init -e SECRET_PROBE=x --entrypoint setpriv volcanic-agentic-core:test --reuid=1654 --regid=1654 --init-groups --inh-caps=-all -- sh -c 'ps -o pid,user,comm -p 1; cat /proc/1/environ >/dev/null && echo READABLE || echo denied'` +Expected: PID 1 `root docker-init`, then `Permission denied` / `denied`. + +Record each command and its result for the report. + +- [ ] **Step 7: Edit the spec for the deviation** + +In `docs/superpowers/specs/2026-09-26-docker-deployment-design.md`: +- Decisions table, `Entry` row → ``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 | +- `User` row → 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) | +- Dockerfile §Build stage step 1: replace "(Abstractions, core, ClaudeCode, ClaudeCode.Bridge, Telegram, plus the Scheduler and GoogleCalendar extensions once the host references them)" with "(Abstractions, core, ClaudeCode, ClaudeCode.Bridge, GoogleCalendar, Mcp, Scheduler, Telegram)". +- Runtime step 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." +- Env table: add a row `HOME` | `/home/app` | The image user is root, so Docker would otherwise set `/root`. +- docker-compose §secrets bullet: "the bot (PID 1 in the container) makes itself non-dumpable" → "the bot (the only child of the root-owned PID 1) makes itself non-dumpable, and PID 1's own `/proc/1/environ` is root-only". +- First run §Fallback: `docker exec -it claude auth login --claudeai` → `docker exec -u app -it claude auth login --claudeai` (`docker exec` defaults to root, whose credentials the bot couldn't read). + +- [ ] **Step 8: Build, full tests, commit** + +Run: `cd /d/Projects/Names/wt-docker-deployment && dotnet build && dotnet test` → green (only the 3 baseline failures). + +```bash +cd /d/Projects/Names/wt-docker-deployment && git add Dockerfile .dockerignore .gitattributes tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs docs/superpowers/specs/2026-09-26-docker-deployment-design.md && git commit -m "feat(docker): Dockerfile for the Telegram host" -m "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." +``` + +--- + +### Task 2: Playwright in the image (`INSTALL_PLAYWRIGHT=true`) + +**Files:** +- Modify: `Dockerfile` (replace the `# INSTALL_PLAYWRIGHT block (T2)` line) + +**Interfaces:** +- Consumes: T1's `Dockerfile` (`ARG INSTALL_PLAYWRIGHT`, `ARG PLAYWRIGHT_MCP_VERSION=0.0.82`, `ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright`). +- Produces: `playwright-mcp` on PATH (`/usr/bin/playwright-mcp`), Chromium under `/opt/ms-playwright` readable by everyone, image `volcanic-agentic-core:test-pw` (kept until T6). + +- [ ] **Step 1: Replace the placeholder comment with the install block** + +```dockerfile +# 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 +``` + +If `test -f "$pw"` fails, list `$(npm root -g)/@playwright/mcp/node_modules` in a throwaway build step and point `pw` at the `playwright` package that `@playwright/mcp` itself resolves (never a separately installed `playwright`). If `playwright-mcp --version` isn't supported by 0.0.82, replace it with `npm ls -g @playwright/mcp` and grep for `@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}`, and note that in the report. + +- [ ] **Step 2: Test stays green** + +Run: `cd /d/Projects/Names/wt-docker-deployment && dotnet test tests/Volcanic.AgenticCore.Extensions.Tests --filter "FullyQualifiedName~PlaywrightPresetTests"` → PASS (the ARG line is untouched and still unique). + +- [ ] **Step 3: Build both variants** + +Run: `MSYS_NO_PATHCONV=1 docker build --build-arg INSTALL_PLAYWRIGHT=false -t volcanic-agentic-core:test .` (fast; confirms the `if` is a no-op). +Run (background or with a long timeout; up to ~20 min): `MSYS_NO_PATHCONV=1 docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core:test-pw .` +Expected: both succeed; the second prints `0.0.82` from `playwright-mcp --version`. Record `docker image ls volcanic-agentic-core` sizes. + +- [ ] **Step 4: Offline checks as the bot's user** + +`MSYS_NO_PATHCONV=1 docker run --rm --network none --entrypoint setpriv volcanic-agentic-core:test-pw --reuid=1654 --regid=1654 --init-groups --inh-caps=-all -- sh -c 'command -v playwright-mcp; playwright-mcp --version; ls /opt/ms-playwright'` +Expected: `/usr/bin/playwright-mcp`, `0.0.82`, a `chromium-*` directory (and `chromium_headless_shell-*` if this Playwright version installs it). + +Headless Chromium launch as uid 1654 with `--no-sandbox`, offline: +`MSYS_NO_PATHCONV=1 docker run --rm --network none --entrypoint setpriv volcanic-agentic-core:test-pw --reuid=1654 --regid=1654 --init-groups --inh-caps=-all -- node -e 'const { chromium } = require("/usr/lib/node_modules/@playwright/mcp/node_modules/playwright"); (async () => { const b = await chromium.launch({ args: ["--no-sandbox"] }); const p = await b.newPage(); await p.setContent("

ok

"); console.log(await p.textContent("h1")); await b.close(); })().catch(e => { console.error(e); process.exit(1); });'` +Expected: `ok`. (Adjust the `require` path to what Step 1 found.) + +- [ ] **Step 5: Host startup with Playwright enabled, offline** + +`MSYS_NO_PATHCONV=1 docker run -d --name volcanic-probe-pw --init --network none -e TELEGRAM_BOT_TOKEN=123:fake -e TELEGRAM_ALLOWED_USER_IDS=1 -e PLAYWRIGHT_ENABLED=true -e PLAYWRIGHT_COMMAND=playwright-mcp -e PLAYWRIGHT_EXTRA_ARGS=--no-sandbox volcanic-agentic-core:test-pw`, wait ~15 s, `docker logs volcanic-probe-pw`. +Expected: `MCP: playwright enabled (headless, chromium)` and the other startup lines from Global Constraints; afterwards `Telegram unreachable (attempt n)` warnings (no network), no crash. Then `docker rm -f volcanic-probe-pw`. + +- [ ] **Step 6: Commit** + +```bash +cd /d/Projects/Names/wt-docker-deployment && git add Dockerfile && git commit -m "feat(docker): optional Playwright MCP and Chromium in the image" +``` + +--- + +### Task 3: docker-compose.yml, .env.example and a real host start + +**Files:** +- Create: `docker-compose.yml`, `.env.example` +- Modify: `.gitignore` (add `.env`) + +**Interfaces:** +- Consumes: T1's image contract (entrypoint, `/data/*` volumes, image env). +- Produces: compose service `bot`, volumes `workspaces`, `agent`, `claude`; the variable names that T4's `docs/docker.md` documents. + +- [ ] **Step 1: `.gitignore`** + +Append: + +``` +# Local compose configuration (secrets) +.env +``` + +- [ ] **Step 2: `docker-compose.yml`** (the spec's file verbatim, plus one comment on `init`) + +```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. + # docker-init runs as root (the image user), so the agent can't read its /proc/1/environ. + 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`, `user:`, domain or `SERVICE_FQDN_*`; no `ANTHROPIC_*`, `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CODE_USE_*` entries. + +- [ ] **Step 3: `.env.example`** (comments on their own lines; every value empty or a default) + +```dotenv +# Copy to .env for `docker compose up -d --build`. On Coolify, set these as the resource's environment variables. +# Never add ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, CLAUDE_CODE_OAUTH_TOKEN, +# CLAUDE_CODE_USE_BEDROCK or CLAUDE_CODE_USE_VERTEX: the Claude Code provider is subscription-only and refuses to run. + +# Required. Allowed Telegram user ids, comma separated; the first one is the owner (re-login prompts, "Online"). +TELEGRAM_BOT_TOKEN= +TELEGRAM_ALLOWED_USER_IDS= + +# Model every chat uses, log level, container time zone. +TELEGRAM_MODEL=claude-code/claude-opus-5-5 +LOG_LEVEL=Information +TZ=UTC + +# Build: Playwright MCP + Chromium in the image (~+400 MB); Claude Code version (2.1 = newest 2.1.x at build time). +INSTALL_PLAYWRIGHT=true +CLAUDE_CODE_VERSION=2.1 + +# Speech to text for voice notes (empty = off). +OPENAI_API_KEY= +STT_MODEL= + +# Google Calendar (both set = on; each chat connects its own account with /calendar_connect). +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# Default time zone for chats that never ran /timezone (IANA name, e.g. Europe/Berlin; empty = TZ). +DEFAULT_TIMEZONE= + +# Deny-listed variables the agent's processes may see anyway, comma separated (e.g. GH_TOKEN). +PROCESS_ENV_ALLOW= + +# Playwright MCP preset; empty = compose defaults (enabled if built in, headless, chromium, --no-sandbox, playwright-mcp). +# An override of PLAYWRIGHT_EXTRA_ARGS must keep --no-sandbox. +PLAYWRIGHT_ENABLED= +PLAYWRIGHT_HEADLESS= +PLAYWRIGHT_BROWSER= +PLAYWRIGHT_EXTRA_ARGS= +PLAYWRIGHT_COMMAND= +``` + +- [ ] **Step 4: `docker compose config` with a dummy `.env`** + +Create `.env` (never committed; `git status` must not list it): + +```dotenv +TELEGRAM_BOT_TOKEN=123:fake +TELEGRAM_ALLOWED_USER_IDS=1 +INSTALL_PLAYWRIGHT=false +``` + +Run: `cd /d/Projects/Names/wt-docker-deployment && docker compose -p volcanic-test config` +Expected: valid output with `INSTALL_PLAYWRIGHT: "false"` build arg, `PLAYWRIGHT_ENABLED: "false"` (nested default follows the build arg), `PLAYWRIGHT_EXTRA_ARGS: --no-sandbox`, `PLAYWRIGHT_COMMAND: playwright-mcp`, `Telegram__Model: claude-code/claude-opus-5-5`, `init: true`, `shm_size`, three named volumes, no `ports`. Also run it once with `.env.example` copied to `.env` plus the two required values, and check `PLAYWRIGHT_ENABLED: "true"`. + +- [ ] **Step 5: Real host start through compose (fake token)** + +Keep the dummy `.env` (`INSTALL_PLAYWRIGHT=false`). Run `docker compose -p volcanic-test up -d --build`, wait ~20 s, then `docker compose -p volcanic-test logs bot` and `docker compose -p volcanic-test ps -a`. +Expected in the log: the startup lines from Global Constraints (`Using Claude Code at /usr/bin/claude, model claude-code/claude-opus-5-5`, `Allowed users: 1. Workspaces under /data/workspaces`, `Process environment: 15 deny rules, allow: none`, `MCP: playwright disabled (set PLAYWRIGHT_ENABLED=true)`), no diagnostic-socket warning, then a `401`/unauthorized failure and exit code 1; `restart: unless-stopped` restarts it (visible restart loop). Nothing else may fail. +Then: `docker compose -p volcanic-test down -v --rmi local` (removes only this project's container, its three `volcanic-test_*` volumes and the `volcanic-test-bot` image). + +- [ ] **Step 6: Running-bot probe offline (`/proc` and diagnostics checks)** + +`MSYS_NO_PATHCONV=1 docker run -d --name volcanic-probe --init --network none -e TELEGRAM_BOT_TOKEN=123:fake -e TELEGRAM_ALLOWED_USER_IDS=1 -e OPENAI_API_KEY=sk-fake-probe-key volcanic-agentic-core:test`, wait ~10 s (no network → the poller backs off and keeps running), then: + +`MSYS_NO_PATHCONV=1 docker exec -u app volcanic-probe sh -c 'id; ps -o pid,user,comm -A; pid=$(pgrep -f Volcanic.AgenticCore.Telegram.dll); echo bot=$pid; cat /proc/1/environ >/dev/null 2>&1 && echo pid1-READABLE || echo pid1-denied; cat /proc/$pid/environ >/dev/null 2>&1 && echo bot-READABLE || echo bot-denied; ls /tmp/dotnet-diagnostic-* 2>/dev/null || echo no-diagnostic-socket'` +Expected: `uid=1654(app)`; PID 1 `docker-init` as `root`, the bot's `dotnet` as `app`; `pid1-denied`; `bot-denied`; `no-diagnostic-socket`. `docker logs volcanic-probe` shows the startup lines, `Speech to text: gpt-4o-transcribe`, and `Telegram unreachable` warnings. (The env filter itself, i.e. `TELEGRAM_BOT_TOKEN`/`OPENAI_API_KEY` missing from the agent's `bash`, needs a real chat; `docker exec` doesn't go through the spawn policy. Put it in `openIssues`.) +Then `docker rm -f volcanic-probe`, and delete the dummy `.env`. + +- [ ] **Step 7: Commit** + +```bash +cd /d/Projects/Names/wt-docker-deployment && git status --short && git add docker-compose.yml .env.example .gitignore && git commit -m "feat(docker): compose file and .env.example for Coolify" +``` + +--- + +### Task 4: `docs/docker.md` + +**Files:** +- Create: `docs/docker.md` + +**Interfaces:** +- Consumes: T1–T3's file names, build args, variable names, volume names, the `setpriv`/root PID 1 design. +- Produces: `docs/docker.md` with the anchors `#configuration`, `#first-run-and-claude-login`, `#troubleshooting` that T5 links to. + +- [ ] **Step 1: Write the doc** + +Sections and required content (plain prose, short; use the unslop skill's rules: no filler, no "robust/seamless"): + +1. `# Running the Telegram bot in Docker` intro: one container from the repo's `Dockerfile` + `docker-compose.yml`; Coolify or any Docker host; long polling, so no ports and no domain. Local run: `cp .env.example .env`, fill the two required values, `docker compose up -d --build`. `dotnet run` (see `telegram-bot.md`) stays the development path. +2. `## What's in the image`: .NET 10 runtime + the published host; agent tools (the apt list); Node 24 (NodeSource); `claude` (`@anthropic-ai/claude-code`); optionally `@playwright/mcp` 0.0.82 + Chromium under `/opt/ms-playwright`. No .NET SDK. The bot runs as `app` (uid 1654); PID 1 (`docker-init`) stays root so the agent can't read the container's environment from `/proc/1/environ`. Don't set `user:` in compose (`setpriv` needs root to switch users). +3. `## Build arguments` table: `INSTALL_PLAYWRIGHT` (Dockerfile `false`, compose `true`), `CLAUDE_CODE_VERSION` (`2.1`; exact version to pin), `NODE_MAJOR` (`24`), `PLAYWRIGHT_MCP_VERSION` (`0.0.82`; must equal `PlaywrightPreset.DefaultPackage`, a unit test checks it; compose doesn't override it). +4. `## Configuration`: environment only. Table `Variable | Default | Purpose | Agent's processes see it` with rows: `TELEGRAM_BOT_TOKEN` (required; no, redacted), `TELEGRAM_ALLOWED_USER_IDS` (required, comma separated, first = owner; yes), `TELEGRAM_MODEL` → `Telegram__Model` (`claude-code/claude-opus-5-5`), `TZ` (`UTC`), `LOG_LEVEL` → `Logging__LogLevel__Default` (`Information`), `OPENAI_API_KEY` (empty = speech to text off; no, redacted), `STT_MODEL` (`gpt-4o-transcribe`), `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` (both = calendar on; secret: no, redacted), `DEFAULT_TIMEZONE` (empty = `TZ`), `PROCESS_ENV_ALLOW`, `PLAYWRIGHT_ENABLED` (follows `INSTALL_PLAYWRIGHT`), `PLAYWRIGHT_HEADLESS` (`true`), `PLAYWRIGHT_BROWSER` (`chromium`), `PLAYWRIGHT_EXTRA_ARGS` (`--no-sandbox`, keep it when overriding), `PLAYWRIGHT_COMMAND` (`playwright-mcp`). Then: what the agent can't see: the default deny rules (`*_API_KEY`, `*_APIKEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD` and their `Section__Key` forms, plus the named secrets), any variable whose value is a configured secret, and `[REDACTED]` in tool output; `PROCESS_ENV_ALLOW=GH_TOKEN` lets one through (an explicit name on the deny list, such as `TELEGRAM_BOT_TOKEN`, can't be allowed). Other settings: any `Section__Key` (`Telegram__ReconnectMaxDelay=00:02:00`, `Scheduler__*`, `GoogleCalendar__*`, `ProcessEnvironment__*`, `Telegram__Speech__*`, `Telegram__InputDebounce`, `VOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS`/`..._TOTAL_TIMEOUT_MS`) added in Coolify and to `environment:` in the compose file. Set in the image, normally left alone: `Telegram__WorkspaceRoot=/data/workspaces`, `VOLCANIC_AGENT_DIR=/data/agent`, `CLAUDE_CONFIG_DIR=/data/claude`, `DOTNET_EnableDiagnostics=0` (unset it, with `ProcessEnvironment__HardenHostProcess=false`, only to attach `dotnet-dump`/`dotnet-trace`). + A warning block: **Never set `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CODE_USE_BEDROCK` or `CLAUDE_CODE_USE_VERTEX`.** The provider is subscription-only and refuses to run. In Coolify, check shared/team variables too. +5. `## Volumes`: `workspaces` → `/data/workspaces` (one directory per chat, `AGENTS.md`/`CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, `inbox/`, `browser/`, project-level `.volcanic/extensions/`), `agent` → `/data/agent` (sessions, `settings.json`, `extensions/*.dll`, chat settings, `scheduler/jobs.json`, `google-calendar/` tokens), `claude` → `/data/claude` (Claude login). Named volumes survive redeploys; Coolify prefixes them with the resource uuid and deletes them only with the resource's volumes. Bind mounts work if the host directories are owned by uid 1654 (`chown -R 1654:1654 `). `HOME` (`~/.gitconfig`, `~/.ssh`, npm cache) isn't persisted. +6. `## Deploying on Coolify`: new resource from this repo, build pack *Docker Compose*, set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ALLOWED_USER_IDS` (owner first) plus any optional ones, no domain, deploy. The log shows `Using Claude Code at /usr/bin/claude, ...` and `Allowed users: ...`. No healthcheck: the bot serves no HTTP; lost connectivity backs off, a `401` exits 1 and invalid config exits 2, which `restart: unless-stopped` turns into a visible restart loop. +7. `## First run and Claude login`: owner sends `/start`, then `/claude-code-login`; the link and code prompt arrive as one message; paste the code; credentials land in `/data/claude`; `/claude-code-doctor` should report logged in, `claude.ai`, bridge ok. Later expiries use the owner re-login prompt. Fallback: `docker exec -u app -it claude auth login --claudeai` (`-u app` matters: `docker exec` defaults to root and root-owned credentials are unreadable by the bot). +8. `## Updating`: redeploy (rebuild) to get the newest `claude` 2.1.x; the CLI can't update itself in place. Set `CLAUDE_CODE_VERSION` to an exact version to pin. Bumping Playwright means changing `PlaywrightPreset.DefaultPackage` and the Dockerfile ARG in one commit. +9. `## Extensions and project context`: `docker cp my.dll :/data/agent/extensions/` + restart (built against the same `Volcanic.AgenticCore.Abstractions` version), or `/data/workspaces//.volcanic/extensions/`. Context files live in the chat workspace. +10. `## Troubleshooting`: `[executable_missing]` → image built without `claude`? rebuild, check `claude --version` in the build log; `[auth]` → `/claude-code-login` (or the `docker exec -u app` fallback); `[mcp_startup]` → bridge files missing from `/app` (`Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll` + `.runtimeconfig.json`; the build guard should have caught it); `Permission denied` under `/data` with bind mounts → `chown -R 1654:1654`; `409 Conflict` in the log → another instance or a webhook polls the same bot token; browser errors → `INSTALL_PLAYWRIGHT=true` build, `shm_size`, `--no-sandbox` kept. +11. `## Security`: short; the container is the isolation boundary; the process environment hook, non-dumpable bot, root PID 1, redaction; link to `telegram-bot.md#security`. + +- [ ] **Step 2: Check names against the code and the compose file** + +Run: `cd /d/Projects/Names/wt-docker-deployment && grep -o '[A-Z][A-Z_]*[A-Z]' docs/docker.md | sort -u` and confirm every variable name exists in `docker-compose.yml`, the `Dockerfile`, or the Global Constraints list; fix typos. + +- [ ] **Step 3: Commit** + +```bash +cd /d/Projects/Names/wt-docker-deployment && git add docs/docker.md && git commit -m "docs(docker): Docker and Coolify deployment guide" +``` + +--- + +### Task 5: Cross-links and the sibling specs + +**Files:** +- Modify: `README.md`, `docs/telegram-bot.md`, `docs/mcp.md`, `docs/superpowers/specs/2026-09-25-telegram-host-design.md`, `docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md` + +**Interfaces:** +- Consumes: `docs/docker.md` from T4. +- Produces: nothing code-facing. + +- [ ] **Step 1: `README.md`** + +After the Quick start code block (right after the line `TELEGRAM_BOT_TOKEN=123:abc ... dotnet run --project hosts/Volcanic.AgenticCore.Telegram` and the closing fence), add: + +```markdown +To run the bot in a container (Coolify or any Docker host): `cp .env.example .env`, fill in the two required values, +`docker compose up -d --build`. See [docs/docker.md](docs/docker.md). +``` + +In `## Documentation`, after the Telegram bot line, add: `- [Docker and Coolify](docs/docker.md): the container image, configuration, volumes, first login.` + +- [ ] **Step 2: `docs/telegram-bot.md`** + +In `## Security`, first paragraph: replace "Run the bot as a dedicated user or in a container, and treat the workspace root as writable by anyone on the allow-list." with "Run it in the container image ([docker.md](docker.md)), which is the recommended setup, or at least as a dedicated user, and treat the workspace root as writable by anyone on the allow-list." +Replace "Set `DOTNET_EnableDiagnostics=0` in the bot's environment too (a container image should set it): otherwise" with "Set `DOTNET_EnableDiagnostics=0` in the bot's environment too (the Docker image, [docker.md](docker.md), sets it): otherwise". + +- [ ] **Step 3: `docs/mcp.md`** + +Replace "The Docker image (`docker.md`, when the docker-deployment spec lands) preinstalls both" with "The Docker image ([docker.md](docker.md), built with `INSTALL_PLAYWRIGHT=true`) preinstalls both". + +- [ ] **Step 4: Sibling specs** + +- `2026-09-25-telegram-host-design.md` line 23: "Out of scope: webhooks, Docker/Coolify, voice and ..." → "Out of scope: webhooks, Docker/Coolify (see `2026-09-26-docker-deployment-design.md`), voice and ...". +- `2026-09-26-mcp-client-playwright-design.md` line 4: `Status: implemented (the Dockerfile version test arrives with 2026-09-26-docker-deployment-design.md)` → `Status: implemented (the Dockerfile version test, PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage, landed with 2026-09-26-docker-deployment-design.md)`. In the Testing bullet (around line 395), replace "(deferred: the Dockerfile and that test come with the docker-deployment spec)" with "(`Dockerfile_version_matches_DefaultPackage`, added with the docker-deployment spec)". + +- [ ] **Step 5: Verify links and commit** + +Run: `cd /d/Projects/Names/wt-docker-deployment && grep -n "docker.md" README.md docs/*.md` (each link target exists) and `grep -rn "a container image should set\|when the docker-deployment spec lands\|deferred: the Dockerfile" docs README.md` → no hits. + +```bash +cd /d/Projects/Names/wt-docker-deployment && git add README.md docs/telegram-bot.md docs/mcp.md docs/superpowers/specs/2026-09-25-telegram-host-design.md docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md && git commit -m "docs(docker): link the container setup from the README, bot and MCP docs" +``` + +--- + +### Task 6: Final verification, spec status and cleanup + +**Files:** +- Modify: `docs/superpowers/specs/2026-09-26-docker-deployment-design.md` (Status line, Verification section) + +**Interfaces:** +- Consumes: everything above. +- Produces: the verification record for the report. + +- [ ] **Step 1: Full .NET build and tests** + +Run: `cd /d/Projects/Names/wt-docker-deployment && dotnet build && dotnet test` → green (only the 3 baseline failures), including `PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage`. + +- [ ] **Step 2: Rebuild both images from the final tree** + +`MSYS_NO_PATHCONV=1 docker build --build-arg INSTALL_PLAYWRIGHT=false -t volcanic-agentic-core:test .` and `MSYS_NO_PATHCONV=1 docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core:test-pw .` (mostly cached). Re-run T1 Step 6's three checks against `:test` and T2 Step 4's `playwright-mcp --version` against `:test-pw`. Any failure → fix in the owning file, commit as `fix(docker): ...`. + +- [ ] **Step 3: Spec Status and Verification** + +In `2026-09-26-docker-deployment-design.md`: `Status: draft.` → `Status: implemented (plan: docs/superpowers/plans/2026-09-26-docker-deployment.md).` Replace the Verification intro "No unit tests (nothing in C# changes). Manual checklist in the implementation plan:" with "One unit test (`PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage`); no C# behaviour changes. Checked during implementation without a real bot: both image variants build; bridge guard, `claude --version`, the agent tools, uid 1654, `/data` ownership; root-owned PID 1 whose `/proc/1/environ` and the bot's are unreadable to `app`; no `dotnet-diagnostic-*` socket; `playwright-mcp --version` offline and a headless Chromium launch as `app`; `docker compose config`; host startup log and the `401` → exit 1 path. The rest of this list needs a real bot and stays a manual checklist:" and keep items 1–5. + +- [ ] **Step 4: Cleanup (only what this plan created)** + +```bash +cd /d/Projects/Names/wt-docker-deployment && docker rm -f volcanic-probe volcanic-probe-pw 2>/dev/null; docker compose -p volcanic-test down -v --rmi local 2>/dev/null; docker rmi volcanic-agentic-core:test volcanic-agentic-core:test-pw; docker volume ls --filter name=volcanic-test; docker image ls volcanic-agentic-core; rm -f .env; git status --short +``` + +Expected: no `volcanic-test_*` volumes, no `volcanic-agentic-core` images, no `.env`, a clean tree. Do not prune build cache or other images (base images pulled by the build may stay; list them in the report). + +- [ ] **Step 5: Commit** + +```bash +cd /d/Projects/Names/wt-docker-deployment && git add docs/superpowers/specs/2026-09-26-docker-deployment-design.md && git commit -m "docs(docker): mark the deployment spec implemented" +``` + +Report every verification step with its result. `openIssues` must list at least: the chat-driven checks (Verification 2 env/`date`/`/timezone`, `/claude-code-login` + `/claude-code-doctor` green, Verification 3 "tell me the H1" in a chat, Verification 4 redeploy with a logged-in bot, Verification 5 `ANTHROPIC_API_KEY` refusal), a real Coolify deploy, and anything from T1–T3 that could not be checked. From 4ed3758f9c7fc20b5ce2f9ac47e06b1d25b1e2e2 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sun, 27 Sep 2026 12:04:42 +0200 Subject: [PATCH 2/8] 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. --- .dockerignore | 17 +++++ .gitattributes | 4 + Dockerfile | 76 +++++++++++++++++++ .../2026-09-26-docker-deployment-design.md | 32 ++++---- .../Mcp/PlaywrightPresetTests.cs | 20 +++++ 5 files changed, 134 insertions(+), 15 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 Dockerfile 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); + } } From 9a9e55f7830475fb9f228e11655f4f7bbe61952a Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sun, 27 Sep 2026 12:09:52 +0200 Subject: [PATCH 3/8] feat(docker): optional Playwright MCP and Chromium in the image INSTALL_PLAYWRIGHT=true installs @playwright/mcp@0.0.82 globally and runs its own playwright dependency's installer (install --with-deps chromium) into /opt/ms-playwright, readable by all users. The build fails if playwright-mcp --version does not run. --- Dockerfile | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b1bccfd..7b08ebe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,7 +52,18 @@ RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ # 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) +# 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. RUN test "$(id -u app)" = 1654 && test "$(id -g app)" = 1654 \ From 2461dfebce740f3d7ac78a202f4f545b2ee8ec5c Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sun, 27 Sep 2026 12:12:49 +0200 Subject: [PATCH 4/8] feat(docker): compose file and .env.example for Coolify One bot service with init: true, 1 GB /dev/shm, three named volumes and every setting as an environment variable; PLAYWRIGHT_ENABLED follows INSTALL_PLAYWRIGHT. .env is git-ignored. --- .env.example | 38 ++++++++++++++++++++++++++++++++++++++ .gitignore | 2 ++ docker-compose.yml | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 .env.example create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ba8cf31 --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +# Copy to .env for `docker compose up -d --build`. On Coolify, set these as the resource's environment variables. +# Never add ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, CLAUDE_CODE_OAUTH_TOKEN, +# CLAUDE_CODE_USE_BEDROCK or CLAUDE_CODE_USE_VERTEX: the Claude Code provider is subscription-only and refuses to run. + +# Required. Allowed Telegram user ids, comma separated; the first one is the owner (re-login prompts, "Online"). +TELEGRAM_BOT_TOKEN= +TELEGRAM_ALLOWED_USER_IDS= + +# Model every chat uses, log level, container time zone. +TELEGRAM_MODEL=claude-code/claude-opus-5-5 +LOG_LEVEL=Information +TZ=UTC + +# Build: Playwright MCP + Chromium in the image (~+400 MB); Claude Code version (2.1 = newest 2.1.x at build time). +INSTALL_PLAYWRIGHT=true +CLAUDE_CODE_VERSION=2.1 + +# Speech to text for voice notes (empty = off). +OPENAI_API_KEY= +STT_MODEL= + +# Google Calendar (both set = on; each chat connects its own account with /calendar_connect). +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# Default time zone for chats that never ran /timezone (IANA name, e.g. Europe/Berlin; empty = TZ). +DEFAULT_TIMEZONE= + +# Deny-listed variables the agent's processes may see anyway, comma separated (e.g. GH_TOKEN). +PROCESS_ENV_ALLOW= + +# Playwright MCP preset; empty = compose defaults (enabled if built in, headless, chromium, --no-sandbox, playwright-mcp). +# An override of PLAYWRIGHT_EXTRA_ARGS must keep --no-sandbox. +PLAYWRIGHT_ENABLED= +PLAYWRIGHT_HEADLESS= +PLAYWRIGHT_BROWSER= +PLAYWRIGHT_EXTRA_ARGS= +PLAYWRIGHT_COMMAND= diff --git a/.gitignore b/.gitignore index 9308d23..c97b6d3 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ artifacts/ .volcanic/ # Claude Code worktrees and local state .claude/ +# Local compose configuration (secrets) +.env diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c65aa2b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +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. + # docker-init runs as root (the image user), so the agent can't read its /proc/1/environ. + 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: From f0c511f953036667d639831fb976bf936f37046c Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sun, 27 Sep 2026 12:15:45 +0200 Subject: [PATCH 5/8] docs(docker): Docker and Coolify deployment guide Image contents, build arguments, the environment table with what the agent's processes can't see, volumes, Coolify steps, first Claude login, updating (the npm layer is cached: build without cache or pin CLAUDE_CODE_VERSION), extensions, troubleshooting and security. --- docs/docker.md | 200 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/docker.md diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..45171f1 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,200 @@ +# Running the Telegram bot in Docker + +The repo's `Dockerfile` and `docker-compose.yml` run the [Telegram bot](telegram-bot.md) as one container, on +Coolify or any Docker host. The bot uses long polling, so the container needs no open port and no domain. + +Local run: + +```bash +cp .env.example .env # fill in TELEGRAM_BOT_TOKEN and TELEGRAM_ALLOWED_USER_IDS +docker compose up -d --build +docker compose logs -f bot +``` + +`dotnet run` (see [telegram-bot.md](telegram-bot.md#setup)) stays the development path. + +## What's in the image + +- The .NET 10 runtime and the published host in `/app`. No .NET SDK, so the agent can't build .NET projects. +- Tools for the agent's `bash`: `bash`, `git`, `ripgrep`, `python3` (with `python-is-python3` and `python3-venv`), + `curl`, `ca-certificates`, `tzdata`, `jq`, `less`, `procps`, `openssh-client`. +- Node.js 24 from NodeSource, with `npm` and `npx`. +- The `claude` CLI (`@anthropic-ai/claude-code`), which the Claude Code provider drives. +- Optionally `@playwright/mcp` 0.0.82 (the `playwright-mcp` binary) and Chromium under `/opt/ms-playwright` + (`PLAYWRIGHT_BROWSERS_PATH`), for the browser tools in [mcp.md](mcp.md). + +The bot and everything it starts run as `app` (uid/gid 1654, `HOME=/home/app`). The image's own user stays root: +the entrypoint `setpriv` drops to `app` before it starts `dotnet`, so PID 1 (`docker-init`, from compose's +`init: true`) is owned by root and the agent can't read the container's environment from `/proc/1/environ`. +Don't set `user:` in the compose file: `setpriv` needs root to switch users. For the same reason `docker exec` +opens a root shell; add `-u app` to act as the bot. + +## Build arguments + +| Argument | Default | Purpose | +|---|---|---| +| `INSTALL_PLAYWRIGHT` | `false` in the `Dockerfile`, `true` in `docker-compose.yml` | Adds Playwright MCP and Chromium, about 400 MB | +| `CLAUDE_CODE_VERSION` | `2.1` | npm version spec for `claude`: `2.1` is the newest 2.1.x at build time; an exact version such as `2.1.3` pins it | +| `NODE_MAJOR` | `24` | Node.js major version from NodeSource | +| `PLAYWRIGHT_MCP_VERSION` | `0.0.82` | Must equal the version in `PlaywrightPreset.DefaultPackage`; `PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage` fails when they differ. The compose file doesn't override it | + +The compose file passes `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` from `.env` (or Coolify's variables). +Without compose: `docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core .` + +## Configuration + +Everything is set through environment variables: `.env` locally, the resource's environment variables on Coolify. +The compose file maps them into the container. + +| Variable | Default | Purpose | Agent's processes see it | +|---|---|---|---| +| `TELEGRAM_BOT_TOKEN` | — | Required. The token from @BotFather | No, and redacted from tool output | +| `TELEGRAM_ALLOWED_USER_IDS` | — | Required. Comma-separated Telegram user ids; the first one is the owner (the `🟢 Online.` message, Claude re-login prompts) | Yes | +| `TELEGRAM_MODEL` | `claude-code/claude-opus-5-5` | Model every chat uses; compose passes it as `Telegram__Model` | Yes, as `Telegram__Model` | +| `TZ` | `UTC` | The container's time zone | The agent's `bash` gets the chat's zone as `TZ` instead | +| `LOG_LEVEL` | `Information` | Passed as `Logging__LogLevel__Default` | Yes | +| `OPENAI_API_KEY` | — | Turns on speech to text for voice notes | No, and redacted | +| `STT_MODEL` | `gpt-4o-transcribe` | Transcription model | Yes | +| `GOOGLE_CLIENT_ID` | — | With the secret, turns on Google Calendar ([google-calendar.md](google-calendar.md)) | Yes | +| `GOOGLE_CLIENT_SECRET` | — | See above | No, and redacted | +| `DEFAULT_TIMEZONE` | the container's `TZ` | IANA zone for chats that never ran `/timezone`, e.g. `Europe/Berlin`. An unknown name stops startup | Yes | +| `PROCESS_ENV_ALLOW` | — | Comma-separated names or globs let through despite the deny rules, e.g. `GH_TOKEN` | Yes | +| `PLAYWRIGHT_ENABLED` | same as `INSTALL_PLAYWRIGHT` | Browser tools on or off | Yes | +| `PLAYWRIGHT_HEADLESS` | `true` | The container has no display | Yes | +| `PLAYWRIGHT_BROWSER` | `chromium` | Only Chromium is installed in the image | Yes | +| `PLAYWRIGHT_EXTRA_ARGS` | `--no-sandbox` | Extra `playwright-mcp` arguments. Keep `--no-sandbox` when you override it: Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies | Yes | +| `PLAYWRIGHT_COMMAND` | `playwright-mcp` | The preinstalled server, so the first browser call doesn't wait for an `npx` download | Yes | + +`TELEGRAM_MODEL`, `LOG_LEVEL`, `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` are read by the compose file only; the +bot itself reads the names in the right-hand form. + +> **Never set `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `CLAUDE_CODE_OAUTH_TOKEN`, +> `CLAUDE_CODE_USE_BEDROCK` or `CLAUDE_CODE_USE_VERTEX`.** The Claude Code provider runs on a Claude subscription only +> and refuses to run when one of them is present. In Coolify, check shared, project and team variables too: they are +> the likely way one gets in. + +### What the agent can't see + +The process environment filter removes variables from the environment of the agent's `bash`, scheduled scripts and +MCP servers: + +- anything matching the default deny rules: `*_API_KEY`, `*_APIKEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD` and their + `Section__Key` forms (`*__APIKEY`, `*__*TOKEN`, `*__*SECRET`, `*__*PASSWORD`), plus `TELEGRAM_BOT_TOKEN`, + `OPENAI_API_KEY`, `GOOGLE_CLIENT_SECRET`, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` and `CLAUDE_CODE_OAUTH_TOKEN`; +- any variable whose value is the bot token, the speech API key or the Google client secret, whatever its name. + +Those values are also replaced by `[REDACTED]` in tool output. The startup log names the rules, never values: +`Process environment: 15 deny rules, allow: none`. + +To give the agent a credential on purpose, add the variable in Coolify, add it to `environment:` in the compose file, +and list its name in `PROCESS_ENV_ALLOW` (`PROCESS_ENV_ALLOW=GH_TOKEN` lets `gh` work). A name that is on the deny +list by name, such as `TELEGRAM_BOT_TOKEN`, can't be allowed: trying stops startup. + +### Other settings + +Any setting of the bot is reachable as `Section__Key`: add the variable in Coolify and a matching line under +`environment:` in `docker-compose.yml`. For example `Telegram__ReconnectMaxDelay=00:02:00`, `Telegram__InputDebounce`, +`Telegram__Speech__Language`, `Scheduler__MaxJobsPerTarget`, `GoogleCalendar__*`, `ProcessEnvironment__Deny__0`, and +the provider's `VOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS` / `VOLCANIC_CLAUDE_CODE_TOTAL_TIMEOUT_MS`. The settings are +listed in [telegram-bot.md](telegram-bot.md#configuration) and [claude-code-provider.md](claude-code-provider.md#configuration). + +The image sets these; normally leave them alone: + +| Variable | Value | | +|---|---|---| +| `Telegram__WorkspaceRoot` | `/data/workspaces` | One directory per chat | +| `VOLCANIC_AGENT_DIR` | `/data/agent` | Sessions, settings, scheduler jobs, calendar tokens | +| `CLAUDE_CONFIG_DIR` | `/data/claude` | The Claude login | +| `HOME` | `/home/app` | | +| `DOTNET_EnableDiagnostics` | `0` | Turns off the .NET diagnostic socket, which would hand the bot's environment to the agent's processes. Unset it, together with `ProcessEnvironment__HardenHostProcess=false`, only to attach `dotnet-dump` or `dotnet-trace` while debugging | +| `DOTNET_CLI_TELEMETRY_OPTOUT`, `DOTNET_NOLOGO` | `1` | | + +## Volumes + +| Volume | Path | Contents | +|---|---|---| +| `workspaces` | `/data/workspaces` | One directory per chat (`/data/workspaces/`): `AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`, `inbox/` (received files), `browser/` (screenshots, downloads), project-level `.volcanic/extensions/` | +| `agent` | `/data/agent` | Sessions (`sessions/telegram-/`), `settings.json`, `extensions/*.dll`, chat time zones (`telegram/chat-settings.json`), `scheduler/jobs.json`, Google Calendar tokens (`google-calendar/`) | +| `claude` | `/data/claude` | The Claude login (credentials and onboarding state) | + +Named volumes survive redeploys. Coolify prefixes them with the resource's uuid and deletes them only when you delete +the resource together with its volumes. Bind mounts work too if the host directories belong to uid 1654 +(`chown -R 1654:1654 `). + +`HOME` (`/home/app`) isn't a volume: `~/.gitconfig`, `~/.ssh`, `gh` logins and the npm cache are lost on redeploy. + +## Deploying on Coolify + +1. Create a resource from this repository with the *Docker Compose* build pack. +2. Set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ALLOWED_USER_IDS` (owner first) and any optional variables from + [Configuration](#configuration). Leave the domain empty. +3. Deploy. The log shows lines such as `Using Claude Code at /usr/bin/claude, model claude-code/claude-opus-5-5` and + `Allowed users: . Workspaces under /data/workspaces`, and the owner gets `🟢 Online.`. + +There is no healthcheck, because the bot serves no HTTP. Lost connectivity is retried with backoff inside the +process. A rejected token (`401`) exits with code 1 and invalid configuration exits with code 2; +`restart: unless-stopped` turns either into a restart loop you can see in the logs. + +## First run and Claude login + +1. The owner opens the bot, sends `/start`, then `/claude-code-login`. +2. The login link and the code prompt arrive as one message. Open the link, sign in, and paste the code shown on the + page back into the chat. The credentials are stored in `/data/claude`. +3. `/claude-code-doctor` should now report that you're logged in through `claude.ai` and that the bridge works. + +When the login expires later, the owner gets the same prompt automatically and the paused request continues after +the code is pasted. + +Without the bot: `docker exec -u app -it claude auth login --claudeai`. The `-u app` matters: `docker exec` +runs as root by default, and credentials written by root can't be read by the bot. + +## Updating + +Redeploy to rebuild the image. The `claude` CLI can't update itself inside the container (its install directory is +owned by root), so a new version only arrives with a build. The `npm install` layer is cached, though: to pick up a +newer 2.1.x, build without cache (Coolify's force deploy without cache, or `docker compose build --no-cache`), or set +`CLAUDE_CODE_VERSION` to an exact version, which also pins it. A Claude Code upgrade invalidates the prompt cache +once. + +Bumping Playwright MCP means changing `PlaywrightPreset.DefaultPackage` and `ARG PLAYWRIGHT_MCP_VERSION` in the +`Dockerfile` in the same commit; the unit test above catches a mismatch. + +## Extensions and project context + +Extension assemblies go into `/data/agent/extensions/` (every chat) or `/data/workspaces//.volcanic/extensions/` +(one chat), then restart: + +```bash +docker cp MyExtension.dll :/data/agent/extensions/ +docker exec chown app:app /data/agent/extensions/MyExtension.dll +docker restart +``` + +Build them against the same `Volcanic.AgenticCore.Abstractions` version as the image. See [extensions.md](extensions.md). + +Project context files (`AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`) live in the chat's workspace directory. +The agent can edit them, or you can with `docker exec -u app -it bash`. + +## Troubleshooting + +| Symptom | Cause and fix | +|---|---| +| `[executable_missing]` in a reply, or `Claude Code (claude) was not found on PATH` at startup | The image has no working `claude`. Rebuild and check that `claude --version` ran in the build log | +| `[auth]` in a reply | The Claude login is missing or expired: `/claude-code-login`, or the `docker exec -u app` command above | +| `[mcp_startup]` in a reply | The bridge files are missing from `/app`: `Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll` and `Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json`. The build checks for both, so rebuild from a clean tree | +| `Permission denied` under `/data` | Bind-mounted directories owned by another user: `chown -R 1654:1654 ` on the host | +| `Telegram reports another poller or a webhook for this bot (409)` | Another instance polls with the same token (an old container, a local `dotnet run`), or the bot has a webhook. Stop the other instance; remove a webhook with `https://api.telegram.org/bot/deleteWebhook` | +| `Telegram rejected the bot token (401 Unauthorized)`, restart loop | Wrong or revoked `TELEGRAM_BOT_TOKEN` | +| Browser tools missing or failing | The image was built with `INSTALL_PLAYWRIGHT=false`, `PLAYWRIGHT_ENABLED` is off, or `PLAYWRIGHT_EXTRA_ARGS` lost `--no-sandbox`. Chromium crashes also come from a small `/dev/shm`; keep `shm_size: 1gb` | +| The provider refuses to run because of an API key | One of the `ANTHROPIC_*` / `CLAUDE_CODE_*` variables above is set, possibly as a shared Coolify variable. Remove it | + +## Security + +The container is the isolation boundary. Inside it, the agent's `bash` runs as `app`, the same user as the bot: it +can read the workspaces, the agent directory with every chat's sessions, and the Claude login in `/data/claude`. +What it can't get at are the configured secrets: they are filtered out of its environment, the bot process is +non-dumpable (its `/proc//environ` is unreadable and it can't be attached to), PID 1 is root's so +`/proc/1/environ` is unreadable too, the diagnostic socket is off, and secret values are redacted from tool output. +None of this is a sandbox. Anyone on the allow-list can run any command in the container. + +Details in [telegram-bot.md](telegram-bot.md#security). From 92c881377bf57e87e3f9af2be070da926625ccc2 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sun, 27 Sep 2026 12:17:41 +0200 Subject: [PATCH 6/8] docs(docker): link the container setup from the README, bot and MCP docs --- README.md | 4 ++++ docs/mcp.md | 2 +- .../superpowers/specs/2026-09-25-telegram-host-design.md | 2 +- .../specs/2026-09-26-mcp-client-playwright-design.md | 6 +++--- docs/telegram-bot.md | 9 +++++---- 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 03f8ab2..34cf6f5 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,9 @@ dotnet run --project samples/Volcanic.AgenticCore.Sample.Headless -- --model cla TELEGRAM_BOT_TOKEN=123:abc TELEGRAM_ALLOWED_USER_IDS=111111111 dotnet run --project hosts/Volcanic.AgenticCore.Telegram ``` +To run the bot in a container (Coolify or any Docker host): `cp .env.example .env`, fill in the two required values, +`docker compose up -d --build`. See [docs/docker.md](docs/docker.md). + Hosting it yourself: ```csharp @@ -69,6 +72,7 @@ await session.PromptAsync("Explain this repository"); - [Writing extensions](docs/extensions.md): tools, commands, lifecycle hooks, providers, message injection, discovery. - [Providers and models](docs/providers.md): model keys, authentication, the built-in provider extensions. - [Telegram bot](docs/telegram-bot.md): setup, commands, security, connection recovery. +- [Docker and Coolify](docs/docker.md): the container image, configuration, volumes, first login. - [Google Calendar](docs/google-calendar.md): Google Cloud setup, connecting a chat, token storage, time zones. - [Building a UI (Telegram guide)](docs/ui-integration.md): how to put a chat front end on the core. - [Claude Code provider](docs/claude-code-provider.md): requirements, how the bridge works, limits, the doctor diff --git a/docs/mcp.md b/docs/mcp.md index 8eb2d32..b75b528 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -85,7 +85,7 @@ Configured values win over the variables; an empty variable counts as unset. The The server needs Node and the Chromium build that the pinned `@playwright/mcp` expects: `npx playwright install --with-deps chromium`, run from that package so the versions match (`PLAYWRIGHT_BROWSERS_PATH` is honoured). The first `npx` run downloads the package, which is why the preset allows -120 s to start. The Docker image (`docker.md`, when the docker-deployment spec lands) preinstalls both and sets +120 s to start. The Docker image ([docker.md](docker.md), built with `INSTALL_PLAYWRIGHT=true`) preinstalls both and sets `PLAYWRIGHT_COMMAND=playwright-mcp` and `PLAYWRIGHT_EXTRA_ARGS=--no-sandbox`. Chromium's sandbox needs user namespaces, which Docker's default profile blocks, so there the container is the isolation boundary. A missing browser shows up as an error from the first `browser_*` call. diff --git a/docs/superpowers/specs/2026-09-25-telegram-host-design.md b/docs/superpowers/specs/2026-09-25-telegram-host-design.md index b0f9779..f2f7daa 100644 --- a/docs/superpowers/specs/2026-09-25-telegram-host-design.md +++ b/docs/superpowers/specs/2026-09-25-telegram-host-design.md @@ -20,7 +20,7 @@ Chat with the agent through a Telegram bot. A new host project in this repo, run | Access | Mandatory user-id allow-list; empty list = refuse to start; other users are ignored silently | | Chats | Private chats only | -Out of scope: webhooks, Docker/Coolify, voice and documents, group chats, several bot tokens, a custom polling loop. +Out of scope: webhooks, Docker/Coolify (see `2026-09-26-docker-deployment-design.md`), voice and documents, group chats, several bot tokens, a custom polling loop. ## Configuration diff --git a/docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md b/docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md index df6e79f..a7cef14 100644 --- a/docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md +++ b/docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md @@ -1,7 +1,7 @@ # MCP client extension and Playwright browser tools — design Date: 2026-09-26 -Status: implemented (the Dockerfile version test arrives with 2026-09-26-docker-deployment-design.md) +Status: implemented (the Dockerfile version test, PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage, landed with 2026-09-26-docker-deployment-design.md) Depends on: `2026-09-26-telegram-attachments-design.md` (the `send` tool that delivers browser output); `2026-09-26-process-environment-hook-design.md` (the environment every server process gets, and the redaction of known secrets in tool output). @@ -393,8 +393,8 @@ No test touches the network, npm or a real browser. `settings.json` merge and precedence; - Playwright preset: argv for headless/browser/extra args, output dir and profile under the workspace, a pinned package; with `PLAYWRIGHT_COMMAND` the binary is used and `-y ` is dropped; the Dockerfile's - `PLAYWRIGHT_MCP_VERSION` default equals `DefaultPackage`'s version (deferred: the Dockerfile and that test come with the - docker-deployment spec); the prompt section appears only with `browser_*` tools active and mentions `send` only when it exists; + `PLAYWRIGHT_MCP_VERSION` default equals `DefaultPackage`'s version (`Dockerfile_version_matches_DefaultPackage`, added + with the docker-deployment spec); the prompt section appears only with `browser_*` tools active and mentions `send` only when it exists; - end to end through the core with `FauxProvider`: the scripted model calls the fake `image` tool, and the tool result message holds the image. - **Claude Code**: a `ToolCatalog` test that `browser_navigate`-style names alias to themselves (no digest). diff --git a/docs/telegram-bot.md b/docs/telegram-bot.md index a12f017..16c7446 100644 --- a/docs/telegram-bot.md +++ b/docs/telegram-bot.md @@ -193,8 +193,9 @@ storage and time zones are in [google-calendar.md](google-calendar.md). ## Security The agent has the built-in tools, including `bash`, and runs them as the bot's OS user. Only allow-listed user ids -are served (everyone else is ignored and logged), and only in private chats. Run the bot as a dedicated user or in a -container, and treat the workspace root as writable by anyone on the allow-list. +are served (everyone else is ignored and logged), and only in private chats. Run it in the container image +([docker.md](docker.md)), which is the recommended setup, or at least as a dedicated user, and treat the workspace root +as writable by anyone on the allow-list. Scheduled watcher scripts run unattended as the bot's OS user, with the same trust as the `bash` tool that every allow-listed user already has, and the same filtered environment. @@ -211,8 +212,8 @@ agent can use `gh`. At startup the bot logs which rules apply, names only: On Linux the bot's own environment can't be read through `/proc//environ` or ptrace: at startup the process makes itself non-dumpable (`prctl(PR_SET_DUMPABLE, 0)`). Set `DOTNET_EnableDiagnostics=0` in the bot's environment -too (a container image should set it): otherwise the .NET diagnostic socket still hands the environment to any process -of the same user, and the bot logs a warning saying so. +too (the Docker image, [docker.md](docker.md), sets it): otherwise the .NET diagnostic socket still hands the +environment to any process of the same user, and the bot logs a warning saying so. The bot token and the other configured secrets are replaced by `[REDACTED]` in tool output, so the model never sees them in a tool result, whichever file or process they were read from. Encoded forms (base64, URL-encoded, split From b9a95a57c8999d7d48ba455911cf08c2b60127dd Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sun, 27 Sep 2026 12:22:06 +0200 Subject: [PATCH 7/8] docs(docker): mark the deployment spec implemented Status implemented, verification intro rewritten, and a verification record of the final checks (dotnet build/test, both image variants, offline probes of uid, /proc environ and the diagnostics socket, playwright-mcp --version offline, headless Chromium as app, docker compose config, compose start with a fake token -> 401 -> exit 1). Open question 2 (Playwright compose default) marked resolved. --- .../2026-09-26-docker-deployment-design.md | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) 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 f92b6c9..8d73fe6 100644 --- a/docs/superpowers/specs/2026-09-26-docker-deployment-design.md +++ b/docs/superpowers/specs/2026-09-26-docker-deployment-design.md @@ -1,6 +1,6 @@ # Docker deployment (Coolify) — design -Date: 2026-09-26. Status: draft. +Date: 2026-09-26. Status: implemented (plan: docs/superpowers/plans/2026-09-26-docker-deployment.md). 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). @@ -215,7 +215,12 @@ the `claude` volume also works but is not the documented path. ## Verification -No unit tests (nothing in C# changes). Manual checklist in the implementation plan: +One unit test (`PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage`); no C# behaviour changes. Checked +during implementation without a real bot: both image variants build; bridge guard, `claude --version`, the agent +tools, uid 1654, `/data` ownership; root-owned PID 1 whose `/proc/1/environ` and the bot's are unreadable to `app`; +no `dotnet-diagnostic-*` socket; `playwright-mcp --version` offline and a headless Chromium launch as `app`; +`docker compose config`; host startup log and the `401` → exit 1 path. The rest of this list needs a real bot and +stays a manual checklist: 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` @@ -227,12 +232,35 @@ No unit tests (nothing in C# changes). Manual checklist in the implementation pl 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). +Verification record (2026-09-27, Docker Desktop 29 on Windows 11, Linux containers): + +- `dotnet build`: 0 warnings, 0 errors. `dotnet test`: all green except the three Windows-only baseline failures in + `Volcanic.AgenticCore.Tests`. Changing the Dockerfile's `PLAYWRIGHT_MCP_VERSION` to `0.0.81` makes + `Dockerfile_version_matches_DefaultPackage` fail (`Expected "0.0.82", Actual "0.0.81"`). +- `docker build --build-arg INSTALL_PLAYWRIGHT=false` and `=true` both succeed. `claude --version` → `2.1.283 (Claude + Code)`; bridge `runtimeconfig.json` present; `DOTNET_EnableDiagnostics=0`; `git`, `rg`, `python3`, `node` (v24.21.0), + `npx`, `jq`, `setpriv` on `PATH`; `/data/{workspaces,agent,claude}` owned by `app`. With Playwright, + `playwright-mcp --version` → `Version 0.0.82` with `--network none`, Chromium under `/opt/ms-playwright`, and a + headless Chromium launch as `app` renders a page. +- Running host, `--init --network none`: PID 1 `docker-init` as root, the bot's `dotnet` as uid/gid 1654 with no + effective capabilities; `/proc/1/environ` and the bot's `environ` are `Permission denied` for `app` (the bot's also + for a root `docker exec`, since the host is not dumpable); no `dotnet-diagnostic-*` socket. The log shows + `Host process hardened: not dumpable`, `Process environment: 15 deny rules, allow: none`, the MCP line + (`disabled` / `enabled (headless, chromium)`), `Speech to text`, `Using Claude Code at /usr/bin/claude, model + claude-code/claude-opus-5-5`, `Scheduler`, `Default time zone: UTC`, `Allowed users: 1. Workspaces under + /data/workspaces`, then `Telegram unreachable` backoff. +- `docker compose config`: with a minimal `.env` (`INSTALL_PLAYWRIGHT=false`), `PLAYWRIGHT_ENABLED` follows it to + `"false"`; with `.env.example`'s defaults it is `"true"`; `--no-sandbox`, `playwright-mcp`, `init: true`, + `shm_size`, three named volumes, no ports. +- `docker compose up -d --build` with `TELEGRAM_BOT_TOKEN=123:fake`: same startup lines, then `Telegram rejected the bot + token (401 Unauthorized)`, exit 1, and a visible `restart: unless-stopped` loop; no other warnings. + ## 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.) +2. ~~Playwright default~~: resolved. The MCP spec shipped first, so compose keeps building it in (`true`). + (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 From 7bb41f0b5bd24b6c7634ce57ea3d00508e165f49 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sun, 27 Sep 2026 12:34:58 +0200 Subject: [PATCH 8/8] fix(docker): address review findings Entrypoint drops the capability bounding set and sets no_new_privs. Security docs and spec no longer claim the agent can't reach the secrets; docker exec fallbacks use a clean environment. PLAYWRIGHT_ENABLED drops the nested compose default. Tests pin the Dockerfile's Playwright install step and the compose model fallback. --- .env.example | 7 ++- .gitignore | 2 + Dockerfile | 3 +- docker-compose.yml | 5 +- docs/docker.md | 62 +++++++++++++++---- .../2026-09-26-docker-deployment-design.md | 46 ++++++++++---- .../Mcp/PlaywrightPresetTests.cs | 2 + .../DockerComposeTests.cs | 27 ++++++++ 8 files changed, 124 insertions(+), 30 deletions(-) create mode 100644 tests/Volcanic.AgenticCore.Telegram.Tests/DockerComposeTests.cs diff --git a/.env.example b/.env.example index ba8cf31..cd3b0b6 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,8 @@ TELEGRAM_BOT_TOKEN= TELEGRAM_ALLOWED_USER_IDS= -# Model every chat uses, log level, container time zone. -TELEGRAM_MODEL=claude-code/claude-opus-5-5 +# Model every chat uses (empty = the compose default), log level, container time zone. +TELEGRAM_MODEL= LOG_LEVEL=Information TZ=UTC @@ -29,8 +29,9 @@ DEFAULT_TIMEZONE= # Deny-listed variables the agent's processes may see anyway, comma separated (e.g. GH_TOKEN). PROCESS_ENV_ALLOW= -# Playwright MCP preset; empty = compose defaults (enabled if built in, headless, chromium, --no-sandbox, playwright-mcp). +# Playwright MCP preset; empty = compose defaults (enabled, headless, chromium, --no-sandbox, playwright-mcp). # An override of PLAYWRIGHT_EXTRA_ARGS must keep --no-sandbox. +# An image built with INSTALL_PLAYWRIGHT=false also needs PLAYWRIGHT_ENABLED=false. PLAYWRIGHT_ENABLED= PLAYWRIGHT_HEADLESS= PLAYWRIGHT_BROWSER= diff --git a/.gitignore b/.gitignore index c97b6d3..7eaf345 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ artifacts/ .claude/ # Local compose configuration (secrets) .env +.env.* +!.env.example diff --git a/Dockerfile b/Dockerfile index 7b08ebe..3fbffd3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,4 +84,5 @@ ENV Telegram__WorkspaceRoot=/data/workspaces \ # 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"] +# An empty bounding set and no_new_privs keep the agent's processes from regaining root through setuid binaries. +ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--bounding-set=-all", "--no-new-privs", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"] diff --git a/docker-compose.yml b/docker-compose.yml index c65aa2b..c21c384 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,8 +25,9 @@ services: - 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 MCP preset (MCP spec): the preinstalled binary, no npx download. Set PLAYWRIGHT_ENABLED=false for an + # image built with INSTALL_PLAYWRIGHT=false (no nested default: Coolify parses these expressions itself). + - PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-true} - PLAYWRIGHT_HEADLESS=${PLAYWRIGHT_HEADLESS:-true} # no display in the container - PLAYWRIGHT_BROWSER=${PLAYWRIGHT_BROWSER:-} - PLAYWRIGHT_EXTRA_ARGS=${PLAYWRIGHT_EXTRA_ARGS:---no-sandbox} diff --git a/docs/docker.md b/docs/docker.md index 45171f1..38e6511 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -25,9 +25,14 @@ docker compose logs -f bot The bot and everything it starts run as `app` (uid/gid 1654, `HOME=/home/app`). The image's own user stays root: the entrypoint `setpriv` drops to `app` before it starts `dotnet`, so PID 1 (`docker-init`, from compose's -`init: true`) is owned by root and the agent can't read the container's environment from `/proc/1/environ`. -Don't set `user:` in the compose file: `setpriv` needs root to switch users. For the same reason `docker exec` -opens a root shell; add `-u app` to act as the bot. +`init: true`) is owned by root and the agent can't read the container's environment from `/proc/1/environ`. It also +empties the capability bounding set and sets `no_new_privs`, so nothing the agent runs regains root through a setuid +binary such as `su` or `mount`. Don't set `user:` in the compose file: `setpriv` needs root to switch users. For the +same reason `docker exec` opens a root shell; add `-u app` to act as the bot. Any command that writes under `HOME` +(`npm`, `npx`, `git config`, `gh auth`, `claude`) must run with `-u app`: as root it leaves root-owned files in +`/home/app` (`HOME` is set image-wide), and the bot's `npx` and `git` then fail with `EACCES` until the next redeploy. +An interactive `app` session should also start from a clean environment (see +[First run and Claude login](#first-run-and-claude-login)). ## Build arguments @@ -39,7 +44,20 @@ opens a root shell; add `-u app` to act as the bot. | `PLAYWRIGHT_MCP_VERSION` | `0.0.82` | Must equal the version in `PlaywrightPreset.DefaultPackage`; `PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage` fails when they differ. The compose file doesn't override it | The compose file passes `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` from `.env` (or Coolify's variables). -Without compose: `docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core .` +Without compose: + +```bash +docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core . +docker run -d --init --shm-size 1g --restart unless-stopped --env-file .env \ + -e PLAYWRIGHT_ENABLED=true -e PLAYWRIGHT_HEADLESS=true -e PLAYWRIGHT_EXTRA_ARGS=--no-sandbox \ + -e PLAYWRIGHT_COMMAND=playwright-mcp \ + -v workspaces:/data/workspaces -v agent:/data/agent -v claude:/data/claude volcanic-agentic-core +``` + +`--init` is required: without it `dotnet` is PID 1, so nothing reaps the zombies that killed `claude` trees and `bash` +children leave behind, and PID 1 is no longer root's. `--shm-size 1g` keeps Chromium from crashing. The compose +file's defaults (the `-e` lines, which win over `--env-file`) don't apply to a plain `docker run`, and neither does +its `TELEGRAM_MODEL` / `LOG_LEVEL` mapping: pass `-e Telegram__Model=...` instead. ## Configuration @@ -59,14 +77,14 @@ The compose file maps them into the container. | `GOOGLE_CLIENT_SECRET` | — | See above | No, and redacted | | `DEFAULT_TIMEZONE` | the container's `TZ` | IANA zone for chats that never ran `/timezone`, e.g. `Europe/Berlin`. An unknown name stops startup | Yes | | `PROCESS_ENV_ALLOW` | — | Comma-separated names or globs let through despite the deny rules, e.g. `GH_TOKEN` | Yes | -| `PLAYWRIGHT_ENABLED` | same as `INSTALL_PLAYWRIGHT` | Browser tools on or off | Yes | +| `PLAYWRIGHT_ENABLED` | `true` | Browser tools on or off. Set it to `false` for an image built with `INSTALL_PLAYWRIGHT=false` | Yes | | `PLAYWRIGHT_HEADLESS` | `true` | The container has no display | Yes | | `PLAYWRIGHT_BROWSER` | `chromium` | Only Chromium is installed in the image | Yes | | `PLAYWRIGHT_EXTRA_ARGS` | `--no-sandbox` | Extra `playwright-mcp` arguments. Keep `--no-sandbox` when you override it: Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies | Yes | | `PLAYWRIGHT_COMMAND` | `playwright-mcp` | The preinstalled server, so the first browser call doesn't wait for an `npx` download | Yes | -`TELEGRAM_MODEL`, `LOG_LEVEL`, `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` are read by the compose file only; the -bot itself reads the names in the right-hand form. +`TELEGRAM_MODEL` and `LOG_LEVEL` are read by the compose file only, which passes them to the bot as `Telegram__Model` +and `Logging__LogLevel__Default`; `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` are build arguments (see above). > **Never set `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `CLAUDE_CODE_OAUTH_TOKEN`, > `CLAUDE_CODE_USE_BEDROCK` or `CLAUDE_CODE_USE_VERTEX`.** The Claude Code provider runs on a Claude subscription only @@ -145,8 +163,17 @@ process. A rejected token (`401`) exits with code 1 and invalid configuration ex When the login expires later, the owner gets the same prompt automatically and the paused request continues after the code is pasted. -Without the bot: `docker exec -u app -it claude auth login --claudeai`. The `-u app` matters: `docker exec` -runs as root by default, and credentials written by root can't be read by the bot. +Without the bot: + +```bash +docker exec -u app -it env -i HOME=/home/app \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin CLAUDE_CONFIG_DIR=/data/claude TERM=xterm \ + claude auth login --claudeai +``` + +The `-u app` matters: `docker exec` runs as root by default, and credentials written by root can't be read by the bot. +So does `env -i`: a `docker exec` process gets the whole container environment, bot token and API keys included, and +while it runs as `app` the agent's `bash` or a scheduled watcher can read it from `/proc//environ`. ## Updating @@ -173,7 +200,9 @@ docker restart Build them against the same `Volcanic.AgenticCore.Abstractions` version as the image. See [extensions.md](extensions.md). Project context files (`AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`) live in the chat's workspace directory. -The agent can edit them, or you can with `docker exec -u app -it bash`. +The agent can edit them, or you can with `docker exec -u app -it env -i HOME=/home/app +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin TERM=xterm bash` (clean environment, for the reason +given under [First run and Claude login](#first-run-and-claude-login)). ## Troubleshooting @@ -192,9 +221,16 @@ The agent can edit them, or you can with `docker exec -u app -it bas The container is the isolation boundary. Inside it, the agent's `bash` runs as `app`, the same user as the bot: it can read the workspaces, the agent directory with every chat's sessions, and the Claude login in `/data/claude`. -What it can't get at are the configured secrets: they are filtered out of its environment, the bot process is +The configured secrets are kept out of its way: they are filtered out of its environment, the bot process is non-dumpable (its `/proc//environ` is unreadable and it can't be attached to), PID 1 is root's so -`/proc/1/environ` is unreadable too, the diagnostic socket is off, and secret values are redacted from tool output. -None of this is a sandbox. Anyone on the allow-list can run any command in the container. +`/proc/1/environ` is unreadable too, the diagnostic socket is off, setuid binaries can't raise its privileges, and +secret values are redacted from tool output. + +That stops secrets leaking by accident, not an agent that goes after them on purpose. `app` owns `/data/workspaces` +and `/data/agent`, so the agent can drop a DLL into `/.volcanic/extensions/` (loaded into the bot process, +with its unfiltered environment, at the next `/new`) or add an MCP server whose `env` uses `${env:TELEGRAM_BOT_TOKEN}` +to `/data/agent/settings.json`, then kill the bot so that `restart: unless-stopped` brings it back with that server. +Treat everyone on the allow-list as able to obtain every secret you configure. None of this is a sandbox: anyone on +the allow-list can run any command in the container. Details in [telegram-bot.md](telegram-bot.md#security). 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 8d73fe6..423b10c 100644 --- a/docs/superpowers/specs/2026-09-26-docker-deployment-design.md +++ b/docs/superpowers/specs/2026-09-26-docker-deployment-design.md @@ -21,7 +21,7 @@ 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 ["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 | +| Entry | `ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--bounding-set=-all", "--no-new-privs", "--", "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 | @@ -113,8 +113,9 @@ services: - 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 MCP preset (MCP spec): the preinstalled binary, no npx download. Set PLAYWRIGHT_ENABLED=false for an + # image built with INSTALL_PLAYWRIGHT=false (no nested default: Coolify parses these expressions itself). + - PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-true} - PLAYWRIGHT_HEADLESS=${PLAYWRIGHT_HEADLESS:-true} # no display in the container - PLAYWRIGHT_BROWSER=${PLAYWRIGHT_BROWSER:-} - PLAYWRIGHT_EXTRA_ARGS=${PLAYWRIGHT_EXTRA_ARGS:---no-sandbox} @@ -159,13 +160,24 @@ volumes: 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. + session. The entrypoint also gives the bot an empty capability bounding set and `no_new_privs`, so nothing it + spawns regains root through the base image's setuid binaries (`su`, `mount`, `passwd`, ...). + All of this keeps secrets out of the agent's environment and tool output by accident; it does **not** stop an agent + that goes after them on purpose. `app` owns `/data/workspaces` and `/data/agent`, extensions are discovered on every + session creation, and MCP servers expand `${env:NAME}` from the host's unfiltered environment, so the agent can drop + a DLL into `/.volcanic/extensions/` (loaded into the bot at the next `/new`) or add an MCP server with + `env: {"X": "${env:TELEGRAM_BOT_TOKEN}"}` to `/data/agent/settings.json` (loaded after it kills the same-uid bot + and `restart: unless-stopped` brings it back). Anyone on the allow-list must be treated as able to obtain every + secret configured here. The container stays the real isolation boundary: none of this is a sandbox (open question 5). - 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. + `PLAYWRIGHT_ENABLED` defaults to `true`, so an image built with `INSTALL_PLAYWRIGHT=false` also needs + `PLAYWRIGHT_ENABLED=false` (otherwise the `playwright` server is logged as unavailable and its tools are missing). + It doesn't follow `INSTALL_PLAYWRIGHT` through a nested default (`${PLAYWRIGHT_ENABLED:-${INSTALL_PLAYWRIGHT:-true}}`): + Compose v2 supports that, but Coolify parses these expressions itself to build its variable UI, and a value cut at + the first `}` would make the host exit 2 in a restart loop. - 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 @@ -192,8 +204,11 @@ 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 -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 +Fallback without the bot: `docker exec -u app -it env -i HOME=/home/app +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin CLAUDE_CONFIG_DIR=/data/claude TERM=xterm claude auth +login --claudeai` (`docker exec` defaults to root, whose credentials the bot couldn't read). `env -i` matters: a +`docker exec` process gets the whole container environment, secrets included, and an ordinary (dumpable) `app` process +has its `/proc//environ` readable by the agent's `bash` and watcher scripts for as long as it runs. Copying an existing `.credentials.json` into the `claude` volume also works but is not the documented path. ## Extensions and project context @@ -249,11 +264,16 @@ Verification record (2026-09-27, Docker Desktop 29 on Windows 11, Linux containe (`disabled` / `enabled (headless, chromium)`), `Speech to text`, `Using Claude Code at /usr/bin/claude, model claude-code/claude-opus-5-5`, `Scheduler`, `Default time zone: UTC`, `Allowed users: 1. Workspaces under /data/workspaces`, then `Telegram unreachable` backoff. -- `docker compose config`: with a minimal `.env` (`INSTALL_PLAYWRIGHT=false`), `PLAYWRIGHT_ENABLED` follows it to - `"false"`; with `.env.example`'s defaults it is `"true"`; `--no-sandbox`, `playwright-mcp`, `init: true`, - `shm_size`, three named volumes, no ports. +- `docker compose config`: with a minimal `.env` (`INSTALL_PLAYWRIGHT=false`, `PLAYWRIGHT_ENABLED=false`), both are + `"false"`; with `.env.example`'s defaults both are `"true"` and `Telegram__Model` is the compose fallback; + `--no-sandbox`, `playwright-mcp`, `init: true`, `shm_size`, three named volumes, no ports. Not yet checked on + Coolify itself. - `docker compose up -d --build` with `TELEGRAM_BOT_TOKEN=123:fake`: same startup lines, then `Telegram rejected the bot token (401 Unauthorized)`, exit 1, and a visible `restart: unless-stopped` loop; no other warnings. +- After review (entrypoint with `--bounding-set=-all --no-new-privs`): the bot's `/proc//status` shows + `CapEff`/`CapBnd` all zero and `NoNewPrivs: 1`; `su` as `app` fails; the host starts and logs the same lines; a + headless Chromium launch (`--no-sandbox`) under the same `setpriv` flags renders a page; the `env -i` login fallback + runs `claude --version` with only `HOME`, `PATH`, `CLAUDE_CONFIG_DIR`, `TERM` in its environment. ## Open questions @@ -265,3 +285,7 @@ Verification record (2026-09-27, Docker Desktop 29 on Windows 11, Linux containe 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? +5. The agent can reach the configured secrets on purpose (see the secrets bullet): it owns the directories that + extension and MCP-server discovery read. Mitigate with a host option that turns off workspace/agent-dir extension + and settings discovery in the container, or with root-owned, read-only `extensions/` and `settings.json` paths? + Until then this is documented, and the allow-list is the trust boundary. diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs index 73d3b8d..24c1700 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs @@ -97,6 +97,8 @@ public class PlaywrightPresetTests var match = Assert.Single(matches); Assert.Equal(PlaywrightPreset.PackageVersion, match.Groups["version"].Value); + // The install step must use the ARG, not a version of its own. + Assert.Contains("\"@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}\"", dockerfile, StringComparison.Ordinal); } /// A file in the repository root, found by walking up from the test output to VolcanicAgenticCore.slnx. diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/DockerComposeTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/DockerComposeTests.cs new file mode 100644 index 0000000..0a99739 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/DockerComposeTests.cs @@ -0,0 +1,27 @@ +using System.Text.RegularExpressions; + +namespace Volcanic.AgenticCore.Telegram.Tests; + +/// Values the repo-root docker-compose.yml copies from the host's code. +public sealed class DockerComposeTests +{ + [Fact] + public void Compose_model_fallback_matches_TelegramOptions_DefaultModel() + { + var compose = File.ReadAllText(RepoFile("docker-compose.yml")); + var matches = Regex.Matches(compose, @"Telegram__Model=\$\{TELEGRAM_MODEL:-(?[^}]+)\}"); + + var match = Assert.Single(matches); + Assert.Equal(TelegramOptions.DefaultModel, match.Groups["model"].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); + } +}