fix(phase7): run chrome-devtools MCP under a dedicated user with scoped CA trust

The Phase 7 chrome-devtools MCP entry (npx ... --headless --isolated)
reported `✓ Connected` to `claude mcp list` but every tool call
returned either `Protocol error (Target.setDiscoverTargets): Target
closed` or `net::ERR_CERT_AUTHORITY_INVALID`. Two distinct causes:

1. Chromium's user-namespace sandbox refuses to initialize as root
   (the guest's only user), so the browser dies before the CDP pipe
   is read.
2. Chromium on Linux ignores `/etc/ssl/certs/ca-certificates.crt`
   and only honours its built-in root store + the per-user NSS DB,
   so the microsandbox MITM CA isn't trusted by chromium even though
   curl/openssl trust it via the bundle.

Both of these had naïve fixes (`--no-sandbox` and `--acceptInsecureCerts`)
but we'd rather keep chromium's nested sandbox active (defence in depth
against untrusted content the agent navigates to) and only trust *our*
one CA (not every untrusted cert).

Approach:

- **Dedicated `chrome` user (UID 9999)** baked into the image via
  direct /etc/passwd + /etc/shadow + /etc/group edits (debian-slim
  has no `useradd`; the shadow entry is needed because `su` calls
  PAM and rejects users without one). Sudoers rule grants password-
  less `root -> chrome`.

- **Wrapper `/usr/local/bin/agent-vm-chrome-mcp`** that re-execs the
  MCP under chrome via `sudo -u chrome -H -n`. The env allow-list
  preserves agentd-set NODE_EXTRA_CA_CERTS / SSL_CERT_FILE /
  CURL_CA_BUNDLE / REQUESTS_CA_BUNDLE / PATH; without these, sudo's
  default env_reset strips them and node fails `npx -y` with
  SELF_SIGNED_CERT_IN_CHAIN fetching from registry.npmjs.org.

- **Pre-initialized empty NSS DB** at /home/chrome/.pki/nssdb (built
  at image time with `certutil -N --empty-password`) plus
  `libnss3-tools` package so the launcher's runtime `certutil -A`
  has something to add to.

- **Launcher prelude (run.rs)** runs `certutil -A -t TC -n
  microsandbox -i /usr/local/share/ca-certificates/microsandbox-ca.crt`
  on every boot. Can't bake the CA into the image — agentd writes it
  into the guest at boot time, and the cert is per-boot. Add-by-name
  is idempotent so re-running on every launch is fine.

- **MCP config (secrets.rs)** swaps `command: npx` for `command:
  /usr/local/bin/agent-vm-chrome-mcp` with the original npx + args
  passed through. No more `--no-sandbox`, `--disable-dev-shm-usage`,
  or `--acceptInsecureCerts`.

Verified e2e inside a fresh sandbox: `take_snapshot` after
`navigate_page https://example.com` returns the real "Example
Domain" heading text; `ps -ef | grep chrome` shows chromium running
as `chrome`, not root; NSS DB lists only `microsandbox` with `CT,,`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Evgeny Boger 2026-05-24 20:39:55 +03:00
parent daa5922f36
commit f40e6df2bc
3 changed files with 109 additions and 2 deletions

View file

@ -597,10 +597,31 @@ pub async fn launch(agent: Agent, args: Args) -> Result<i32> {
// docker compose up, env-var exports). Runs once per launch with
// PWD set to the project dir; non-zero exit aborts the launch
// with the same exit code.
// 3. Adds the microsandbox MITM CA to the `chrome` user's NSS DB
// so chromium (launched by chrome-devtools-mcp under that user
// via /usr/local/bin/agent-vm-chrome-mcp) verifies the
// intercepted TLS chain instead of failing every HTTPS page
// with ERR_CERT_AUTHORITY_INVALID. The CA file
// `/usr/local/share/ca-certificates/microsandbox-ca.crt` is
// written into the guest by agentd at boot, so this can't be
// baked into the image — the per-boot CA is what we have to
// inject. `certutil -A` of an already-present nickname is a
// no-op (we re-run on every launch but the cost is one fork +
// one sqlite open), so we don't bother gating on "did we
// already do this?". `|| true` keeps a missing chrome user
// (AGENT_VM_NO_CHROME_MCP build, or a future image variant
// without the MCP) from breaking the prelude.
let project_guest_path_escaped = shell_escape(&project_guest_path);
let prelude = format!(
"sed -i '/^nameserver .*:/d' /etc/resolv.conf 2>/dev/null || true\n\
[ -t 0 ] || exec < /dev/null\n\
if [ -f /usr/local/share/ca-certificates/microsandbox-ca.crt ] \\\n\
&& [ -d /home/chrome/.pki/nssdb ]; then\n\
\tsudo -u chrome -n -- certutil -d sql:/home/chrome/.pki/nssdb -A \\\n\
\t\t-t TC -n microsandbox \\\n\
\t\t-i /usr/local/share/ca-certificates/microsandbox-ca.crt \\\n\
\t\t2>/dev/null || true\n\
fi\n\
_hook={path}/.agent-vm.runtime.sh\n\
if [ -f \"$_hook\" ]; then\n\
\techo \"==> sourcing $_hook\" >&2\n\

View file

@ -650,6 +650,29 @@ fn write_default_claude_root_state(path: &Path, project_guest_path: &str) -> Res
// the in-VM Claude can drive a real headless Chromium for tasks
// that need browser interaction. The user-set `mcpServers` map
// is preserved otherwise. Opt out via AGENT_VM_NO_CHROME_MCP=1.
//
// The MCP runs under the dedicated `chrome` user via the
// `agent-vm-chrome-mcp` wrapper baked into the image. Two reasons
// we don't just `command: "npx"` like a normal MCP:
//
// - **Sandbox preservation.** chromium's user-namespace sandbox
// refuses to initialize as root; if we launch it as root the
// CDP target dies immediately and every tool call returns
// `Protocol error (Target.setDiscoverTargets): Target closed`.
// The microVM is already the outer security boundary, but
// keeping chromium's nested sandbox is real defence-in-depth
// for content the agent navigates to. Running the MCP under a
// non-root user makes the sandbox work without `--no-sandbox`.
// - **Scoped CA trust.** Every outbound HTTPS connection from
// the guest is MITM'd by microsandbox's intercept proxy. curl
// and openssl trust the proxy's `microsandbox CA` because
// debian's `update-ca-certificates` runs at guest boot. Chromium
// on Linux ignores the system bundle and only honours its
// built-in root store + the per-user NSS DB, so we need to
// install just our one CA into chrome's NSS DB
// (`/home/chrome/.pki/nssdb/`, populated by the launcher's bash
// prelude at boot). With that, no `--acceptInsecureCerts` (which
// would accept *any* untrusted cert) — only our CA is trusted.
if std::env::var("AGENT_VM_NO_CHROME_MCP").is_err() {
let mcp = obj
.entry("mcpServers".to_string())
@ -659,8 +682,14 @@ fn write_default_claude_root_state(path: &Path, project_guest_path: &str) -> Res
mcp.insert(
"chrome-devtools".into(),
serde_json::json!({
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest", "--headless=true", "--isolated=true"],
"command": "/usr/local/bin/agent-vm-chrome-mcp",
"args": [
"npx",
"-y",
"chrome-devtools-mcp@latest",
"--headless=true",
"--isolated=true",
],
}),
);
}

View file

@ -29,16 +29,73 @@ RUN apt-get update \
# but is the simplest source; users who don't want it can pass
# AGENT_VM_NO_CHROME_MCP=1 to suppress the server entry in agent
# settings.
#
# `sudo` + `libnss3-tools` are here because the chrome MCP runs as a
# dedicated non-root `chrome` user (see the next RUN block). sudo is
# how the wrapper drops into it; libnss3-tools (`certutil`) is how the
# launcher injects the per-boot microsandbox CA into chrome's NSS DB.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
chromium \
fonts-liberation \
sudo \
libnss3-tools \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sf /usr/bin/chromium /usr/bin/google-chrome \
&& ln -sf /usr/bin/chromium /usr/bin/google-chrome-stable \
&& mkdir -p /opt/google/chrome \
&& ln -sf /usr/bin/chromium /opt/google/chrome/chrome
# Dedicated `chrome` user so chromium's user-namespace sandbox can
# initialize. As root chromium refuses to set up its sandbox and the
# CDP target closes immediately (`Protocol error
# (Target.setDiscoverTargets): Target closed`); the alternative is
# `--no-sandbox`, which we'd rather not pass — the microVM is the
# outer boundary, but keeping chromium's nested sandbox active is a
# real defence-in-depth gain when the agent makes the browser load
# untrusted content.
#
# /etc/passwd is edited by hand because debian-slim doesn't ship
# `useradd`. Empty NSS DB is created at image time so the per-launch
# `certutil -A` only has to add the CA, not also format the DB.
# Sudoers rule keeps `root -> chrome` password-less; no other
# (chrome -> root) transition is allowed.
#
# The wrapper script is what the in-image Chrome MCP entry actually
# launches: it re-execs `npx chrome-devtools-mcp@latest` under the
# `chrome` user with stdin/stdout preserved so the MCP JSON-RPC pipe
# from claude still works.
RUN echo 'chrome:x:9999:9999:Chrome MCP:/home/chrome:/bin/bash' >> /etc/passwd \
&& echo 'chrome:!:19000:0:99999:7:::' >> /etc/shadow \
&& echo 'chrome:x:9999:' >> /etc/group \
&& mkdir -p /home/chrome/.pki/nssdb \
&& certutil -d sql:/home/chrome/.pki/nssdb -N --empty-password \
&& chown -R 9999:9999 /home/chrome \
&& echo 'root ALL=(chrome) NOPASSWD: ALL' \
> /etc/sudoers.d/agent-vm-chrome \
&& chmod 0440 /etc/sudoers.d/agent-vm-chrome \
&& printf '%s\n' \
'#!/bin/bash' \
'# agent-vm chrome MCP wrapper.' \
'# Re-exec the MCP server under the `chrome` user so chromium'\''s' \
'# user-namespace sandbox initializes (it refuses to as root).' \
'# Args (npx + chrome-devtools-mcp + flags) come from the MCP' \
'# config in ~/.claude.json; we just switch UID.' \
'#' \
'# We preserve a small allow-list of env vars across the sudo' \
'# call: agentd boot-sets NODE_EXTRA_CA_CERTS / SSL_CERT_FILE /' \
'# CURL_CA_BUNDLE / REQUESTS_CA_BUNDLE so any HTTPS client in' \
'# the guest trusts the microsandbox MITM CA. Without these' \
'# preserved, node fails `npx -y` with SELF_SIGNED_CERT_IN_CHAIN' \
'# fetching from registry.npmjs.org and the MCP exits before' \
'# the JSON-RPC handshake. PATH is needed so npx is findable.' \
'set -e' \
'exec sudo -u chrome -H -n \' \
' --preserve-env=NODE_EXTRA_CA_CERTS,SSL_CERT_FILE,CURL_CA_BUNDLE,REQUESTS_CA_BUNDLE,PATH \' \
' -- "$@"' \
> /usr/local/bin/agent-vm-chrome-mcp \
&& chmod 0755 /usr/local/bin/agent-vm-chrome-mcp
# GitHub CLI from the official apt repo (Phase 6).
RUN install -dm 755 /etc/apt/keyrings \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \