#!/usr/bin/env python3 """Fixed, narrowly-scoped headless-Chromium screenshot helper for the board Reviewer's visual verification of an HTML/CSS change (docs/intent/board-reviewer-visual-verification.md). Takes only a commit hash and a repo-relative .html path -- extracts that exact file's content via `git show :` into a throwaway temp file rather than reading the live working tree (which may have moved past the reviewed commit by the time Review actually runs), then renders it headless in both light and dark `prefers-color-scheme`. Deliberately a single fixed script, not a raw chromium invocation the model constructs itself: the Reviewer's --allowedTools grant pins this exact script path (see worker.py's run_review()), so the model can only ever run *this* script, with *some* two arguments -- both of which this script re-validates on its own regardless of what the allowlist already constrains, same "wrapper enforces, model proposes" precedent as every other board-agent role. Only ever wired up for the Claude runtime: Codex's review sandbox (`--sandbox read-only`) blocks every write, verified live and documented in this directory's README ("Why read-only, not a scoped writable dir") -- Chromium has nowhere to write a screenshot to under that sandbox, so this tool is simply not offered to a Codex/OpenRouter review at all. """ import os import re import subprocess import sys import tempfile from pathlib import Path COMMIT_RE = re.compile(r"^[0-9a-f]{7,40}$") # Empirically verified 2026-09-10 against the deployed Chromium build: # --force-prefers-color-scheme has no effect on a file:// page in headless # mode here (identical output regardless of value); --blink-settings= # preferredColorScheme=<0|1> does. 0 renders dark, 1 renders light -- # verified by the actual rendered pixel color, not assumed from the # flag's nominal enum ordering. COLOR_SCHEMES = {"dark": 0, "light": 1} TIMEOUT_SECONDS = 30 def main(): if len(sys.argv) != 3: print("usage: screenshot.py ", file=sys.stderr) return 2 commit_hash, relative = sys.argv[1], sys.argv[2] if not COMMIT_RE.fullmatch(commit_hash): print("commit hash must be a plain hex sha", file=sys.stderr) return 2 if not relative.endswith(".html") or relative.startswith("/") or ".." in Path(relative).parts: print("only a repo-relative .html path is allowed", file=sys.stderr) return 2 repo_dir = os.environ["BOARD_REPO_DIR"] show = subprocess.run( ["git", "-C", repo_dir, "show", f"{commit_hash}:{relative}"], capture_output=True, text=True, timeout=TIMEOUT_SECONDS, check=False, ) if show.returncode != 0 or not show.stdout.strip(): print(f"git show {commit_hash}:{relative} failed: {show.stderr.strip()}", file=sys.stderr) return 1 out_dir = Path(tempfile.mkdtemp(prefix="board-review-screenshot-")) html_path = out_dir / "page.html" html_path.write_text(show.stdout, encoding="utf-8") chromium = os.environ["BOARD_CHROMIUM_BIN"] for scheme, value in COLOR_SCHEMES.items(): out_path = out_dir / f"{scheme}.png" result = subprocess.run( [ chromium, "--headless=new", "--disable-gpu", # Chromium's own setuid sandbox needs privilege escalation # this service's NoNewPrivileges=true already forbids, so # it would fail to start without this -- containment comes # entirely from the outer systemd sandbox instead (see the # nix module comment next to BOARD_CHROMIUM_BIN). "--no-sandbox", f"--blink-settings=preferredColorScheme={value}", "--window-size=1280,900", f"--screenshot={out_path}", f"file://{html_path}", ], capture_output=True, text=True, timeout=TIMEOUT_SECONDS, check=False, ) if result.returncode != 0 or not out_path.exists(): print(f"screenshot failed for {scheme}: {result.stderr.strip()[-500:]}", file=sys.stderr) return 1 print(out_path) return 0 if __name__ == "__main__": sys.exit(main())