From 7bb41f0b5bd24b6c7634ce57ea3d00508e165f49 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Sun, 27 Sep 2026 12:34:58 +0200 Subject: [PATCH] fix(docker): address review findings Entrypoint drops the capability bounding set and sets no_new_privs. Security docs and spec no longer claim the agent can't reach the secrets; docker exec fallbacks use a clean environment. PLAYWRIGHT_ENABLED drops the nested compose default. Tests pin the Dockerfile's Playwright install step and the compose model fallback. --- .env.example | 7 ++- .gitignore | 2 + Dockerfile | 3 +- docker-compose.yml | 5 +- docs/docker.md | 62 +++++++++++++++---- .../2026-09-26-docker-deployment-design.md | 46 ++++++++++---- .../Mcp/PlaywrightPresetTests.cs | 2 + .../DockerComposeTests.cs | 27 ++++++++ 8 files changed, 124 insertions(+), 30 deletions(-) create mode 100644 tests/Volcanic.AgenticCore.Telegram.Tests/DockerComposeTests.cs diff --git a/.env.example b/.env.example index ba8cf31..cd3b0b6 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,8 @@ TELEGRAM_BOT_TOKEN= TELEGRAM_ALLOWED_USER_IDS= -# Model every chat uses, log level, container time zone. -TELEGRAM_MODEL=claude-code/claude-opus-5-5 +# Model every chat uses (empty = the compose default), log level, container time zone. +TELEGRAM_MODEL= LOG_LEVEL=Information TZ=UTC @@ -29,8 +29,9 @@ DEFAULT_TIMEZONE= # Deny-listed variables the agent's processes may see anyway, comma separated (e.g. GH_TOKEN). PROCESS_ENV_ALLOW= -# Playwright MCP preset; empty = compose defaults (enabled if built in, headless, chromium, --no-sandbox, playwright-mcp). +# Playwright MCP preset; empty = compose defaults (enabled, headless, chromium, --no-sandbox, playwright-mcp). # An override of PLAYWRIGHT_EXTRA_ARGS must keep --no-sandbox. +# An image built with INSTALL_PLAYWRIGHT=false also needs PLAYWRIGHT_ENABLED=false. PLAYWRIGHT_ENABLED= PLAYWRIGHT_HEADLESS= PLAYWRIGHT_BROWSER= diff --git a/.gitignore b/.gitignore index c97b6d3..7eaf345 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ artifacts/ .claude/ # Local compose configuration (secrets) .env +.env.* +!.env.example diff --git a/Dockerfile b/Dockerfile index 7b08ebe..3fbffd3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,4 +84,5 @@ ENV Telegram__WorkspaceRoot=/data/workspaces \ # No USER: PID 1 (docker-init with compose's init: true) must stay root, or the agent could read every secret from # /proc/1/environ. setpriv drops to app and execs dotnet, so Environment.ProcessPath is the muxer the bridge needs. -ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"] +# An empty bounding set and no_new_privs keep the agent's processes from regaining root through setuid binaries. +ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--bounding-set=-all", "--no-new-privs", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"] diff --git a/docker-compose.yml b/docker-compose.yml index c65aa2b..c21c384 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,8 +25,9 @@ services: - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - DEFAULT_TIMEZONE=${DEFAULT_TIMEZONE:-} # chats' default zone (scheduler spec §7); empty → TZ - PROCESS_ENV_ALLOW=${PROCESS_ENV_ALLOW:-} # deny-listed variables the agent may see, e.g. GH_TOKEN - # Playwright MCP preset (MCP spec). Enabled when the image has it; the preinstalled binary, no npx download. - - PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-${INSTALL_PLAYWRIGHT:-true}} + # Playwright MCP preset (MCP spec): the preinstalled binary, no npx download. Set PLAYWRIGHT_ENABLED=false for an + # image built with INSTALL_PLAYWRIGHT=false (no nested default: Coolify parses these expressions itself). + - PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-true} - PLAYWRIGHT_HEADLESS=${PLAYWRIGHT_HEADLESS:-true} # no display in the container - PLAYWRIGHT_BROWSER=${PLAYWRIGHT_BROWSER:-} - PLAYWRIGHT_EXTRA_ARGS=${PLAYWRIGHT_EXTRA_ARGS:---no-sandbox} diff --git a/docs/docker.md b/docs/docker.md index 45171f1..38e6511 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -25,9 +25,14 @@ docker compose logs -f bot The bot and everything it starts run as `app` (uid/gid 1654, `HOME=/home/app`). The image's own user stays root: the entrypoint `setpriv` drops to `app` before it starts `dotnet`, so PID 1 (`docker-init`, from compose's -`init: true`) is owned by root and the agent can't read the container's environment from `/proc/1/environ`. -Don't set `user:` in the compose file: `setpriv` needs root to switch users. For the same reason `docker exec` -opens a root shell; add `-u app` to act as the bot. +`init: true`) is owned by root and the agent can't read the container's environment from `/proc/1/environ`. It also +empties the capability bounding set and sets `no_new_privs`, so nothing the agent runs regains root through a setuid +binary such as `su` or `mount`. Don't set `user:` in the compose file: `setpriv` needs root to switch users. For the +same reason `docker exec` opens a root shell; add `-u app` to act as the bot. Any command that writes under `HOME` +(`npm`, `npx`, `git config`, `gh auth`, `claude`) must run with `-u app`: as root it leaves root-owned files in +`/home/app` (`HOME` is set image-wide), and the bot's `npx` and `git` then fail with `EACCES` until the next redeploy. +An interactive `app` session should also start from a clean environment (see +[First run and Claude login](#first-run-and-claude-login)). ## Build arguments @@ -39,7 +44,20 @@ opens a root shell; add `-u app` to act as the bot. | `PLAYWRIGHT_MCP_VERSION` | `0.0.82` | Must equal the version in `PlaywrightPreset.DefaultPackage`; `PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage` fails when they differ. The compose file doesn't override it | The compose file passes `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` from `.env` (or Coolify's variables). -Without compose: `docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core .` +Without compose: + +```bash +docker build --build-arg INSTALL_PLAYWRIGHT=true -t volcanic-agentic-core . +docker run -d --init --shm-size 1g --restart unless-stopped --env-file .env \ + -e PLAYWRIGHT_ENABLED=true -e PLAYWRIGHT_HEADLESS=true -e PLAYWRIGHT_EXTRA_ARGS=--no-sandbox \ + -e PLAYWRIGHT_COMMAND=playwright-mcp \ + -v workspaces:/data/workspaces -v agent:/data/agent -v claude:/data/claude volcanic-agentic-core +``` + +`--init` is required: without it `dotnet` is PID 1, so nothing reaps the zombies that killed `claude` trees and `bash` +children leave behind, and PID 1 is no longer root's. `--shm-size 1g` keeps Chromium from crashing. The compose +file's defaults (the `-e` lines, which win over `--env-file`) don't apply to a plain `docker run`, and neither does +its `TELEGRAM_MODEL` / `LOG_LEVEL` mapping: pass `-e Telegram__Model=...` instead. ## Configuration @@ -59,14 +77,14 @@ The compose file maps them into the container. | `GOOGLE_CLIENT_SECRET` | — | See above | No, and redacted | | `DEFAULT_TIMEZONE` | the container's `TZ` | IANA zone for chats that never ran `/timezone`, e.g. `Europe/Berlin`. An unknown name stops startup | Yes | | `PROCESS_ENV_ALLOW` | — | Comma-separated names or globs let through despite the deny rules, e.g. `GH_TOKEN` | Yes | -| `PLAYWRIGHT_ENABLED` | same as `INSTALL_PLAYWRIGHT` | Browser tools on or off | Yes | +| `PLAYWRIGHT_ENABLED` | `true` | Browser tools on or off. Set it to `false` for an image built with `INSTALL_PLAYWRIGHT=false` | Yes | | `PLAYWRIGHT_HEADLESS` | `true` | The container has no display | Yes | | `PLAYWRIGHT_BROWSER` | `chromium` | Only Chromium is installed in the image | Yes | | `PLAYWRIGHT_EXTRA_ARGS` | `--no-sandbox` | Extra `playwright-mcp` arguments. Keep `--no-sandbox` when you override it: Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies | Yes | | `PLAYWRIGHT_COMMAND` | `playwright-mcp` | The preinstalled server, so the first browser call doesn't wait for an `npx` download | Yes | -`TELEGRAM_MODEL`, `LOG_LEVEL`, `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` are read by the compose file only; the -bot itself reads the names in the right-hand form. +`TELEGRAM_MODEL` and `LOG_LEVEL` are read by the compose file only, which passes them to the bot as `Telegram__Model` +and `Logging__LogLevel__Default`; `INSTALL_PLAYWRIGHT` and `CLAUDE_CODE_VERSION` are build arguments (see above). > **Never set `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`, `CLAUDE_CODE_OAUTH_TOKEN`, > `CLAUDE_CODE_USE_BEDROCK` or `CLAUDE_CODE_USE_VERTEX`.** The Claude Code provider runs on a Claude subscription only @@ -145,8 +163,17 @@ process. A rejected token (`401`) exits with code 1 and invalid configuration ex When the login expires later, the owner gets the same prompt automatically and the paused request continues after the code is pasted. -Without the bot: `docker exec -u app -it claude auth login --claudeai`. The `-u app` matters: `docker exec` -runs as root by default, and credentials written by root can't be read by the bot. +Without the bot: + +```bash +docker exec -u app -it env -i HOME=/home/app \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin CLAUDE_CONFIG_DIR=/data/claude TERM=xterm \ + claude auth login --claudeai +``` + +The `-u app` matters: `docker exec` runs as root by default, and credentials written by root can't be read by the bot. +So does `env -i`: a `docker exec` process gets the whole container environment, bot token and API keys included, and +while it runs as `app` the agent's `bash` or a scheduled watcher can read it from `/proc//environ`. ## Updating @@ -173,7 +200,9 @@ docker restart Build them against the same `Volcanic.AgenticCore.Abstractions` version as the image. See [extensions.md](extensions.md). Project context files (`AGENTS.md` or `CLAUDE.md`, `PERSONA.md`, `MEMORY.md`) live in the chat's workspace directory. -The agent can edit them, or you can with `docker exec -u app -it bash`. +The agent can edit them, or you can with `docker exec -u app -it env -i HOME=/home/app +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin TERM=xterm bash` (clean environment, for the reason +given under [First run and Claude login](#first-run-and-claude-login)). ## Troubleshooting @@ -192,9 +221,16 @@ The agent can edit them, or you can with `docker exec -u app -it bas The container is the isolation boundary. Inside it, the agent's `bash` runs as `app`, the same user as the bot: it can read the workspaces, the agent directory with every chat's sessions, and the Claude login in `/data/claude`. -What it can't get at are the configured secrets: they are filtered out of its environment, the bot process is +The configured secrets are kept out of its way: they are filtered out of its environment, the bot process is non-dumpable (its `/proc//environ` is unreadable and it can't be attached to), PID 1 is root's so -`/proc/1/environ` is unreadable too, the diagnostic socket is off, and secret values are redacted from tool output. -None of this is a sandbox. Anyone on the allow-list can run any command in the container. +`/proc/1/environ` is unreadable too, the diagnostic socket is off, setuid binaries can't raise its privileges, and +secret values are redacted from tool output. + +That stops secrets leaking by accident, not an agent that goes after them on purpose. `app` owns `/data/workspaces` +and `/data/agent`, so the agent can drop a DLL into `/.volcanic/extensions/` (loaded into the bot process, +with its unfiltered environment, at the next `/new`) or add an MCP server whose `env` uses `${env:TELEGRAM_BOT_TOKEN}` +to `/data/agent/settings.json`, then kill the bot so that `restart: unless-stopped` brings it back with that server. +Treat everyone on the allow-list as able to obtain every secret you configure. None of this is a sandbox: anyone on +the allow-list can run any command in the container. Details in [telegram-bot.md](telegram-bot.md#security). diff --git a/docs/superpowers/specs/2026-09-26-docker-deployment-design.md b/docs/superpowers/specs/2026-09-26-docker-deployment-design.md index 8d73fe6..423b10c 100644 --- a/docs/superpowers/specs/2026-09-26-docker-deployment-design.md +++ b/docs/superpowers/specs/2026-09-26-docker-deployment-design.md @@ -21,7 +21,7 @@ build and to the Claude Code provider's subscription-only rules. | Files | `Dockerfile`, `.dockerignore`, `docker-compose.yml`, `.env.example` at the repo root; user doc `docs/docker.md` | | Build | Multi-stage: `mcr.microsoft.com/dotnet/sdk:10.0-noble` → `mcr.microsoft.com/dotnet/runtime:10.0-noble` | | Publish | Framework-dependent `dotnet publish -c Release` of the Telegram host only. No self-contained, no AOT, no single-file: `claude` launches the bridge as `dotnet .dll`, so the `dotnet` muxer and a loose `Bridge.dll` + `.runtimeconfig.json` must exist | -| Entry | `ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]`: `setpriv` (util-linux, already in the base image) drops to `app` and execs `dotnet`, so `Environment.ProcessPath` is still the muxer `BridgeLocator.FindDotnet` wants. No entrypoint script | +| Entry | `ENTRYPOINT ["setpriv", "--reuid=1654", "--regid=1654", "--init-groups", "--inh-caps=-all", "--bounding-set=-all", "--no-new-privs", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]`: `setpriv` (util-linux, already in the base image) drops to `app` and execs `dotnet`, so `Environment.ProcessPath` is still the muxer `BridgeLocator.FindDotnet` wants. No entrypoint script | | Agent tools | `bash`, `git`, `ripgrep`, `python3` (+ `python-is-python3`, `python3-venv`), `curl`, `ca-certificates`, `tzdata`, `jq`, `less`, `procps`, `openssh-client` | | Node | Node.js 24 from NodeSource (`ARG NODE_MAJOR=24`); Ubuntu noble's `nodejs` is too old | | Claude CLI | `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}`, `ARG CLAUDE_CODE_VERSION=2.1` (newest 2.1.x at build time; set an exact version to pin). Updated by rebuilding, never in place | @@ -113,8 +113,9 @@ services: - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - DEFAULT_TIMEZONE=${DEFAULT_TIMEZONE:-} # chats' default zone (scheduler spec §7); empty → TZ - PROCESS_ENV_ALLOW=${PROCESS_ENV_ALLOW:-} # deny-listed variables the agent may see, e.g. GH_TOKEN - # Playwright MCP preset (MCP spec). Enabled when the image has it; the preinstalled binary, no npx download. - - PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-${INSTALL_PLAYWRIGHT:-true}} + # Playwright MCP preset (MCP spec): the preinstalled binary, no npx download. Set PLAYWRIGHT_ENABLED=false for an + # image built with INSTALL_PLAYWRIGHT=false (no nested default: Coolify parses these expressions itself). + - PLAYWRIGHT_ENABLED=${PLAYWRIGHT_ENABLED:-true} - PLAYWRIGHT_HEADLESS=${PLAYWRIGHT_HEADLESS:-true} # no display in the container - PLAYWRIGHT_BROWSER=${PLAYWRIGHT_BROWSER:-} - PLAYWRIGHT_EXTRA_ARGS=${PLAYWRIGHT_EXTRA_ARGS:---no-sandbox} @@ -159,13 +160,24 @@ volumes: PID 1's own `/proc/1/environ` is root-only, and the configured secrets' values are replaced by `[REDACTED]` in tool output (process-environment-hook spec §8–§9). The side effect is no core dumps and no `dotnet-dump`/`dotnet-trace` attach; `ProcessEnvironment__HardenHostProcess=false` turns it off for a debugging - session. The container stays the real isolation boundary: none of this is a sandbox. + session. The entrypoint also gives the bot an empty capability bounding set and `no_new_privs`, so nothing it + spawns regains root through the base image's setuid binaries (`su`, `mount`, `passwd`, ...). + All of this keeps secrets out of the agent's environment and tool output by accident; it does **not** stop an agent + that goes after them on purpose. `app` owns `/data/workspaces` and `/data/agent`, extensions are discovered on every + session creation, and MCP servers expand `${env:NAME}` from the host's unfiltered environment, so the agent can drop + a DLL into `/.volcanic/extensions/` (loaded into the bot at the next `/new`) or add an MCP server with + `env: {"X": "${env:TELEGRAM_BOT_TOKEN}"}` to `/data/agent/settings.json` (loaded after it kills the same-uid bot + and `restart: unless-stopped` brings it back). Anyone on the allow-list must be treated as able to obtain every + secret configured here. The container stays the real isolation boundary: none of this is a sandbox (open question 5). - Playwright: `PLAYWRIGHT_COMMAND=playwright-mcp` runs the globally installed, version-pinned server, so the first browser call in a chat doesn't wait on an npx download. `PLAYWRIGHT_EXTRA_ARGS` defaults to `--no-sandbox`: Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies to the non-root `app` user, so the container is the isolation boundary. Overriding `PLAYWRIGHT_EXTRA_ARGS` must keep `--no-sandbox`. - `PLAYWRIGHT_ENABLED` follows `INSTALL_PLAYWRIGHT` (nested default, supported by Compose v2), so an image built - without Playwright doesn't advertise tools it can't run. + `PLAYWRIGHT_ENABLED` defaults to `true`, so an image built with `INSTALL_PLAYWRIGHT=false` also needs + `PLAYWRIGHT_ENABLED=false` (otherwise the `playwright` server is logged as unavailable and its tools are missing). + It doesn't follow `INSTALL_PLAYWRIGHT` through a nested default (`${PLAYWRIGHT_ENABLED:-${INSTALL_PLAYWRIGHT:-true}}`): + Compose v2 supports that, but Coolify parses these expressions itself to build its variable UI, and a value cut at + the first `}` would make the host exit 2 in a restart loop. - The owner (first id in `TELEGRAM_ALLOWED_USER_IDS`) gets the `🟢 Online.` message and the Claude Code re-login prompts. The calendar has no owner special case: every chat connects its own account. - Coolify prefixes named volumes with the resource uuid; they survive redeploys and are only removed when the @@ -192,8 +204,11 @@ shows the service as running without health status; that is acceptable. `/data/claude`. `/claude-code-doctor` should then report logged in, `claude.ai`, bridge ok. 4. Later expiries use the existing owner re-login flow; nothing Docker-specific. -Fallback without the bot: `docker exec -u app -it claude auth login --claudeai` (same `CLAUDE_CONFIG_DIR`; -`docker exec` defaults to root, whose credentials the bot couldn't read). Copying an existing `.credentials.json` into +Fallback without the bot: `docker exec -u app -it env -i HOME=/home/app +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin CLAUDE_CONFIG_DIR=/data/claude TERM=xterm claude auth +login --claudeai` (`docker exec` defaults to root, whose credentials the bot couldn't read). `env -i` matters: a +`docker exec` process gets the whole container environment, secrets included, and an ordinary (dumpable) `app` process +has its `/proc//environ` readable by the agent's `bash` and watcher scripts for as long as it runs. Copying an existing `.credentials.json` into the `claude` volume also works but is not the documented path. ## Extensions and project context @@ -249,11 +264,16 @@ Verification record (2026-09-27, Docker Desktop 29 on Windows 11, Linux containe (`disabled` / `enabled (headless, chromium)`), `Speech to text`, `Using Claude Code at /usr/bin/claude, model claude-code/claude-opus-5-5`, `Scheduler`, `Default time zone: UTC`, `Allowed users: 1. Workspaces under /data/workspaces`, then `Telegram unreachable` backoff. -- `docker compose config`: with a minimal `.env` (`INSTALL_PLAYWRIGHT=false`), `PLAYWRIGHT_ENABLED` follows it to - `"false"`; with `.env.example`'s defaults it is `"true"`; `--no-sandbox`, `playwright-mcp`, `init: true`, - `shm_size`, three named volumes, no ports. +- `docker compose config`: with a minimal `.env` (`INSTALL_PLAYWRIGHT=false`, `PLAYWRIGHT_ENABLED=false`), both are + `"false"`; with `.env.example`'s defaults both are `"true"` and `Telegram__Model` is the compose fallback; + `--no-sandbox`, `playwright-mcp`, `init: true`, `shm_size`, three named volumes, no ports. Not yet checked on + Coolify itself. - `docker compose up -d --build` with `TELEGRAM_BOT_TOKEN=123:fake`: same startup lines, then `Telegram rejected the bot token (401 Unauthorized)`, exit 1, and a visible `restart: unless-stopped` loop; no other warnings. +- After review (entrypoint with `--bounding-set=-all --no-new-privs`): the bot's `/proc//status` shows + `CapEff`/`CapBnd` all zero and `NoNewPrivs: 1`; `su` as `app` fails; the host starts and logs the same lines; a + headless Chromium launch (`--no-sandbox`) under the same `setpriv` flags renders a page; the `env -i` login fallback + runs `claude --version` with only `HOME`, `PATH`, `CLAUDE_CONFIG_DIR`, `TERM` in its environment. ## Open questions @@ -265,3 +285,7 @@ Verification record (2026-09-27, Docker Desktop 29 on Windows 11, Linux containe at the cost of manual bumps? 4. Should the agent get a .NET SDK in the image (`INSTALL_DOTNET_SDK` build arg, +~700 MB) so it can build .NET projects in its workspace? +5. The agent can reach the configured secrets on purpose (see the secrets bullet): it owns the directories that + extension and MCP-server discovery read. Mitigate with a host option that turns off workspace/agent-dir extension + and settings discovery in the container, or with root-owned, read-only `extensions/` and `settings.json` paths? + Until then this is documented, and the allow-list is the trust boundary. diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs index 73d3b8d..24c1700 100644 --- a/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/Mcp/PlaywrightPresetTests.cs @@ -97,6 +97,8 @@ public class PlaywrightPresetTests var match = Assert.Single(matches); Assert.Equal(PlaywrightPreset.PackageVersion, match.Groups["version"].Value); + // The install step must use the ARG, not a version of its own. + Assert.Contains("\"@playwright/mcp@${PLAYWRIGHT_MCP_VERSION}\"", dockerfile, StringComparison.Ordinal); } /// A file in the repository root, found by walking up from the test output to VolcanicAgenticCore.slnx. diff --git a/tests/Volcanic.AgenticCore.Telegram.Tests/DockerComposeTests.cs b/tests/Volcanic.AgenticCore.Telegram.Tests/DockerComposeTests.cs new file mode 100644 index 0000000..0a99739 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Telegram.Tests/DockerComposeTests.cs @@ -0,0 +1,27 @@ +using System.Text.RegularExpressions; + +namespace Volcanic.AgenticCore.Telegram.Tests; + +/// Values the repo-root docker-compose.yml copies from the host's code. +public sealed class DockerComposeTests +{ + [Fact] + public void Compose_model_fallback_matches_TelegramOptions_DefaultModel() + { + var compose = File.ReadAllText(RepoFile("docker-compose.yml")); + var matches = Regex.Matches(compose, @"Telegram__Model=\$\{TELEGRAM_MODEL:-(?[^}]+)\}"); + + var match = Assert.Single(matches); + Assert.Equal(TelegramOptions.DefaultModel, match.Groups["model"].Value); + } + + /// A file in the repository root, found by walking up from the test output to VolcanicAgenticCore.slnx. + private static string RepoFile(string name) + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent) + { + if (File.Exists(Path.Combine(dir.FullName, "VolcanicAgenticCore.slnx"))) return Path.Combine(dir.FullName, name); + } + throw new InvalidOperationException("VolcanicAgenticCore.slnx not found above " + AppContext.BaseDirectory); + } +}