diff --git a/containers/Dockerfile b/containers/Dockerfile index cabb6b39..0dd45cd3 100644 --- a/containers/Dockerfile +++ b/containers/Dockerfile @@ -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,8 +116,11 @@ 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" +# Must match the installed Chromium major; the build asserts it below. +ENV AGENT_BROWSER_USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36" +# AGENT_BROWSER_ARGS is comma-separated, so no flag value may contain a comma +# (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 USER root @@ -134,6 +139,29 @@ RUN set -eu; \ USER pentester RUN /home/pentester/.npm-global/bin/agent-browser doctor --offline --quick +# `agent-browser --headed` exits 0 even when Chrome fails to launch, so assert +# headed mode here. `--no-sandbox` is build-only: buildkit has no unprivileged +# user namespaces. The stale X lock/socket must go or the runtime Xvfb refuses. +RUN set -eu; \ + chrome_major="$(chromium --version | grep -oE '[0-9]+' | head -1)"; \ + printf '%s' "${AGENT_BROWSER_USER_AGENT}" | grep -q "Chrome/${chrome_major}\." || { \ + echo "AGENT_BROWSER_USER_AGENT disagrees with installed Chromium ${chrome_major}"; exit 1; \ + }; \ + Xvfb :99 -screen 0 1280x800x24 -nolisten tcp >/tmp/xvfb-build.log 2>&1 & \ + xvfb_pid=$!; \ + 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; \ + kill "${xvfb_pid}" 2>/dev/null || true; \ + wait "${xvfb_pid}" 2>/dev/null || 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}"; \ diff --git a/containers/docker-entrypoint.sh b/containers/docker-entrypoint.sh index e4f472d6..1885b7d8 100644 --- a/containers/docker-entrypoint.sh +++ b/containers/docker-entrypoint.sh @@ -117,6 +117,46 @@ echo ". /etc/profile.d/proxy.sh" >> ~/.zshrc echo "✅ System-wide proxy configuration complete" +# A virtual display so the agent can fall back to headed Chrome when a target +# rejects headless; without it headed mode dies but still exits 0. +DISPLAY_NUM="${STRIX_DISPLAY_NUM:-99}" +DISPLAY_GEOMETRY="${STRIX_DISPLAY_GEOMETRY:-1280x800x24}" + +if ! xdpyinfo -display ":${DISPLAY_NUM}" >/dev/null 2>&1; then + # Nothing is answering, so a leftover lock/socket is stale; Xvfb refuses to + # start with one present. + 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 + +# Best-effort session bus: without it headed Chrome spews dbus errors that read +# like fatal failures in tool output. +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 + +cat << EOF | sudo tee /etc/profile.d/browser.sh +export DISPLAY=:${DISPLAY_NUM} +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 diff --git a/strix/skills/tooling/agent_browser.md b/strix/skills/tooling/agent_browser.md index a254bfaf..6c072b3a 100644 --- a/strix/skills/tooling/agent_browser.md +++ b/strix/skills/tooling/agent_browser.md @@ -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 # 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 # connect to a specific CDP port --profile # use a Chrome profile (login state survives)