fix(container): make headed browsing actually work in the sandbox

Headed mode was documented but never functional: the image ships Chromium
with no X server, so `agent-browser --headed` died with "Missing X server or
$DISPLAY" while still exiting 0.

- install xvfb/x11-utils/xdotool/dbus-x11/imagemagick and Noto fonts
- start Xvfb and export DISPLAY for every shell from the entrypoint
- derive the browser User-Agent from the installed Chromium instead of
  pinning a version that drifts
- assert headed launch at build time, since the CLI cannot be relied on to
  exit non-zero
- document the bot-protection escape hatch and its traps in the skill
This commit is contained in:
Ahmed Allam
2026-08-09 23:58:20 +00:00
committed by Devin AI
parent 7b3c8f9b74
commit 1f30e1ed8b
3 changed files with 145 additions and 10 deletions
+35 -3
View File
@@ -58,7 +58,9 @@ RUN apt-get update && \
libcap2-bin \
gdb \
libnss3-tools \
chromium fonts-liberation
chromium fonts-liberation fonts-noto-core fonts-noto-color-emoji \
xvfb x11-utils xdotool dbus-x11 \
imagemagick
RUN setcap cap_net_raw,cap_net_admin,cap_net_bind_service+eip $(which nmap)
@@ -114,12 +116,42 @@ RUN npm install -g retire@latest && \
ln -sf ast-grep /home/pentester/.npm-global/lib/node_modules/@ast-grep/cli/sg
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
ENV AGENT_BROWSER_ARGS="--disable-blink-features=AutomationControlled,--no-first-run,--no-default-browser-check,--lang=en-US"
# The User-Agent is derived from the installed Chromium at container start
# (containers/docker-entrypoint.sh): a hardcoded version silently drifts from the
# real browser as the image is rebuilt, and a UA that disagrees with the client
# hints and JS fingerprint is exactly what bot protection looks for.
# NOTE: AGENT_BROWSER_ARGS is comma-separated, so no flag value may contain a
# comma — `--window-size=1280,800` would split into a bogus `800` argument that
# Chrome treats as a second URL ("Multiple targets are not supported"). The window
# geometry comes from the virtual display instead.
ENV AGENT_BROWSER_ARGS="--disable-blink-features=AutomationControlled,--no-first-run,--no-default-browser-check,--lang=en-US,--password-store=basic,--use-mock-keychain,--disable-dev-shm-usage"
ENV AGENT_BROWSER_SCREENSHOT_DIR=/workspace/.agent-browser-screenshots
ENV AGENT_BROWSER_IDLE_TIMEOUT_MS=180000
RUN /home/pentester/.npm-global/bin/agent-browser doctor --offline --quick
# `agent-browser --headed` exits 0 even when Chrome fails to launch, so a broken
# headed mode is invisible at runtime — assert it here instead. Targets are
# increasingly gated behind bot protection that rejects headless browsers, which
# makes headed mode part of the login path, not a debugging nicety.
#
# `--no-sandbox` is for this build step only: buildkit runs without unprivileged
# user namespaces, so Chromium's zygote sandbox can't initialize here. It is not
# part of the runtime browser args. The X lock and socket are removed afterwards;
# a leftover makes the runtime Xvfb refuse to start ("Server is already active").
RUN set -eu; \
Xvfb :99 -screen 0 1280x800x24 -nolisten tcp >/tmp/xvfb-build.log 2>&1 & \
for _ in $(seq 1 20); do xdpyinfo -display :99 >/dev/null 2>&1 && break; sleep 0.5; done; \
xdpyinfo -display :99 >/dev/null; \
DISPLAY=:99 agent-browser --headed --args "${AGENT_BROWSER_ARGS},--no-sandbox" \
open about:blank >/tmp/headed-check.log 2>&1 || true; \
if grep -qi 'Missing X server\|exited early' /tmp/headed-check.log; then \
echo "headed browser check failed:"; cat /tmp/headed-check.log; exit 1; \
fi; \
DISPLAY=:99 xwininfo -root -children | grep -qi chromium; \
agent-browser close >/dev/null 2>&1 || true; \
pkill -f 'Xvfb :99' || true; \
rm -rf /tmp/.X99-lock /tmp/.X11-unix/X99 /tmp/xvfb-build.log /tmp/headed-check.log
RUN set -eux; \
TS_PARSER_DIR="/home/pentester/.tree-sitter/parsers"; \
mkdir -p "${TS_PARSER_DIR}"; \
+50
View File
@@ -117,6 +117,56 @@ echo ". /etc/profile.d/proxy.sh" >> ~/.zshrc
echo "✅ System-wide proxy configuration complete"
# Bot protection routinely rejects headless browsers, so the agent needs to be
# able to fall back to a real headed Chrome. There is no physical display here,
# so provide a virtual one; without it headed mode dies with "Missing X server or
# $DISPLAY" while still exiting 0, which is silent from the agent's point of view.
DISPLAY_NUM="${STRIX_DISPLAY_NUM:-99}"
DISPLAY_GEOMETRY="${STRIX_DISPLAY_GEOMETRY:-1280x800x24}"
if ! xdpyinfo -display ":${DISPLAY_NUM}" >/dev/null 2>&1; then
# No server is answering, so any lock/socket left over from an earlier boot (or
# from image build) is stale — Xvfb otherwise refuses with "Server is already
# active for display N".
rm -f "/tmp/.X${DISPLAY_NUM}-lock" "/tmp/.X11-unix/X${DISPLAY_NUM}" 2>/dev/null || true
Xvfb ":${DISPLAY_NUM}" -screen 0 "${DISPLAY_GEOMETRY}" -nolisten tcp \
> /tmp/xvfb.log 2>&1 &
for _ in $(seq 1 20); do
xdpyinfo -display ":${DISPLAY_NUM}" >/dev/null 2>&1 && break
sleep 0.5
done
fi
if xdpyinfo -display ":${DISPLAY_NUM}" >/dev/null 2>&1; then
echo "✅ Virtual display :${DISPLAY_NUM} ready (${DISPLAY_GEOMETRY})"
else
echo "⚠️ Xvfb failed to start; headed browsing is unavailable. Xvfb log:"
cat /tmp/xvfb.log 2>/dev/null || echo "(no log available)"
fi
# A session bus keeps headed Chrome from spewing dbus connection errors that look
# like fatal failures in tool output. Best-effort: Chrome runs fine without it.
if [ ! -S /run/dbus/system_bus_socket ]; then
sudo mkdir -p /run/dbus
sudo dbus-daemon --system --fork > /tmp/dbus.log 2>&1 || true
fi
# Keep the advertised Chrome version in step with the browser actually installed:
# a User-Agent that disagrees with the client hints and JS fingerprint is a
# trivially detectable automation signal.
CHROME_MAJOR="$(chromium --version 2>/dev/null | grep -oE '[0-9]+' | head -1)"
BROWSER_UA="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROME_MAJOR:-131}.0.0.0 Safari/537.36"
cat << EOF | sudo tee /etc/profile.d/browser.sh
export DISPLAY=:${DISPLAY_NUM}
export AGENT_BROWSER_USER_AGENT="${BROWSER_UA}"
EOF
echo ". /etc/profile.d/browser.sh" >> ~/.bashrc
echo ". /etc/profile.d/browser.sh" >> ~/.zshrc
. /etc/profile.d/browser.sh
echo "Adding CA to browser trust store..."
sudo -u pentester mkdir -p /home/pentester/.pki/nssdb
sudo -u pentester certutil -N -d sql:/home/pentester/.pki/nssdb --empty-password
+60 -7
View File
@@ -189,6 +189,59 @@ After any page-changing action, pick one:
Avoid bare `wait 2000` except when debugging — it makes scripts slow and
flaky. Timeouts default to 25 seconds.
## Bot protection (read before fighting a login)
Many targets sit behind a bot check (Cloudflare Turnstile, hCaptcha, Datadome).
You will see it in a snapshot as a challenge iframe plus inputs that go
`[disabled]` when you submit:
```
- textbox "Email address" [disabled, ref=e18]: user@example.com
- button "Continue with email" [disabled, ref=e12]
- Iframe "Widget containing a Cloudflare security challenge" [ref=e15]
- checkbox "Verify you are human" [checked=false, ref=e22]
```
`checked=false` that never flips means the challenge is refusing you, not that
the click missed. Headless Chrome is itself one of the strongest signals these
systems key on, so switch to a real browser window instead of retrying:
```bash
agent-browser close --all # a running daemon makes --headed a no-op
export AGENT_BROWSER_HEADED=1 # applies to every later command
agent-browser open https://target.tld/login
agent-browser get url # confirm it actually launched
```
The sandbox provides a virtual display and `DISPLAY` is already exported, so this
works with no setup. Three traps:
- **Set the env var, don't just pass `--headed` to `open`.** The flag is
per-invocation: the next bare `agent-browser snapshot` tries to start a
*headless* daemon and dies with `Multiple targets are not supported in headless
mode`. Export `AGENT_BROWSER_HEADED=1` (or pass `--headed` to every command).
- **`--headed` is ignored when a daemon is already running** — it prints
`⚠ --headed ignored: daemon already running`. Always `close --all` first.
- **A failed launch still exits 0.** Read the output text: `✗ Chrome exited
early` or `Missing X server` means you are not headed, whatever the exit code
says. Confirm with `agent-browser get url` before concluding anything.
Then behave like a person rather than a script:
- Drive real input events — `click`, `hover`, `keyboard type` — never `eval` with
`element.value = ...`. Assigning `value` directly leaves React-controlled
inputs internally empty, so the form submits blank or stays disabled.
- `focus` the field, then `keyboard type "text"` when `fill` appears to work but
the app doesn't react.
- Click the challenge checkbox by its ref inside the iframe, then
`wait --text` / `wait --url` for the *result*; don't re-click while it verifies.
- Save the session once you're through (`state save`, or `--session-name`) so a
browser restart doesn't send you back to the challenge.
If the challenge still refuses after a couple of honest attempts, stop. Report
that the target is gated and hand back a bounded result — burning your whole
window on one login costs more coverage than the login was worth.
## Common workflows
### Log in
@@ -397,12 +450,12 @@ agent-browser dialog dismiss # cancel
## Readiness & recovery
The first `agent-browser open` in a session launches the headless-Chrome
daemon; later commands reuse it. A daemon left idle for 3 minutes shuts itself
down to free memory for the other agents, so an `open` after a long gap is a
fresh browser rather than a resumed one — expect to re-navigate, and re-`state
load` if you were logged in. Distinguish the failure modes and react differently
— do **not** blindly re-run the same failing command in a loop:
The first `agent-browser open` in a session launches the Chrome daemon (headless
unless you pass `--headed`); later commands reuse it. A daemon left idle for 3
minutes shuts itself down to free memory for the other agents, so an `open` after
a long gap is a fresh browser rather than a resumed one — expect to re-navigate,
and re-`state load` if you were logged in. Distinguish the failure modes and react
differently — do **not** blindly re-run the same failing command in a loop:
- **Daemon / connection failure** (`Failed to connect`, `connection refused`,
socket missing, `browser not running`): the daemon isn't up or has died. Run
@@ -490,7 +543,7 @@ and [references/authentication.md](references/authentication.md).
```bash
--session <name> # isolated browser session
--json # JSON output (for machine parsing)
--headed # show the window (default is headless)
--headed # real browser window (default is headless); see "Bot protection"
--auto-connect # connect to an already-running Chrome
--cdp <port> # connect to a specific CDP port
--profile <name|path> # use a Chrome profile (login state survives)