From 33fcc8f6934db5cfed88d24028189662aaaa4e76 Mon Sep 17 00:00:00 2001 From: Ksenia Berezina Date: Wed, 16 Sep 2026 16:00:13 -0400 Subject: [PATCH] Add headed mode to autowebcompat diagnosis agent --- agents/autowebcompat-diagnosis/Dockerfile | 10 +- .../autowebcompat_diagnosis/__main__.py | 3 + .../autowebcompat_diagnosis/agent.py | 164 ++++++++++++------ .../repro_reference.mjs | 2 +- 4 files changed, 121 insertions(+), 58 deletions(-) diff --git a/agents/autowebcompat-diagnosis/Dockerfile b/agents/autowebcompat-diagnosis/Dockerfile index b15ac52e65..5ffb438b9f 100644 --- a/agents/autowebcompat-diagnosis/Dockerfile +++ b/agents/autowebcompat-diagnosis/Dockerfile @@ -29,7 +29,7 @@ FROM base AS agent # The agent needs Node.js + npm to run the DevTools MCP servers (npm packages; # the python base ships neither) and the shared libraries the browsers require -# to run headless. The browser binaries themselves are downloaded at agent +# to run browsers. The browser binaries themselves are downloaded at agent # startup (a fresh build per run): Firefox via mozdownload/mozinstall and Chrome # for Testing via the Chrome-for-Testing JSON API (see browser.py). RUN apt-get update \ @@ -49,6 +49,9 @@ RUN apt-get update \ fonts-liberation \ # Codecs and audio/video related dependencies ffmpeg \ + # Window manager and display backend + fluxbox \ + xvfb \ && rm -rf /var/lib/apt/lists/* # Install the DevTools MCP servers and Puppeteer from the pinned @@ -69,6 +72,11 @@ RUN useradd --create-home --shell /bin/bash agent \ && mkdir -p /workspace \ && chown agent:agent /workspace /app/diagnosis +ENV SCREEN_WIDTH=1280 +ENV SCREEN_HEIGHT=1024 +ENV SCREEN_DEPTH=24 +ENV DISPLAY=:99.0 + USER agent COPY --from=builder /opt/venv /opt/venv diff --git a/agents/autowebcompat-diagnosis/hackbot_agents/autowebcompat_diagnosis/__main__.py b/agents/autowebcompat-diagnosis/hackbot_agents/autowebcompat_diagnosis/__main__.py index cc771c371e..dc80fddc0a 100644 --- a/agents/autowebcompat-diagnosis/hackbot_agents/autowebcompat_diagnosis/__main__.py +++ b/agents/autowebcompat-diagnosis/hackbot_agents/autowebcompat_diagnosis/__main__.py @@ -36,6 +36,8 @@ class AgentInputs(BaseSettings): | None ) = None + headless: bool = False + model_config = SettingsConfigDict(extra="ignore") @property @@ -66,6 +68,7 @@ async def main(ctx: HackbotContext) -> AutowebcompatResult: effort=inputs.effort, log=ctx.log_path, verbose=True, + headless=inputs.headless, ), tracker, input_data, diff --git a/agents/autowebcompat-diagnosis/hackbot_agents/autowebcompat_diagnosis/agent.py b/agents/autowebcompat-diagnosis/hackbot_agents/autowebcompat_diagnosis/agent.py index 0a217b1a20..6451afa805 100644 --- a/agents/autowebcompat-diagnosis/hackbot_agents/autowebcompat_diagnosis/agent.py +++ b/agents/autowebcompat-diagnosis/hackbot_agents/autowebcompat_diagnosis/agent.py @@ -12,7 +12,7 @@ from datetime import datetime from enum import Enum from pathlib import Path -from typing import Any, Generic, Literal +from typing import Any, Generic, Literal, Self from claude_agent_sdk import ( ClaudeAgentOptions, @@ -100,6 +100,7 @@ class TaskConfig: ) = None log: Path | None = None verbose: bool = True + headless: bool = False @dataclass @@ -246,21 +247,27 @@ async def run(self) -> ResultT: return self.result_collector.result -def run_script(script_path: Path, browser: str, browser_path: Path) -> int | None: +def run_script( + script_path: Path, browser: str, browser_path: Path, headless: bool +) -> int | None: """Run the reproduction script in one browser; return its exit code. Returns ``None`` if the script timed out, i.e. gave no verdict. """ script_timeout = 5 * 60 + env = { + **os.environ, + "NODE_PATH": str(NODE_MODULES), + "BROWSER": browser, + "BROWSER_BIN": str(browser_path), + } + if headless: + env["HEADLESS"] = "1" + try: proc = subprocess.run( ["node", str(script_path)], - env={ - **os.environ, - "NODE_PATH": str(NODE_MODULES), - "BROWSER": browser, - "BROWSER_BIN": str(browser_path), - }, + env=env, capture_output=True, text=True, timeout=script_timeout, @@ -280,7 +287,7 @@ def run_script(script_path: Path, browser: str, browser_path: Path) -> int | Non def run_confirmation_script( - script_path: Path, firefox_path: Path, chrome_path: Path + script_path: Path, firefox_path: Path, chrome_path: Path, headless: bool ) -> ReproScriptResult | None: """Check the script still demonstrates the difference, without an agent. @@ -289,10 +296,10 @@ def run_confirmation_script( outcome — wrong exit codes, a script error, or a timeout — so the caller can fall back to the agent task. """ - firefox_code = run_script(script_path, "firefox", firefox_path) + firefox_code = run_script(script_path, "firefox", firefox_path, headless) if firefox_code != 1: return None - chrome_code = run_script(script_path, "chrome", chrome_path) + chrome_code = run_script(script_path, "chrome", chrome_path, headless) if chrome_code != 0: return None @@ -398,7 +405,7 @@ def __init__( "firefox-devtools", build_firefox_devtools_server( firefox_path=firefox_path, - headless=True, + headless=task_config.headless, enable_script=True, enable_privileged_context=False, ), @@ -406,7 +413,9 @@ def __init__( ) self.add_mcp_server( "chrome-devtools", - build_chrome_devtools_server(chrome_path=chrome_path, headless=True), + build_chrome_devtools_server( + chrome_path=chrome_path, headless=task_config.headless + ), CHROME_DEVTOOLS_TOOLS, ) @@ -432,8 +441,8 @@ def system_prompt(self) -> str: script that demonstrates it. Do not investigate why the difference happens. 1. Confirm the issue: run the reproduction steps against the reported - site in Firefox with the Firefox DevTools MCP (headless, as is every browser - on this system), then run the same steps in Chrome with the Chrome DevTools + site in Firefox with the Firefox DevTools MCP, then run the same steps + in Chrome with the Chrome DevTools MCP. - A genuine web-compat issue reproduces in Firefox but not in Chrome. If the behavior is identical in both, your steps may be wrong; refine the steps @@ -496,7 +505,7 @@ def __init__( "firefox-devtools", build_firefox_devtools_server( firefox_path=firefox_path, - headless=True, + headless=task_config.headless, enable_script=True, enable_privileged_context=False, ), @@ -504,7 +513,9 @@ def __init__( ) self.add_mcp_server( "chrome-devtools", - build_chrome_devtools_server(chrome_path=chrome_path, headless=True), + build_chrome_devtools_server( + chrome_path=chrome_path, headless=task_config.headless + ), CHROME_DEVTOOLS_TOOLS, ) @@ -599,6 +610,43 @@ def into_result(self) -> AutowebcompatDiagnosisResult: ) +class Environment: + def __init__(self): + self.started_processes = [] + + def start(self, cmd: list[str]) -> None: + logging.info("Running %s", " ".join(cmd)) + self.started_processes.append(subprocess.Popen(cmd)) + + def start_xvfb(self) -> None: + self.start( + [ + "Xvfb", + os.environ["DISPLAY"], + "-screen", + "0", + "%sx%sx%s" + % ( + os.environ["SCREEN_WIDTH"], + os.environ["SCREEN_HEIGHT"], + os.environ["SCREEN_DEPTH"], + ), + ] + ) + self.start(["fluxbox", "-display", os.environ["DISPLAY"]]) + + def __enter__(self) -> Self: + return self + + def __exit__(self, *args, **kwargs) -> None: + for process in self.started_processes: + process.terminate() + try: + process.wait(timeout=30) + except subprocess.TimeoutExpired: + process.kill() + + async def run_autowebcompat_diagnosis( config: TaskConfig, tracker: RunTracker, @@ -607,52 +655,56 @@ async def run_autowebcompat_diagnosis( publish_file: PublishFile, ) -> AutowebcompatDiagnosisResult: """Confirm a web-compat issue reproduces, then diagnose why.""" - firefox_browser = FirefoxBrowsers() - chrome_browser = ChromeBrowsers() + with Environment() as env: + if not config.headless: + env.start_xvfb() - plan_task = DiagnosisPlan(config, tracker, input_data, bugzilla_mcp_server) - plan_result = await plan_task.run() + firefox_browser = FirefoxBrowsers() + chrome_browser = ChromeBrowsers() - channel = FirefoxChannel(plan_result.firefox_channel) - logger.info( - "Diagnosing on Firefox %s: %s", channel.value, plan_result.channel_rationale - ) - firefox_path = getattr(firefox_browser, channel.value) - chrome_path = chrome_browser.stable - - # If the attached script still demonstrates the difference, that settles the - # reproduction without spending an agent task on it. - repro_result = None - if plan_result.script_path is not None: - repro_result = run_confirmation_script( - plan_result.script_path, firefox_path, chrome_path + plan_task = DiagnosisPlan(config, tracker, input_data, bugzilla_mcp_server) + plan_result = await plan_task.run() + + channel = FirefoxChannel(plan_result.firefox_channel) + logger.info( + "Diagnosing on Firefox %s: %s", channel.value, plan_result.channel_rationale ) + firefox_path = getattr(firefox_browser, channel.value) + chrome_path = chrome_browser.stable + + # If the attached script still demonstrates the difference, that settles the + # reproduction without spending an agent task on it. + repro_result = None + if plan_result.script_path is not None: + repro_result = run_confirmation_script( + plan_result.script_path, firefox_path, chrome_path, config.headless + ) + if repro_result is None: + logger.info( + "Attached script did not demonstrate the difference; " + "falling back to the reproduction task" + ) if repro_result is None: - logger.info( - "Attached script did not demonstrate the difference; " - "falling back to the reproduction task" + repro_task = ReproScript( + config, tracker, firefox_path, chrome_path, plan_result ) - if repro_result is None: - repro_task = ReproScript( - config, tracker, firefox_path, chrome_path, plan_result - ) - repro_result = await repro_task.run() + repro_result = await repro_task.run() - results = DiagnosisResults(publish_file, repro_result) + results = DiagnosisResults(publish_file, repro_result) - if not repro_result.reproduced: - logger.info( - "Issue did not reproduce (%s); skipping diagnosis", - repro_result.failure_reason, - ) - return results.into_result() + if not repro_result.reproduced: + logger.info( + "Issue did not reproduce (%s); skipping diagnosis", + repro_result.failure_reason, + ) + return results.into_result() - if repro_result.script_path is None: - logger.info("No validated script; diagnosing from the reproduction steps") + if repro_result.script_path is None: + logger.info("No validated script; diagnosing from the reproduction steps") - diagnosis_task = Diagnosis( - config, tracker, firefox_path, chrome_path, plan_result, repro_result - ) - results.set_diagnosis(await diagnosis_task.run()) + diagnosis_task = Diagnosis( + config, tracker, firefox_path, chrome_path, plan_result, repro_result + ) + results.set_diagnosis(await diagnosis_task.run()) - return results.into_result() + return results.into_result() diff --git a/agents/autowebcompat-diagnosis/repro_reference.mjs b/agents/autowebcompat-diagnosis/repro_reference.mjs index 7b149bfa9f..5e0e34b9bf 100644 --- a/agents/autowebcompat-diagnosis/repro_reference.mjs +++ b/agents/autowebcompat-diagnosis/repro_reference.mjs @@ -36,7 +36,7 @@ async function probe() { const browser = await puppeteer.launch({ browser: BROWSER, executablePath: BROWSER_BIN, - headless: true, + headless: process.env.HEADLESS ? true : false, ...(BROWSER === "chrome" ? { args: ["--no-sandbox"] } : {}), }); try {