Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion agents/autowebcompat-diagnosis/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ class AgentInputs(BaseSettings):
| None
) = None

headless: bool = False

model_config = SettingsConfigDict(extra="ignore")

@property
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -100,6 +100,7 @@ class TaskConfig:
) = None
log: Path | None = None
verbose: bool = True
headless: bool = False


@dataclass
Expand Down Expand Up @@ -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,
Expand All @@ -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.

Expand All @@ -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

Expand Down Expand Up @@ -398,15 +405,17 @@ 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,
),
DEVTOOLS_TOOLS,
)
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,
)

Expand All @@ -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
Expand Down Expand Up @@ -496,15 +505,17 @@ 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,
),
DEVTOOLS_TOOLS,
)
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,
)

Expand Down Expand Up @@ -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,
Expand All @@ -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()
2 changes: 1 addition & 1 deletion agents/autowebcompat-diagnosis/repro_reference.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down