mirror of
https://github.com/wirenboard/agent-vm.git
synced 2026-07-10 00:14:30 +00:00
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:
parent
daa5922f36
commit
f40e6df2bc
3 changed files with 109 additions and 2 deletions
|
|
@ -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\
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue