forked from admin/VolcanicAgenticCore
Merge branch 'feat/docker-deployment'
This commit is contained in:
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
**/TestResults
|
||||||
|
**/*.user
|
||||||
|
**/Properties/launchSettings.json
|
||||||
|
.git
|
||||||
|
.vs
|
||||||
|
.idea
|
||||||
|
.claude
|
||||||
|
.volcanic
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
tests
|
||||||
|
samples
|
||||||
|
docs
|
||||||
|
*.md
|
||||||
|
docker-compose.yml
|
||||||
39
.env.example
Normal file
39
.env.example
Normal file
@@ -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=
|
||||||
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Dockerfile text eol=lf
|
||||||
|
.dockerignore text eol=lf
|
||||||
|
docker-compose.yml text eol=lf
|
||||||
|
.env.example text eol=lf
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -14,3 +14,7 @@ artifacts/
|
|||||||
.volcanic/
|
.volcanic/
|
||||||
# Claude Code worktrees and local state
|
# Claude Code worktrees and local state
|
||||||
.claude/
|
.claude/
|
||||||
|
# Local compose configuration (secrets)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|||||||
88
Dockerfile
Normal file
88
Dockerfile
Normal file
@@ -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 <Bridge>.dll`, so the loose dll and its runtimeconfig must exist.
|
||||||
|
RUN dotnet publish hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj -c Release --no-restore -o /out \
|
||||||
|
&& test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll \
|
||||||
|
&& test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime:10.0-noble
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
ARG NODE_MAJOR=24
|
||||||
|
ARG CLAUDE_CODE_VERSION=2.1
|
||||||
|
ARG INSTALL_PLAYWRIGHT=false
|
||||||
|
# Must equal the version in PlaywrightPreset.DefaultPackage (the single source of truth);
|
||||||
|
# PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage fails when they differ.
|
||||||
|
ARG PLAYWRIGHT_MCP_VERSION=0.0.82
|
||||||
|
|
||||||
|
# What the agent's bash tool can run, then Node from NodeSource (Ubuntu noble's nodejs is too old).
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
bash git ripgrep python3 python-is-python3 python3-venv curl ca-certificates tzdata jq less procps openssh-client gnupg \
|
||||||
|
&& mkdir -p /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
||||||
|
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# The global prefix stays root-owned, so claude's auto-updater can't replace it at runtime: an upgrade changes what
|
||||||
|
# system/init validation sees and invalidates the prompt cache, so it only arrives with a rebuild.
|
||||||
|
RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
|
||||||
|
&& claude --version \
|
||||||
|
&& npm cache clean --force
|
||||||
|
|
||||||
|
# Set even without Playwright, so an MCP server started with the host environment finds browsers here.
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
|
||||||
|
# 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"]
|
||||||
@@ -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
|
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:
|
Hosting it yourself:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
@@ -69,6 +72,7 @@ await session.PromptAsync("Explain this repository");
|
|||||||
- [Writing extensions](docs/extensions.md): tools, commands, lifecycle hooks, providers, message injection, discovery.
|
- [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.
|
- [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.
|
- [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.
|
- [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.
|
- [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
|
- [Claude Code provider](docs/claude-code-provider.md): requirements, how the bridge works, limits, the doctor
|
||||||
|
|||||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@@ -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:
|
||||||
236
docs/docker.md
Normal file
236
docs/docker.md
Normal file
@@ -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/<chatId>`): `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-<chatId>/`), `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 <dir>`).
|
||||||
|
|
||||||
|
`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: <ids>. 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 <container> 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/<pid>/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/<chatId>/.volcanic/extensions/`
|
||||||
|
(one chat), then restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker cp MyExtension.dll <container>:/data/agent/extensions/
|
||||||
|
docker exec <container> chown app:app /data/agent/extensions/MyExtension.dll
|
||||||
|
docker restart <container>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <container> 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 <dir>` 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<token>/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/<pid>/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 `<workspace>/.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).
|
||||||
@@ -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:
|
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
|
`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
|
(`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,
|
`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
|
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.
|
an error from the first `browser_*` call.
|
||||||
|
|||||||
596
docs/superpowers/plans/2026-09-26-docker-deployment.md
Normal file
596
docs/superpowers/plans/2026-09-26-docker-deployment.md
Normal file
@@ -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" </proc/1/environ | grep SECRET_PROBE'` prints `SECRET_PROBE=x`, because `docker-init` runs as the container user and is dumpable. The agent's `bash` could read every secret from `/proc/1/environ`, defeating process-environment-hook §8. Fix (also checked): leave the image user as root and use `ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]`; with `--init`, `docker-init` is PID 1 as root and `cat /proc/1/environ` from uid 1654 fails with `Permission denied`. `setpriv` execs `dotnet`, so `Environment.ProcessPath` is still the muxer. `docker exec` then defaults to root, so the docs use `docker exec -u app`.
|
||||||
|
- The spec is the source of truth; when the implementation has to differ, edit the spec in the same commit and say so in the commit body.
|
||||||
|
- Commits: Conventional Commits, scope `docker` (`feat(docker): ...`, `docs(docker): ...`). Stage only the files the task lists.
|
||||||
|
- Test commands (from the worktree root): `dotnet test tests/Volcanic.AgenticCore.Extensions.Tests --filter "FullyQualifiedName~PlaywrightPresetTests"`; end of T1 and T6: `dotnet build && dotnet test` (green = only the three baseline failures).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Change | Task |
|
||||||
|
|---|---|---|
|
||||||
|
| `tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs` | `Dockerfile_version_matches_DefaultPackage` + `RepoFile` helper | T1 |
|
||||||
|
| `Dockerfile` | **new**: build stage, runtime stage, tools, Node, Claude CLI, env, `setpriv` entrypoint | T1 |
|
||||||
|
| `.dockerignore` | **new** | T1 |
|
||||||
|
| `.gitattributes` | **new**: LF for the Docker files | T1 |
|
||||||
|
| `docs/superpowers/specs/2026-09-26-docker-deployment-design.md` | root PID 1 + `setpriv` deviation, csproj list | T1 |
|
||||||
|
| `Dockerfile` | `INSTALL_PLAYWRIGHT` block | T2 |
|
||||||
|
| `docker-compose.yml`, `.env.example` | **new** | T3 |
|
||||||
|
| `.gitignore` | `.env` | T3 |
|
||||||
|
| `docs/docker.md` | **new** user doc | T4 |
|
||||||
|
| `README.md`, `docs/telegram-bot.md`, `docs/mcp.md` | links, container recommendation, `DOTNET_EnableDiagnostics` wording | T5 |
|
||||||
|
| `docs/superpowers/specs/2026-09-25-telegram-host-design.md`, `docs/superpowers/specs/2026-09-26-mcp-client-playwright-design.md` | out-of-scope line, Status note, deferred test bullet | T5 |
|
||||||
|
| `docs/superpowers/specs/2026-09-26-docker-deployment-design.md` | Status, Verification results | T6 |
|
||||||
|
|
||||||
|
## Spec Testing / Verification coverage
|
||||||
|
|
||||||
|
| Spec item | Where |
|
||||||
|
|---|---|
|
||||||
|
| MCP spec's deferred test: Dockerfile `PLAYWRIGHT_MCP_VERSION` equals `DefaultPackage` | T1 `PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage` |
|
||||||
|
| Verification 1: `docker compose build` with `INSTALL_PLAYWRIGHT=false` and `true`; bridge guard and `claude --version` pass | T1 (false), T2 (true), T3 (`docker compose up --build`), T6 (re-run from cache) |
|
||||||
|
| Verification 2: uid 1654, secrets not in the agent's env, `/proc/1/environ` denied, no `dotnet-diagnostic-*` socket | T3 probe container (`--network none`, `docker exec -u app`); the chat-driven parts (real bot, `/claude-code-login`, `/timezone`) are manual → report `openIssues` |
|
||||||
|
| Verification 3: `git`, `rg`, `python3`, `node`, `npx`; `playwright-mcp --version` offline; browser works | T1 tool check, T2 offline `--version` + headless Chromium launch as uid 1654; "tell me the H1" in a real chat is manual → `openIssues` |
|
||||||
|
| Verification 4: redeploy keeps chats, login, workspace files | T3 checks volume ownership/writability; a real redeploy with a logged-in bot is manual → `openIssues` |
|
||||||
|
| Verification 5: `ANTHROPIC_API_KEY` makes the provider refuse | Manual (needs a real chat) → `openIssues`; covered in code by existing ClaudeCode tests |
|
||||||
|
| Docs section (docker.md, README, telegram-bot.md, telegram-host spec line) | T4, T5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Dockerfile, .dockerignore and the Playwright version pin test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs`
|
||||||
|
- Create: `Dockerfile`, `.dockerignore`, `.gitattributes`
|
||||||
|
- Modify: `docs/superpowers/specs/2026-09-26-docker-deployment-design.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `PlaywrightPreset.PackageVersion` (`"0.0.82"`, `extensions/Volcanic.AgenticCore.Extensions.Mcp/PlaywrightPreset.cs`).
|
||||||
|
- Produces: repo-root `Dockerfile` with the line `ARG PLAYWRIGHT_MCP_VERSION=0.0.82`, a `ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright` line followed by the comment `# INSTALL_PLAYWRIGHT block (T2)` that T2 replaces, and image `volcanic-agentic-core:test`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Append to `PlaywrightPresetTests` (the file already has `using System.Text.RegularExpressions;`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void Dockerfile_version_matches_DefaultPackage()
|
||||||
|
{
|
||||||
|
var dockerfile = File.ReadAllText(RepoFile("Dockerfile"));
|
||||||
|
var matches = Regex.Matches(dockerfile, @"^ARG PLAYWRIGHT_MCP_VERSION=(?<version>\S+)\s*$", RegexOptions.Multiline);
|
||||||
|
|
||||||
|
var match = Assert.Single(matches);
|
||||||
|
Assert.Equal(PlaywrightPreset.PackageVersion, match.Groups["version"].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A file in the repository root, found by walking up from the test output to <c>VolcanicAgenticCore.slnx</c>.</summary>
|
||||||
|
private static string RepoFile(string name)
|
||||||
|
{
|
||||||
|
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(dir.FullName, "VolcanicAgenticCore.slnx"))) return Path.Combine(dir.FullName, name);
|
||||||
|
}
|
||||||
|
throw new InvalidOperationException("VolcanicAgenticCore.slnx not found above " + AppContext.BaseDirectory);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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 <Bridge>.dll`, so the loose dll and its runtimeconfig must exist.
|
||||||
|
RUN dotnet publish hosts/Volcanic.AgenticCore.Telegram/Volcanic.AgenticCore.Telegram.csproj -c Release --no-restore -o /out \
|
||||||
|
&& test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll \
|
||||||
|
&& test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime:10.0-noble
|
||||||
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
ARG NODE_MAJOR=24
|
||||||
|
ARG CLAUDE_CODE_VERSION=2.1
|
||||||
|
ARG INSTALL_PLAYWRIGHT=false
|
||||||
|
# Must equal the version in PlaywrightPreset.DefaultPackage (the single source of truth);
|
||||||
|
# PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage fails when they differ.
|
||||||
|
ARG PLAYWRIGHT_MCP_VERSION=0.0.82
|
||||||
|
|
||||||
|
# What the agent's bash tool can run, then Node from NodeSource (Ubuntu noble's nodejs is too old).
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
bash git ripgrep python3 python-is-python3 python3-venv curl ca-certificates tzdata jq less procps openssh-client gnupg \
|
||||||
|
&& mkdir -p /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
||||||
|
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# The global prefix stays root-owned, so claude's auto-updater can't replace it at runtime: an upgrade changes what
|
||||||
|
# system/init validation sees and invalidates the prompt cache, so it only arrives with a rebuild.
|
||||||
|
RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
|
||||||
|
&& claude --version \
|
||||||
|
&& npm cache clean --force
|
||||||
|
|
||||||
|
# Set even without Playwright, so an MCP server started with the host environment finds browsers here.
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
|
||||||
|
# INSTALL_PLAYWRIGHT block (T2)
|
||||||
|
|
||||||
|
# The entrypoint hard-codes uid/gid 1654. Named volumes copy this ownership on first mount.
|
||||||
|
RUN test "$(id -u app)" = 1654 && test "$(id -g app)" = 1654 \
|
||||||
|
&& mkdir -p /data/workspaces /data/agent /data/claude /home/app \
|
||||||
|
&& chown -R app:app /data /home/app
|
||||||
|
|
||||||
|
COPY --from=build /out /app
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV Telegram__WorkspaceRoot=/data/workspaces \
|
||||||
|
VOLCANIC_AGENT_DIR=/data/agent \
|
||||||
|
CLAUDE_CONFIG_DIR=/data/claude \
|
||||||
|
HOME=/home/app \
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT=1 \
|
||||||
|
DOTNET_NOLOGO=1 \
|
||||||
|
TZ=UTC \
|
||||||
|
DOTNET_EnableDiagnostics=0
|
||||||
|
|
||||||
|
# No USER: PID 1 (docker-init with compose's init: true) must stay root, or the agent could read every secret from
|
||||||
|
# /proc/1/environ. setpriv drops to app and execs dotnet, so Environment.ProcessPath is the muxer the bridge needs.
|
||||||
|
ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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 <container> claude auth login --claudeai` → `docker exec -u app -it <container> 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("<h1>ok</h1>"); 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 <dir>`). `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 <container> 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 <container>:/data/agent/extensions/` + restart (built against the same `Volcanic.AgenticCore.Abstractions` version), or `/data/workspaces/<chatId>/.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.
|
||||||
@@ -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 |
|
| Access | Mandatory user-id allow-list; empty list = refuse to start; other users are ignored silently |
|
||||||
| Chats | Private chats only |
|
| 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
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Docker deployment (Coolify) — design
|
# 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),
|
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).
|
`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` |
|
| Files | `Dockerfile`, `.dockerignore`, `docker-compose.yml`, `.env.example` at the repo root; user doc `docs/docker.md` |
|
||||||
| Build | Multi-stage: `mcr.microsoft.com/dotnet/sdk:10.0-noble` → `mcr.microsoft.com/dotnet/runtime:10.0-noble` |
|
| Build | Multi-stage: `mcr.microsoft.com/dotnet/sdk:10.0-noble` → `mcr.microsoft.com/dotnet/runtime:10.0-noble` |
|
||||||
| Publish | Framework-dependent `dotnet publish -c Release` of the Telegram host only. No self-contained, no AOT, no single-file: `claude` launches the bridge as `dotnet <Bridge>.dll`, so the `dotnet` muxer and a loose `Bridge.dll` + `.runtimeconfig.json` must exist |
|
| Publish | Framework-dependent `dotnet publish -c Release` of the Telegram host only. No self-contained, no AOT, no single-file: `claude` launches the bridge as `dotnet <Bridge>.dll`, so the `dotnet` muxer and a loose `Bridge.dll` + `.runtimeconfig.json` must exist |
|
||||||
| Entry | `ENTRYPOINT ["dotnet", "Volcanic.AgenticCore.Telegram.dll"]`, so `Environment.ProcessPath` is the muxer `BridgeLocator.FindDotnet` wants. No entrypoint script |
|
| Entry | `ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--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` |
|
| Agent tools | `bash`, `git`, `ripgrep`, `python3` (+ `python-is-python3`, `python3-venv`), `curl`, `ca-certificates`, `tzdata`, `jq`, `less`, `procps`, `openssh-client` |
|
||||||
| Node | Node.js 24 from NodeSource (`ARG NODE_MAJOR=24`); Ubuntu noble's `nodejs` is too old |
|
| Node | Node.js 24 from NodeSource (`ARG NODE_MAJOR=24`); Ubuntu noble's `nodejs` is too old |
|
||||||
| Claude CLI | `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}`, `ARG CLAUDE_CODE_VERSION=2.1` (newest 2.1.x at build time; set an exact version to pin). Updated by rebuilding, never in place |
|
| Claude CLI | `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}`, `ARG CLAUDE_CODE_VERSION=2.1` (newest 2.1.x at build time; set an exact version to pin). Updated by rebuilding, never in place |
|
||||||
| Playwright | `ARG INSTALL_PLAYWRIGHT=false` in the Dockerfile; compose passes `true`. Installs `@playwright/mcp` globally at the version pinned by the MCP spec, and Chromium + OS deps under `/opt/ms-playwright`. Compose points the preset at the preinstalled `playwright-mcp` (no npx download) and adds `--no-sandbox` |
|
| Playwright | `ARG INSTALL_PLAYWRIGHT=false` in the Dockerfile; compose passes `true`. Installs `@playwright/mcp` globally at the version pinned by the MCP spec, and Chromium + OS deps under `/opt/ms-playwright`. Compose points the preset at the preinstalled `playwright-mcp` (no npx download) and adds `--no-sandbox` |
|
||||||
| User | The runtime image's built-in non-root `app` user (`$APP_UID`, 1654), `HOME=/home/app` |
|
| User | The image's default user stays root so that PID 1 (compose's `init: true`, i.e. `docker-init`) is root-owned; the bot and everything it spawns run as the base image's non-root `app` user (1654, `$APP_UID`), `HOME=/home/app`. With `USER app`, `docker-init` runs as `app` and its `/proc/1/environ` (the whole container environment, secrets included) is readable by the agent's `bash` (checked on Docker 29) |
|
||||||
| Data | Three named volumes under `/data`: `workspaces`, `agent`, `claude` |
|
| Data | Three named volumes under `/data`: `workspaces`, `agent`, `claude` |
|
||||||
| Config | Environment only, set in Coolify. Required: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USER_IDS`. Optional: `OPENAI_API_KEY`, `STT_MODEL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `DEFAULT_TIMEZONE`, `PROCESS_ENV_ALLOW`, `TZ`, `PLAYWRIGHT_ENABLED`, `PLAYWRIGHT_HEADLESS`, `PLAYWRIGHT_BROWSER`, `PLAYWRIGHT_EXTRA_ARGS`, `PLAYWRIGHT_COMMAND` |
|
| Config | Environment only, set in Coolify. Required: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USER_IDS`. Optional: `OPENAI_API_KEY`, `STT_MODEL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `DEFAULT_TIMEZONE`, `PROCESS_ENV_ALLOW`, `TZ`, `PLAYWRIGHT_ENABLED`, `PLAYWRIGHT_HEADLESS`, `PLAYWRIGHT_BROWSER`, `PLAYWRIGHT_EXTRA_ARGS`, `PLAYWRIGHT_COMMAND` |
|
||||||
| Healthcheck | None (see below) |
|
| Healthcheck | None (see below) |
|
||||||
@@ -42,8 +42,7 @@ the image build.
|
|||||||
Build stage (`sdk:10.0-noble`):
|
Build stage (`sdk:10.0-noble`):
|
||||||
|
|
||||||
1. `WORKDIR /src`. Copy `Directory.Build.props`, `VolcanicAgenticCore.slnx` and only the `*.csproj` files the host
|
1. `WORKDIR /src`. Copy `Directory.Build.props`, `VolcanicAgenticCore.slnx` and only the `*.csproj` files the host
|
||||||
needs (Abstractions, core, ClaudeCode, ClaudeCode.Bridge, Telegram, plus the Scheduler and GoogleCalendar
|
needs (Abstractions, core, ClaudeCode, ClaudeCode.Bridge, GoogleCalendar, Mcp, Scheduler, Telegram), then
|
||||||
extensions once the host references them), then
|
|
||||||
`dotnet restore hosts/Volcanic.AgenticCore.Telegram` — a cached layer until a project file changes.
|
`dotnet restore hosts/Volcanic.AgenticCore.Telegram` — a cached layer until a project file changes.
|
||||||
2. Copy `src/`, `extensions/`, `hosts/` and `dotnet publish hosts/Volcanic.AgenticCore.Telegram -c Release
|
2. Copy `src/`, `extensions/`, `hosts/` and `dotnet publish hosts/Volcanic.AgenticCore.Telegram -c Release
|
||||||
--no-restore -o /out`. `TreatWarningsAsErrors` applies, so a warning fails the image build as it fails `dotnet build`.
|
--no-restore -o /out`. `TreatWarningsAsErrors` applies, so a warning fails the image build as it fails `dotnet build`.
|
||||||
@@ -67,15 +66,17 @@ Runtime stage (`runtime:10.0-noble`), as root:
|
|||||||
process inherits the environment. `ARG PLAYWRIGHT_MCP_VERSION` defaults to the exact version in the MCP spec's
|
process inherits the environment. `ARG PLAYWRIGHT_MCP_VERSION` defaults to the exact version in the MCP spec's
|
||||||
`PlaywrightPreset.DefaultPackage`, which is the single source of truth. A unit test in that spec fails when the two
|
`PlaywrightPreset.DefaultPackage`, which is the single source of truth. A unit test in that spec fails when the two
|
||||||
differ, so bumping the version means changing both in one commit. Compose does not override this arg.
|
differ, so bumping the version means changing both in one commit. Compose does not override this arg.
|
||||||
4. `mkdir -p /data/workspaces /data/agent /data/claude && chown -R app:app /data`. Named volumes copy this
|
4. `mkdir -p /data/workspaces /data/agent /data/claude /home/app && chown -R app:app /data /home/app`. Named volumes
|
||||||
ownership on first mount, which is what lets a non-root process write to them.
|
copy this ownership on first mount, which is what lets a non-root process write to them.
|
||||||
5. `COPY --from=build /out /app`, `WORKDIR /app`, `USER $APP_UID`.
|
5. `COPY --from=build /out /app`, `WORKDIR /app`. No `USER` (see the User row); an earlier step checks that `app` is
|
||||||
|
uid/gid 1654, which the entrypoint hard-codes.
|
||||||
6. Environment baked into the image (compose and Coolify may override):
|
6. Environment baked into the image (compose and Coolify may override):
|
||||||
|
|
||||||
| Variable | Value | Why |
|
| Variable | Value | Why |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Telegram__WorkspaceRoot` | `/data/workspaces` | Chat `<id>` works in `/data/workspaces/<id>` |
|
| `Telegram__WorkspaceRoot` | `/data/workspaces` | Chat `<id>` works in `/data/workspaces/<id>` |
|
||||||
| `VOLCANIC_AGENT_DIR` | `/data/agent` | Sessions, `extensions/*.dll`, `settings.json`, `auth.json` |
|
| `VOLCANIC_AGENT_DIR` | `/data/agent` | Sessions, `extensions/*.dll`, `settings.json`, `auth.json` |
|
||||||
|
| `HOME` | `/home/app` | The image user is root, so Docker would otherwise set `/root` |
|
||||||
| `CLAUDE_CONFIG_DIR` | `/data/claude` | Moves both `~/.claude/` and `~/.claude.json` (credentials and onboarding state) into one volume. Already on the provider's env allow-list, so every `claude` child and `claude auth login` see it |
|
| `CLAUDE_CONFIG_DIR` | `/data/claude` | Moves both `~/.claude/` and `~/.claude.json` (credentials and onboarding state) into one volume. Already on the provider's env allow-list, so every `claude` child and `claude auth login` see it |
|
||||||
| `DOTNET_CLI_TELEMETRY_OPTOUT`, `DOTNET_NOLOGO` | `1` | Quiet |
|
| `DOTNET_CLI_TELEMETRY_OPTOUT`, `DOTNET_NOLOGO` | `1` | Quiet |
|
||||||
| `TZ` | `UTC` | Overridable |
|
| `TZ` | `UTC` | Overridable |
|
||||||
@@ -112,8 +113,9 @@ services:
|
|||||||
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||||
- DEFAULT_TIMEZONE=${DEFAULT_TIMEZONE:-} # chats' default zone (scheduler spec §7); empty → TZ
|
- 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
|
- 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 MCP preset (MCP spec): the preinstalled binary, no npx download. Set PLAYWRIGHT_ENABLED=false for an
|
||||||
- PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-${INSTALL_PLAYWRIGHT:-true}}
|
# 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_HEADLESS=${PLAYWRIGHT_HEADLESS:-true} # no display in the container
|
||||||
- PLAYWRIGHT_BROWSER=${PLAYWRIGHT_BROWSER:-}
|
- PLAYWRIGHT_BROWSER=${PLAYWRIGHT_BROWSER:-}
|
||||||
- PLAYWRIGHT_EXTRA_ARGS=${PLAYWRIGHT_EXTRA_ARGS:---no-sandbox}
|
- 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
|
`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD` or their `Section__Key` forms) are removed from the environment of
|
||||||
the agent's `bash`, scheduled scripts and MCP servers by the process environment hook. To give the agent a
|
the agent's `bash`, scheduled scripts and MCP servers by the process environment hook. To give the agent a
|
||||||
credential on purpose (say `GH_TOKEN` for `gh`), add it in Coolify and list its name in `PROCESS_ENV_ALLOW`; an MCP
|
credential on purpose (say `GH_TOKEN` for `gh`), add it in Coolify and list its name in `PROCESS_ENV_ALLOW`; an MCP
|
||||||
server gets one through its own `env` entry instead. On top of that, the bot (PID 1 in the container) makes itself
|
server gets one through its own `env` entry instead. On top of that, the bot (the only child of the root-owned
|
||||||
non-dumpable at startup, so the agent can't read `/proc/1/environ` or attach to it, and the configured secrets'
|
PID 1) makes itself non-dumpable at startup, so the agent can't read its `/proc/<pid>/environ` or attach to it,
|
||||||
values are replaced by `[REDACTED]` in tool output (process-environment-hook spec §8–§9). The side effect is no
|
PID 1's own `/proc/1/environ` is root-only, and the configured secrets' values are replaced by `[REDACTED]` in
|
||||||
core dumps and no `dotnet-dump`/`dotnet-trace` attach; `ProcessEnvironment__HardenHostProcess=false` turns it off
|
tool output (process-environment-hook spec §8–§9). The side effect is no core dumps and no
|
||||||
for a debugging session. The container stays the real isolation boundary: none of this is a sandbox.
|
`dotnet-dump`/`dotnet-trace` attach; `ProcessEnvironment__HardenHostProcess=false` turns it off for a debugging
|
||||||
|
session. The 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 `<workspace>/.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
|
- Playwright: `PLAYWRIGHT_COMMAND=playwright-mcp` runs the globally installed, version-pinned server, so the first
|
||||||
browser call in a chat doesn't wait on an npx download. `PLAYWRIGHT_EXTRA_ARGS` defaults to `--no-sandbox`:
|
browser call in a chat doesn't wait on an npx download. `PLAYWRIGHT_EXTRA_ARGS` defaults to `--no-sandbox`:
|
||||||
Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies to the non-root `app` user,
|
Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies to the non-root `app` user,
|
||||||
so the container is the isolation boundary. Overriding `PLAYWRIGHT_EXTRA_ARGS` must keep `--no-sandbox`.
|
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
|
`PLAYWRIGHT_ENABLED` defaults to `true`, so an image built with `INSTALL_PLAYWRIGHT=false` also needs
|
||||||
without Playwright doesn't advertise tools it can't run.
|
`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
|
- 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.
|
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
|
- 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.
|
`/data/claude`. `/claude-code-doctor` should then report logged in, `claude.ai`, bridge ok.
|
||||||
4. Later expiries use the existing owner re-login flow; nothing Docker-specific.
|
4. Later expiries use the existing owner re-login flow; nothing Docker-specific.
|
||||||
|
|
||||||
Fallback without the bot: `docker exec -it <container> claude auth login --claudeai` (runs as `app`, same
|
Fallback without the bot: `docker exec -u app -it <container> env -i HOME=/home/app
|
||||||
`CLAUDE_CONFIG_DIR`). Copying an existing `.credentials.json` into the `claude` volume also works but is not the
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin CLAUDE_CONFIG_DIR=/data/claude TERM=xterm claude auth
|
||||||
documented path.
|
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/<pid>/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
|
## Extensions and project context
|
||||||
|
|
||||||
@@ -213,7 +230,12 @@ documented path.
|
|||||||
|
|
||||||
## Verification
|
## 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.
|
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`
|
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.
|
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).
|
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/<pid>/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
|
## Open questions
|
||||||
|
|
||||||
1. Persist `HOME` too? `~/.gitconfig`, `~/.ssh`, `gh` auth and the npm/npx cache are lost on redeploy with this
|
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.
|
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
|
2. ~~Playwright default~~: resolved. The MCP spec shipped first, so compose keeps building it in (`true`).
|
||||||
default to `false` if that spec lands later. (Sandboxing and which binary is launched are settled above.)
|
(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,
|
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?
|
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
|
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?
|
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.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# MCP client extension and Playwright browser tools — design
|
# MCP client extension and Playwright browser tools — design
|
||||||
|
|
||||||
Date: 2026-09-26
|
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);
|
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
|
`2026-09-26-process-environment-hook-design.md` (the environment every server process gets, and the redaction of
|
||||||
known secrets in tool output).
|
known secrets in tool output).
|
||||||
@@ -393,8 +393,8 @@ No test touches the network, npm or a real browser.
|
|||||||
`settings.json` merge and precedence;
|
`settings.json` merge and precedence;
|
||||||
- Playwright preset: argv for headless/browser/extra args, output dir and profile under the workspace, a pinned
|
- 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 <package>` is dropped; the Dockerfile's
|
package; with `PLAYWRIGHT_COMMAND` the binary is used and `-y <package>` is dropped; the Dockerfile's
|
||||||
`PLAYWRIGHT_MCP_VERSION` default equals `DefaultPackage`'s version (deferred: the Dockerfile and that test come with the
|
`PLAYWRIGHT_MCP_VERSION` default equals `DefaultPackage`'s version (`Dockerfile_version_matches_DefaultPackage`, added
|
||||||
docker-deployment spec); the prompt section appears only with `browser_*` tools active and mentions `send` only when it exists;
|
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
|
- end to end through the core with `FauxProvider`: the scripted model calls the fake `image` tool, and the tool
|
||||||
result message holds the image.
|
result message holds the image.
|
||||||
- **Claude Code**: a `ToolCatalog` test that `browser_navigate`-style names alias to themselves (no digest).
|
- **Claude Code**: a `ToolCatalog` test that `browser_navigate`-style names alias to themselves (no digest).
|
||||||
|
|||||||
@@ -193,8 +193,9 @@ storage and time zones are in [google-calendar.md](google-calendar.md).
|
|||||||
## Security
|
## Security
|
||||||
|
|
||||||
The agent has the built-in tools, including `bash`, and runs them as the bot's OS user. Only allow-listed user ids
|
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
|
are served (everyone else is ignored and logged), and only in private chats. Run it in the container image
|
||||||
container, and treat the workspace root as writable by anyone on the allow-list.
|
([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
|
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.
|
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/<pid>/environ` or ptrace: at startup the process
|
On Linux the bot's own environment can't be read through `/proc/<pid>/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
|
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
|
too (the Docker image, [docker.md](docker.md), sets it): otherwise the .NET diagnostic socket still hands the
|
||||||
of the same user, and the bot logs a warning saying so.
|
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
|
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
|
them in a tool result, whichever file or process they were read from. Encoded forms (base64, URL-encoded, split
|
||||||
|
|||||||
@@ -88,4 +88,26 @@ public class PlaywrightPresetTests
|
|||||||
Assert.Contains("browser_close", without, StringComparison.Ordinal);
|
Assert.Contains("browser_close", without, StringComparison.Ordinal);
|
||||||
Assert.Contains("passwords", without, StringComparison.Ordinal);
|
Assert.Contains("passwords", without, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dockerfile_version_matches_DefaultPackage()
|
||||||
|
{
|
||||||
|
var dockerfile = File.ReadAllText(RepoFile("Dockerfile"));
|
||||||
|
var matches = Regex.Matches(dockerfile, @"^ARG PLAYWRIGHT_MCP_VERSION=(?<version>\S+)\s*$", RegexOptions.Multiline);
|
||||||
|
|
||||||
|
var match = Assert.Single(matches);
|
||||||
|
Assert.Equal(PlaywrightPreset.PackageVersion, match.Groups["version"].Value);
|
||||||
|
// The install step must use the ARG, not a version of its own.
|
||||||
|
Assert.Contains("\"@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}\"", dockerfile, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A file in the repository root, found by walking up from the test output to <c>VolcanicAgenticCore.slnx</c>.</summary>
|
||||||
|
private static string RepoFile(string name)
|
||||||
|
{
|
||||||
|
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(dir.FullName, "VolcanicAgenticCore.slnx"))) return Path.Combine(dir.FullName, name);
|
||||||
|
}
|
||||||
|
throw new InvalidOperationException("VolcanicAgenticCore.slnx not found above " + AppContext.BaseDirectory);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Volcanic.AgenticCore.Telegram.Tests;
|
||||||
|
|
||||||
|
/// <summary>Values the repo-root <c>docker-compose.yml</c> copies from the host's code.</summary>
|
||||||
|
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:-(?<model>[^}]+)\}");
|
||||||
|
|
||||||
|
var match = Assert.Single(matches);
|
||||||
|
Assert.Equal(TelegramOptions.DefaultModel, match.Groups["model"].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A file in the repository root, found by walking up from the test output to <c>VolcanicAgenticCore.slnx</c>.</summary>
|
||||||
|
private static string RepoFile(string name)
|
||||||
|
{
|
||||||
|
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(dir.FullName, "VolcanicAgenticCore.slnx"))) return Path.Combine(dir.FullName, name);
|
||||||
|
}
|
||||||
|
throw new InvalidOperationException("VolcanicAgenticCore.slnx not found above " + AppContext.BaseDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user