# Docker deployment (Coolify) — design Date: 2026-09-26. Status: implemented (plan: docs/superpowers/plans/2026-09-26-docker-deployment.md). Depends on: `2026-09-26-process-environment-hook-design.md` (the secrets below stay out of agent-spawned processes), `2026-09-26-scheduler-design.md` §7 (the default time zone). ## Goal Run the Telegram host (`hosts/Volcanic.AgenticCore.Telegram`) as a single container on Coolify, deployed from this repo's `docker-compose.yml`. The container carries everything the agent's `bash` tool and the Claude Code provider need, runs as a non-root user, and keeps workspaces, sessions and the Claude login across redeploys. `docker compose up -d --build` on any Docker host runs the same thing locally. Reference: VolcanicSecretary (`Dockerfile`, `docker-compose.yml`, `docker-entrypoint.sh`), adapted to a .NET build and to the Claude Code provider's subscription-only rules. ## Decisions | Topic | Decision | |---|---| | 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", "--bounding-set=-all", "--no-new-privs", "--", "dotnet", "Volcanic.AgenticCore.Telegram.dll"]`: `setpriv` (util-linux, already in the base image) drops to `app` and execs `dotnet`, so `Environment.ProcessPath` is still the muxer `BridgeLocator.FindDotnet` wants. No entrypoint script | | Agent tools | `bash`, `git`, `ripgrep`, `python3` (+ `python-is-python3`, `python3-venv`), `curl`, `ca-certificates`, `tzdata`, `jq`, `less`, `procps`, `openssh-client` | | Node | Node.js 24 from NodeSource (`ARG NODE_MAJOR=24`); Ubuntu noble's `nodejs` is too old | | Claude CLI | `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}`, `ARG CLAUDE_CODE_VERSION=2.1` (newest 2.1.x at build time; set an exact version to pin). Updated by rebuilding, never in place | | Playwright | `ARG INSTALL_PLAYWRIGHT=false` in the Dockerfile; compose passes `true`. Installs `@playwright/mcp` globally at the version pinned by the MCP spec, and Chromium + OS deps under `/opt/ms-playwright`. Compose points the preset at the preinstalled `playwright-mcp` (no npx download) and adds `--no-sandbox` | | User | The image's default user stays root so that PID 1 (compose's `init: true`, i.e. `docker-init`) is root-owned; the bot and everything it spawns run as the base image's non-root `app` user (1654, `$APP_UID`), `HOME=/home/app`. With `USER app`, `docker-init` runs as `app` and its `/proc/1/environ` (the whole container environment, secrets included) is readable by the agent's `bash` (checked on Docker 29) | | Data | Three named volumes under `/data`: `workspaces`, `agent`, `claude` | | Config | Environment only, set in Coolify. Required: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USER_IDS`. Optional: `OPENAI_API_KEY`, `STT_MODEL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `DEFAULT_TIMEZONE`, `PROCESS_ENV_ALLOW`, `TZ`, `PLAYWRIGHT_ENABLED`, `PLAYWRIGHT_HEADLESS`, `PLAYWRIGHT_BROWSER`, `PLAYWRIGHT_EXTRA_ARGS`, `PLAYWRIGHT_COMMAND` | | Healthcheck | None (see below) | | Coolify | No `ports`, no `expose`, no domain, no `SERVICE_FQDN_*`; `restart: unless-stopped`; `init: true` | | Local script | None. `cp .env.example .env && docker compose up -d --build` is the local run; the `dotnet run` path in `docs/telegram-bot.md` stays for development | Out of scope: webhooks/HTTP endpoints, a .NET SDK inside the runtime image for the agent to use, CI image publishing to a registry, multi-arch builds (the image is built on the Coolify server, whatever its arch), running tests in the image build. ## Dockerfile Build stage (`sdk:10.0-noble`): 1. `WORKDIR /src`. Copy `Directory.Build.props`, `VolcanicAgenticCore.slnx` and only the `*.csproj` files the host needs (Abstractions, core, ClaudeCode, ClaudeCode.Bridge, GoogleCalendar, Mcp, Scheduler, Telegram), then `dotnet restore hosts/Volcanic.AgenticCore.Telegram` — a cached layer until a project file changes. 2. Copy `src/`, `extensions/`, `hosts/` and `dotnet publish hosts/Volcanic.AgenticCore.Telegram -c Release --no-restore -o /out`. `TreatWarningsAsErrors` applies, so a warning fails the image build as it fails `dotnet build`. 3. Guard: `test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.dll && test -f /out/Volcanic.AgenticCore.Extensions.ClaudeCode.Bridge.runtimeconfig.json`. The bridge reaches the output through a `ReferenceOutputAssembly="false"` project reference; if `publish` drops the runtimeconfig (it does not in `build`), fix the csproj, not the Dockerfile. Runtime stage (`runtime:10.0-noble`), as root: 1. `apt-get install --no-install-recommends` the agent tools above, then NodeSource `nodejs`; clean apt lists. 2. `npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} && claude --version` (fails the build if the CLI does not run). The global prefix `/usr/lib/node_modules` stays root-owned, so the CLI's auto-updater cannot replace the binary at runtime: a Claude Code upgrade changes what `system/init` validation sees and invalidates the prompt cache, so it should only arrive with a rebuild. 3. If `INSTALL_PLAYWRIGHT=true`: `ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright`, `npm install -g @playwright/mcp@${PLAYWRIGHT_MCP_VERSION}`, then run the `playwright` CLI **from that package's own dependency** with `install --with-deps chromium`, so browser build and library version match. `chmod -R a+rX /opt/ms-playwright`. Roughly +400 MB. `PLAYWRIGHT_BROWSERS_PATH` is set unconditionally so the MCP spec (`2026-09-26-mcp-client-playwright-design.md`) can rely on it: its server process inherits the environment. `ARG PLAYWRIGHT_MCP_VERSION` defaults to the exact version in the MCP spec's `PlaywrightPreset.DefaultPackage`, which is the single source of truth. A unit test in that spec fails when the two differ, so bumping the version means changing both in one commit. Compose does not override this arg. 4. `mkdir -p /data/workspaces /data/agent /data/claude /home/app && chown -R app:app /data /home/app`. Named volumes copy this ownership on first mount, which is what lets a non-root process write to them. 5. `COPY --from=build /out /app`, `WORKDIR /app`. No `USER` (see the User row); an earlier step checks that `app` is uid/gid 1654, which the entrypoint hard-codes. 6. Environment baked into the image (compose and Coolify may override): | Variable | Value | Why | |---|---|---| | `Telegram__WorkspaceRoot` | `/data/workspaces` | Chat `` works in `/data/workspaces/` | | `VOLCANIC_AGENT_DIR` | `/data/agent` | Sessions, `extensions/*.dll`, `settings.json`, `auth.json` | | `HOME` | `/home/app` | The image user is root, so Docker would otherwise set `/root` | | `CLAUDE_CONFIG_DIR` | `/data/claude` | Moves both `~/.claude/` and `~/.claude.json` (credentials and onboarding state) into one volume. Already on the provider's env allow-list, so every `claude` child and `claude auth login` see it | | `DOTNET_CLI_TELEMETRY_OPTOUT`, `DOTNET_NOLOGO` | `1` | Quiet | | `TZ` | `UTC` | Overridable | | `DOTNET_EnableDiagnostics` | `0` | Turns off the runtime's diagnostic IPC socket, which would hand the bot's environment to the agent's same-uid processes and bypass the host's non-dumpable hardening (process-environment-hook spec §8). Unset it (with `ProcessEnvironment__HardenHostProcess=false`) only to attach `dotnet-dump`/`dotnet-trace` while debugging | `VOLUME` is not declared in the Dockerfile; the compose file owns volumes (Coolify treats anonymous volumes badly). ## docker-compose.yml ```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. 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: ``` - No `ports`/`expose`: long polling only makes outbound connections. No domain is configured in Coolify, so no Traefik labels are generated. - **No `ANTHROPIC_*`, `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CODE_USE_*` entries**, ever: the provider refuses to run with them. `docs/docker.md` says so next to the Coolify env setup, because shared/team variables in Coolify are the likely way one sneaks in. - Env mapping: the two required settings use the existing shortcuts (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USER_IDS`, comma separated) because a list in `Telegram__AllowedUserIds__0..n` form is awkward in Coolify. Everything else in `TelegramOptions` is reachable as `Telegram__` (e.g. `Telegram__ReconnectMaxDelay=00:02:00`) without a compose change, by adding it in Coolify and to `environment`. The Claude provider's `VOLCANIC_CLAUDE_CODE_IDLE_TIMEOUT_MS` / `..._TOTAL_TIMEOUT_MS` work the same way, as do the `Scheduler__*`, `GoogleCalendar__*`, `ProcessEnvironment__*` and `Telegram__Speech__*` / `Telegram__InputDebounce` settings of the sibling specs. - Optional features use the env shortcuts their specs define: `OPENAI_API_KEY` (+ `STT_MODEL`) turns on speech to text; `GOOGLE_CLIENT_ID` + `GOOGLE_CLIENT_SECRET` turn on the calendar, and each chat then connects its own Google account. Empty values mean off, so they're in `environment` and `.env.example` from the start. `OPENAI_API_KEY` is not on the Claude Code provider's denied list and never reaches `claude`. - Time zones: each chat has its own (`/timezone`). A chat that never set one uses `DEFAULT_TIMEZONE`, else the container's `TZ` (default `UTC`), so a deployment whose users are mostly in one zone should set `DEFAULT_TIMEZONE`. The chat's zone is also passed as `TZ` to the agent's `bash` and watcher scripts (the image has `tzdata`). Calendar times use the calendar's own zone, else the chat's. - Secrets set here (`TELEGRAM_BOT_TOKEN`, `OPENAI_API_KEY`, `GOOGLE_CLIENT_SECRET` and anything matching `*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD` or their `Section__Key` forms) are removed from the environment of the agent's `bash`, scheduled scripts and MCP servers by the process environment hook. To give the agent a credential on purpose (say `GH_TOKEN` for `gh`), add it in Coolify and list its name in `PROCESS_ENV_ALLOW`; an MCP server gets one through its own `env` entry instead. On top of that, the bot (the only child of the root-owned PID 1) makes itself non-dumpable at startup, so the agent can't read its `/proc//environ` or attach to it, PID 1's own `/proc/1/environ` is root-only, and the configured secrets' values are replaced by `[REDACTED]` in tool output (process-environment-hook spec §8–§9). The side effect is no core dumps and no `dotnet-dump`/`dotnet-trace` attach; `ProcessEnvironment__HardenHostProcess=false` turns it off for a debugging session. The entrypoint also gives the bot an empty capability bounding set and `no_new_privs`, so nothing it spawns regains root through the base image's setuid binaries (`su`, `mount`, `passwd`, ...). All of this keeps secrets out of the agent's environment and tool output by accident; it does **not** stop an agent that goes after them on purpose. `app` owns `/data/workspaces` and `/data/agent`, extensions are discovered on every session creation, and MCP servers expand `${env:NAME}` from the host's unfiltered environment, so the agent can drop a DLL into `/.volcanic/extensions/` (loaded into the bot at the next `/new`) or add an MCP server with `env: {"X": "${env:TELEGRAM_BOT_TOKEN}"}` to `/data/agent/settings.json` (loaded after it kills the same-uid bot and `restart: unless-stopped` brings it back). Anyone on the allow-list must be treated as able to obtain every secret configured here. The container stays the real isolation boundary: none of this is a sandbox (open question 5). - Playwright: `PLAYWRIGHT_COMMAND=playwright-mcp` runs the globally installed, version-pinned server, so the first browser call in a chat doesn't wait on an npx download. `PLAYWRIGHT_EXTRA_ARGS` defaults to `--no-sandbox`: Chromium's sandbox needs user namespaces, which Docker's default seccomp profile denies to the non-root `app` user, so the container is the isolation boundary. Overriding `PLAYWRIGHT_EXTRA_ARGS` must keep `--no-sandbox`. `PLAYWRIGHT_ENABLED` 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 resource is deleted with its volumes. Bind mounts (as in VolcanicSecretary) also work but the host directories must then be owned by uid 1654; the doc mentions this in one line. - Deploys: Coolify stops the old container before starting the new one for compose resources. If they ever overlap, the second poller gets `409 Conflict`, which the polling service already backs off from. ## Healthcheck None. The bot serves no HTTP, and the failure modes a probe could detect are already handled in-process: lost connectivity self-heals with backoff (`ReconnectPolicy`), a dead polling loop is restarted, and the unrecoverable cases exit the process (`401` → exit 1, invalid config → exit 2), which `restart: unless-stopped` turns into a visible restart loop in Coolify's logs. A file-heartbeat probe would add a moving part for no new signal. Coolify shows the service as running without health status; that is acceptable. ## First run and Claude login 1. In Coolify: new resource from this repo, build pack *Docker Compose*, set `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ALLOWED_USER_IDS` (owner first), deploy. 2. The start log says Claude Code was found (image-provided) and lists allowed users. 3. The owner opens the bot, sends `/start`, then `/claude-code-login`: link + code prompt arrive as one message, the pasted code is fed to `claude auth login --claudeai` inside the container, and credentials land in `/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 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 - Extra extension assemblies go to `/data/agent/extensions/*.dll` (user directory) or `/data/workspaces//.volcanic/extensions/` (project directory); discovery is unchanged. Getting a DLL there is `docker cp` + restart; they must be built against the same `Volcanic.AgenticCore.Abstractions` version as the image. - `AGENTS.md` / `CLAUDE.md`, `PERSONA.md`, `MEMORY.md` live in each chat's workspace directory in the `workspaces` volume, edited by the agent or by `docker exec`. ## Docs - New `docs/docker.md`: build args, env table (with which variables the agent can't see, and `PROCESS_ENV_ALLOW`), volumes, Coolify steps, first login, no-API-key warning, updating Claude Code (redeploy; set `CLAUDE_CODE_VERSION` to pin), troubleshooting (`[executable_missing]`, `[auth]`, `[mcp_startup]` → bridge files, permission denied on bind mounts → chown 1654, `409` → another instance). - `README.md`: one line under the Telegram quick start linking `docs/docker.md`. - `docs/telegram-bot.md`: "Security" points at the container as the recommended way to run it. - Update the "Out of scope: … Docker/Coolify" line in `2026-09-25-telegram-host-design.md` to reference this spec. ## Verification One unit test (`PlaywrightPresetTests.Dockerfile_version_matches_DefaultPackage`); no C# behaviour changes. Checked during implementation without a real bot: both image variants build; bridge guard, `claude --version`, the agent tools, uid 1654, `/data` ownership; root-owned PID 1 whose `/proc/1/environ` and the bot's are unreadable to `app`; no `dotnet-diagnostic-*` socket; `playwright-mcp --version` offline and a headless Chromium launch as `app`; `docker compose config`; host startup log and the `401` → exit 1 path. The rest of this list needs a real bot and stays a manual checklist: 1. `docker compose build` succeeds with `INSTALL_PLAYWRIGHT=false` and `true`; the bridge guard and `claude --version` pass. 2. `docker compose up` with a real bot: allowed user gets answers; `/claude-code-login` then `/claude-code-doctor` all green; `id` via the agent's bash reports uid 1654; `env` via the agent's bash shows neither `TELEGRAM_BOT_TOKEN` nor `OPENAI_API_KEY`, and `date` shows the chat's zone after `/timezone Europe/Berlin`; `cat /proc/1/environ` via the agent's bash fails with permission denied, and no `dotnet-diagnostic-*` socket exists under `/tmp`. 3. Agent can run `git`, `rg`, `python3`, `node`, `npx`; with Playwright, `playwright-mcp --version` prints the pinned version offline, and "Open https://example.com and tell me the H1" works in a chat on the first try (no npx download, no sandbox error). 4. `docker compose down && docker compose up -d --build`: previous chat resumes, no re-login needed, workspace files remain. 5. Setting `ANTHROPIC_API_KEY` in compose makes the provider refuse with its documented error (then remove it). Verification record (2026-09-27, Docker Desktop 29 on Windows 11, Linux containers): - `dotnet build`: 0 warnings, 0 errors. `dotnet test`: all green except the three Windows-only baseline failures in `Volcanic.AgenticCore.Tests`. Changing the Dockerfile's `PLAYWRIGHT_MCP_VERSION` to `0.0.81` makes `Dockerfile_version_matches_DefaultPackage` fail (`Expected "0.0.82", Actual "0.0.81"`). - `docker build --build-arg INSTALL_PLAYWRIGHT=false` and `=true` both succeed. `claude --version` → `2.1.283 (Claude Code)`; bridge `runtimeconfig.json` present; `DOTNET_EnableDiagnostics=0`; `git`, `rg`, `python3`, `node` (v24.21.0), `npx`, `jq`, `setpriv` on `PATH`; `/data/{workspaces,agent,claude}` owned by `app`. With Playwright, `playwright-mcp --version` → `Version 0.0.82` with `--network none`, Chromium under `/opt/ms-playwright`, and a headless Chromium launch as `app` renders a page. - Running host, `--init --network none`: PID 1 `docker-init` as root, the bot's `dotnet` as uid/gid 1654 with no effective capabilities; `/proc/1/environ` and the bot's `environ` are `Permission denied` for `app` (the bot's also for a root `docker exec`, since the host is not dumpable); no `dotnet-diagnostic-*` socket. The log shows `Host process hardened: not dumpable`, `Process environment: 15 deny rules, allow: none`, the MCP line (`disabled` / `enabled (headless, chromium)`), `Speech to text`, `Using Claude Code at /usr/bin/claude, model claude-code/claude-opus-5-5`, `Scheduler`, `Default time zone: UTC`, `Allowed users: 1. Workspaces under /data/workspaces`, then `Telegram unreachable` backoff. - `docker compose config`: with a minimal `.env` (`INSTALL_PLAYWRIGHT=false`, `PLAYWRIGHT_ENABLED=false`), both are `"false"`; with `.env.example`'s defaults both are `"true"` and `Telegram__Model` is the compose fallback; `--no-sandbox`, `playwright-mcp`, `init: true`, `shm_size`, three named volumes, no ports. Not yet checked on Coolify itself. - `docker compose up -d --build` with `TELEGRAM_BOT_TOKEN=123:fake`: same startup lines, then `Telegram rejected the bot token (401 Unauthorized)`, exit 1, and a visible `restart: unless-stopped` loop; no other warnings. - After review (entrypoint with `--bounding-set=-all --no-new-privs`): the bot's `/proc//status` shows `CapEff`/`CapBnd` all zero and `NoNewPrivs: 1`; `su` as `app` fails; the host starts and logs the same lines; a headless Chromium launch (`--no-sandbox`) under the same `setpriv` flags renders a page; the `env -i` login fallback runs `claude --version` with only `HOME`, `PATH`, `CLAUDE_CONFIG_DIR`, `TERM` in its environment. ## Open questions 1. Persist `HOME` too? `~/.gitconfig`, `~/.ssh`, `gh` auth and the npm/npx cache are lost on redeploy with this design. A fourth volume at `/home/app` would keep them; left out until there is a need. 2. ~~Playwright default~~: resolved. The MCP spec shipped first, so compose keeps building it in (`true`). (Sandboxing and which binary is launched are settled above.) 3. `CLAUDE_CODE_VERSION=2.1` tracks the newest 2.1.x at build time. Pin an exact, doctor-verified version instead, at the cost of manual bumps? 4. Should the agent get a .NET SDK in the image (`INSTALL_DOTNET_SDK` build arg, +~700 MB) so it can build .NET projects in its workspace? 5. The agent can reach the configured secrets on purpose (see the secrets bullet): it owns the directories that extension and MCP-server discovery read. Mitigate with a host option that turns off workspace/agent-dir extension and settings discovery in the container, or with root-owned, read-only `extensions/` and `settings.json` paths? Until then this is documented, and the allow-list is the trust boundary.