fix(docker): address review findings

Entrypoint drops the capability bounding set and sets no_new_privs. Security docs
and spec no longer claim the agent can't reach the secrets; docker exec fallbacks
use a clean environment. PLAYWRIGHT_ENABLED drops the nested compose default.
Tests pin the Dockerfile's Playwright install step and the compose model fallback.
This commit is contained in:
EugeneTes
2026-09-27 12:34:58 +02:00
parent b9a95a57c8
commit 7bb41f0b5b
8 changed files with 124 additions and 30 deletions

View File

@@ -6,8 +6,8 @@
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_ALLOWED_USER_IDS= TELEGRAM_ALLOWED_USER_IDS=
# Model every chat uses, log level, container time zone. # Model every chat uses (empty = the compose default), log level, container time zone.
TELEGRAM_MODEL=claude-code/claude-opus-5-5 TELEGRAM_MODEL=
LOG_LEVEL=Information LOG_LEVEL=Information
TZ=UTC TZ=UTC
@@ -29,8 +29,9 @@ DEFAULT_TIMEZONE=
# Deny-listed variables the agent's processes may see anyway, comma separated (e.g. GH_TOKEN). # Deny-listed variables the agent's processes may see anyway, comma separated (e.g. GH_TOKEN).
PROCESS_ENV_ALLOW= PROCESS_ENV_ALLOW=
# Playwright MCP preset; empty = compose defaults (enabled if built in, headless, chromium, --no-sandbox, playwright-mcp). # Playwright MCP preset; empty = compose defaults (enabled, headless, chromium, --no-sandbox, playwright-mcp).
# An override of PLAYWRIGHT_EXTRA_ARGS must keep --no-sandbox. # An override of PLAYWRIGHT_EXTRA_ARGS must keep --no-sandbox.
# An image built with INSTALL_PLAYWRIGHT=false also needs PLAYWRIGHT_ENABLED=false.
PLAYWRIGHT_ENABLED= PLAYWRIGHT_ENABLED=
PLAYWRIGHT_HEADLESS= PLAYWRIGHT_HEADLESS=
PLAYWRIGHT_BROWSER= PLAYWRIGHT_BROWSER=

2
.gitignore vendored
View File

@@ -16,3 +16,5 @@ artifacts/
.claude/ .claude/
# Local compose configuration (secrets) # Local compose configuration (secrets)
.env .env
.env.*
!.env.example

View File

@@ -84,4 +84,5 @@ ENV Telegram__WorkspaceRoot=/data/workspaces \
# No USER: PID 1 (docker-init with compose's init: true) must stay root, or the agent could read every secret from # 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. # /proc/1/environ. setpriv drops to app and execs dotnet, so Environment.ProcessPath is the muxer the bridge needs.
ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"] # An empty bounding set and no_new_privs keep the agent's processes from regaining root through setuid binaries.
ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--bounding-set=-all", "--no-new-privs", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]

View File

@@ -25,8 +25,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}

View File

@@ -25,9 +25,14 @@ docker compose logs -f bot
The bot and everything it starts run as `app` (uid/gid 1654, `HOME=/home/app`). The image's own user stays root: The 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 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`. `init: true`) is owned by root and the agent can't read the container's environment from `/proc/1/environ`. It also
Don't set `user:` in the compose file: `setpriv` needs root to switch users. For the same reason `docker exec` empties the capability bounding set and sets `no_new_privs`, so nothing the agent runs regains root through a setuid
opens a root shell; add `-u app` to act as the bot. 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 ## Build arguments
@@ -39,7 +44,20 @@ opens a root shell; add `-u app` to act as the bot.
| `PLAYWRIGHT_MCP_VERSION` | `0.0.82` | Must equal the version in `PlaywrightPreset.DefaultPackage`; `PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage` fails when they differ. The compose file doesn't override it | | `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). The compose file passes `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` from `.env` (or Coolify's variables).
Without compose: `docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core .` Without compose:
```bash
docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core .
docker run -d --init --shm-size 1g --restart unless-stopped --env-file .env \
-e PLAYWRIGHT_ENABLED=true -e PLAYWRIGHT_HEADLESS=true -e PLAYWRIGHT_EXTRA_ARGS=--no-sandbox \
-e PLAYWRIGHT_COMMAND=playwright-mcp \
-v workspaces:/data/workspaces -v agent:/data/agent -v claude:/data/claude volcanic-agentic-core
```
`--init` is required: without it `dotnet` is PID 1, so nothing reaps the zombies that killed `claude` trees and `bash`
children leave behind, and PID 1 is no longer root's. `--shm-size 1g` keeps Chromium from crashing. The compose
file's defaults (the `-e` lines, which win over `--env-file`) don't apply to a plain `docker run`, and neither does
its `TELEGRAM_MODEL` / `LOG_LEVEL` mapping: pass `-e Telegram__Model=...` instead.
## Configuration ## Configuration
@@ -59,14 +77,14 @@ The compose file maps them into the container.
| `GOOGLE_CLIENT_SECRET` | — | See above | No, and redacted | | `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 | | `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 | | `PROCESS_ENV_ALLOW` | — | Comma-separated names or globs let through despite the deny rules, e.g. `GH_TOKEN` | Yes |
| `PLAYWRIGHT_ENABLED` | same as `INSTALL_PLAYWRIGHT` | Browser tools on or off | Yes | | `PLAYWRIGHT_ENABLED` | `true` | Browser tools on or off. Set it to `false` for an image built with `INSTALL_PLAYWRIGHT=false` | Yes |
| `PLAYWRIGHT_HEADLESS` | `true` | The container has no display | Yes | | `PLAYWRIGHT_HEADLESS` | `true` | The container has no display | Yes |
| `PLAYWRIGHT_BROWSER` | `chromium` | Only Chromium is installed in the image | 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_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 | | `PLAYWRIGHT_COMMAND` | `playwright-mcp` | The preinstalled server, so the first browser call doesn't wait for an `npx` download | Yes |
`TELEGRAM_MODEL`, `LOG_LEVEL`, `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` are read by the compose file only; the `TELEGRAM_MODEL` and `LOG_LEVEL` are read by the compose file only, which passes them to the bot as `Telegram__Model`
bot itself reads the names in the right-hand form. 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`, > **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 > `CLAUDE_CODE_USE_BEDROCK` or `CLAUDE_CODE_USE_VERTEX`.** The Claude Code provider runs on a Claude subscription only
@@ -145,8 +163,17 @@ process. A rejected token (`401`) exits with code 1 and invalid configuration ex
When the login expires later, the owner gets the same prompt automatically and the paused request continues after When the login expires later, the owner gets the same prompt automatically and the paused request continues after
the code is pasted. the code is pasted.
Without the bot: `docker exec -u app -it <container> claude auth login --claudeai`. The `-u app` matters: `docker exec` Without the bot:
runs as root by default, and credentials written by root can't be read by 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 ## Updating
@@ -173,7 +200,9 @@ docker restart <container>
Build them against the same `Volcanic.AgenticCore.Abstractions` version as the image. See [extensions.md](extensions.md). 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. 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> bash`. 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 ## Troubleshooting
@@ -192,9 +221,16 @@ The agent can edit them, or you can with `docker exec -u app -it <container> bas
The container is the isolation boundary. Inside it, the agent's `bash` runs as `app`, the same user as the bot: it 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`. can read the workspaces, the agent directory with every chat's sessions, and the Claude login in `/data/claude`.
What it can't get at are the configured secrets: they are filtered out of its environment, the bot process is The configured secrets are kept out of its way: they are filtered out of its environment, the bot process is
non-dumpable (its `/proc/<pid>/environ` is unreadable and it can't be attached to), PID 1 is root's so 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, and secret values are redacted from tool output. `/proc/1/environ` is unreadable too, the diagnostic socket is off, setuid binaries can't raise its privileges, and
None of this is a sandbox. Anyone on the allow-list can run any command in the container. 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). Details in [telegram-bot.md](telegram-bot.md#security).

View File

@@ -21,7 +21,7 @@ build and to the Claude Code provider's subscription-only rules.
| Files | `Dockerfile`, `.dockerignore`, `docker-compose.yml`, `.env.example` at the repo root; user doc `docs/docker.md` | | 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 ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]`: `setpriv` (util-linux, already in the base image) drops to `app` and execs `dotnet`, so `Environment.ProcessPath` is still the muxer `BridgeLocator.FindDotnet` wants. No entrypoint script | | Entry | `ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--bounding-set=-all", "--no-new-privs", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]`: `setpriv` (util-linux, already in the base image) drops to `app` and execs `dotnet`, so `Environment.ProcessPath` is still the muxer `BridgeLocator.FindDotnet` wants. No entrypoint script |
| Agent tools | `bash`, `git`, `ripgrep`, `python3` (+ `python-is-python3`, `python3-venv`), `curl`, `ca-certificates`, `tzdata`, `jq`, `less`, `procps`, `openssh-client` | | 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 |
@@ -113,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}
@@ -159,13 +160,24 @@ volumes:
PID 1's own `/proc/1/environ` is root-only, and the configured secrets' values are replaced by `[REDACTED]` in PID 1's own `/proc/1/environ` is root-only, and the configured secrets' values are replaced by `[REDACTED]` in
tool output (process-environment-hook spec §8–§9). The side effect is no core dumps and no tool output (process-environment-hook spec §8–§9). The side effect is no core dumps and no
`dotnet-dump`/`dotnet-trace` attach; `ProcessEnvironment__HardenHostProcess=false` turns it off for a debugging `dotnet-dump`/`dotnet-trace` attach; `ProcessEnvironment__HardenHostProcess=false` turns it off for a debugging
session. The container stays the real isolation boundary: none of this is a sandbox. session. The entrypoint also gives the bot an empty capability bounding set and `no_new_privs`, so nothing it
spawns regains root through the base image's setuid binaries (`su`, `mount`, `passwd`, ...).
All of this keeps secrets out of the agent's environment and tool output by accident; it does **not** stop an agent
that goes after them on purpose. `app` owns `/data/workspaces` and `/data/agent`, extensions are discovered on every
session creation, and MCP servers expand `${env:NAME}` from the host's unfiltered environment, so the agent can drop
a DLL into `<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
@@ -192,8 +204,11 @@ shows the service as running without health status; that is acceptable.
`/data/claude`. `/claude-code-doctor` should then report logged in, `claude.ai`, bridge ok. `/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 -u app -it <container> claude auth login --claudeai` (same `CLAUDE_CONFIG_DIR`; Fallback without the bot: `docker exec -u app -it <container> env -i HOME=/home/app
`docker exec` defaults to root, whose credentials the bot couldn't read). Copying an existing `.credentials.json` into PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin CLAUDE_CONFIG_DIR=/data/claude TERM=xterm claude auth
login --claudeai` (`docker exec` defaults to root, whose credentials the bot couldn't read). `env -i` matters: a
`docker exec` process gets the whole container environment, secrets included, and an ordinary (dumpable) `app` process
has its `/proc/<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. the `claude` volume also works but is not the documented path.
## Extensions and project context ## Extensions and project context
@@ -249,11 +264,16 @@ Verification record (2026-09-27, Docker Desktop 29 on Windows 11, Linux containe
(`disabled` / `enabled (headless, chromium)`), `Speech to text`, `Using Claude Code at /usr/bin/claude, model (`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 claude-code/claude-opus-5-5`, `Scheduler`, `Default time zone: UTC`, `Allowed users: 1. Workspaces under
/data/workspaces`, then `Telegram unreachable` backoff. /data/workspaces`, then `Telegram unreachable` backoff.
- `docker compose config`: with a minimal `.env` (`INSTALL_PLAYWRIGHT=false`), `PLAYWRIGHT_ENABLED` follows it to - `docker compose config`: with a minimal `.env` (`INSTALL_PLAYWRIGHT=false`, `PLAYWRIGHT_ENABLED=false`), both are
`"false"`; with `.env.example`'s defaults it is `"true"`; `--no-sandbox`, `playwright-mcp`, `init: true`, `"false"`; with `.env.example`'s defaults both are `"true"` and `Telegram__Model` is the compose fallback;
`shm_size`, three named volumes, no ports. `--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 - `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. 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
@@ -265,3 +285,7 @@ Verification record (2026-09-27, Docker Desktop 29 on Windows 11, Linux containe
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.

View File

@@ -97,6 +97,8 @@ public class PlaywrightPresetTests
var match = Assert.Single(matches); var match = Assert.Single(matches);
Assert.Equal(PlaywrightPreset.PackageVersion, match.Groups["version"].Value); 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> /// <summary>A file in the repository root, found by walking up from the test output to <c>VolcanicAgenticCore.slnx</c>.</summary>

View File

@@ -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);
}
}