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/.env.example b/.env.example new file mode 100644 index 0000000..cd3b0b6 --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# 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 (empty = the compose default), log level, container time zone. +TELEGRAM_MODEL= +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, 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= +PLAYWRIGHT_EXTRA_ARGS= +PLAYWRIGHT_COMMAND= 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/.gitignore b/.gitignore index 9308d23..7eaf345 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ artifacts/ .volcanic/ # Claude Code worktrees and local state .claude/ +# Local compose configuration (secrets) +.env +.env.* +!.env.example diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3fbffd3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,88 @@ +# The Telegram host as one container. User guide: docs/docker.md. +# Spec: docs/superpowers/specs/2026-09-26-docker-deployment-design.md + +FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build +WORKDIR /src + +# Restore layer: only the project files the host needs, cached until one of them changes. +COPY Directory.Build.props VolcanicAgenticCore.slnx ./ +COPY src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj src/Volcanic.AgenticCore.Abstractions/ +COPY src/Volcanic.AgenticCore/Volcanic.AgenticCore.csproj src/Volcanic.AgenticCore/ +COPY extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/Volcanic.AgenticCore.Extensions.ClaudeCode.csproj extensions/Volcanic.AgenticCore.Extensions.ClaudeCode/ +COPY extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.csproj extensions/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge/ +COPY extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/Volcanic.AgenticCore.Extensions.GoogleCalendar.csproj extensions/Volcanic.AgenticCore.Extensions.GoogleCalendar/ +COPY extensions/Volcanic.AgenticCore.Extensions.Mcp/Volcanic.AgenticCore.Extensions.Mcp.csproj extensions/Volcanic.AgenticCore.Extensions.Mcp/ +COPY extensions/Volcanic.AgenticCore.Extensions.Scheduler/Volcanic.AgenticCore.Extensions.Scheduler.csproj extensions/Volcanic.AgenticCore.Extensions.Scheduler/ +COPY hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj hosts/Volcanic.AgenticCore.Telegram/ +RUN dotnet restore hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj + +COPY src/ src/ +COPY extensions/ extensions/ +COPY hosts/ hosts/ +# Framework-dependent: claude starts the bridge as `dotnet .dll`, so the loose dll and its runtimeconfig must exist. +RUN dotnet publish hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj -c Release --no-restore -o /out \ + && test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll \ + && test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json + +FROM mcr.microsoft.com/dotnet/runtime:10.0-noble +ARG DEBIAN_FRONTEND=noninteractive +ARG NODE_MAJOR=24 +ARG CLAUDE_CODE_VERSION=2.1 +ARG INSTALL_PLAYWRIGHT=false +# Must equal the version in PlaywrightPreset.DefaultPackage (the single source of truth); +# PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage fails when they differ. +ARG PLAYWRIGHT_MCP_VERSION=0.0.82 + +# What the agent's bash tool can run, then Node from NodeSource (Ubuntu noble's nodejs is too old). +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash git ripgrep python3 python-is-python3 python3-venv curl ca-certificates tzdata jq less procps openssh-client gnupg \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# The global prefix stays root-owned, so claude's auto-updater can't replace it at runtime: an upgrade changes what +# system/init validation sees and invalidates the prompt cache, so it only arrives with a rebuild. +RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + && claude --version \ + && npm cache clean --force + +# Set even without Playwright, so an MCP server started with the host environment finds browsers here. +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright +# Playwright MCP at the pinned version, and Chromium installed by that package's own playwright dependency, so browser +# build and library version match. Roughly +400 MB. +RUN if [ "$INSTALL_PLAYWRIGHT" = "true" ]; then \ + npm install -g "@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}" \ + && pw="$(npm root -g)/@playwright/mcp/node_modules/playwright/cli.js" \ + && test -f "$pw" \ + && node "$pw" install --with-deps chromium \ + && chmod -R a+rX "$PLAYWRIGHT_BROWSERS_PATH" \ + && playwright-mcp --version \ + && npm cache clean --force \ + && rm -rf /var/lib/apt/lists/*; \ + fi + +# The entrypoint hard-codes uid/gid 1654. Named volumes copy this ownership on first mount. +RUN test "$(id -u app)" = 1654 && test "$(id -g app)" = 1654 \ + && mkdir -p /data/workspaces /data/agent /data/claude /home/app \ + && chown -R app:app /data /home/app + +COPY --from=build /out /app +WORKDIR /app + +ENV Telegram__WorkspaceRoot=/data/workspaces \ + VOLCANIC_AGENT_DIR=/data/agent \ + CLAUDE_CONFIG_DIR=/data/claude \ + HOME=/home/app \ + DOTNET_CLI_TELEMETRY_OPTOUT=1 \ + DOTNET_NOLOGO=1 \ + TZ=UTC \ + DOTNET_EnableDiagnostics=0 + +# No USER: PID 1 (docker-init with compose's init: true) must stay root, or the agent could read every secret from +# /proc/1/environ. setpriv drops to app and execs dotnet, so Environment.ProcessPath is the muxer the bridge needs. +# An empty bounding set and no_new_privs keep the agent's processes from regaining root through setuid binaries. +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/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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c21c384 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +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): 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} + - PLAYWRIGHT_COMMAND=${PLAYWRIGHT_COMMAND:-playwright-mcp} + volumes: + - workspaces:/data/workspaces + - agent:/data/agent + - claude:/data/claude + +volumes: + workspaces: + agent: + claude: diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..38e6511 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,236 @@ +# 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`. 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 + +| 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: + +```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 + +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` | `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` 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 +> 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: + +```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 + +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 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 + +| 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`. +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, 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/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/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. 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-docker-deployment-design.md b/docs/superpowers/specs/2026-09-26-docker-deployment-design.md index 03b712e..9a7ff43 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). @@ -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", "--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 | | 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 | @@ -112,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} @@ -153,17 +155,29 @@ 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 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 @@ -190,9 +204,12 @@ 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 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 @@ -213,7 +230,12 @@ documented path. ## Verification -No unit tests (nothing in C# changes). Manual checklist in the implementation plan: +Two unit tests (`PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage` and `DockerComposeTests.Compose_model_fallback_matches_TelegramOptions_DefaultModel`); 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` @@ -225,13 +247,45 @@ 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=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 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 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/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 diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs index b025bd5..24c1700 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs @@ -88,4 +88,26 @@ 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); + // 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. + 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); + } } 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); + } +}