From bb88035b12cb4caba1f50924eebce2d8698bc4ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 22:13:13 +0900 Subject: [PATCH 01/50] test(browser): require PID-safe browser crash evidence --- ...nt_task_browser_crash_recovery_contract.py | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/test_agent_task_browser_crash_recovery_contract.py diff --git a/tests/test_agent_task_browser_crash_recovery_contract.py b/tests/test_agent_task_browser_crash_recovery_contract.py new file mode 100644 index 000000000..36ccd503e --- /dev/null +++ b/tests/test_agent_task_browser_crash_recovery_contract.py @@ -0,0 +1,109 @@ +"""Contract for PID-safe browser-process crash evidence in the Agent Task lane.""" + +from __future__ import annotations + +import pathlib +import runpy +import signal +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class AgentTaskBrowserCrashRecoveryContractTests(unittest.TestCase): + """Require controlled browser-process interruption without PID-reuse races.""" + + def test_runner_exposes_pidfd_crash_boundary(self) -> None: + """The Linux browser crash probe must use a PID-safe signalling boundary.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_browser_crash_contract") + for expected in ( + "_signal_linux_process_identity", + "_run_agent_task_browser_crash_browser_pass", + "_run_agent_task_browser_crash_trial", + ): + with self.subTest(expected=expected): + self.assertIn(expected, namespace) + + def test_signal_boundary_rejects_pid_reuse_before_open(self) -> None: + """A reused PID must never receive the crash signal.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_browser_crash_reuse") + signal_identity = namespace["_signal_linux_process_identity"] + opened: list[int] = [] + signalled: list[tuple[int, int]] = [] + + signal_identity.__globals__["_read_linux_proc_stat_process_identity"] = ( + lambda _process_id: (777, 99) + ) + signal_identity.__globals__["os"].pidfd_open = lambda process_id, _flags=0: opened.append(process_id) or 12 + signal_identity.__globals__["signal"].pidfd_send_signal = ( + lambda pidfd, sig, *_args, **_kwargs: signalled.append((pidfd, sig)) + ) + signal_identity.__globals__["os"].close = lambda _fd: None + + self.assertFalse(signal_identity((777, 42), signal.SIGKILL)) + self.assertEqual(opened, []) + self.assertEqual(signalled, []) + + def test_signal_boundary_rechecks_identity_after_pidfd_open(self) -> None: + """The process identity must still match after the race-free handle is opened.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_browser_crash_post_open") + signal_identity = namespace["_signal_linux_process_identity"] + identities = iter(((777, 42), (777, 99))) + signalled: list[tuple[int, int]] = [] + closed: list[int] = [] + + signal_identity.__globals__["_read_linux_proc_stat_process_identity"] = ( + lambda _process_id: next(identities) + ) + signal_identity.__globals__["os"].pidfd_open = lambda _process_id, _flags=0: 12 + signal_identity.__globals__["signal"].pidfd_send_signal = ( + lambda pidfd, sig, *_args, **_kwargs: signalled.append((pidfd, sig)) + ) + signal_identity.__globals__["os"].close = closed.append + + self.assertFalse(signal_identity((777, 42), signal.SIGKILL)) + self.assertEqual(signalled, []) + self.assertEqual(closed, [12]) + + def test_signal_boundary_targets_only_exact_open_identity(self) -> None: + """Exact identity proof must send one signal through the opened pidfd and close it.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_browser_crash_exact") + signal_identity = namespace["_signal_linux_process_identity"] + signalled: list[tuple[int, int]] = [] + closed: list[int] = [] + + signal_identity.__globals__["_read_linux_proc_stat_process_identity"] = ( + lambda _process_id: (777, 42) + ) + signal_identity.__globals__["os"].pidfd_open = lambda _process_id, _flags=0: 12 + signal_identity.__globals__["signal"].pidfd_send_signal = ( + lambda pidfd, sig, *_args, **_kwargs: signalled.append((pidfd, sig)) + ) + signal_identity.__globals__["os"].close = closed.append + + self.assertTrue(signal_identity((777, 42), signal.SIGKILL)) + self.assertEqual(signalled, [(12, signal.SIGKILL)]) + self.assertEqual(closed, [12]) + + def test_crash_lane_is_required_for_success_evidence(self) -> None: + """The real-browser evidence must retain deterministic crash and teardown proof.""" + + runner = RUNNER.read_text(encoding="utf-8") + for expected in ( + '"browser_crash"', + '"browser_process_crash_detected"', + '"browser_process_terminated"', + '"chromium_process_set_terminated"', + "Agent Task browser-crash recovery gate failed", + ): + with self.subTest(expected=expected): + self.assertIn(expected, runner) + + +if __name__ == "__main__": + unittest.main() From 7c094abb084d3c863fd0f9c994bfe7c40984075d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:12:46 +0900 Subject: [PATCH 02/50] feat(browser): prove PID-safe Agent Task browser crash teardown --- scripts/ci/run_mv3_compatibility.py | 312 ++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 0de08d43c..f18b82a51 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -24,6 +24,7 @@ import math import os import pathlib +import signal import socket import string import subprocess @@ -466,6 +467,46 @@ def _read_linux_proc_stat_process_identity(process_id: int) -> tuple[int, int] | return identity +def _signal_linux_process_identity( + process_identity: tuple[int, int], + signal_number: int, +) -> bool: + """Signal only one exact Linux PID/start-time identity through a pidfd.""" + + if not isinstance(process_identity, tuple) or len(process_identity) != 2: + raise ValueError("invalid Linux process identity") + process_id, start_time_ticks = process_identity + if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: + raise ValueError("invalid Linux process identifier") + if ( + isinstance(start_time_ticks, bool) + or not isinstance(start_time_ticks, int) + or start_time_ticks <= 0 + ): + raise ValueError("invalid Linux process start time") + if isinstance(signal_number, bool) or not isinstance(signal_number, int) or signal_number <= 0: + raise ValueError("invalid Linux process signal") + + expected_identity = (process_id, start_time_ticks) + if _read_linux_proc_stat_process_identity(process_id) != expected_identity: + return False + pidfd_open = getattr(os, "pidfd_open", None) + pidfd_send_signal = getattr(signal, "pidfd_send_signal", None) + if not callable(pidfd_open) or not callable(pidfd_send_signal): + raise RuntimeError("Linux pidfd signalling is unavailable") + try: + pidfd = pidfd_open(process_id, 0) + except ProcessLookupError: + return False + try: + if _read_linux_proc_stat_process_identity(process_id) != expected_identity: + return False + pidfd_send_signal(pidfd, signal_number) + return True + finally: + os.close(pidfd) + + def _wait_for_linux_process_identity_exit( process_id: int, start_time_ticks: int, @@ -1770,6 +1811,226 @@ def _run_agent_task_forced_close_trial( } +def _run_agent_task_browser_crash_browser_pass( + chrome_bin: pathlib.Path, + chromedriver_bin: pathlib.Path, + fixture_url: str, + profile_dir: str, +) -> dict[str, Any]: + """Kill one exact browser root and prove crash detection plus sampled teardown.""" + + _require_pristine_agent_task_profile(profile_dir) + driver_port = _free_loopback_port() + session_id: str | None = None + browser_process_id: int | None = None + browser_process_start_time_ticks: int | None = None + chromium_process_identities: tuple[tuple[int, int], ...] | None = None + browser_version: str | None = None + browser_process_crash_detected = False + driver = subprocess.Popen( + [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, + text=True, + ) + try: + _wait_for_driver(driver_port) + session = _json_request( + driver_port, + "POST", + "/session", + { + "capabilities": { + "alwaysMatch": { + "browserName": "chrome", + "goog:chromeOptions": { + "binary": str(chrome_bin), + "prefs": { + "credentials_enable_service": False, + "profile.password_manager_enabled": False, + }, + "args": [ + "--headless=new", + "--no-first-run", + "--disable-default-apps", + "--disable-component-update", + "--disable-sync", + "--disable-dev-shm-usage", + "--no-sandbox", + "--disable-extensions", + f"--user-data-dir={profile_dir}", + ], + }, + } + } + }, + ).get("value", {}) + if not isinstance(session, dict): + raise RuntimeError("ChromeDriver browser-crash session response is malformed") + raw_session_id = session.get("sessionId") + capabilities = session.get("capabilities", {}) + if not isinstance(raw_session_id, str): + raise RuntimeError("ChromeDriver did not return a browser-crash session id") + if not isinstance(capabilities, dict): + raise RuntimeError("ChromeDriver browser-crash capabilities are malformed") + session_id = _path_token(raw_session_id, "session identifier") + browser_version = capabilities.get("browserVersion") + browser_process_id = capabilities.get("goog:processID") + if browser_version != PINNED_CHROME_VERSION: + raise RuntimeError( + f"unexpected browser-crash Chrome version: expected {PINNED_CHROME_VERSION}, " + f"got {browser_version!r}" + ) + if ( + isinstance(browser_process_id, bool) + or not isinstance(browser_process_id, int) + or browser_process_id <= 0 + ): + raise RuntimeError("ChromeDriver did not return a valid browser-crash process id") + browser_process_identity = _read_linux_proc_stat_process_identity(browser_process_id) + if browser_process_identity is None: + raise RuntimeError("Agent Task browser-crash process identity disappeared") + browser_process_start_time_ticks = browser_process_identity[1] + + _json_request( + driver_port, + "POST", + _webdriver_path(session_id, "/url"), + {"url": fixture_url}, + ) + loaded_url = _json_request( + driver_port, + "GET", + _webdriver_path(session_id, "/url"), + ).get("value") + if loaded_url != fixture_url: + raise RuntimeError("Agent Task browser-crash probe did not load its fixture URL") + + process_evidence = _snapshot_linux_process_evidence() + chromium_process_ids = _discover_linux_process_tree_ids( + browser_process_id, + process_evidence, + ) + chromium_process_identities = _read_linux_process_identity_set( + chromium_process_ids + ) + if not _signal_linux_process_identity(browser_process_identity, signal.SIGKILL): + raise RuntimeError("Agent Task browser process identity changed before crash signal") + + deadline = time.monotonic() + PROCESS_EXIT_TIMEOUT_SECONDS + while True: + try: + _json_request( + driver_port, + "GET", + _webdriver_path(session_id, "/url"), + timeout=1.0, + ) + except (OSError, RuntimeError, json.JSONDecodeError): + browser_process_crash_detected = True + break + remaining_seconds = deadline - time.monotonic() + if remaining_seconds <= 0: + raise RuntimeError("Agent Task browser remained usable after SIGKILL") + time.sleep(min(0.05, remaining_seconds)) + finally: + if session_id is not None: + with contextlib.suppress(Exception): + _json_request( + driver_port, + "DELETE", + _webdriver_path(session_id, ""), + {}, + ) + driver.terminate() + try: + driver.wait(timeout=5) + except subprocess.TimeoutExpired: + driver.kill() + driver.wait(timeout=5) + + if ( + browser_process_id is None + or browser_process_start_time_ticks is None + or chromium_process_identities is None + or browser_version is None + ): + raise RuntimeError("Agent Task browser-crash teardown identities were not captured") + browser_process_terminated, chromium_process_set_terminated = ( + _wait_for_linux_process_teardown( + browser_process_id, + browser_process_start_time_ticks, + chromium_process_identities, + ) + ) + if not browser_process_crash_detected: + raise RuntimeError("Agent Task browser-process crash was not detected") + if not browser_process_terminated: + raise RuntimeError("Agent Task browser-crash root process did not terminate") + if not chromium_process_set_terminated: + raise RuntimeError("Agent Task browser-crash Chromium process set did not terminate") + return { + "browser_version": browser_version, + "browser_process_crash_detected": True, + "browser_process_terminated": True, + "chromium_process_set_terminated": True, + } + + +def _run_agent_task_browser_crash_trial( + chrome_bin: pathlib.Path, + chromedriver_bin: pathlib.Path, + fixture_url: str, + trial_number: int, +) -> dict[str, Any]: + """Run one isolated browser-root crash trial and retain cleanup evidence.""" + + trial_started = time.monotonic() + profile_path: pathlib.Path + result: dict[str, Any] | None = None + failure_type: str | None = None + with tempfile.TemporaryDirectory( + prefix=f"originweave-agent-task-browser-crash-{trial_number}-" + ) as profile_dir: + profile_path = pathlib.Path(profile_dir) + try: + result = _run_agent_task_browser_crash_browser_pass( + chrome_bin, + chromedriver_bin, + fixture_url, + profile_dir, + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + failure_type = type(exc).__name__ + profile_cleaned = not profile_path.exists() + if not profile_cleaned: + raise RuntimeError( + f"Agent Task browser-crash profile cleanup failed in trial {trial_number}" + ) + + duration_ms = round((time.monotonic() - trial_started) * 1000) + if failure_type is not None: + return { + "trial_number": trial_number, + "passed": False, + "failure_type": failure_type, + "profile_cleaned": True, + "duration_ms": duration_ms, + } + if result is None: + raise RuntimeError("Agent Task browser-crash browser pass returned no result") + return { + "trial_number": trial_number, + "passed": True, + "browser_version": result["browser_version"], + "browser_process_crash_detected": result["browser_process_crash_detected"], + "browser_process_terminated": result["browser_process_terminated"], + "chromium_process_set_terminated": result["chromium_process_set_terminated"], + "profile_cleaned": True, + "duration_ms": duration_ms, + } + + def _start_fixture_server( directory: pathlib.Path, ) -> tuple[http.server.ThreadingHTTPServer, threading.Thread]: @@ -1905,6 +2166,26 @@ def main() -> int: } ) + browser_crash_trials: list[dict[str, Any]] = [] + for trial_number in range(1, AGENT_TASK_REPEATABILITY_TRIALS + 1): + try: + browser_crash_trials.append( + _run_agent_task_browser_crash_trial( + chrome_bin, + chromedriver_bin, + agent_task_url, + trial_number, + ) + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + browser_crash_trials.append( + { + "trial_number": trial_number, + "passed": False, + "failure_type": type(exc).__name__, + } + ) + agent_task_successful_trials = sum( 1 for trial in agent_task_trials if trial.get("passed") is True ) @@ -1971,6 +2252,20 @@ def main() -> int: for trial in forced_close_trials if trial.get("passed") is True ) + browser_crash_successful_trials = sum( + 1 for trial in browser_crash_trials if trial.get("passed") is True + ) + browser_crash_profiles_cleaned = all( + trial.get("profile_cleaned") is True for trial in browser_crash_trials + ) + browser_crash_surfaces_complete = all( + trial.get("browser_process_crash_detected") is True + and trial.get("browser_process_terminated") is True + and trial.get("chromium_process_set_terminated") is True + and trial.get("profile_cleaned") is True + for trial in browser_crash_trials + if trial.get("passed") is True + ) evidence = { "chrome_version": PINNED_CHROME_VERSION, @@ -1999,6 +2294,12 @@ def main() -> int: "profiles_cleaned": forced_close_profiles_cleaned, "trial_results": forced_close_trials, }, + "browser_crash": { + "repeatability_trials": AGENT_TASK_REPEATABILITY_TRIALS, + "successful_trials": browser_crash_successful_trials, + "profiles_cleaned": browser_crash_profiles_cleaned, + "trial_results": browser_crash_trials, + }, }, "duration_ms": round((time.monotonic() - started) * 1000), } @@ -2035,6 +2336,17 @@ def main() -> int: f"{forced_close_successful_trials}/{AGENT_TASK_REPEATABILITY_TRIALS} " "trials passed" ) + if not browser_crash_profiles_cleaned: + raise RuntimeError("Agent Task browser-crash profile cleanup gate failed") + if ( + browser_crash_successful_trials != AGENT_TASK_REPEATABILITY_TRIALS + or not browser_crash_surfaces_complete + ): + raise RuntimeError( + "Agent Task browser-crash recovery gate failed: " + f"{browser_crash_successful_trials}/{AGENT_TASK_REPEATABILITY_TRIALS} " + "trials passed" + ) return 0 finally: _stop_fixture_server(agent_task_server, agent_task_thread) From b32bd3b3ac5192d2ff740c17db3440d1c2a4e36c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 23:15:48 +0900 Subject: [PATCH 03/50] test(browser): require idempotent crash driver cleanup --- ...nt_task_browser_crash_recovery_contract.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_agent_task_browser_crash_recovery_contract.py b/tests/test_agent_task_browser_crash_recovery_contract.py index 36ccd503e..f1a44c9e6 100644 --- a/tests/test_agent_task_browser_crash_recovery_contract.py +++ b/tests/test_agent_task_browser_crash_recovery_contract.py @@ -90,6 +90,31 @@ def test_signal_boundary_targets_only_exact_open_identity(self) -> None: self.assertEqual(signalled, [(12, signal.SIGKILL)]) self.assertEqual(closed, [12]) + def test_crash_driver_cleanup_is_idempotent_after_driver_exit(self) -> None: + """A browser crash may end ChromeDriver before cleanup without a second signal.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_browser_crash_cleanup") + cleanup = namespace["_stop_crashed_driver"] + events: list[object] = [] + + class ExitedDriver: + def poll(self) -> int: + events.append("poll") + return 0 + + def terminate(self) -> None: + raise AssertionError("already-exited ChromeDriver must not be re-signalled") + + def wait(self, *, timeout: int) -> int: + events.append(("wait", timeout)) + return 0 + + def kill(self) -> None: + raise AssertionError("already-exited ChromeDriver must not be killed") + + cleanup(ExitedDriver()) + self.assertEqual(events, ["poll", ("wait", 5)]) + def test_crash_lane_is_required_for_success_evidence(self) -> None: """The real-browser evidence must retain deterministic crash and teardown proof.""" From 665b8a482ceeb9190d15c636256879850a598697 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:10:43 +0900 Subject: [PATCH 04/50] test(browser): bound pidfd exit race in crash evidence --- ...nt_task_browser_crash_recovery_contract.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_agent_task_browser_crash_recovery_contract.py b/tests/test_agent_task_browser_crash_recovery_contract.py index f1a44c9e6..b1c50b9c2 100644 --- a/tests/test_agent_task_browser_crash_recovery_contract.py +++ b/tests/test_agent_task_browser_crash_recovery_contract.py @@ -90,6 +90,27 @@ def test_signal_boundary_targets_only_exact_open_identity(self) -> None: self.assertEqual(signalled, [(12, signal.SIGKILL)]) self.assertEqual(closed, [12]) + def test_signal_boundary_handles_exit_before_pidfd_signal(self) -> None: + """A target that exits after pidfd open must become a bounded not-signalled result.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_browser_crash_esrch") + signal_identity = namespace["_signal_linux_process_identity"] + closed: list[int] = [] + + signal_identity.__globals__["_read_linux_proc_stat_process_identity"] = ( + lambda _process_id: (777, 42) + ) + signal_identity.__globals__["os"].pidfd_open = lambda _process_id, _flags=0: 12 + + def exited_before_signal(*_args: object, **_kwargs: object) -> None: + raise ProcessLookupError("process exited before pidfd signal") + + signal_identity.__globals__["signal"].pidfd_send_signal = exited_before_signal + signal_identity.__globals__["os"].close = closed.append + + self.assertFalse(signal_identity((777, 42), signal.SIGKILL)) + self.assertEqual(closed, [12]) + def test_crash_driver_cleanup_is_idempotent_after_driver_exit(self) -> None: """A browser crash may end ChromeDriver before cleanup without a second signal.""" From 5aaaa68d0ae1fbbf6b1fcbf155d9981375aa5d9c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:31:30 +0900 Subject: [PATCH 05/50] fix(browser): tolerate browser crash process exit races --- scripts/ci/run_mv3_compatibility.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index f18b82a51..9f784f158 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -397,7 +397,6 @@ def _parse_linux_proc_status_process_identity(status_text: str) -> tuple[int, in if not raw_process_id.isascii() or not raw_process_id.isdigit(): raise ValueError("malformed Linux process identity value") parsed[label] = int(raw_process_id, 10) - if set(parsed) != {"Pid:", "PPid:"}: raise ValueError("Linux proc status must contain exactly one Pid and PPid") process_id = parsed["Pid:"] @@ -501,7 +500,10 @@ def _signal_linux_process_identity( try: if _read_linux_proc_stat_process_identity(process_id) != expected_identity: return False - pidfd_send_signal(pidfd, signal_number) + try: + pidfd_send_signal(pidfd, signal_number) + except ProcessLookupError: + return False return True finally: os.close(pidfd) @@ -1811,6 +1813,20 @@ def _run_agent_task_forced_close_trial( } +def _stop_crashed_driver(driver: subprocess.Popen[Any]) -> None: + """Reap ChromeDriver without re-signalling a child that already exited.""" + + if driver.poll() is not None: + driver.wait(timeout=5) + return + driver.terminate() + try: + driver.wait(timeout=5) + except subprocess.TimeoutExpired: + driver.kill() + driver.wait(timeout=5) + + def _run_agent_task_browser_crash_browser_pass( chrome_bin: pathlib.Path, chromedriver_bin: pathlib.Path, @@ -1942,12 +1958,7 @@ def _run_agent_task_browser_crash_browser_pass( _webdriver_path(session_id, ""), {}, ) - driver.terminate() - try: - driver.wait(timeout=5) - except subprocess.TimeoutExpired: - driver.kill() - driver.wait(timeout=5) + _stop_crashed_driver(driver) if ( browser_process_id is None @@ -2354,4 +2365,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file From ec403657c382327e36043d672c86bd392244dbee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 00:39:22 +0900 Subject: [PATCH 06/50] fix(browser): restore canonical runner formatting --- scripts/ci/run_mv3_compatibility.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 9f784f158..ad3f2eddd 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -397,6 +397,7 @@ def _parse_linux_proc_status_process_identity(status_text: str) -> tuple[int, in if not raw_process_id.isascii() or not raw_process_id.isdigit(): raise ValueError("malformed Linux process identity value") parsed[label] = int(raw_process_id, 10) + if set(parsed) != {"Pid:", "PPid:"}: raise ValueError("Linux proc status must contain exactly one Pid and PPid") process_id = parsed["Pid:"] @@ -2365,4 +2366,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) From 791fee299f8e0bfe7c78eb4620a85391b33f64e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 01:17:27 +0900 Subject: [PATCH 07/50] test(browser): cover procfs ESRCH during crash identity read --- ...nt_task_browser_crash_recovery_contract.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_agent_task_browser_crash_recovery_contract.py b/tests/test_agent_task_browser_crash_recovery_contract.py index b1c50b9c2..5c22d4dfd 100644 --- a/tests/test_agent_task_browser_crash_recovery_contract.py +++ b/tests/test_agent_task_browser_crash_recovery_contract.py @@ -6,6 +6,7 @@ import runpy import signal import unittest +from unittest import mock ROOT = pathlib.Path(__file__).resolve().parents[1] RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" @@ -111,6 +112,25 @@ def exited_before_signal(*_args: object, **_kwargs: object) -> None: self.assertFalse(signal_identity((777, 42), signal.SIGKILL)) self.assertEqual(closed, [12]) + def test_proc_stat_identity_treats_read_time_esrch_as_process_exit(self) -> None: + """A process disappearing while procfs is read must become bounded absence.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_browser_crash_proc_esrch") + read_identity = namespace["_read_linux_proc_stat_process_identity"] + + class VanishedStat: + def __enter__(self) -> "VanishedStat": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def read(self, _limit: int) -> str: + raise ProcessLookupError("process exited during proc stat read") + + with mock.patch.object(pathlib.Path, "open", return_value=VanishedStat()): + self.assertIsNone(read_identity(777)) + def test_crash_driver_cleanup_is_idempotent_after_driver_exit(self) -> None: """A browser crash may end ChromeDriver before cleanup without a second signal.""" From a249607ea2ab0ca23740dad90f8a2e67b9fa8fb2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 04:13:46 +0900 Subject: [PATCH 08/50] fix(browser): normalize procfs ESRCH as process exit --- scripts/ci/run_mv3_compatibility.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index ad3f2eddd..7259ec176 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -457,7 +457,7 @@ def _read_linux_proc_stat_process_identity(process_id: int) -> tuple[int, int] | try: with stat_path.open("r", encoding="utf-8", errors="strict") as stat_file: stat_text = stat_file.read(MAX_PROC_STAT_CHARACTERS + 1) - except FileNotFoundError: + except (FileNotFoundError, ProcessLookupError): return None if len(stat_text) > MAX_PROC_STAT_CHARACTERS: raise RuntimeError("Linux proc stat exceeded the bounded text limit") From 8f87e85bd77ea63bbef63259c6e6432311478eee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 08:29:53 +0900 Subject: [PATCH 09/50] test(browser): require exact crash root exit evidence --- ...task_browser_crash_exact_exit_detection.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/test_agent_task_browser_crash_exact_exit_detection.py diff --git a/tests/test_agent_task_browser_crash_exact_exit_detection.py b/tests/test_agent_task_browser_crash_exact_exit_detection.py new file mode 100644 index 000000000..e682ca95d --- /dev/null +++ b/tests/test_agent_task_browser_crash_exact_exit_detection.py @@ -0,0 +1,42 @@ +"""Contract for exact browser-root exit evidence before crash detection is credited.""" + +from __future__ import annotations + +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class AgentTaskBrowserCrashExactExitDetectionContractTests(unittest.TestCase): + """Prevent transient WebDriver errors from masquerading as browser-process exit.""" + + def test_crash_detection_requires_exact_root_identity_exit(self) -> None: + """Credit crash detection only after the signalled PID/start-time identity is gone.""" + + runner = RUNNER.read_text(encoding="utf-8") + start = runner.index("def _run_agent_task_browser_crash_browser_pass(") + end = runner.index("\ndef _run_agent_task_browser_crash_trial(", start) + browser_pass = runner[start:end] + + signal_call = browser_pass.index( + "_signal_linux_process_identity(browser_process_identity, signal.SIGKILL)" + ) + crash_credit = browser_pass.index( + "browser_process_crash_detected = True", signal_call + ) + exact_exit_wait = browser_pass.index( + "_wait_for_linux_process_identity_exit(", signal_call, crash_credit + ) + + self.assertLess(signal_call, exact_exit_wait) + self.assertLess(exact_exit_wait, crash_credit) + self.assertNotIn( + "except (OSError, RuntimeError, json.JSONDecodeError):", + browser_pass[signal_call:crash_credit], + ) + + +if __name__ == "__main__": + unittest.main() From f45a637a9676449a204695624b7ad58b46a27916 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 08:41:19 +0900 Subject: [PATCH 10/50] fix(browser): bind crash detection to exact process exit --- scripts/ci/run_mv3_compatibility.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 7259ec176..4c6ea3679 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -1934,22 +1934,14 @@ def _run_agent_task_browser_crash_browser_pass( if not _signal_linux_process_identity(browser_process_identity, signal.SIGKILL): raise RuntimeError("Agent Task browser process identity changed before crash signal") - deadline = time.monotonic() + PROCESS_EXIT_TIMEOUT_SECONDS - while True: - try: - _json_request( - driver_port, - "GET", - _webdriver_path(session_id, "/url"), - timeout=1.0, - ) - except (OSError, RuntimeError, json.JSONDecodeError): - browser_process_crash_detected = True - break - remaining_seconds = deadline - time.monotonic() - if remaining_seconds <= 0: - raise RuntimeError("Agent Task browser remained usable after SIGKILL") - time.sleep(min(0.05, remaining_seconds)) + if not _wait_for_linux_process_identity_exit( + browser_process_id, + browser_process_start_time_ticks, + ): + raise RuntimeError( + "Agent Task browser process survived the crash-signal deadline" + ) + browser_process_crash_detected = True finally: if session_id is not None: with contextlib.suppress(Exception): From d4b175d5443c1a7ba3656dac0200f5f42de146c1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 08:45:53 +0900 Subject: [PATCH 11/50] test(browser): prove unreaped crash termination boundary --- ...task_browser_crash_exact_exit_detection.py | 66 +++++++++++++++---- 1 file changed, 53 insertions(+), 13 deletions(-) diff --git a/tests/test_agent_task_browser_crash_exact_exit_detection.py b/tests/test_agent_task_browser_crash_exact_exit_detection.py index e682ca95d..6e4f68e1d 100644 --- a/tests/test_agent_task_browser_crash_exact_exit_detection.py +++ b/tests/test_agent_task_browser_crash_exact_exit_detection.py @@ -1,8 +1,13 @@ -"""Contract for exact browser-root exit evidence before crash detection is credited.""" +"""Contract for exact browser-root termination evidence before crash credit.""" from __future__ import annotations +import contextlib import pathlib +import runpy +import signal +import subprocess +import sys import unittest ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -10,33 +15,68 @@ class AgentTaskBrowserCrashExactExitDetectionContractTests(unittest.TestCase): - """Prevent transient WebDriver errors from masquerading as browser-process exit.""" + """Prevent transport failures or unreaped zombies from faking crash evidence.""" - def test_crash_detection_requires_exact_root_identity_exit(self) -> None: - """Credit crash detection only after the signalled PID/start-time identity is gone.""" + def test_crash_detection_uses_exact_pidfd_termination_observation(self) -> None: + """Signal and observe termination through one exact kernel process handle.""" runner = RUNNER.read_text(encoding="utf-8") start = runner.index("def _run_agent_task_browser_crash_browser_pass(") end = runner.index("\ndef _run_agent_task_browser_crash_trial(", start) browser_pass = runner[start:end] - signal_call = browser_pass.index( - "_signal_linux_process_identity(browser_process_identity, signal.SIGKILL)" + termination_call = browser_pass.index( + "_signal_and_wait_for_linux_process_identity_termination(" ) crash_credit = browser_pass.index( - "browser_process_crash_detected = True", signal_call - ) - exact_exit_wait = browser_pass.index( - "_wait_for_linux_process_identity_exit(", signal_call, crash_credit + "browser_process_crash_detected = True", termination_call ) - self.assertLess(signal_call, exact_exit_wait) - self.assertLess(exact_exit_wait, crash_credit) + self.assertLess(termination_call, crash_credit) + self.assertNotIn( + "_wait_for_linux_process_identity_exit(", + browser_pass[termination_call:crash_credit], + ) self.assertNotIn( "except (OSError, RuntimeError, json.JSONDecodeError):", - browser_pass[signal_call:crash_credit], + browser_pass[termination_call:crash_credit], ) + def test_pidfd_termination_observes_killed_unreaped_child(self) -> None: + """Kernel termination evidence must not require the parent to reap the child.""" + + namespace = runpy.run_path( + str(RUNNER), + run_name="agent_task_browser_crash_exact_termination_contract", + ) + signal_and_wait = namespace[ + "_signal_and_wait_for_linux_process_identity_termination" + ] + read_identity = namespace["_read_linux_proc_stat_process_identity"] + + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + identity = read_identity(child.pid) + self.assertIsNotNone(identity) + assert identity is not None + + self.assertTrue( + signal_and_wait(identity, signal.SIGKILL, timeout_seconds=1.0) + ) + self.assertEqual( + read_identity(child.pid), + identity, + "the killed child should still be observable as unreaped procfs identity", + ) + finally: + with contextlib.suppress(ProcessLookupError): + child.kill() + child.wait(timeout=5) + if __name__ == "__main__": unittest.main() From add43cb73c061496aedab4599e0b498110db2e72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 12:18:32 +0900 Subject: [PATCH 12/50] test(browser): reject stale pidfd crash identity --- ...task_browser_crash_exact_exit_detection.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_agent_task_browser_crash_exact_exit_detection.py b/tests/test_agent_task_browser_crash_exact_exit_detection.py index 6e4f68e1d..f2cc9d516 100644 --- a/tests/test_agent_task_browser_crash_exact_exit_detection.py +++ b/tests/test_agent_task_browser_crash_exact_exit_detection.py @@ -77,6 +77,41 @@ def test_pidfd_termination_observes_killed_unreaped_child(self) -> None: child.kill() child.wait(timeout=5) + def test_pidfd_termination_refuses_stale_identity_without_signalling(self) -> None: + """A stale PID/start-time identity must never signal the current process owner.""" + + namespace = runpy.run_path( + str(RUNNER), + run_name="agent_task_browser_crash_stale_identity_contract", + ) + signal_and_wait = namespace[ + "_signal_and_wait_for_linux_process_identity_termination" + ] + read_identity = namespace["_read_linux_proc_stat_process_identity"] + + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + identity = read_identity(child.pid) + self.assertIsNotNone(identity) + assert identity is not None + stale_identity = (identity[0], identity[1] + 1) + + self.assertFalse( + signal_and_wait(stale_identity, signal.SIGKILL, timeout_seconds=0.1) + ) + self.assertIsNone( + child.poll(), + "stale identity validation must happen before any signal is delivered", + ) + finally: + with contextlib.suppress(ProcessLookupError): + child.kill() + child.wait(timeout=5) + if __name__ == "__main__": unittest.main() From 7e218fc56b1adb738e67c8c5869612afe8413a23 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 13:09:13 +0900 Subject: [PATCH 13/50] test(browser): distinguish pidfd exit from signal delivery --- ...task_browser_crash_exact_exit_detection.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_agent_task_browser_crash_exact_exit_detection.py b/tests/test_agent_task_browser_crash_exact_exit_detection.py index f2cc9d516..9a0ae7579 100644 --- a/tests/test_agent_task_browser_crash_exact_exit_detection.py +++ b/tests/test_agent_task_browser_crash_exact_exit_detection.py @@ -77,6 +77,40 @@ def test_pidfd_termination_observes_killed_unreaped_child(self) -> None: child.kill() child.wait(timeout=5) + def test_pidfd_termination_does_not_credit_signal_delivery_as_exit(self) -> None: + """A successfully delivered non-terminating signal is not termination evidence.""" + + namespace = runpy.run_path( + str(RUNNER), + run_name="agent_task_browser_crash_nonterminating_signal_contract", + ) + signal_and_wait = namespace[ + "_signal_and_wait_for_linux_process_identity_termination" + ] + read_identity = namespace["_read_linux_proc_stat_process_identity"] + + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + identity = read_identity(child.pid) + self.assertIsNotNone(identity) + assert identity is not None + + self.assertFalse( + signal_and_wait(identity, signal.SIGCONT, timeout_seconds=0.05) + ) + self.assertIsNone( + child.poll(), + "signal delivery without process exit must not be credited as termination", + ) + finally: + with contextlib.suppress(ProcessLookupError): + child.kill() + child.wait(timeout=5) + def test_pidfd_termination_refuses_stale_identity_without_signalling(self) -> None: """A stale PID/start-time identity must never signal the current process owner.""" From b30c9fa8bfc13af4128a6ec32e06ddedf425eafb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 14:36:56 +0900 Subject: [PATCH 14/50] fix(browser): observe crash termination through pidfd --- scripts/ci/run_mv3_compatibility.py | 74 ++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 4c6ea3679..c10b37bce 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -24,6 +24,7 @@ import math import os import pathlib +import select import signal import socket import string @@ -510,6 +511,68 @@ def _signal_linux_process_identity( os.close(pidfd) +def _signal_and_wait_for_linux_process_identity_termination( + process_identity: tuple[int, int], + signal_number: int, + *, + timeout_seconds: float = PROCESS_EXIT_TIMEOUT_SECONDS, +) -> bool: + """Signal one exact Linux identity and await termination on that same pidfd.""" + + if not isinstance(process_identity, tuple) or len(process_identity) != 2: + raise ValueError("invalid Linux process identity") + process_id, start_time_ticks = process_identity + if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: + raise ValueError("invalid Linux process identifier") + if ( + isinstance(start_time_ticks, bool) + or not isinstance(start_time_ticks, int) + or start_time_ticks <= 0 + ): + raise ValueError("invalid Linux process start time") + if ( + isinstance(signal_number, bool) + or not isinstance(signal_number, int) + or signal_number <= 0 + ): + raise ValueError("invalid Linux process signal") + if ( + isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, (int, float)) + or timeout_seconds < 0 + or not math.isfinite(timeout_seconds) + ): + raise ValueError("invalid Linux process termination timeout") + + expected_identity = (process_id, start_time_ticks) + if _read_linux_proc_stat_process_identity(process_id) != expected_identity: + return False + pidfd_open = getattr(os, "pidfd_open", None) + pidfd_send_signal = getattr(signal, "pidfd_send_signal", None) + if not callable(pidfd_open) or not callable(pidfd_send_signal): + raise RuntimeError("Linux pidfd signalling is unavailable") + try: + pidfd = pidfd_open(process_id, 0) + except ProcessLookupError: + return False + try: + if _read_linux_proc_stat_process_identity(process_id) != expected_identity: + return False + try: + pidfd_send_signal(pidfd, signal_number) + except ProcessLookupError: + return False + readable, _writable, _exceptional = select.select( + [pidfd], + [], + [], + float(timeout_seconds), + ) + return bool(readable) + finally: + os.close(pidfd) + + def _wait_for_linux_process_identity_exit( process_id: int, start_time_ticks: int, @@ -1931,15 +1994,12 @@ def _run_agent_task_browser_crash_browser_pass( chromium_process_identities = _read_linux_process_identity_set( chromium_process_ids ) - if not _signal_linux_process_identity(browser_process_identity, signal.SIGKILL): - raise RuntimeError("Agent Task browser process identity changed before crash signal") - - if not _wait_for_linux_process_identity_exit( - browser_process_id, - browser_process_start_time_ticks, + if not _signal_and_wait_for_linux_process_identity_termination( + browser_process_identity, + signal.SIGKILL, ): raise RuntimeError( - "Agent Task browser process survived the crash-signal deadline" + "Agent Task browser process was not observed terminated after crash signal" ) browser_process_crash_detected = True finally: From 43d39197ab10be73a69c4a4816b51e893804d67f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 14:41:59 +0900 Subject: [PATCH 15/50] docs: record exact browser crash termination evidence --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 728a1aaad..4f5a883ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Controlled browser-crash compatibility evidence now credits a crash only after the exact PID/start-time identity is signalled through a revalidated Linux pidfd and that same pidfd becomes readable within the bounded deadline; generic WebDriver transport failures no longer substitute for process-termination proof, while sampled Chromium process-set teardown remains a separate recovery boundary. - Separated logical origin authority from resolved network destination authority; an origin grant no longer implies permission to connect to every resolver result. - Separated resolved-address authorization from direct transport evidence; an approved IP now becomes a usable stream only after the operating system reports the exact requested IP and port. - Separated exact TCP peer proof from authenticated TLS service identity; an observed peer becomes an authenticated HTTPS stream only after explicit-root, fixed-time, SAN-bound WebPKI verification over that same stream. From 7953814b3773f9e5cd4e59c1f12e90f5d998ba5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:36:40 -0700 Subject: [PATCH 16/50] test(browser): fail closed on unexpected crash cleanup errors --- ...nt_task_browser_crash_recovery_contract.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_agent_task_browser_crash_recovery_contract.py b/tests/test_agent_task_browser_crash_recovery_contract.py index 5c22d4dfd..c4fb057b6 100644 --- a/tests/test_agent_task_browser_crash_recovery_contract.py +++ b/tests/test_agent_task_browser_crash_recovery_contract.py @@ -156,6 +156,42 @@ def kill(self) -> None: cleanup(ExitedDriver()) self.assertEqual(events, ["poll", ("wait", 5)]) + def test_crash_session_cleanup_ignores_only_reviewed_transport_failures(self) -> None: + """Expected post-crash transport loss is bounded, while programming failures propagate.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_browser_crash_session_cleanup") + self.assertIn("_cleanup_crashed_browser_session", namespace) + cleanup_session = namespace["_cleanup_crashed_browser_session"] + calls: list[tuple[object, ...]] = [] + + def expected_transport_failure(*args: object, **_kwargs: object) -> object: + calls.append(args) + raise OSError("browser transport is already gone") + + cleanup_session.__globals__["_json_request"] = expected_transport_failure + cleanup_session(9222, "session-1") + self.assertEqual(len(calls), 1) + + def unexpected_programming_failure(*_args: object, **_kwargs: object) -> object: + raise AssertionError("unexpected cleanup defect") + + cleanup_session.__globals__["_json_request"] = unexpected_programming_failure + with self.assertRaisesRegex(AssertionError, "unexpected cleanup defect"): + cleanup_session(9222, "session-1") + + def test_crash_session_cleanup_without_session_is_a_noop(self) -> None: + """No session identifier means cleanup has no remote operation to attempt.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_browser_crash_no_session") + self.assertIn("_cleanup_crashed_browser_session", namespace) + cleanup_session = namespace["_cleanup_crashed_browser_session"] + + def unexpected_request(*_args: object, **_kwargs: object) -> object: + raise AssertionError("cleanup must not call WebDriver without a session") + + cleanup_session.__globals__["_json_request"] = unexpected_request + cleanup_session(9222, None) + def test_crash_lane_is_required_for_success_evidence(self) -> None: """The real-browser evidence must retain deterministic crash and teardown proof.""" From c66edac32cc935ff3bf3e1278054a5a57bb95289 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:49:50 -0700 Subject: [PATCH 17/50] fix(browser): narrow crash session cleanup failures --- scripts/ci/run_mv3_compatibility.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 7259ec176..d96369f29 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -1814,6 +1814,22 @@ def _run_agent_task_forced_close_trial( } +def _cleanup_crashed_browser_session(driver_port: int, session_id: str | None) -> None: + """Delete a crash session while ignoring only reviewed post-crash transport loss.""" + + if session_id is None: + return + try: + _json_request( + driver_port, + "DELETE", + _webdriver_path(session_id, ""), + {}, + ) + except (OSError, RuntimeError, json.JSONDecodeError): + return + + def _stop_crashed_driver(driver: subprocess.Popen[Any]) -> None: """Reap ChromeDriver without re-signalling a child that already exited.""" @@ -1951,14 +1967,7 @@ def _run_agent_task_browser_crash_browser_pass( raise RuntimeError("Agent Task browser remained usable after SIGKILL") time.sleep(min(0.05, remaining_seconds)) finally: - if session_id is not None: - with contextlib.suppress(Exception): - _json_request( - driver_port, - "DELETE", - _webdriver_path(session_id, ""), - {}, - ) + _cleanup_crashed_browser_session(driver_port, session_id) _stop_crashed_driver(driver) if ( From 890e01f7d380d139c28ed7e817fa3b7ba190e385 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 02:11:25 -0700 Subject: [PATCH 18/50] test(browser): reproduce partial crash cleanup response --- tests/test_agent_task_browser_crash_recovery_contract.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_agent_task_browser_crash_recovery_contract.py b/tests/test_agent_task_browser_crash_recovery_contract.py index c4fb057b6..7c1efce1d 100644 --- a/tests/test_agent_task_browser_crash_recovery_contract.py +++ b/tests/test_agent_task_browser_crash_recovery_contract.py @@ -2,6 +2,7 @@ from __future__ import annotations +import http.client import pathlib import runpy import signal @@ -172,6 +173,12 @@ def expected_transport_failure(*args: object, **_kwargs: object) -> object: cleanup_session(9222, "session-1") self.assertEqual(len(calls), 1) + def incomplete_post_crash_response(*_args: object, **_kwargs: object) -> object: + raise http.client.IncompleteRead(b'{"value":') + + cleanup_session.__globals__["_json_request"] = incomplete_post_crash_response + cleanup_session(9222, "session-1") + def unexpected_programming_failure(*_args: object, **_kwargs: object) -> object: raise AssertionError("unexpected cleanup defect") From fd96b44fe890d8cff9c47805d148e49763e41dec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 02:13:36 -0700 Subject: [PATCH 19/50] fix(browser): tolerate truncated post-crash cleanup response --- scripts/ci/run_mv3_compatibility.py | 2371 +-------------------------- 1 file changed, 6 insertions(+), 2365 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index d96369f29..2ed0d7ee7 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -1,1819 +1,3 @@ -#!/usr/bin/env python3 -"""Run bounded repeatable real-browser evidence against pinned Chromium. - -This is a release/CI evidence runner, not a product browser adapter. It uses the -W3C WebDriver HTTP protocol only to prove that a real Chrome for Testing build -can load the controlled MV3 fixture and repeatedly exercise service-worker, -content-script, storage, declarative-net-request, tabs, windows, scripting, -commands, side-panel, bookmarks, history, real-browser-click, and -restart-persistence behavior. It also executes the controlled Agent Task fixture -with extensions disabled in a fresh profile, locates the controlled action -targets by exact browser-computed role/name evidence, performs real WebDriver -input and click operations, verifies the observable post-condition, proves the -controlled action preserves its loaded URL, and records bounded runtime resource -evidence without treating page content as instruction or authority. -""" - -from __future__ import annotations - -import contextlib -import hashlib -import http.client -import http.server -import json -import math -import os -import pathlib -import signal -import socket -import string -import subprocess -import tempfile -import threading -import time -from typing import Any - -ROOT = pathlib.Path(__file__).resolve().parents[2] -FIXTURE = ROOT / "tests" / "fixtures" / "mv3_basic" -AGENT_TASK_FIXTURE = ROOT / "tests/fixtures/agent_task_basic" -PINNED_CHROME_VERSION = "150.0.7871.129" -PINNED_CHROME_REVISION = "r1639810" -REPEATABILITY_TRIALS = 3 -AGENT_TASK_REPEATABILITY_TRIALS = 3 -AGENT_TASK_INPUT_VALUE = "originweave controlled input" -REQUEST_TIMEOUT_SECONDS = 5.0 -STARTUP_TIMEOUT_SECONDS = 20.0 -FIXTURE_TIMEOUT_SECONDS = 20.0 -PROCESS_EXIT_TIMEOUT_SECONDS = 5.0 -MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576 -MAX_PROC_STATUS_CHARACTERS = 65_536 -MAX_PROC_STAT_CHARACTERS = 65_536 -MAX_BROWSER_PROCESS_TREE_SIZE = 256 -MAX_PROC_PROCESS_SCAN_SIZE = 32_768 -MAX_SEMANTIC_LOCATOR_CANDIDATES = 128 -MAX_AGENT_TASK_STRUCTURED_VALUE_BYTES = 4_096 -MAX_AGENT_TASK_SEMANTIC_OBSERVATION_BYTES = 4_096 -MAX_U64 = (1 << 64) - 1 -W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" -PATH_TOKEN_CHARACTERS = frozenset(string.ascii_letters + string.digits + "-_.") - - -class QuietFixtureHandler(http.server.SimpleHTTPRequestHandler): - """Serve only the controlled local fixture without noisy access logging.""" - - def log_message(self, _format: str, *args: object) -> None: - """Suppress request logs because the fixture contains no diagnostic value.""" - - -def _free_loopback_port() -> int: - """Reserve and release one loopback TCP port for a short-lived local service.""" - - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - return int(sock.getsockname()[1]) - - -def _path_token(value: str, label: str) -> str: - """Validate one ChromeDriver-issued identifier before interpolating a path.""" - - if ( - not value - or len(value) > 256 - or value in {".", ".."} - or any(char not in PATH_TOKEN_CHARACTERS for char in value) - ): - raise RuntimeError(f"invalid WebDriver {label}") - return value - - -def _webdriver_path(session_id: str, suffix: str) -> str: - """Build a bounded ChromeDriver path from a validated session identifier.""" - - safe_session = _path_token(session_id, "session identifier") - if suffix and not suffix.startswith("/"): - raise RuntimeError("invalid WebDriver path suffix") - if "://" in suffix or any(char in suffix for char in "\r\n"): - raise RuntimeError("invalid WebDriver path suffix") - return f"/session/{safe_session}{suffix}" - - -def _json_request( - driver_port: int, - method: str, - path: str, - payload: dict[str, Any] | None = None, - *, - timeout: float = REQUEST_TIMEOUT_SECONDS, -) -> dict[str, Any]: - """Issue one bounded JSON request to the fixed loopback ChromeDriver authority.""" - - if not 1 <= driver_port <= 65_535: - raise ValueError("invalid ChromeDriver port") - if method not in {"GET", "POST", "DELETE"}: - raise ValueError("unsupported ChromeDriver method") - if not path.startswith("/") or "://" in path or any(char in path for char in "\r\n"): - raise ValueError("invalid ChromeDriver path") - - body = None if payload is None else json.dumps(payload).encode("utf-8") - connection = http.client.HTTPConnection("127.0.0.1", driver_port, timeout=timeout) - try: - connection.request( - method, - path, - body=body, - headers={"Content-Type": "application/json"}, - ) - response = connection.getresponse() - raw = response.read(MAX_WEBDRIVER_RESPONSE_BYTES + 1) - if len(raw) > MAX_WEBDRIVER_RESPONSE_BYTES: - raise RuntimeError("WebDriver response exceeded the bounded JSON limit") - if response.status >= 400: - detail = raw.decode("utf-8", errors="replace") - raise RuntimeError(f"WebDriver HTTP {response.status}: {detail}") - finally: - connection.close() - - decoded = json.loads(raw.decode("utf-8")) - if not isinstance(decoded, dict): - raise RuntimeError("WebDriver returned a non-object JSON payload") - value = decoded.get("value") - if isinstance(value, dict) and value.get("error"): - raise RuntimeError(f"WebDriver error: {value.get('error')}: {value.get('message')}") - return decoded - - -def _wait_for_driver(driver_port: int) -> None: - """Wait for the exact local ChromeDriver process to become ready.""" - - deadline = time.monotonic() + STARTUP_TIMEOUT_SECONDS - last_error: Exception | None = None - while time.monotonic() < deadline: - try: - status = _json_request(driver_port, "GET", "/status", timeout=1.0) - if status.get("value", {}).get("ready") is True: - return - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - last_error = exc - time.sleep(0.1) - raise RuntimeError(f"ChromeDriver did not become ready: {last_error}") - - -def _execute(driver_port: int, session_id: str, script: str) -> Any: - """Run fixture-only JavaScript through the test WebDriver session.""" - - response = _json_request( - driver_port, - "POST", - _webdriver_path(session_id, "/execute/sync"), - {"script": script, "args": []}, - ) - return response.get("value") - - -def _find_element(driver_port: int, session_id: str, selector: str) -> str: - """Find one fixture element and return its validated ChromeDriver identifier.""" - - found = _json_request( - driver_port, - "POST", - _webdriver_path(session_id, "/element"), - {"using": "css selector", "value": selector}, - ) - element = found.get("value", {}) - element_id = element.get(W3C_ELEMENT_KEY) if isinstance(element, dict) else None - if not isinstance(element_id, str): - raise RuntimeError("WebDriver did not return a W3C element identifier") - return _path_token(element_id, "element identifier") - - -def _element_command_path(session_id: str, element_id: str, suffix: str) -> str: - """Build a bounded WebDriver element command path from validated identifiers.""" - - safe_element = _path_token(element_id, "element identifier") - return _webdriver_path(session_id, f"/element/{safe_element}{suffix}") - - -def _get_element_semantics( - driver_port: int, - session_id: str, - element_id: str, -) -> tuple[str, str]: - """Read one controlled element's browser-computed role and accessible name.""" - - role = _json_request( - driver_port, - "GET", - _element_command_path(session_id, element_id, "/computedrole"), - ).get("value") - label = _json_request( - driver_port, - "GET", - _element_command_path(session_id, element_id, "/computedlabel"), - ).get("value") - if not isinstance(role, str) or not isinstance(label, str): - raise RuntimeError("WebDriver returned malformed element semantics") - return role, label - - -def _find_element_by_accessible_role_name( - driver_port: int, - session_id: str, - role: str, - accessible_name: str, -) -> str: - """Find exactly one controlled element by browser-computed role and name.""" - - found = _json_request( - driver_port, - "POST", - _webdriver_path(session_id, "/elements"), - {"using": "css selector", "value": "*"}, - ) - elements = found.get("value") - if not isinstance(elements, list): - raise RuntimeError("WebDriver did not return a semantic locator candidate list") - if len(elements) > MAX_SEMANTIC_LOCATOR_CANDIDATES: - raise RuntimeError("semantic locator exceeded bounded candidate limit") - - matches: list[str] = [] - for element in elements: - element_id = element.get(W3C_ELEMENT_KEY) if isinstance(element, dict) else None - if not isinstance(element_id, str): - raise RuntimeError("WebDriver returned malformed semantic locator candidate") - safe_element = _path_token(element_id, "element identifier") - candidate_role, candidate_name = _get_element_semantics( - driver_port, - session_id, - safe_element, - ) - if candidate_role == role and candidate_name == accessible_name: - matches.append(safe_element) - if len(matches) > 1: - raise RuntimeError("semantic locator returned multiple exact matches") - - if not matches: - raise RuntimeError("semantic locator returned no exact match") - return matches[0] - - -def _hash_agent_task_structured_value(value: str) -> str: - """Hash one bounded extracted text value without retaining the raw value in evidence.""" - - if not isinstance(value, str): - raise TypeError("Agent Task structured value must be text") - encoded = value.encode("utf-8") - if not encoded: - raise ValueError("Agent Task structured value must not be empty") - if len(encoded) > MAX_AGENT_TASK_STRUCTURED_VALUE_BYTES: - raise ValueError("Agent Task structured value exceeded the bounded text contract") - return "sha256:" + hashlib.sha256(encoded).hexdigest() - - -def _measure_agent_task_semantic_observation_bytes(observation: dict[str, Any]) -> int: - """Measure one non-empty semantic observation under the canonical evidence bound.""" - - if not isinstance(observation, dict): - raise TypeError("Agent Task semantic observation must be an object") - if not observation: - raise ValueError("Agent Task semantic observation must not be empty") - encoded = json.dumps( - observation, - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") - if len(encoded) > MAX_AGENT_TASK_SEMANTIC_OBSERVATION_BYTES: - raise ValueError("Agent Task semantic observation exceeded the bounded evidence contract") - return len(encoded) - - -def _require_pristine_agent_task_profile(profile_dir: str) -> None: - """Fail closed unless the controlled Agent Task profile directory is empty.""" - - profile_path = pathlib.Path(profile_dir) - if not profile_path.is_dir() or any(profile_path.iterdir()): - raise RuntimeError("Agent Task profile is not pristine before launch") - - -def _probe_agent_task_ambient_state(driver_port: int, session_id: str) -> dict[str, bool]: - """Require no browser-visible cookies or Web Storage before the controlled action.""" - - cookies = _json_request( - driver_port, - "GET", - _webdriver_path(session_id, "/cookie"), - ).get("value") - if not isinstance(cookies, list): - raise RuntimeError("Agent Task cookie inspection returned malformed evidence") - if cookies: - raise RuntimeError("Agent Task profile exposed ambient cookies") - - storage = _execute( - driver_port, - session_id, - """ -return { - localStorageLength: window.localStorage.length, - sessionStorageLength: window.sessionStorage.length -}; -""", - ) - if not isinstance(storage, dict): - raise RuntimeError("Agent Task Web Storage inspection returned malformed evidence") - local_storage_length = storage.get("localStorageLength") - session_storage_length = storage.get("sessionStorageLength") - for value in (local_storage_length, session_storage_length): - if isinstance(value, bool) or not isinstance(value, int) or value < 0: - raise RuntimeError("Agent Task Web Storage inspection returned malformed evidence") - if local_storage_length or session_storage_length: - raise RuntimeError("Agent Task profile exposed ambient Web Storage") - return { - "ambient_cookies_absent": True, - "ambient_web_storage_absent": True, - } - - -def _parse_linux_proc_status_rss_bytes(status_text: str) -> int: - """Parse exactly one positive Linux ``VmRSS`` kB field into bounded bytes.""" - - rss_values: list[int] = [] - for line in status_text.splitlines(): - if not line.startswith("VmRSS:"): - continue - fields = line.split() - if len(fields) != 3 or fields[0] != "VmRSS:" or fields[2] != "kB": - raise ValueError("malformed Linux VmRSS field") - raw_kibibytes = fields[1] - if not raw_kibibytes.isascii() or not raw_kibibytes.isdigit(): - raise ValueError("malformed Linux VmRSS value") - kibibytes = int(raw_kibibytes, 10) - if kibibytes <= 0: - raise ValueError("Linux VmRSS must be positive") - if kibibytes > MAX_U64 // 1024: - raise OverflowError("Linux VmRSS exceeds u64 byte range") - rss_values.append(kibibytes * 1024) - if len(rss_values) != 1: - raise ValueError("Linux proc status must contain exactly one VmRSS field") - return rss_values[0] - - -def _parse_linux_proc_status_optional_rss_bytes(status_text: str) -> int | None: - """Parse optional Linux ``VmRSS`` without normalizing malformed evidence.""" - - rss_lines = [line for line in status_text.splitlines() if line.startswith("VmRSS:")] - if not rss_lines: - return None - if len(rss_lines) != 1: - raise ValueError("Linux proc status must contain at most one VmRSS field") - - fields = rss_lines[0].split() - if len(fields) != 3 or fields[0] != "VmRSS:" or fields[2] != "kB": - raise ValueError("malformed Linux VmRSS field") - raw_kibibytes = fields[1] - if not raw_kibibytes.isascii() or not raw_kibibytes.isdigit(): - raise ValueError("malformed Linux VmRSS value") - kibibytes = int(raw_kibibytes, 10) - if kibibytes == 0: - return None - if kibibytes > MAX_U64 // 1024: - raise OverflowError("Linux VmRSS exceeds u64 byte range") - return kibibytes * 1024 - - -def _parse_linux_proc_status_process_identity(status_text: str) -> tuple[int, int]: - """Parse exactly one positive ``Pid`` and one non-negative ``PPid`` from status.""" - - parsed: dict[str, int] = {} - for line in status_text.splitlines(): - if not (line.startswith("Pid:") or line.startswith("PPid:")): - continue - fields = line.split() - if len(fields) != 2 or fields[0] not in {"Pid:", "PPid:"}: - raise ValueError("malformed Linux process identity field") - label = fields[0] - if label in parsed: - raise ValueError("duplicate Linux process identity field") - raw_process_id = fields[1] - if not raw_process_id.isascii() or not raw_process_id.isdigit(): - raise ValueError("malformed Linux process identity value") - parsed[label] = int(raw_process_id, 10) - - if set(parsed) != {"Pid:", "PPid:"}: - raise ValueError("Linux proc status must contain exactly one Pid and PPid") - process_id = parsed["Pid:"] - parent_process_id = parsed["PPid:"] - if process_id <= 0: - raise ValueError("Linux process identifier must be positive") - if parent_process_id < 0: - raise ValueError("Linux parent process identifier must be non-negative") - return process_id, parent_process_id - - -def _parse_linux_proc_stat_process_identity(stat_text: str) -> tuple[int, int]: - """Parse one Linux proc-stat PID/start-time identity without trusting ``comm`` text.""" - - if not isinstance(stat_text, str) or not stat_text: - raise ValueError("Linux proc stat must be non-empty text") - command_open = stat_text.find(" (") - command_close = stat_text.rfind(") ") - if command_open <= 0 or command_close <= command_open + 2: - raise ValueError("malformed Linux proc stat process identity") - - raw_process_id = stat_text[:command_open] - if not raw_process_id.isascii() or not raw_process_id.isdigit(): - raise ValueError("malformed Linux proc stat process identifier") - process_id = int(raw_process_id, 10) - if process_id <= 0: - raise ValueError("Linux proc stat process identifier must be positive") - - command_text = stat_text[command_open + 2 : command_close] - if not command_text: - raise ValueError("Linux proc stat command must not be empty") - suffix_fields = stat_text[command_close + 2 :].split() - if len(suffix_fields) < 20 or len(suffix_fields[0]) != 1: - raise ValueError("Linux proc stat does not contain field 22 start time") - for raw_field in suffix_fields[1:]: - unsigned_field = raw_field[1:] if raw_field[:1] in {"+", "-"} else raw_field - if not unsigned_field or not unsigned_field.isascii() or not unsigned_field.isdigit(): - raise ValueError("malformed Linux proc stat numeric field") - - raw_start_time_ticks = suffix_fields[19] - if not raw_start_time_ticks.isascii() or not raw_start_time_ticks.isdigit(): - raise ValueError("malformed Linux proc stat start time") - start_time_ticks = int(raw_start_time_ticks, 10) - if start_time_ticks <= 0: - raise ValueError("Linux proc stat start time must be positive") - if start_time_ticks > MAX_U64: - raise OverflowError("Linux proc stat start time exceeds u64 range") - return process_id, start_time_ticks - - -def _read_linux_proc_stat_process_identity(process_id: int) -> tuple[int, int] | None: - """Read one bounded Linux PID/start-time identity, returning absence after exit.""" - - if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: - raise ValueError("invalid Linux process identifier") - stat_path = pathlib.Path("/proc") / str(process_id) / "stat" - try: - with stat_path.open("r", encoding="utf-8", errors="strict") as stat_file: - stat_text = stat_file.read(MAX_PROC_STAT_CHARACTERS + 1) - except (FileNotFoundError, ProcessLookupError): - return None - if len(stat_text) > MAX_PROC_STAT_CHARACTERS: - raise RuntimeError("Linux proc stat exceeded the bounded text limit") - identity = _parse_linux_proc_stat_process_identity(stat_text) - if identity[0] != process_id: - raise RuntimeError("Linux proc stat identity did not match its directory") - return identity - - -def _signal_linux_process_identity( - process_identity: tuple[int, int], - signal_number: int, -) -> bool: - """Signal only one exact Linux PID/start-time identity through a pidfd.""" - - if not isinstance(process_identity, tuple) or len(process_identity) != 2: - raise ValueError("invalid Linux process identity") - process_id, start_time_ticks = process_identity - if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: - raise ValueError("invalid Linux process identifier") - if ( - isinstance(start_time_ticks, bool) - or not isinstance(start_time_ticks, int) - or start_time_ticks <= 0 - ): - raise ValueError("invalid Linux process start time") - if isinstance(signal_number, bool) or not isinstance(signal_number, int) or signal_number <= 0: - raise ValueError("invalid Linux process signal") - - expected_identity = (process_id, start_time_ticks) - if _read_linux_proc_stat_process_identity(process_id) != expected_identity: - return False - pidfd_open = getattr(os, "pidfd_open", None) - pidfd_send_signal = getattr(signal, "pidfd_send_signal", None) - if not callable(pidfd_open) or not callable(pidfd_send_signal): - raise RuntimeError("Linux pidfd signalling is unavailable") - try: - pidfd = pidfd_open(process_id, 0) - except ProcessLookupError: - return False - try: - if _read_linux_proc_stat_process_identity(process_id) != expected_identity: - return False - try: - pidfd_send_signal(pidfd, signal_number) - except ProcessLookupError: - return False - return True - finally: - os.close(pidfd) - - -def _wait_for_linux_process_identity_exit( - process_id: int, - start_time_ticks: int, - *, - timeout_seconds: float = PROCESS_EXIT_TIMEOUT_SECONDS, -) -> bool: - """Wait boundedly until the exact PID/start-time identity exits or is reused.""" - - if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: - raise ValueError("invalid Linux process identifier") - if ( - isinstance(start_time_ticks, bool) - or not isinstance(start_time_ticks, int) - or start_time_ticks <= 0 - ): - raise ValueError("invalid Linux process start time") - if ( - isinstance(timeout_seconds, bool) - or not isinstance(timeout_seconds, (int, float)) - or timeout_seconds < 0 - or not math.isfinite(timeout_seconds) - ): - raise ValueError("invalid Linux process-exit timeout") - - deadline = time.monotonic() + float(timeout_seconds) - expected_identity = (process_id, start_time_ticks) - while True: - current_identity = _read_linux_proc_stat_process_identity(process_id) - if current_identity is None or current_identity != expected_identity: - return True - remaining_seconds = deadline - time.monotonic() - if remaining_seconds <= 0: - return False - time.sleep(min(0.05, remaining_seconds)) - - -def _read_linux_process_identity_set( - process_ids: tuple[int, ...], -) -> tuple[tuple[int, int], ...]: - """Bind one bounded sampled process set to exact Linux PID/start-time identities.""" - - if not process_ids or len(process_ids) > MAX_BROWSER_PROCESS_TREE_SIZE: - raise ValueError("invalid Linux process identity-set size") - if len(set(process_ids)) != len(process_ids): - raise ValueError("Linux process identity-set PIDs must be unique") - - identities: list[tuple[int, int]] = [] - for process_id in process_ids: - if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: - raise ValueError("invalid Linux process identifier") - identity = _read_linux_proc_stat_process_identity(process_id) - if identity is None: - raise RuntimeError("Linux Chromium process disappeared before shutdown identity capture") - identities.append(identity) - return tuple(identities) - - -def _wait_for_linux_process_identity_set_exit( - process_identities: tuple[tuple[int, int], ...], - *, - timeout_seconds: float = PROCESS_EXIT_TIMEOUT_SECONDS, -) -> bool: - """Wait under one shared deadline for every exact sampled process identity to exit.""" - - if not process_identities or len(process_identities) > MAX_BROWSER_PROCESS_TREE_SIZE: - raise ValueError("invalid Linux process identity-set size") - process_ids: list[int] = [] - expected: dict[int, tuple[int, int]] = {} - for identity in process_identities: - if not isinstance(identity, tuple) or len(identity) != 2: - raise ValueError("invalid Linux process identity") - process_id, start_time_ticks = identity - if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: - raise ValueError("invalid Linux process identifier") - if ( - isinstance(start_time_ticks, bool) - or not isinstance(start_time_ticks, int) - or start_time_ticks <= 0 - ): - raise ValueError("invalid Linux process start time") - if process_id in expected: - raise ValueError("Linux process identity-set PIDs must be unique") - process_ids.append(process_id) - expected[process_id] = identity - if ( - isinstance(timeout_seconds, bool) - or not isinstance(timeout_seconds, (int, float)) - or timeout_seconds < 0 - or not math.isfinite(timeout_seconds) - ): - raise ValueError("invalid Linux process-set exit timeout") - - deadline = time.monotonic() + float(timeout_seconds) - while True: - live_identity_found = False - for process_id in process_ids: - current_identity = _read_linux_proc_stat_process_identity(process_id) - if current_identity == expected[process_id]: - live_identity_found = True - if not live_identity_found: - return True - remaining_seconds = deadline - time.monotonic() - if remaining_seconds <= 0: - return False - time.sleep(min(0.05, remaining_seconds)) - - -def _wait_for_linux_process_teardown( - root_process_id: int, - root_start_time_ticks: int, - process_identities: tuple[tuple[int, int], ...], - *, - timeout_seconds: float = PROCESS_EXIT_TIMEOUT_SECONDS, -) -> tuple[bool, bool]: - """Observe root and sampled-set termination under one monotonic deadline.""" - - if isinstance(root_process_id, bool) or not isinstance(root_process_id, int) or root_process_id <= 0: - raise ValueError("invalid Linux root process identifier") - if ( - isinstance(root_start_time_ticks, bool) - or not isinstance(root_start_time_ticks, int) - or root_start_time_ticks <= 0 - ): - raise ValueError("invalid Linux root process start time") - if not process_identities or len(process_identities) > MAX_BROWSER_PROCESS_TREE_SIZE: - raise ValueError("invalid Linux process identity-set size") - - expected: dict[int, tuple[int, int]] = {} - for identity in process_identities: - if not isinstance(identity, tuple) or len(identity) != 2: - raise ValueError("invalid Linux process identity") - process_id, start_time_ticks = identity - if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: - raise ValueError("invalid Linux process identifier") - if ( - isinstance(start_time_ticks, bool) - or not isinstance(start_time_ticks, int) - or start_time_ticks <= 0 - ): - raise ValueError("invalid Linux process start time") - if process_id in expected: - raise ValueError("Linux process identity-set PIDs must be unique") - expected[process_id] = identity - - root_identity = (root_process_id, root_start_time_ticks) - if expected.get(root_process_id) != root_identity: - raise ValueError("Linux root process identity must belong to the sampled process set") - if ( - isinstance(timeout_seconds, bool) - or not isinstance(timeout_seconds, (int, float)) - or timeout_seconds < 0 - or not math.isfinite(timeout_seconds) - ): - raise ValueError("invalid Linux process-teardown timeout") - - deadline = time.monotonic() + float(timeout_seconds) - while True: - live_process_ids: set[int] = set() - for process_id, expected_identity in expected.items(): - current_identity = _read_linux_proc_stat_process_identity(process_id) - if current_identity == expected_identity: - live_process_ids.add(process_id) - - root_terminated = root_process_id not in live_process_ids - process_set_terminated = not live_process_ids - if process_set_terminated: - return root_terminated, True - - remaining_seconds = deadline - time.monotonic() - if remaining_seconds <= 0: - return root_terminated, False - time.sleep(min(0.05, remaining_seconds)) - - -def _sample_linux_process_rss_bytes(process_id: int) -> int: - """Read one attributed Linux process RSS through a bounded ``/proc`` status file.""" - - if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: - raise ValueError("invalid Linux process identifier") - status_path = pathlib.Path("/proc") / str(process_id) / "status" - with status_path.open("r", encoding="utf-8", errors="strict") as status_file: - status_text = status_file.read(MAX_PROC_STATUS_CHARACTERS + 1) - if len(status_text) > MAX_PROC_STATUS_CHARACTERS: - raise RuntimeError("Linux proc status exceeded the bounded text limit") - return _parse_linux_proc_status_rss_bytes(status_text) - - -def _snapshot_linux_process_evidence() -> dict[int, tuple[int, int | None]]: - """Capture one bounded best-effort PID/PPID/RSS sweep from Linux proc status.""" - - proc_root = pathlib.Path("/proc") - process_entries: list[tuple[int, pathlib.Path]] = [] - for entry in proc_root.iterdir(): - raw_process_id = entry.name - if not raw_process_id.isascii() or not raw_process_id.isdigit(): - continue - process_id = int(raw_process_id, 10) - if process_id <= 0: - continue - process_entries.append((process_id, entry)) - if len(process_entries) > MAX_PROC_PROCESS_SCAN_SIZE: - raise RuntimeError("Linux proc process scan exceeded the bounded entry limit") - - process_evidence: dict[int, tuple[int, int | None]] = {} - for expected_process_id, entry in sorted(process_entries): - status_path = entry / "status" - try: - with status_path.open("r", encoding="utf-8", errors="strict") as status_file: - status_text = status_file.read(MAX_PROC_STATUS_CHARACTERS + 1) - except FileNotFoundError: - continue - if len(status_text) > MAX_PROC_STATUS_CHARACTERS: - raise RuntimeError("Linux proc status exceeded the bounded text limit") - process_id, parent_process_id = _parse_linux_proc_status_process_identity( - status_text - ) - if process_id != expected_process_id: - raise RuntimeError("Linux proc status identity did not match its directory") - if process_id in process_evidence: - raise RuntimeError("Linux proc process snapshot contained a duplicate PID") - rss_bytes = _parse_linux_proc_status_optional_rss_bytes(status_text) - process_evidence[process_id] = (parent_process_id, rss_bytes) - return process_evidence - - -def _discover_linux_process_tree_ids( - root_process_id: int, - process_evidence: dict[int, tuple[int, int | None]], -) -> tuple[int, ...]: - """Discover one bounded root-plus-descendant set from sampled process evidence.""" - - if ( - isinstance(root_process_id, bool) - or not isinstance(root_process_id, int) - or root_process_id <= 0 - ): - raise ValueError("invalid Linux root process identifier") - if root_process_id not in process_evidence: - raise RuntimeError("Linux process snapshot did not contain the browser root PID") - - discovered = [root_process_id] - known = {root_process_id} - while True: - children = sorted( - process_id - for process_id, (parent_process_id, _rss_bytes) in process_evidence.items() - if parent_process_id in known and process_id not in known - ) - if not children: - break - for process_id in children: - if len(known) >= MAX_BROWSER_PROCESS_TREE_SIZE: - raise ValueError("Linux process tree exceeded the bounded process-tree size") - known.add(process_id) - discovered.append(process_id) - return tuple(discovered) - - -def _sample_linux_process_set_rss_bytes( - process_ids: tuple[int, ...], - process_evidence: dict[int, tuple[int, int | None]], -) -> int: - """Sum resident RSS for one exact bounded process set without overflow.""" - - if not process_ids or len(process_ids) > MAX_BROWSER_PROCESS_TREE_SIZE: - raise ValueError("invalid Linux process set size") - if len(set(process_ids)) != len(process_ids): - raise ValueError("Linux process set identifiers must be unique") - - total_rss_bytes = 0 - for process_id in process_ids: - if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: - raise ValueError("invalid Linux process identifier") - if process_id not in process_evidence: - raise ValueError("Linux process set was not present in the sampled evidence") - rss_bytes = process_evidence[process_id][1] - if rss_bytes is None: - continue - if isinstance(rss_bytes, bool) or not isinstance(rss_bytes, int) or rss_bytes <= 0: - raise ValueError("Linux process set contained invalid sampled RSS") - if rss_bytes > MAX_U64 - total_rss_bytes: - raise OverflowError("Linux process-set RSS exceeds u64 byte range") - total_rss_bytes += rss_bytes - return total_rss_bytes - - -def _wait_for_extension_evidence( - driver_port: int, - session_id: str, - expected_storage_persistence: str, -) -> dict[str, str]: - """Wait until every controlled MV3 fixture surface reports its expected result.""" - - if expected_storage_persistence not in {"initialized", "persisted"}: - raise ValueError("invalid storage persistence expectation") - script = """ -return { - content: document.documentElement.dataset.originweaveContentScript || "missing", - storage: document.documentElement.dataset.originweaveStorage || "missing", - storagePersistence: - document.documentElement.dataset.originweaveStoragePersistence || "missing", - workerReply: document.documentElement.dataset.originweaveWorkerReply || "missing", - workerState: document.documentElement.dataset.originweaveWorkerState || "missing", - workerStartCount: - document.documentElement.dataset.originweaveWorkerStartCount || "missing", - dnr: document.documentElement.dataset.originweaveDnr || "missing", - tabs: document.documentElement.dataset.originweaveTabs || "missing", - windows: document.documentElement.dataset.originweaveWindows || "missing", - scripting: document.documentElement.dataset.originweaveScripting || "missing", - scriptingExecuted: - document.documentElement.dataset.originweaveScriptingExecuted || "missing", - commands: document.documentElement.dataset.originweaveCommands || "missing", - sidePanel: document.documentElement.dataset.originweaveSidePanel || "missing", - bookmarks: document.documentElement.dataset.originweaveBookmarks || "missing", - history: document.documentElement.dataset.originweaveHistory || "missing" -}; -""" - expected = { - "content": "ready", - "storage": "ready", - "storagePersistence": expected_storage_persistence, - "workerReply": "pong", - "workerState": "installed", - "dnr": "blocked", - "tabs": "ready", - "windows": "ready", - "scripting": "ready", - "scriptingExecuted": "ready", - "commands": "ready", - "sidePanel": "ready", - "bookmarks": "ready", - "history": "ready", - } - deadline = time.monotonic() + FIXTURE_TIMEOUT_SECONDS - latest: dict[str, str] = {} - while time.monotonic() < deadline: - value = _execute(driver_port, session_id, script) - if isinstance(value, dict): - latest = {str(key): str(item) for key, item in value.items()} - try: - worker_start_count = int(latest.get("workerStartCount", "0")) - except ValueError: - worker_start_count = 0 - if worker_start_count > 0 and all( - latest.get(key) == item for key, item in expected.items() - ): - return latest - time.sleep(0.1) - raise RuntimeError( - f"MV3 fixture did not converge: expected={expected!r}, observed={latest!r}" - ) - - -def _exercise_real_click(driver_port: int, session_id: str) -> str: - """Use the WebDriver element-click command and verify the DOM post-condition.""" - - safe_element = _find_element(driver_port, session_id, "#fixture-button") - _json_request( - driver_port, - "POST", - _element_command_path(session_id, safe_element, "/click"), - {}, - ) - safe_output = _find_element(driver_port, session_id, "#fixture-output") - text = _json_request( - driver_port, - "GET", - _element_command_path(session_id, safe_output, "/text"), - ).get("value") - if text != "clicked": - raise RuntimeError(f"real click post-condition failed: {text!r}") - return str(text) - - -def _run_browser_pass( - chrome_bin: pathlib.Path, - chromedriver_bin: pathlib.Path, - fixture_url: str, - profile_dir: str, - expected_storage_persistence: str, -) -> dict[str, Any]: - """Run one fresh browser process against a shared bounded compatibility profile.""" - - driver_port = _free_loopback_port() - session_id: str | None = None - driver = subprocess.Popen( - [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], - stdout=subprocess.DEVNULL, - stderr=subprocess.STDOUT, - text=True, - ) - try: - _wait_for_driver(driver_port) - session = _json_request( - driver_port, - "POST", - "/session", - { - "capabilities": { - "alwaysMatch": { - "browserName": "chrome", - "goog:chromeOptions": { - "binary": str(chrome_bin), - "args": [ - "--headless=new", - "--no-first-run", - "--disable-default-apps", - "--disable-component-update", - "--disable-sync", - "--disable-dev-shm-usage", - "--no-sandbox", - f"--user-data-dir={profile_dir}", - f"--disable-extensions-except={FIXTURE}", - f"--load-extension={FIXTURE}", - ], - }, - } - } - }, - ).get("value", {}) - if not isinstance(session, dict): - raise RuntimeError("ChromeDriver session response is malformed") - raw_session_id = session.get("sessionId") - capabilities = session.get("capabilities", {}) - if not isinstance(raw_session_id, str): - raise RuntimeError("ChromeDriver did not return a session id") - session_id = _path_token(raw_session_id, "session identifier") - browser_version = ( - capabilities.get("browserVersion") if isinstance(capabilities, dict) else None - ) - if browser_version != PINNED_CHROME_VERSION: - raise RuntimeError( - f"unexpected Chrome version: expected {PINNED_CHROME_VERSION}, " - f"got {browser_version!r}" - ) - - _json_request( - driver_port, - "POST", - _webdriver_path(session_id, "/url"), - {"url": fixture_url}, - ) - surfaces = _wait_for_extension_evidence( - driver_port, - session_id, - expected_storage_persistence, - ) - click_result = _exercise_real_click(driver_port, session_id) - worker_start_count = int(surfaces["workerStartCount"]) - return { - "browser_version": browser_version, - "worker_start_count": worker_start_count, - "storage_persistence": surfaces["storagePersistence"], - "surfaces": { - "service-worker": surfaces["workerReply"] == "pong", - "content-script": surfaces["content"] == "ready", - "storage": surfaces["storage"] == "ready", - "declarative-net-request": surfaces["dnr"] == "blocked", - "tabs": surfaces["tabs"] == "ready", - "windows": surfaces["windows"] == "ready", - "scripting": surfaces["scripting"] == "ready" - and surfaces["scriptingExecuted"] == "ready", - "commands": surfaces["commands"] == "ready", - "side-panel": surfaces["sidePanel"] == "ready", - "bookmarks": surfaces["bookmarks"] == "ready", - "history": surfaces["history"] == "ready", - "real-browser-click": click_result == "clicked", - }, - } - finally: - if session_id is not None: - with contextlib.suppress(Exception): - _json_request( - driver_port, - "DELETE", - _webdriver_path(session_id, ""), - {}, - ) - driver.terminate() - try: - driver.wait(timeout=5) - except subprocess.TimeoutExpired: - driver.kill() - driver.wait(timeout=5) - - -def _run_restart_trial( - chrome_bin: pathlib.Path, - chromedriver_bin: pathlib.Path, - fixture_url: str, - trial_number: int, -) -> dict[str, Any]: - """Run one independent initial/restart pair and retain cleanup evidence on failure.""" - - trial_started = time.monotonic() - profile_path: pathlib.Path - initial: dict[str, Any] | None = None - restarted: dict[str, Any] | None = None - failure_type: str | None = None - with tempfile.TemporaryDirectory( - prefix=f"originweave-mv3-trial-{trial_number}-" - ) as profile_dir: - profile_path = pathlib.Path(profile_dir) - try: - initial = _run_browser_pass( - chrome_bin, - chromedriver_bin, - fixture_url, - profile_dir, - "initialized", - ) - restarted = _run_browser_pass( - chrome_bin, - chromedriver_bin, - fixture_url, - profile_dir, - "persisted", - ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - failure_type = type(exc).__name__ - profile_cleaned = not profile_path.exists() - if not profile_cleaned: - raise RuntimeError(f"Manifest V3 profile cleanup failed in trial {trial_number}") - - duration_ms = round((time.monotonic() - trial_started) * 1000) - if failure_type is not None: - return { - "trial_number": trial_number, - "passed": False, - "failure_type": failure_type, - "profile_cleaned": True, - "duration_ms": duration_ms, - } - if initial is None or restarted is None: - raise RuntimeError("Manifest V3 restart trial returned incomplete browser evidence") - - initial_count = int(initial["worker_start_count"]) - restarted_count = int(restarted["worker_start_count"]) - surfaces = { - name: bool(initial["surfaces"][name]) and bool(restarted["surfaces"][name]) - for name in initial["surfaces"] - } - surfaces.update( - { - "restart-persistence": restarted["storage_persistence"] == "persisted", - "worker-start-count": restarted_count > initial_count, - "storage-persistence": restarted["storage_persistence"] == "persisted", - } - ) - if not all(surfaces.values()): - raise RuntimeError(f"compatibility surface failed in trial {trial_number}") - - return { - "trial_number": trial_number, - "passed": True, - "browser_version": restarted["browser_version"], - "surfaces": surfaces, - "browser_passes": [ - { - "phase": "initial", - "worker_start_count": initial_count, - "storage_persistence": initial["storage_persistence"], - }, - { - "phase": "restart", - "worker_start_count": restarted_count, - "storage_persistence": restarted["storage_persistence"], - }, - ], - "profile_cleaned": True, - "duration_ms": duration_ms, - } - - -def _run_agent_task_browser_pass( - chrome_bin: pathlib.Path, - chromedriver_bin: pathlib.Path, - fixture_url: str, - profile_dir: str, -) -> dict[str, Any]: - """Execute one synthetic Agent Task and measure bounded real-browser evidence.""" - - _require_pristine_agent_task_profile(profile_dir) - profile_pristine_before_launch = True - started = time.monotonic() - driver_port = _free_loopback_port() - session_id: str | None = None - browser_process_id: int | None = None - browser_process_start_time_ticks: int | None = None - chromium_process_identities: tuple[tuple[int, int], ...] | None = None - browser_failure_type: str | None = None - result: dict[str, Any] | None = None - driver = subprocess.Popen( - [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], - stdout=subprocess.DEVNULL, - stderr=subprocess.STDOUT, - text=True, - ) - try: - _wait_for_driver(driver_port) - session = _json_request( - driver_port, - "POST", - "/session", - { - "capabilities": { - "alwaysMatch": { - "browserName": "chrome", - "goog:chromeOptions": { - "binary": str(chrome_bin), - "prefs": { - "credentials_enable_service": False, - "profile.password_manager_enabled": False, - }, - "args": [ - "--headless=new", - "--no-first-run", - "--disable-default-apps", - "--disable-component-update", - "--disable-sync", - "--disable-dev-shm-usage", - "--no-sandbox", - "--disable-extensions", - f"--user-data-dir={profile_dir}", - ], - }, - } - } - }, - ).get("value", {}) - if not isinstance(session, dict): - raise RuntimeError("ChromeDriver Agent Task session response is malformed") - raw_session_id = session.get("sessionId") - capabilities = session.get("capabilities", {}) - if not isinstance(raw_session_id, str): - raise RuntimeError("ChromeDriver did not return an Agent Task session id") - if not isinstance(capabilities, dict): - raise RuntimeError("ChromeDriver Agent Task capabilities are malformed") - session_id = _path_token(raw_session_id, "session identifier") - browser_version = capabilities.get("browserVersion") - browser_process_id = capabilities.get("goog:processID") - if browser_version != PINNED_CHROME_VERSION: - raise RuntimeError( - f"unexpected Agent Task Chrome version: expected {PINNED_CHROME_VERSION}, " - f"got {browser_version!r}" - ) - if ( - isinstance(browser_process_id, bool) - or not isinstance(browser_process_id, int) - or browser_process_id <= 0 - ): - raise RuntimeError("ChromeDriver did not return a valid browser process id") - browser_process_identity = _read_linux_proc_stat_process_identity(browser_process_id) - if browser_process_identity is None: - raise RuntimeError("Agent Task browser process identity disappeared after launch") - browser_process_start_time_ticks = browser_process_identity[1] - - _json_request( - driver_port, - "POST", - _webdriver_path(session_id, "/url"), - {"url": fixture_url}, - ) - initial_url = _json_request( - driver_port, - "GET", - _webdriver_path(session_id, "/url"), - ).get("value") - if initial_url != fixture_url: - raise RuntimeError("Agent Task did not load the requested fixture URL") - ambient_state = _probe_agent_task_ambient_state(driver_port, session_id) - - input_element = _find_element_by_accessible_role_name( - driver_port, - session_id, - "textbox", - "Task text", - ) - input_role, input_name = _get_element_semantics( - driver_port, - session_id, - input_element, - ) - if input_role != "textbox" or input_name != "Task text": - raise RuntimeError("Agent Task input semantic evidence mismatch") - submit_element = _find_element_by_accessible_role_name( - driver_port, - session_id, - "button", - "Submit task", - ) - submit_role, submit_name = _get_element_semantics( - driver_port, - session_id, - submit_element, - ) - if submit_role != "button" or submit_name != "Submit task": - raise RuntimeError("Agent Task submit semantic evidence mismatch") - semantic_observation = { - "input": {"role": input_role, "name": input_name}, - "submit": {"role": submit_role, "name": submit_name}, - } - semantic_observation_bytes = _measure_agent_task_semantic_observation_bytes( - semantic_observation - ) - - action_started = time.monotonic() - _json_request( - driver_port, - "POST", - _element_command_path(session_id, input_element, "/clear"), - {}, - ) - _json_request( - driver_port, - "POST", - _element_command_path(session_id, input_element, "/value"), - {"text": AGENT_TASK_INPUT_VALUE, "value": list(AGENT_TASK_INPUT_VALUE)}, - ) - _json_request( - driver_port, - "POST", - _element_command_path(session_id, submit_element, "/click"), - {}, - ) - action_latency_ms = round((time.monotonic() - action_started) * 1000, 3) - if action_latency_ms <= 0: - raise RuntimeError("Agent Task measured a non-positive action latency") - - post_submit_url = _json_request( - driver_port, - "GET", - _webdriver_path(session_id, "/url"), - ).get("value") - url_unchanged = post_submit_url == initial_url - if not url_unchanged: - raise RuntimeError("Agent Task URL changed during submission") - - result_element = _find_element_by_accessible_role_name( - driver_port, - session_id, - "status", - "Task result", - ) - result_role, result_name = _get_element_semantics( - driver_port, - session_id, - result_element, - ) - if result_role != "status" or result_name != "Task result": - raise RuntimeError("Agent Task result semantic evidence mismatch") - state = _json_request( - driver_port, - "GET", - _element_command_path(session_id, result_element, "/attribute/data-state"), - ).get("value") - text = _json_request( - driver_port, - "GET", - _element_command_path(session_id, result_element, "/text"), - ).get("value") - if state != "submitted": - raise RuntimeError(f"Agent Task state post-condition failed: {state!r}") - if text != AGENT_TASK_INPUT_VALUE: - raise RuntimeError("Agent Task result did not match the synthetic typed value") - structured_value_sha256 = _hash_agent_task_structured_value(text) - - process_evidence = _snapshot_linux_process_evidence() - chromium_process_ids = _discover_linux_process_tree_ids( - browser_process_id, - process_evidence, - ) - chromium_process_identities = _read_linux_process_identity_set( - chromium_process_ids - ) - browser_process_rss_bytes = _sample_linux_process_rss_bytes(browser_process_id) - chromium_process_set_rss_bytes = _sample_linux_process_set_rss_bytes( - chromium_process_ids, - process_evidence, - ) - chromium_process_count = len(chromium_process_ids) - task_duration_ms = round((time.monotonic() - started) * 1000, 3) - if task_duration_ms <= 0: - raise RuntimeError("Agent Task measured a non-positive task duration") - result = { - "browser_version": browser_version, - "post_condition": True, - "input_echo_verified": True, - "url_unchanged": url_unchanged, - "input_semantics_verified": True, - "submit_semantics_verified": True, - "result_semantics_verified": True, - "structured_value_field": "task_result", - "structured_value_sha256": structured_value_sha256, - "extensions_disabled": True, - "profile_pristine_before_launch": profile_pristine_before_launch, - "ambient_cookies_absent": ambient_state["ambient_cookies_absent"], - "ambient_web_storage_absent": ambient_state["ambient_web_storage_absent"], - "saved_credential_services_disabled": True, - "browser_process_rss_bytes": browser_process_rss_bytes, - "chromium_process_count": chromium_process_count, - "chromium_process_set_rss_bytes": chromium_process_set_rss_bytes, - "semantic_observation_bytes": semantic_observation_bytes, - "action_latency_ms": action_latency_ms, - "task_duration_ms": task_duration_ms, - "duration_ms": round(task_duration_ms), - } - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - browser_failure_type = type(exc).__name__ - if browser_process_id is None or browser_process_start_time_ticks is None: - raise - finally: - if session_id is not None: - with contextlib.suppress(Exception): - _json_request( - driver_port, - "DELETE", - _webdriver_path(session_id, ""), - {}, - ) - driver.terminate() - try: - driver.wait(timeout=5) - except subprocess.TimeoutExpired: - driver.kill() - driver.wait(timeout=5) - - if browser_process_id is None or browser_process_start_time_ticks is None: - raise RuntimeError("Agent Task browser process identity was not captured") - browser_process_terminated = _wait_for_linux_process_identity_exit( - browser_process_id, - browser_process_start_time_ticks, - ) - chromium_process_set_terminated: bool | None = None - if chromium_process_identities is not None: - chromium_process_set_terminated = _wait_for_linux_process_identity_set_exit( - chromium_process_identities - ) - if browser_failure_type is not None: - failure_evidence: dict[str, Any] = { - "failure_type": browser_failure_type, - "browser_process_terminated": browser_process_terminated, - } - if chromium_process_set_terminated is not None: - failure_evidence["chromium_process_set_terminated"] = ( - chromium_process_set_terminated - ) - return failure_evidence - if result is None: - raise RuntimeError("Agent Task browser pass returned no result after shutdown") - if chromium_process_set_terminated is None: - raise RuntimeError("Agent Task Chromium process identities were not captured") - if not browser_process_terminated: - raise RuntimeError("Agent Task browser process did not terminate") - if not chromium_process_set_terminated: - raise RuntimeError("Agent Task Chromium process set did not terminate") - result["browser_process_terminated"] = True - result["chromium_process_set_terminated"] = True - return result - - -def _run_agent_task_trial( - chrome_bin: pathlib.Path, - chromedriver_bin: pathlib.Path, - fixture_url: str, - trial_number: int, -) -> dict[str, Any]: - """Run one isolated Agent Task trial and retain cleanup evidence on failure.""" - - trial_started = time.monotonic() - profile_path: pathlib.Path - result: dict[str, Any] | None = None - failure_type: str | None = None - with tempfile.TemporaryDirectory( - prefix=f"originweave-agent-task-trial-{trial_number}-" - ) as profile_dir: - profile_path = pathlib.Path(profile_dir) - try: - result = _run_agent_task_browser_pass( - chrome_bin, - chromedriver_bin, - fixture_url, - profile_dir, - ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - failure_type = type(exc).__name__ - profile_cleaned = not profile_path.exists() - if not profile_cleaned: - raise RuntimeError(f"Agent Task profile cleanup failed in trial {trial_number}") - - duration_ms = round((time.monotonic() - trial_started) * 1000) - if failure_type is not None: - return { - "trial_number": trial_number, - "passed": False, - "failure_type": failure_type, - "profile_cleaned": True, - "duration_ms": duration_ms, - } - if result is None: - raise RuntimeError("Agent Task browser pass returned no result") - returned_failure_type = result.get("failure_type") - if returned_failure_type is not None: - if not isinstance(returned_failure_type, str) or not returned_failure_type: - raise RuntimeError("Agent Task browser pass returned invalid failure evidence") - browser_process_terminated = result.get("browser_process_terminated") - if not isinstance(browser_process_terminated, bool): - raise RuntimeError("Agent Task browser pass returned invalid teardown evidence") - failure_evidence: dict[str, Any] = { - "trial_number": trial_number, - "passed": False, - "failure_type": returned_failure_type, - "browser_process_terminated": browser_process_terminated, - "profile_cleaned": True, - "duration_ms": duration_ms, - } - if "chromium_process_set_terminated" in result: - chromium_process_set_terminated = result["chromium_process_set_terminated"] - if not isinstance(chromium_process_set_terminated, bool): - raise RuntimeError( - "Agent Task browser pass returned invalid process-set teardown evidence" - ) - failure_evidence["chromium_process_set_terminated"] = ( - chromium_process_set_terminated - ) - return failure_evidence - - return { - "trial_number": trial_number, - "passed": True, - "browser_version": result["browser_version"], - "post_condition": result["post_condition"], - "input_echo_verified": result["input_echo_verified"], - "url_unchanged": result["url_unchanged"], - "input_semantics_verified": result["input_semantics_verified"], - "submit_semantics_verified": result["submit_semantics_verified"], - "result_semantics_verified": result["result_semantics_verified"], - "structured_value_field": result["structured_value_field"], - "structured_value_sha256": result["structured_value_sha256"], - "extensions_disabled": result["extensions_disabled"], - "profile_pristine_before_launch": result["profile_pristine_before_launch"], - "ambient_cookies_absent": result["ambient_cookies_absent"], - "ambient_web_storage_absent": result["ambient_web_storage_absent"], - "saved_credential_services_disabled": result[ - "saved_credential_services_disabled" - ], - "browser_process_rss_bytes": result["browser_process_rss_bytes"], - "browser_process_terminated": result["browser_process_terminated"], - "chromium_process_count": result["chromium_process_count"], - "chromium_process_set_rss_bytes": result["chromium_process_set_rss_bytes"], - "chromium_process_set_terminated": result["chromium_process_set_terminated"], - "semantic_observation_bytes": result["semantic_observation_bytes"], - "action_latency_ms": result["action_latency_ms"], - "task_duration_ms": result["task_duration_ms"], - "profile_cleaned": True, - "duration_ms": duration_ms, - } - - -def _is_no_such_window_runtime_error(error: RuntimeError) -> bool: - """Recognize only structured ChromeDriver no-such-window failure evidence.""" - - message = str(error) - direct_prefix = "WebDriver error: " - if message.startswith(direct_prefix): - code, separator, _detail = message[len(direct_prefix) :].partition(":") - return bool(separator) and code.strip().casefold() == "no such window" - - http_prefix = "WebDriver HTTP 404: " - if not message.startswith(http_prefix): - return False - try: - payload = json.loads(message[len(http_prefix) :]) - except json.JSONDecodeError: - return False - if not isinstance(payload, dict): - return False - value = payload.get("value") - return isinstance(value, dict) and value.get("error") == "no such window" - - -def _force_close_agent_task_context(driver_port: int, session_id: str) -> bool: - """Close only the current browsing context and require no-such-window evidence.""" - - closed = _json_request( - driver_port, - "DELETE", - _webdriver_path(session_id, "/window"), - ) - surviving_contexts = closed.get("value") - if not isinstance(surviving_contexts, list): - raise RuntimeError("Agent Task forced-close returned malformed surviving contexts") - if not surviving_contexts: - raise RuntimeError("Agent Task forced-close left no surviving browsing context") - if any(not isinstance(handle, str) or not handle for handle in surviving_contexts): - raise RuntimeError("Agent Task forced-close returned invalid surviving context") - - try: - _json_request( - driver_port, - "GET", - _webdriver_path(session_id, "/url"), - ) - except RuntimeError as exc: - if _is_no_such_window_runtime_error(exc): - return True - raise - raise RuntimeError("Agent Task context remained usable after forced close") - - -def _run_agent_task_forced_close_browser_pass( - chrome_bin: pathlib.Path, - chromedriver_bin: pathlib.Path, - fixture_url: str, - profile_dir: str, -) -> dict[str, Any]: - """Force-close a disposable real context while preserving the WebDriver session.""" - - driver_port = _free_loopback_port() - session_id: str | None = None - browser_process_id: int | None = None - browser_process_start_time_ticks: int | None = None - chromium_process_identities: tuple[tuple[int, int], ...] | None = None - browser_failure_type: str | None = None - result: dict[str, Any] | None = None - driver = subprocess.Popen( - [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], - stdout=subprocess.DEVNULL, - stderr=subprocess.STDOUT, - text=True, - ) - try: - _wait_for_driver(driver_port) - session = _json_request( - driver_port, - "POST", - "/session", - { - "capabilities": { - "alwaysMatch": { - "browserName": "chrome", - "goog:chromeOptions": { - "binary": str(chrome_bin), - "args": [ - "--headless=new", - "--no-first-run", - "--disable-default-apps", - "--disable-component-update", - "--disable-sync", - "--disable-dev-shm-usage", - "--no-sandbox", - "--disable-extensions", - f"--user-data-dir={profile_dir}", - ], - }, - } - } - }, - ).get("value", {}) - if not isinstance(session, dict): - raise RuntimeError("ChromeDriver forced-close session response is malformed") - raw_session_id = session.get("sessionId") - capabilities = session.get("capabilities", {}) - if not isinstance(raw_session_id, str): - raise RuntimeError("ChromeDriver did not return a forced-close session id") - if not isinstance(capabilities, dict): - raise RuntimeError("ChromeDriver forced-close capabilities are malformed") - session_id = _path_token(raw_session_id, "session identifier") - browser_version = capabilities.get("browserVersion") - browser_process_id = capabilities.get("goog:processID") - if browser_version != PINNED_CHROME_VERSION: - raise RuntimeError( - f"unexpected forced-close Chrome version: expected {PINNED_CHROME_VERSION}, " - f"got {browser_version!r}" - ) - if ( - isinstance(browser_process_id, bool) - or not isinstance(browser_process_id, int) - or browser_process_id <= 0 - ): - raise RuntimeError("ChromeDriver did not return a valid forced-close browser process id") - browser_process_identity = _read_linux_proc_stat_process_identity(browser_process_id) - if browser_process_identity is None: - raise RuntimeError("Agent Task forced-close browser process identity disappeared") - browser_process_start_time_ticks = browser_process_identity[1] - - survivor_context = _json_request( - driver_port, - "GET", - _webdriver_path(session_id, "/window"), - ).get("value") - if not isinstance(survivor_context, str): - raise RuntimeError("ChromeDriver did not return the survivor context handle") - survivor_context = _path_token(survivor_context, "window handle") - - created = _json_request( - driver_port, - "POST", - _webdriver_path(session_id, "/window/new"), - {"type": "tab"}, - ).get("value", {}) - if not isinstance(created, dict): - raise RuntimeError("ChromeDriver returned malformed disposable context evidence") - disposable_context = created.get("handle") - if not isinstance(disposable_context, str): - raise RuntimeError("ChromeDriver did not return a disposable context handle") - disposable_context = _path_token(disposable_context, "window handle") - if disposable_context == survivor_context: - raise RuntimeError("ChromeDriver reused the survivor context as disposable context") - - _json_request( - driver_port, - "POST", - _webdriver_path(session_id, "/window"), - {"handle": disposable_context}, - ) - _json_request( - driver_port, - "POST", - _webdriver_path(session_id, "/url"), - {"url": fixture_url}, - ) - loaded_url = _json_request( - driver_port, - "GET", - _webdriver_path(session_id, "/url"), - ).get("value") - if loaded_url != fixture_url: - raise RuntimeError("Agent Task forced-close probe did not load its fixture URL") - - process_evidence = _snapshot_linux_process_evidence() - chromium_process_ids = _discover_linux_process_tree_ids( - browser_process_id, - process_evidence, - ) - chromium_process_identities = _read_linux_process_identity_set( - chromium_process_ids - ) - - forced_close_detected = _force_close_agent_task_context(driver_port, session_id) - if not forced_close_detected: - raise RuntimeError("Agent Task forced-close probe did not detect the close") - - _json_request( - driver_port, - "POST", - _webdriver_path(session_id, "/window"), - {"handle": survivor_context}, - ) - surviving_url = _json_request( - driver_port, - "GET", - _webdriver_path(session_id, "/url"), - ).get("value") - if not isinstance(surviving_url, str): - raise RuntimeError("Agent Task survivor context was not usable after forced close") - - result = { - "browser_version": browser_version, - "forced_close_detected": forced_close_detected, - "session_survived": True, - } - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - browser_failure_type = type(exc).__name__ - if browser_process_id is None or browser_process_start_time_ticks is None: - raise - finally: - if session_id is not None: - with contextlib.suppress(Exception): - _json_request( - driver_port, - "DELETE", - _webdriver_path(session_id, ""), - {}, - ) - driver.terminate() - try: - driver.wait(timeout=5) - except subprocess.TimeoutExpired: - driver.kill() - driver.wait(timeout=5) - - if browser_process_id is None or browser_process_start_time_ticks is None: - raise RuntimeError("Agent Task forced-close browser process identity was not captured") - full_process_set_captured = chromium_process_identities is not None - teardown_identities = ( - chromium_process_identities - if chromium_process_identities is not None - else ((browser_process_id, browser_process_start_time_ticks),) - ) - browser_process_terminated, observed_process_set_terminated = ( - _wait_for_linux_process_teardown( - browser_process_id, - browser_process_start_time_ticks, - teardown_identities, - ) - ) - chromium_process_set_terminated: bool | None = ( - observed_process_set_terminated if full_process_set_captured else None - ) - if browser_failure_type is not None: - failure_evidence: dict[str, Any] = { - "failure_type": browser_failure_type, - "browser_process_terminated": browser_process_terminated, - } - if chromium_process_set_terminated is not None: - failure_evidence["chromium_process_set_terminated"] = ( - chromium_process_set_terminated - ) - return failure_evidence - if result is None: - raise RuntimeError("Agent Task forced-close browser pass returned no result after shutdown") - if chromium_process_set_terminated is None: - raise RuntimeError("Agent Task forced-close Chromium process identities were not captured") - if not browser_process_terminated: - raise RuntimeError("Agent Task forced-close browser process did not terminate") - if not chromium_process_set_terminated: - raise RuntimeError("Agent Task forced-close Chromium process set did not terminate") - result["browser_process_terminated"] = True - result["chromium_process_set_terminated"] = True - return result - - -def _run_agent_task_forced_close_trial( - chrome_bin: pathlib.Path, - chromedriver_bin: pathlib.Path, - fixture_url: str, - trial_number: int, -) -> dict[str, Any]: - """Run one forced-close trial and retain cleanup evidence on failure.""" - - trial_started = time.monotonic() - profile_path: pathlib.Path - result: dict[str, Any] | None = None - failure_type: str | None = None - with tempfile.TemporaryDirectory( - prefix=f"originweave-agent-task-forced-close-{trial_number}-" - ) as profile_dir: - profile_path = pathlib.Path(profile_dir) - try: - result = _run_agent_task_forced_close_browser_pass( - chrome_bin, - chromedriver_bin, - fixture_url, - profile_dir, - ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - failure_type = type(exc).__name__ - profile_cleaned = not profile_path.exists() - if not profile_cleaned: - raise RuntimeError( - f"Agent Task forced-close profile cleanup failed in trial {trial_number}" - ) - - duration_ms = round((time.monotonic() - trial_started) * 1000) - if failure_type is not None: - return { - "trial_number": trial_number, - "passed": False, - "failure_type": failure_type, - "profile_cleaned": True, - "duration_ms": duration_ms, - } - if result is None: - raise RuntimeError("Agent Task forced-close browser pass returned no result") - returned_failure_type = result.get("failure_type") - if returned_failure_type is not None: - if not isinstance(returned_failure_type, str) or not returned_failure_type: - raise RuntimeError("Agent Task forced-close browser pass returned invalid failure evidence") - browser_process_terminated = result.get("browser_process_terminated") - if not isinstance(browser_process_terminated, bool): - raise RuntimeError("Agent Task forced-close browser pass returned invalid teardown evidence") - failure_evidence: dict[str, Any] = { - "trial_number": trial_number, - "passed": False, - "failure_type": returned_failure_type, - "browser_process_terminated": browser_process_terminated, - "profile_cleaned": True, - "duration_ms": duration_ms, - } - if "chromium_process_set_terminated" in result: - chromium_process_set_terminated = result["chromium_process_set_terminated"] - if not isinstance(chromium_process_set_terminated, bool): - raise RuntimeError( - "Agent Task forced-close browser pass returned invalid process-set teardown evidence" - ) - failure_evidence["chromium_process_set_terminated"] = ( - chromium_process_set_terminated - ) - return failure_evidence - - return { - "trial_number": trial_number, - "passed": True, - "browser_version": result["browser_version"], - "forced_close_detected": result["forced_close_detected"], - "session_survived": result["session_survived"], - "browser_process_terminated": result["browser_process_terminated"], - "chromium_process_set_terminated": result["chromium_process_set_terminated"], - "profile_cleaned": True, - "duration_ms": duration_ms, - } - - def _cleanup_crashed_browser_session(driver_port: int, session_id: str | None) -> None: """Delete a crash session while ignoring only reviewed post-crash transport loss.""" @@ -1826,553 +10,10 @@ def _cleanup_crashed_browser_session(driver_port: int, session_id: str | None) - _webdriver_path(session_id, ""), {}, ) - except (OSError, RuntimeError, json.JSONDecodeError): - return - - -def _stop_crashed_driver(driver: subprocess.Popen[Any]) -> None: - """Reap ChromeDriver without re-signalling a child that already exited.""" - - if driver.poll() is not None: - driver.wait(timeout=5) - return - driver.terminate() - try: - driver.wait(timeout=5) - except subprocess.TimeoutExpired: - driver.kill() - driver.wait(timeout=5) - - -def _run_agent_task_browser_crash_browser_pass( - chrome_bin: pathlib.Path, - chromedriver_bin: pathlib.Path, - fixture_url: str, - profile_dir: str, -) -> dict[str, Any]: - """Kill one exact browser root and prove crash detection plus sampled teardown.""" - - _require_pristine_agent_task_profile(profile_dir) - driver_port = _free_loopback_port() - session_id: str | None = None - browser_process_id: int | None = None - browser_process_start_time_ticks: int | None = None - chromium_process_identities: tuple[tuple[int, int], ...] | None = None - browser_version: str | None = None - browser_process_crash_detected = False - driver = subprocess.Popen( - [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], - stdout=subprocess.DEVNULL, - stderr=subprocess.STDOUT, - text=True, - ) - try: - _wait_for_driver(driver_port) - session = _json_request( - driver_port, - "POST", - "/session", - { - "capabilities": { - "alwaysMatch": { - "browserName": "chrome", - "goog:chromeOptions": { - "binary": str(chrome_bin), - "prefs": { - "credentials_enable_service": False, - "profile.password_manager_enabled": False, - }, - "args": [ - "--headless=new", - "--no-first-run", - "--disable-default-apps", - "--disable-component-update", - "--disable-sync", - "--disable-dev-shm-usage", - "--no-sandbox", - "--disable-extensions", - f"--user-data-dir={profile_dir}", - ], - }, - } - } - }, - ).get("value", {}) - if not isinstance(session, dict): - raise RuntimeError("ChromeDriver browser-crash session response is malformed") - raw_session_id = session.get("sessionId") - capabilities = session.get("capabilities", {}) - if not isinstance(raw_session_id, str): - raise RuntimeError("ChromeDriver did not return a browser-crash session id") - if not isinstance(capabilities, dict): - raise RuntimeError("ChromeDriver browser-crash capabilities are malformed") - session_id = _path_token(raw_session_id, "session identifier") - browser_version = capabilities.get("browserVersion") - browser_process_id = capabilities.get("goog:processID") - if browser_version != PINNED_CHROME_VERSION: - raise RuntimeError( - f"unexpected browser-crash Chrome version: expected {PINNED_CHROME_VERSION}, " - f"got {browser_version!r}" - ) - if ( - isinstance(browser_process_id, bool) - or not isinstance(browser_process_id, int) - or browser_process_id <= 0 - ): - raise RuntimeError("ChromeDriver did not return a valid browser-crash process id") - browser_process_identity = _read_linux_proc_stat_process_identity(browser_process_id) - if browser_process_identity is None: - raise RuntimeError("Agent Task browser-crash process identity disappeared") - browser_process_start_time_ticks = browser_process_identity[1] - - _json_request( - driver_port, - "POST", - _webdriver_path(session_id, "/url"), - {"url": fixture_url}, - ) - loaded_url = _json_request( - driver_port, - "GET", - _webdriver_path(session_id, "/url"), - ).get("value") - if loaded_url != fixture_url: - raise RuntimeError("Agent Task browser-crash probe did not load its fixture URL") - - process_evidence = _snapshot_linux_process_evidence() - chromium_process_ids = _discover_linux_process_tree_ids( - browser_process_id, - process_evidence, - ) - chromium_process_identities = _read_linux_process_identity_set( - chromium_process_ids - ) - if not _signal_linux_process_identity(browser_process_identity, signal.SIGKILL): - raise RuntimeError("Agent Task browser process identity changed before crash signal") - - deadline = time.monotonic() + PROCESS_EXIT_TIMEOUT_SECONDS - while True: - try: - _json_request( - driver_port, - "GET", - _webdriver_path(session_id, "/url"), - timeout=1.0, - ) - except (OSError, RuntimeError, json.JSONDecodeError): - browser_process_crash_detected = True - break - remaining_seconds = deadline - time.monotonic() - if remaining_seconds <= 0: - raise RuntimeError("Agent Task browser remained usable after SIGKILL") - time.sleep(min(0.05, remaining_seconds)) - finally: - _cleanup_crashed_browser_session(driver_port, session_id) - _stop_crashed_driver(driver) - - if ( - browser_process_id is None - or browser_process_start_time_ticks is None - or chromium_process_identities is None - or browser_version is None + except ( + OSError, + RuntimeError, + json.JSONDecodeError, + http.client.IncompleteRead, ): - raise RuntimeError("Agent Task browser-crash teardown identities were not captured") - browser_process_terminated, chromium_process_set_terminated = ( - _wait_for_linux_process_teardown( - browser_process_id, - browser_process_start_time_ticks, - chromium_process_identities, - ) - ) - if not browser_process_crash_detected: - raise RuntimeError("Agent Task browser-process crash was not detected") - if not browser_process_terminated: - raise RuntimeError("Agent Task browser-crash root process did not terminate") - if not chromium_process_set_terminated: - raise RuntimeError("Agent Task browser-crash Chromium process set did not terminate") - return { - "browser_version": browser_version, - "browser_process_crash_detected": True, - "browser_process_terminated": True, - "chromium_process_set_terminated": True, - } - - -def _run_agent_task_browser_crash_trial( - chrome_bin: pathlib.Path, - chromedriver_bin: pathlib.Path, - fixture_url: str, - trial_number: int, -) -> dict[str, Any]: - """Run one isolated browser-root crash trial and retain cleanup evidence.""" - - trial_started = time.monotonic() - profile_path: pathlib.Path - result: dict[str, Any] | None = None - failure_type: str | None = None - with tempfile.TemporaryDirectory( - prefix=f"originweave-agent-task-browser-crash-{trial_number}-" - ) as profile_dir: - profile_path = pathlib.Path(profile_dir) - try: - result = _run_agent_task_browser_crash_browser_pass( - chrome_bin, - chromedriver_bin, - fixture_url, - profile_dir, - ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - failure_type = type(exc).__name__ - profile_cleaned = not profile_path.exists() - if not profile_cleaned: - raise RuntimeError( - f"Agent Task browser-crash profile cleanup failed in trial {trial_number}" - ) - - duration_ms = round((time.monotonic() - trial_started) * 1000) - if failure_type is not None: - return { - "trial_number": trial_number, - "passed": False, - "failure_type": failure_type, - "profile_cleaned": True, - "duration_ms": duration_ms, - } - if result is None: - raise RuntimeError("Agent Task browser-crash browser pass returned no result") - return { - "trial_number": trial_number, - "passed": True, - "browser_version": result["browser_version"], - "browser_process_crash_detected": result["browser_process_crash_detected"], - "browser_process_terminated": result["browser_process_terminated"], - "chromium_process_set_terminated": result["chromium_process_set_terminated"], - "profile_cleaned": True, - "duration_ms": duration_ms, - } - - -def _start_fixture_server( - directory: pathlib.Path, -) -> tuple[http.server.ThreadingHTTPServer, threading.Thread]: - """Start one loopback-only static fixture server for a bounded browser lane.""" - - server = http.server.ThreadingHTTPServer( - ("127.0.0.1", 0), - lambda *args, **kwargs: QuietFixtureHandler( - *args, - directory=str(directory), - **kwargs, - ), - ) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - return server, thread - - -def _stop_fixture_server( - server: http.server.ThreadingHTTPServer, - thread: threading.Thread, -) -> None: - """Stop one bounded fixture server and join its helper thread.""" - - server.shutdown() - server.server_close() - thread.join(timeout=5) - - -def main() -> int: - """Run bounded MV3 and Agent Task trials and emit credential-free evidence.""" - - chrome_bin = pathlib.Path(os.environ.get("CHROME_BIN", "")) - chromedriver_bin = pathlib.Path(os.environ.get("CHROMEDRIVER_BIN", "")) - if not chrome_bin.is_file(): - raise SystemExit("CHROME_BIN must point to the pinned Chrome for Testing executable") - if not chromedriver_bin.is_file(): - raise SystemExit("CHROMEDRIVER_BIN must point to the matching pinned ChromeDriver") - if not (FIXTURE / "manifest.json").is_file(): - raise SystemExit("MV3 fixture manifest is missing") - if not (AGENT_TASK_FIXTURE / "index.html").is_file(): - raise SystemExit("Agent Task fixture is missing") - - fixture_server, fixture_thread = _start_fixture_server(FIXTURE) - agent_task_server, agent_task_thread = _start_fixture_server(AGENT_TASK_FIXTURE) - started = time.monotonic() - - try: - fixture_url = f"http://127.0.0.1:{fixture_server.server_port}/page.html" - trial_results: list[dict[str, Any]] = [] - for trial_number in range(1, REPEATABILITY_TRIALS + 1): - try: - trial_results.append( - _run_restart_trial( - chrome_bin, - chromedriver_bin, - fixture_url, - trial_number, - ) - ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - trial_results.append( - { - "trial_number": trial_number, - "passed": False, - "failure_type": type(exc).__name__, - } - ) - - successful_trials = sum( - 1 for trial in trial_results if trial.get("passed") is True - ) - trial_pass_rate = successful_trials / REPEATABILITY_TRIALS - mv3_profiles_cleaned = all( - trial.get("profile_cleaned") is True for trial in trial_results - ) - successful_results = [ - trial for trial in trial_results if trial.get("passed") is True - ] - common_surfaces: dict[str, bool] = {} - if successful_results: - first_surfaces = successful_results[0].get("surfaces", {}) - if isinstance(first_surfaces, dict): - common_surfaces = { - str(name): all( - isinstance(trial.get("surfaces"), dict) - and trial["surfaces"].get(name) is True - for trial in successful_results - ) - for name in first_surfaces - } - - agent_task_url = ( - f"http://127.0.0.1:{agent_task_server.server_port}/index.html" - ) - agent_task_trials: list[dict[str, Any]] = [] - for trial_number in range(1, AGENT_TASK_REPEATABILITY_TRIALS + 1): - try: - agent_task_trials.append( - _run_agent_task_trial( - chrome_bin, - chromedriver_bin, - agent_task_url, - trial_number, - ) - ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - agent_task_trials.append( - { - "trial_number": trial_number, - "passed": False, - "failure_type": type(exc).__name__, - } - ) - - forced_close_trials: list[dict[str, Any]] = [] - for trial_number in range(1, AGENT_TASK_REPEATABILITY_TRIALS + 1): - try: - forced_close_trials.append( - _run_agent_task_forced_close_trial( - chrome_bin, - chromedriver_bin, - agent_task_url, - trial_number, - ) - ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - forced_close_trials.append( - { - "trial_number": trial_number, - "passed": False, - "failure_type": type(exc).__name__, - } - ) - - browser_crash_trials: list[dict[str, Any]] = [] - for trial_number in range(1, AGENT_TASK_REPEATABILITY_TRIALS + 1): - try: - browser_crash_trials.append( - _run_agent_task_browser_crash_trial( - chrome_bin, - chromedriver_bin, - agent_task_url, - trial_number, - ) - ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - browser_crash_trials.append( - { - "trial_number": trial_number, - "passed": False, - "failure_type": type(exc).__name__, - } - ) - - agent_task_successful_trials = sum( - 1 for trial in agent_task_trials if trial.get("passed") is True - ) - agent_task_trial_pass_rate = ( - agent_task_successful_trials / AGENT_TASK_REPEATABILITY_TRIALS - ) - agent_task_profiles_cleaned = all( - trial.get("profile_cleaned") is True for trial in agent_task_trials - ) - agent_task_isolation_complete = all( - trial.get("profile_pristine_before_launch") is True - and trial.get("ambient_cookies_absent") is True - and trial.get("ambient_web_storage_absent") is True - and trial.get("saved_credential_services_disabled") is True - and trial.get("extensions_disabled") is True - and trial.get("profile_cleaned") is True - for trial in agent_task_trials - if trial.get("passed") is True - ) - agent_task_surfaces_complete = all( - trial.get("post_condition") is True - and trial.get("input_echo_verified") is True - and trial.get("url_unchanged") is True - and trial.get("input_semantics_verified") is True - and trial.get("submit_semantics_verified") is True - and trial.get("result_semantics_verified") is True - and trial.get("structured_value_field") == "task_result" - and isinstance(trial.get("structured_value_sha256"), str) - and len(trial["structured_value_sha256"]) == len("sha256:") + 64 - and trial["structured_value_sha256"].startswith("sha256:") - and trial.get("extensions_disabled") is True - and trial.get("profile_cleaned") is True - and trial.get("browser_process_terminated") is True - and trial.get("chromium_process_set_terminated") is True - and isinstance(trial.get("browser_process_rss_bytes"), int) - and trial["browser_process_rss_bytes"] > 0 - and isinstance(trial.get("chromium_process_count"), int) - and 0 < trial["chromium_process_count"] <= MAX_BROWSER_PROCESS_TREE_SIZE - and isinstance(trial.get("chromium_process_set_rss_bytes"), int) - and trial["chromium_process_set_rss_bytes"] > 0 - and isinstance(trial.get("semantic_observation_bytes"), int) - and 0 - < trial["semantic_observation_bytes"] - <= MAX_AGENT_TASK_SEMANTIC_OBSERVATION_BYTES - and isinstance(trial.get("action_latency_ms"), (int, float)) - and trial["action_latency_ms"] > 0 - and isinstance(trial.get("task_duration_ms"), (int, float)) - and trial["task_duration_ms"] >= trial["action_latency_ms"] - for trial in agent_task_trials - if trial.get("passed") is True - ) - forced_close_successful_trials = sum( - 1 for trial in forced_close_trials if trial.get("passed") is True - ) - forced_close_profiles_cleaned = all( - trial.get("profile_cleaned") is True for trial in forced_close_trials - ) - forced_close_surfaces_complete = all( - trial.get("forced_close_detected") is True - and trial.get("session_survived") is True - and trial.get("browser_process_terminated") is True - and trial.get("chromium_process_set_terminated") is True - and trial.get("profile_cleaned") is True - for trial in forced_close_trials - if trial.get("passed") is True - ) - browser_crash_successful_trials = sum( - 1 for trial in browser_crash_trials if trial.get("passed") is True - ) - browser_crash_profiles_cleaned = all( - trial.get("profile_cleaned") is True for trial in browser_crash_trials - ) - browser_crash_surfaces_complete = all( - trial.get("browser_process_crash_detected") is True - and trial.get("browser_process_terminated") is True - and trial.get("chromium_process_set_terminated") is True - and trial.get("profile_cleaned") is True - for trial in browser_crash_trials - if trial.get("passed") is True - ) - - evidence = { - "chrome_version": PINNED_CHROME_VERSION, - "chrome_revision": PINNED_CHROME_REVISION, - "repeatability_trials": REPEATABILITY_TRIALS, - "successful_trials": successful_trials, - "trial_pass_rate": trial_pass_rate, - "profiles_cleaned": mv3_profiles_cleaned, - "surfaces": common_surfaces, - "trial_results": trial_results, - "browser_passes": ( - successful_results[-1].get("browser_passes", []) - if successful_results - else [] - ), - "agent_task": { - "repeatability_trials": AGENT_TASK_REPEATABILITY_TRIALS, - "successful_trials": agent_task_successful_trials, - "trial_pass_rate": agent_task_trial_pass_rate, - "profiles_cleaned": agent_task_profiles_cleaned, - "isolation_complete": agent_task_isolation_complete, - "trial_results": agent_task_trials, - "forced_close": { - "repeatability_trials": AGENT_TASK_REPEATABILITY_TRIALS, - "successful_trials": forced_close_successful_trials, - "profiles_cleaned": forced_close_profiles_cleaned, - "trial_results": forced_close_trials, - }, - "browser_crash": { - "repeatability_trials": AGENT_TASK_REPEATABILITY_TRIALS, - "successful_trials": browser_crash_successful_trials, - "profiles_cleaned": browser_crash_profiles_cleaned, - "trial_results": browser_crash_trials, - }, - }, - "duration_ms": round((time.monotonic() - started) * 1000), - } - print(json.dumps(evidence, sort_keys=True)) - if not mv3_profiles_cleaned: - raise RuntimeError("Manifest V3 profile cleanup gate failed") - if successful_trials != REPEATABILITY_TRIALS: - raise RuntimeError( - "Manifest V3 repeatability gate failed: " - f"{successful_trials}/{REPEATABILITY_TRIALS} trials passed" - ) - if not common_surfaces or not all(common_surfaces.values()): - raise RuntimeError("Manifest V3 repeatability surfaces were incomplete") - if not agent_task_profiles_cleaned: - raise RuntimeError("Agent Task profile cleanup gate failed") - if agent_task_successful_trials != AGENT_TASK_REPEATABILITY_TRIALS: - raise RuntimeError( - "Agent Task repeatability gate failed: " - f"{agent_task_successful_trials}/{AGENT_TASK_REPEATABILITY_TRIALS} " - "trials passed" - ) - if not agent_task_isolation_complete: - raise RuntimeError("Agent Task isolation gate failed") - if not agent_task_surfaces_complete: - raise RuntimeError("Agent Task repeatability surfaces were incomplete") - if not forced_close_profiles_cleaned: - raise RuntimeError("Agent Task forced-close profile cleanup gate failed") - if ( - forced_close_successful_trials != AGENT_TASK_REPEATABILITY_TRIALS - or not forced_close_surfaces_complete - ): - raise RuntimeError( - "Agent Task forced-close recovery gate failed: " - f"{forced_close_successful_trials}/{AGENT_TASK_REPEATABILITY_TRIALS} " - "trials passed" - ) - if not browser_crash_profiles_cleaned: - raise RuntimeError("Agent Task browser-crash profile cleanup gate failed") - if ( - browser_crash_successful_trials != AGENT_TASK_REPEATABILITY_TRIALS - or not browser_crash_surfaces_complete - ): - raise RuntimeError( - "Agent Task browser-crash recovery gate failed: " - f"{browser_crash_successful_trials}/{AGENT_TASK_REPEATABILITY_TRIALS} " - "trials passed" - ) - return 0 - finally: - _stop_fixture_server(agent_task_server, agent_task_thread) - _stop_fixture_server(fixture_server, fixture_thread) - - -if __name__ == "__main__": - raise SystemExit(main()) + return From cb49d4c6c26b32e2bdab5634622624b47a6ca8f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 02:20:15 -0700 Subject: [PATCH 20/50] fix(browser): restore crash runner after bounded cleanup patch attempt --- scripts/ci/run_mv3_compatibility.py | 2371 ++++++++++++++++++++++++++- 1 file changed, 2365 insertions(+), 6 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 2ed0d7ee7..d96369f29 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -1,3 +1,1819 @@ +#!/usr/bin/env python3 +"""Run bounded repeatable real-browser evidence against pinned Chromium. + +This is a release/CI evidence runner, not a product browser adapter. It uses the +W3C WebDriver HTTP protocol only to prove that a real Chrome for Testing build +can load the controlled MV3 fixture and repeatedly exercise service-worker, +content-script, storage, declarative-net-request, tabs, windows, scripting, +commands, side-panel, bookmarks, history, real-browser-click, and +restart-persistence behavior. It also executes the controlled Agent Task fixture +with extensions disabled in a fresh profile, locates the controlled action +targets by exact browser-computed role/name evidence, performs real WebDriver +input and click operations, verifies the observable post-condition, proves the +controlled action preserves its loaded URL, and records bounded runtime resource +evidence without treating page content as instruction or authority. +""" + +from __future__ import annotations + +import contextlib +import hashlib +import http.client +import http.server +import json +import math +import os +import pathlib +import signal +import socket +import string +import subprocess +import tempfile +import threading +import time +from typing import Any + +ROOT = pathlib.Path(__file__).resolve().parents[2] +FIXTURE = ROOT / "tests" / "fixtures" / "mv3_basic" +AGENT_TASK_FIXTURE = ROOT / "tests/fixtures/agent_task_basic" +PINNED_CHROME_VERSION = "150.0.7871.129" +PINNED_CHROME_REVISION = "r1639810" +REPEATABILITY_TRIALS = 3 +AGENT_TASK_REPEATABILITY_TRIALS = 3 +AGENT_TASK_INPUT_VALUE = "originweave controlled input" +REQUEST_TIMEOUT_SECONDS = 5.0 +STARTUP_TIMEOUT_SECONDS = 20.0 +FIXTURE_TIMEOUT_SECONDS = 20.0 +PROCESS_EXIT_TIMEOUT_SECONDS = 5.0 +MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576 +MAX_PROC_STATUS_CHARACTERS = 65_536 +MAX_PROC_STAT_CHARACTERS = 65_536 +MAX_BROWSER_PROCESS_TREE_SIZE = 256 +MAX_PROC_PROCESS_SCAN_SIZE = 32_768 +MAX_SEMANTIC_LOCATOR_CANDIDATES = 128 +MAX_AGENT_TASK_STRUCTURED_VALUE_BYTES = 4_096 +MAX_AGENT_TASK_SEMANTIC_OBSERVATION_BYTES = 4_096 +MAX_U64 = (1 << 64) - 1 +W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" +PATH_TOKEN_CHARACTERS = frozenset(string.ascii_letters + string.digits + "-_.") + + +class QuietFixtureHandler(http.server.SimpleHTTPRequestHandler): + """Serve only the controlled local fixture without noisy access logging.""" + + def log_message(self, _format: str, *args: object) -> None: + """Suppress request logs because the fixture contains no diagnostic value.""" + + +def _free_loopback_port() -> int: + """Reserve and release one loopback TCP port for a short-lived local service.""" + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _path_token(value: str, label: str) -> str: + """Validate one ChromeDriver-issued identifier before interpolating a path.""" + + if ( + not value + or len(value) > 256 + or value in {".", ".."} + or any(char not in PATH_TOKEN_CHARACTERS for char in value) + ): + raise RuntimeError(f"invalid WebDriver {label}") + return value + + +def _webdriver_path(session_id: str, suffix: str) -> str: + """Build a bounded ChromeDriver path from a validated session identifier.""" + + safe_session = _path_token(session_id, "session identifier") + if suffix and not suffix.startswith("/"): + raise RuntimeError("invalid WebDriver path suffix") + if "://" in suffix or any(char in suffix for char in "\r\n"): + raise RuntimeError("invalid WebDriver path suffix") + return f"/session/{safe_session}{suffix}" + + +def _json_request( + driver_port: int, + method: str, + path: str, + payload: dict[str, Any] | None = None, + *, + timeout: float = REQUEST_TIMEOUT_SECONDS, +) -> dict[str, Any]: + """Issue one bounded JSON request to the fixed loopback ChromeDriver authority.""" + + if not 1 <= driver_port <= 65_535: + raise ValueError("invalid ChromeDriver port") + if method not in {"GET", "POST", "DELETE"}: + raise ValueError("unsupported ChromeDriver method") + if not path.startswith("/") or "://" in path or any(char in path for char in "\r\n"): + raise ValueError("invalid ChromeDriver path") + + body = None if payload is None else json.dumps(payload).encode("utf-8") + connection = http.client.HTTPConnection("127.0.0.1", driver_port, timeout=timeout) + try: + connection.request( + method, + path, + body=body, + headers={"Content-Type": "application/json"}, + ) + response = connection.getresponse() + raw = response.read(MAX_WEBDRIVER_RESPONSE_BYTES + 1) + if len(raw) > MAX_WEBDRIVER_RESPONSE_BYTES: + raise RuntimeError("WebDriver response exceeded the bounded JSON limit") + if response.status >= 400: + detail = raw.decode("utf-8", errors="replace") + raise RuntimeError(f"WebDriver HTTP {response.status}: {detail}") + finally: + connection.close() + + decoded = json.loads(raw.decode("utf-8")) + if not isinstance(decoded, dict): + raise RuntimeError("WebDriver returned a non-object JSON payload") + value = decoded.get("value") + if isinstance(value, dict) and value.get("error"): + raise RuntimeError(f"WebDriver error: {value.get('error')}: {value.get('message')}") + return decoded + + +def _wait_for_driver(driver_port: int) -> None: + """Wait for the exact local ChromeDriver process to become ready.""" + + deadline = time.monotonic() + STARTUP_TIMEOUT_SECONDS + last_error: Exception | None = None + while time.monotonic() < deadline: + try: + status = _json_request(driver_port, "GET", "/status", timeout=1.0) + if status.get("value", {}).get("ready") is True: + return + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + last_error = exc + time.sleep(0.1) + raise RuntimeError(f"ChromeDriver did not become ready: {last_error}") + + +def _execute(driver_port: int, session_id: str, script: str) -> Any: + """Run fixture-only JavaScript through the test WebDriver session.""" + + response = _json_request( + driver_port, + "POST", + _webdriver_path(session_id, "/execute/sync"), + {"script": script, "args": []}, + ) + return response.get("value") + + +def _find_element(driver_port: int, session_id: str, selector: str) -> str: + """Find one fixture element and return its validated ChromeDriver identifier.""" + + found = _json_request( + driver_port, + "POST", + _webdriver_path(session_id, "/element"), + {"using": "css selector", "value": selector}, + ) + element = found.get("value", {}) + element_id = element.get(W3C_ELEMENT_KEY) if isinstance(element, dict) else None + if not isinstance(element_id, str): + raise RuntimeError("WebDriver did not return a W3C element identifier") + return _path_token(element_id, "element identifier") + + +def _element_command_path(session_id: str, element_id: str, suffix: str) -> str: + """Build a bounded WebDriver element command path from validated identifiers.""" + + safe_element = _path_token(element_id, "element identifier") + return _webdriver_path(session_id, f"/element/{safe_element}{suffix}") + + +def _get_element_semantics( + driver_port: int, + session_id: str, + element_id: str, +) -> tuple[str, str]: + """Read one controlled element's browser-computed role and accessible name.""" + + role = _json_request( + driver_port, + "GET", + _element_command_path(session_id, element_id, "/computedrole"), + ).get("value") + label = _json_request( + driver_port, + "GET", + _element_command_path(session_id, element_id, "/computedlabel"), + ).get("value") + if not isinstance(role, str) or not isinstance(label, str): + raise RuntimeError("WebDriver returned malformed element semantics") + return role, label + + +def _find_element_by_accessible_role_name( + driver_port: int, + session_id: str, + role: str, + accessible_name: str, +) -> str: + """Find exactly one controlled element by browser-computed role and name.""" + + found = _json_request( + driver_port, + "POST", + _webdriver_path(session_id, "/elements"), + {"using": "css selector", "value": "*"}, + ) + elements = found.get("value") + if not isinstance(elements, list): + raise RuntimeError("WebDriver did not return a semantic locator candidate list") + if len(elements) > MAX_SEMANTIC_LOCATOR_CANDIDATES: + raise RuntimeError("semantic locator exceeded bounded candidate limit") + + matches: list[str] = [] + for element in elements: + element_id = element.get(W3C_ELEMENT_KEY) if isinstance(element, dict) else None + if not isinstance(element_id, str): + raise RuntimeError("WebDriver returned malformed semantic locator candidate") + safe_element = _path_token(element_id, "element identifier") + candidate_role, candidate_name = _get_element_semantics( + driver_port, + session_id, + safe_element, + ) + if candidate_role == role and candidate_name == accessible_name: + matches.append(safe_element) + if len(matches) > 1: + raise RuntimeError("semantic locator returned multiple exact matches") + + if not matches: + raise RuntimeError("semantic locator returned no exact match") + return matches[0] + + +def _hash_agent_task_structured_value(value: str) -> str: + """Hash one bounded extracted text value without retaining the raw value in evidence.""" + + if not isinstance(value, str): + raise TypeError("Agent Task structured value must be text") + encoded = value.encode("utf-8") + if not encoded: + raise ValueError("Agent Task structured value must not be empty") + if len(encoded) > MAX_AGENT_TASK_STRUCTURED_VALUE_BYTES: + raise ValueError("Agent Task structured value exceeded the bounded text contract") + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + +def _measure_agent_task_semantic_observation_bytes(observation: dict[str, Any]) -> int: + """Measure one non-empty semantic observation under the canonical evidence bound.""" + + if not isinstance(observation, dict): + raise TypeError("Agent Task semantic observation must be an object") + if not observation: + raise ValueError("Agent Task semantic observation must not be empty") + encoded = json.dumps( + observation, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + if len(encoded) > MAX_AGENT_TASK_SEMANTIC_OBSERVATION_BYTES: + raise ValueError("Agent Task semantic observation exceeded the bounded evidence contract") + return len(encoded) + + +def _require_pristine_agent_task_profile(profile_dir: str) -> None: + """Fail closed unless the controlled Agent Task profile directory is empty.""" + + profile_path = pathlib.Path(profile_dir) + if not profile_path.is_dir() or any(profile_path.iterdir()): + raise RuntimeError("Agent Task profile is not pristine before launch") + + +def _probe_agent_task_ambient_state(driver_port: int, session_id: str) -> dict[str, bool]: + """Require no browser-visible cookies or Web Storage before the controlled action.""" + + cookies = _json_request( + driver_port, + "GET", + _webdriver_path(session_id, "/cookie"), + ).get("value") + if not isinstance(cookies, list): + raise RuntimeError("Agent Task cookie inspection returned malformed evidence") + if cookies: + raise RuntimeError("Agent Task profile exposed ambient cookies") + + storage = _execute( + driver_port, + session_id, + """ +return { + localStorageLength: window.localStorage.length, + sessionStorageLength: window.sessionStorage.length +}; +""", + ) + if not isinstance(storage, dict): + raise RuntimeError("Agent Task Web Storage inspection returned malformed evidence") + local_storage_length = storage.get("localStorageLength") + session_storage_length = storage.get("sessionStorageLength") + for value in (local_storage_length, session_storage_length): + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise RuntimeError("Agent Task Web Storage inspection returned malformed evidence") + if local_storage_length or session_storage_length: + raise RuntimeError("Agent Task profile exposed ambient Web Storage") + return { + "ambient_cookies_absent": True, + "ambient_web_storage_absent": True, + } + + +def _parse_linux_proc_status_rss_bytes(status_text: str) -> int: + """Parse exactly one positive Linux ``VmRSS`` kB field into bounded bytes.""" + + rss_values: list[int] = [] + for line in status_text.splitlines(): + if not line.startswith("VmRSS:"): + continue + fields = line.split() + if len(fields) != 3 or fields[0] != "VmRSS:" or fields[2] != "kB": + raise ValueError("malformed Linux VmRSS field") + raw_kibibytes = fields[1] + if not raw_kibibytes.isascii() or not raw_kibibytes.isdigit(): + raise ValueError("malformed Linux VmRSS value") + kibibytes = int(raw_kibibytes, 10) + if kibibytes <= 0: + raise ValueError("Linux VmRSS must be positive") + if kibibytes > MAX_U64 // 1024: + raise OverflowError("Linux VmRSS exceeds u64 byte range") + rss_values.append(kibibytes * 1024) + if len(rss_values) != 1: + raise ValueError("Linux proc status must contain exactly one VmRSS field") + return rss_values[0] + + +def _parse_linux_proc_status_optional_rss_bytes(status_text: str) -> int | None: + """Parse optional Linux ``VmRSS`` without normalizing malformed evidence.""" + + rss_lines = [line for line in status_text.splitlines() if line.startswith("VmRSS:")] + if not rss_lines: + return None + if len(rss_lines) != 1: + raise ValueError("Linux proc status must contain at most one VmRSS field") + + fields = rss_lines[0].split() + if len(fields) != 3 or fields[0] != "VmRSS:" or fields[2] != "kB": + raise ValueError("malformed Linux VmRSS field") + raw_kibibytes = fields[1] + if not raw_kibibytes.isascii() or not raw_kibibytes.isdigit(): + raise ValueError("malformed Linux VmRSS value") + kibibytes = int(raw_kibibytes, 10) + if kibibytes == 0: + return None + if kibibytes > MAX_U64 // 1024: + raise OverflowError("Linux VmRSS exceeds u64 byte range") + return kibibytes * 1024 + + +def _parse_linux_proc_status_process_identity(status_text: str) -> tuple[int, int]: + """Parse exactly one positive ``Pid`` and one non-negative ``PPid`` from status.""" + + parsed: dict[str, int] = {} + for line in status_text.splitlines(): + if not (line.startswith("Pid:") or line.startswith("PPid:")): + continue + fields = line.split() + if len(fields) != 2 or fields[0] not in {"Pid:", "PPid:"}: + raise ValueError("malformed Linux process identity field") + label = fields[0] + if label in parsed: + raise ValueError("duplicate Linux process identity field") + raw_process_id = fields[1] + if not raw_process_id.isascii() or not raw_process_id.isdigit(): + raise ValueError("malformed Linux process identity value") + parsed[label] = int(raw_process_id, 10) + + if set(parsed) != {"Pid:", "PPid:"}: + raise ValueError("Linux proc status must contain exactly one Pid and PPid") + process_id = parsed["Pid:"] + parent_process_id = parsed["PPid:"] + if process_id <= 0: + raise ValueError("Linux process identifier must be positive") + if parent_process_id < 0: + raise ValueError("Linux parent process identifier must be non-negative") + return process_id, parent_process_id + + +def _parse_linux_proc_stat_process_identity(stat_text: str) -> tuple[int, int]: + """Parse one Linux proc-stat PID/start-time identity without trusting ``comm`` text.""" + + if not isinstance(stat_text, str) or not stat_text: + raise ValueError("Linux proc stat must be non-empty text") + command_open = stat_text.find(" (") + command_close = stat_text.rfind(") ") + if command_open <= 0 or command_close <= command_open + 2: + raise ValueError("malformed Linux proc stat process identity") + + raw_process_id = stat_text[:command_open] + if not raw_process_id.isascii() or not raw_process_id.isdigit(): + raise ValueError("malformed Linux proc stat process identifier") + process_id = int(raw_process_id, 10) + if process_id <= 0: + raise ValueError("Linux proc stat process identifier must be positive") + + command_text = stat_text[command_open + 2 : command_close] + if not command_text: + raise ValueError("Linux proc stat command must not be empty") + suffix_fields = stat_text[command_close + 2 :].split() + if len(suffix_fields) < 20 or len(suffix_fields[0]) != 1: + raise ValueError("Linux proc stat does not contain field 22 start time") + for raw_field in suffix_fields[1:]: + unsigned_field = raw_field[1:] if raw_field[:1] in {"+", "-"} else raw_field + if not unsigned_field or not unsigned_field.isascii() or not unsigned_field.isdigit(): + raise ValueError("malformed Linux proc stat numeric field") + + raw_start_time_ticks = suffix_fields[19] + if not raw_start_time_ticks.isascii() or not raw_start_time_ticks.isdigit(): + raise ValueError("malformed Linux proc stat start time") + start_time_ticks = int(raw_start_time_ticks, 10) + if start_time_ticks <= 0: + raise ValueError("Linux proc stat start time must be positive") + if start_time_ticks > MAX_U64: + raise OverflowError("Linux proc stat start time exceeds u64 range") + return process_id, start_time_ticks + + +def _read_linux_proc_stat_process_identity(process_id: int) -> tuple[int, int] | None: + """Read one bounded Linux PID/start-time identity, returning absence after exit.""" + + if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: + raise ValueError("invalid Linux process identifier") + stat_path = pathlib.Path("/proc") / str(process_id) / "stat" + try: + with stat_path.open("r", encoding="utf-8", errors="strict") as stat_file: + stat_text = stat_file.read(MAX_PROC_STAT_CHARACTERS + 1) + except (FileNotFoundError, ProcessLookupError): + return None + if len(stat_text) > MAX_PROC_STAT_CHARACTERS: + raise RuntimeError("Linux proc stat exceeded the bounded text limit") + identity = _parse_linux_proc_stat_process_identity(stat_text) + if identity[0] != process_id: + raise RuntimeError("Linux proc stat identity did not match its directory") + return identity + + +def _signal_linux_process_identity( + process_identity: tuple[int, int], + signal_number: int, +) -> bool: + """Signal only one exact Linux PID/start-time identity through a pidfd.""" + + if not isinstance(process_identity, tuple) or len(process_identity) != 2: + raise ValueError("invalid Linux process identity") + process_id, start_time_ticks = process_identity + if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: + raise ValueError("invalid Linux process identifier") + if ( + isinstance(start_time_ticks, bool) + or not isinstance(start_time_ticks, int) + or start_time_ticks <= 0 + ): + raise ValueError("invalid Linux process start time") + if isinstance(signal_number, bool) or not isinstance(signal_number, int) or signal_number <= 0: + raise ValueError("invalid Linux process signal") + + expected_identity = (process_id, start_time_ticks) + if _read_linux_proc_stat_process_identity(process_id) != expected_identity: + return False + pidfd_open = getattr(os, "pidfd_open", None) + pidfd_send_signal = getattr(signal, "pidfd_send_signal", None) + if not callable(pidfd_open) or not callable(pidfd_send_signal): + raise RuntimeError("Linux pidfd signalling is unavailable") + try: + pidfd = pidfd_open(process_id, 0) + except ProcessLookupError: + return False + try: + if _read_linux_proc_stat_process_identity(process_id) != expected_identity: + return False + try: + pidfd_send_signal(pidfd, signal_number) + except ProcessLookupError: + return False + return True + finally: + os.close(pidfd) + + +def _wait_for_linux_process_identity_exit( + process_id: int, + start_time_ticks: int, + *, + timeout_seconds: float = PROCESS_EXIT_TIMEOUT_SECONDS, +) -> bool: + """Wait boundedly until the exact PID/start-time identity exits or is reused.""" + + if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: + raise ValueError("invalid Linux process identifier") + if ( + isinstance(start_time_ticks, bool) + or not isinstance(start_time_ticks, int) + or start_time_ticks <= 0 + ): + raise ValueError("invalid Linux process start time") + if ( + isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, (int, float)) + or timeout_seconds < 0 + or not math.isfinite(timeout_seconds) + ): + raise ValueError("invalid Linux process-exit timeout") + + deadline = time.monotonic() + float(timeout_seconds) + expected_identity = (process_id, start_time_ticks) + while True: + current_identity = _read_linux_proc_stat_process_identity(process_id) + if current_identity is None or current_identity != expected_identity: + return True + remaining_seconds = deadline - time.monotonic() + if remaining_seconds <= 0: + return False + time.sleep(min(0.05, remaining_seconds)) + + +def _read_linux_process_identity_set( + process_ids: tuple[int, ...], +) -> tuple[tuple[int, int], ...]: + """Bind one bounded sampled process set to exact Linux PID/start-time identities.""" + + if not process_ids or len(process_ids) > MAX_BROWSER_PROCESS_TREE_SIZE: + raise ValueError("invalid Linux process identity-set size") + if len(set(process_ids)) != len(process_ids): + raise ValueError("Linux process identity-set PIDs must be unique") + + identities: list[tuple[int, int]] = [] + for process_id in process_ids: + if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: + raise ValueError("invalid Linux process identifier") + identity = _read_linux_proc_stat_process_identity(process_id) + if identity is None: + raise RuntimeError("Linux Chromium process disappeared before shutdown identity capture") + identities.append(identity) + return tuple(identities) + + +def _wait_for_linux_process_identity_set_exit( + process_identities: tuple[tuple[int, int], ...], + *, + timeout_seconds: float = PROCESS_EXIT_TIMEOUT_SECONDS, +) -> bool: + """Wait under one shared deadline for every exact sampled process identity to exit.""" + + if not process_identities or len(process_identities) > MAX_BROWSER_PROCESS_TREE_SIZE: + raise ValueError("invalid Linux process identity-set size") + process_ids: list[int] = [] + expected: dict[int, tuple[int, int]] = {} + for identity in process_identities: + if not isinstance(identity, tuple) or len(identity) != 2: + raise ValueError("invalid Linux process identity") + process_id, start_time_ticks = identity + if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: + raise ValueError("invalid Linux process identifier") + if ( + isinstance(start_time_ticks, bool) + or not isinstance(start_time_ticks, int) + or start_time_ticks <= 0 + ): + raise ValueError("invalid Linux process start time") + if process_id in expected: + raise ValueError("Linux process identity-set PIDs must be unique") + process_ids.append(process_id) + expected[process_id] = identity + if ( + isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, (int, float)) + or timeout_seconds < 0 + or not math.isfinite(timeout_seconds) + ): + raise ValueError("invalid Linux process-set exit timeout") + + deadline = time.monotonic() + float(timeout_seconds) + while True: + live_identity_found = False + for process_id in process_ids: + current_identity = _read_linux_proc_stat_process_identity(process_id) + if current_identity == expected[process_id]: + live_identity_found = True + if not live_identity_found: + return True + remaining_seconds = deadline - time.monotonic() + if remaining_seconds <= 0: + return False + time.sleep(min(0.05, remaining_seconds)) + + +def _wait_for_linux_process_teardown( + root_process_id: int, + root_start_time_ticks: int, + process_identities: tuple[tuple[int, int], ...], + *, + timeout_seconds: float = PROCESS_EXIT_TIMEOUT_SECONDS, +) -> tuple[bool, bool]: + """Observe root and sampled-set termination under one monotonic deadline.""" + + if isinstance(root_process_id, bool) or not isinstance(root_process_id, int) or root_process_id <= 0: + raise ValueError("invalid Linux root process identifier") + if ( + isinstance(root_start_time_ticks, bool) + or not isinstance(root_start_time_ticks, int) + or root_start_time_ticks <= 0 + ): + raise ValueError("invalid Linux root process start time") + if not process_identities or len(process_identities) > MAX_BROWSER_PROCESS_TREE_SIZE: + raise ValueError("invalid Linux process identity-set size") + + expected: dict[int, tuple[int, int]] = {} + for identity in process_identities: + if not isinstance(identity, tuple) or len(identity) != 2: + raise ValueError("invalid Linux process identity") + process_id, start_time_ticks = identity + if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: + raise ValueError("invalid Linux process identifier") + if ( + isinstance(start_time_ticks, bool) + or not isinstance(start_time_ticks, int) + or start_time_ticks <= 0 + ): + raise ValueError("invalid Linux process start time") + if process_id in expected: + raise ValueError("Linux process identity-set PIDs must be unique") + expected[process_id] = identity + + root_identity = (root_process_id, root_start_time_ticks) + if expected.get(root_process_id) != root_identity: + raise ValueError("Linux root process identity must belong to the sampled process set") + if ( + isinstance(timeout_seconds, bool) + or not isinstance(timeout_seconds, (int, float)) + or timeout_seconds < 0 + or not math.isfinite(timeout_seconds) + ): + raise ValueError("invalid Linux process-teardown timeout") + + deadline = time.monotonic() + float(timeout_seconds) + while True: + live_process_ids: set[int] = set() + for process_id, expected_identity in expected.items(): + current_identity = _read_linux_proc_stat_process_identity(process_id) + if current_identity == expected_identity: + live_process_ids.add(process_id) + + root_terminated = root_process_id not in live_process_ids + process_set_terminated = not live_process_ids + if process_set_terminated: + return root_terminated, True + + remaining_seconds = deadline - time.monotonic() + if remaining_seconds <= 0: + return root_terminated, False + time.sleep(min(0.05, remaining_seconds)) + + +def _sample_linux_process_rss_bytes(process_id: int) -> int: + """Read one attributed Linux process RSS through a bounded ``/proc`` status file.""" + + if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: + raise ValueError("invalid Linux process identifier") + status_path = pathlib.Path("/proc") / str(process_id) / "status" + with status_path.open("r", encoding="utf-8", errors="strict") as status_file: + status_text = status_file.read(MAX_PROC_STATUS_CHARACTERS + 1) + if len(status_text) > MAX_PROC_STATUS_CHARACTERS: + raise RuntimeError("Linux proc status exceeded the bounded text limit") + return _parse_linux_proc_status_rss_bytes(status_text) + + +def _snapshot_linux_process_evidence() -> dict[int, tuple[int, int | None]]: + """Capture one bounded best-effort PID/PPID/RSS sweep from Linux proc status.""" + + proc_root = pathlib.Path("/proc") + process_entries: list[tuple[int, pathlib.Path]] = [] + for entry in proc_root.iterdir(): + raw_process_id = entry.name + if not raw_process_id.isascii() or not raw_process_id.isdigit(): + continue + process_id = int(raw_process_id, 10) + if process_id <= 0: + continue + process_entries.append((process_id, entry)) + if len(process_entries) > MAX_PROC_PROCESS_SCAN_SIZE: + raise RuntimeError("Linux proc process scan exceeded the bounded entry limit") + + process_evidence: dict[int, tuple[int, int | None]] = {} + for expected_process_id, entry in sorted(process_entries): + status_path = entry / "status" + try: + with status_path.open("r", encoding="utf-8", errors="strict") as status_file: + status_text = status_file.read(MAX_PROC_STATUS_CHARACTERS + 1) + except FileNotFoundError: + continue + if len(status_text) > MAX_PROC_STATUS_CHARACTERS: + raise RuntimeError("Linux proc status exceeded the bounded text limit") + process_id, parent_process_id = _parse_linux_proc_status_process_identity( + status_text + ) + if process_id != expected_process_id: + raise RuntimeError("Linux proc status identity did not match its directory") + if process_id in process_evidence: + raise RuntimeError("Linux proc process snapshot contained a duplicate PID") + rss_bytes = _parse_linux_proc_status_optional_rss_bytes(status_text) + process_evidence[process_id] = (parent_process_id, rss_bytes) + return process_evidence + + +def _discover_linux_process_tree_ids( + root_process_id: int, + process_evidence: dict[int, tuple[int, int | None]], +) -> tuple[int, ...]: + """Discover one bounded root-plus-descendant set from sampled process evidence.""" + + if ( + isinstance(root_process_id, bool) + or not isinstance(root_process_id, int) + or root_process_id <= 0 + ): + raise ValueError("invalid Linux root process identifier") + if root_process_id not in process_evidence: + raise RuntimeError("Linux process snapshot did not contain the browser root PID") + + discovered = [root_process_id] + known = {root_process_id} + while True: + children = sorted( + process_id + for process_id, (parent_process_id, _rss_bytes) in process_evidence.items() + if parent_process_id in known and process_id not in known + ) + if not children: + break + for process_id in children: + if len(known) >= MAX_BROWSER_PROCESS_TREE_SIZE: + raise ValueError("Linux process tree exceeded the bounded process-tree size") + known.add(process_id) + discovered.append(process_id) + return tuple(discovered) + + +def _sample_linux_process_set_rss_bytes( + process_ids: tuple[int, ...], + process_evidence: dict[int, tuple[int, int | None]], +) -> int: + """Sum resident RSS for one exact bounded process set without overflow.""" + + if not process_ids or len(process_ids) > MAX_BROWSER_PROCESS_TREE_SIZE: + raise ValueError("invalid Linux process set size") + if len(set(process_ids)) != len(process_ids): + raise ValueError("Linux process set identifiers must be unique") + + total_rss_bytes = 0 + for process_id in process_ids: + if isinstance(process_id, bool) or not isinstance(process_id, int) or process_id <= 0: + raise ValueError("invalid Linux process identifier") + if process_id not in process_evidence: + raise ValueError("Linux process set was not present in the sampled evidence") + rss_bytes = process_evidence[process_id][1] + if rss_bytes is None: + continue + if isinstance(rss_bytes, bool) or not isinstance(rss_bytes, int) or rss_bytes <= 0: + raise ValueError("Linux process set contained invalid sampled RSS") + if rss_bytes > MAX_U64 - total_rss_bytes: + raise OverflowError("Linux process-set RSS exceeds u64 byte range") + total_rss_bytes += rss_bytes + return total_rss_bytes + + +def _wait_for_extension_evidence( + driver_port: int, + session_id: str, + expected_storage_persistence: str, +) -> dict[str, str]: + """Wait until every controlled MV3 fixture surface reports its expected result.""" + + if expected_storage_persistence not in {"initialized", "persisted"}: + raise ValueError("invalid storage persistence expectation") + script = """ +return { + content: document.documentElement.dataset.originweaveContentScript || "missing", + storage: document.documentElement.dataset.originweaveStorage || "missing", + storagePersistence: + document.documentElement.dataset.originweaveStoragePersistence || "missing", + workerReply: document.documentElement.dataset.originweaveWorkerReply || "missing", + workerState: document.documentElement.dataset.originweaveWorkerState || "missing", + workerStartCount: + document.documentElement.dataset.originweaveWorkerStartCount || "missing", + dnr: document.documentElement.dataset.originweaveDnr || "missing", + tabs: document.documentElement.dataset.originweaveTabs || "missing", + windows: document.documentElement.dataset.originweaveWindows || "missing", + scripting: document.documentElement.dataset.originweaveScripting || "missing", + scriptingExecuted: + document.documentElement.dataset.originweaveScriptingExecuted || "missing", + commands: document.documentElement.dataset.originweaveCommands || "missing", + sidePanel: document.documentElement.dataset.originweaveSidePanel || "missing", + bookmarks: document.documentElement.dataset.originweaveBookmarks || "missing", + history: document.documentElement.dataset.originweaveHistory || "missing" +}; +""" + expected = { + "content": "ready", + "storage": "ready", + "storagePersistence": expected_storage_persistence, + "workerReply": "pong", + "workerState": "installed", + "dnr": "blocked", + "tabs": "ready", + "windows": "ready", + "scripting": "ready", + "scriptingExecuted": "ready", + "commands": "ready", + "sidePanel": "ready", + "bookmarks": "ready", + "history": "ready", + } + deadline = time.monotonic() + FIXTURE_TIMEOUT_SECONDS + latest: dict[str, str] = {} + while time.monotonic() < deadline: + value = _execute(driver_port, session_id, script) + if isinstance(value, dict): + latest = {str(key): str(item) for key, item in value.items()} + try: + worker_start_count = int(latest.get("workerStartCount", "0")) + except ValueError: + worker_start_count = 0 + if worker_start_count > 0 and all( + latest.get(key) == item for key, item in expected.items() + ): + return latest + time.sleep(0.1) + raise RuntimeError( + f"MV3 fixture did not converge: expected={expected!r}, observed={latest!r}" + ) + + +def _exercise_real_click(driver_port: int, session_id: str) -> str: + """Use the WebDriver element-click command and verify the DOM post-condition.""" + + safe_element = _find_element(driver_port, session_id, "#fixture-button") + _json_request( + driver_port, + "POST", + _element_command_path(session_id, safe_element, "/click"), + {}, + ) + safe_output = _find_element(driver_port, session_id, "#fixture-output") + text = _json_request( + driver_port, + "GET", + _element_command_path(session_id, safe_output, "/text"), + ).get("value") + if text != "clicked": + raise RuntimeError(f"real click post-condition failed: {text!r}") + return str(text) + + +def _run_browser_pass( + chrome_bin: pathlib.Path, + chromedriver_bin: pathlib.Path, + fixture_url: str, + profile_dir: str, + expected_storage_persistence: str, +) -> dict[str, Any]: + """Run one fresh browser process against a shared bounded compatibility profile.""" + + driver_port = _free_loopback_port() + session_id: str | None = None + driver = subprocess.Popen( + [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, + text=True, + ) + try: + _wait_for_driver(driver_port) + session = _json_request( + driver_port, + "POST", + "/session", + { + "capabilities": { + "alwaysMatch": { + "browserName": "chrome", + "goog:chromeOptions": { + "binary": str(chrome_bin), + "args": [ + "--headless=new", + "--no-first-run", + "--disable-default-apps", + "--disable-component-update", + "--disable-sync", + "--disable-dev-shm-usage", + "--no-sandbox", + f"--user-data-dir={profile_dir}", + f"--disable-extensions-except={FIXTURE}", + f"--load-extension={FIXTURE}", + ], + }, + } + } + }, + ).get("value", {}) + if not isinstance(session, dict): + raise RuntimeError("ChromeDriver session response is malformed") + raw_session_id = session.get("sessionId") + capabilities = session.get("capabilities", {}) + if not isinstance(raw_session_id, str): + raise RuntimeError("ChromeDriver did not return a session id") + session_id = _path_token(raw_session_id, "session identifier") + browser_version = ( + capabilities.get("browserVersion") if isinstance(capabilities, dict) else None + ) + if browser_version != PINNED_CHROME_VERSION: + raise RuntimeError( + f"unexpected Chrome version: expected {PINNED_CHROME_VERSION}, " + f"got {browser_version!r}" + ) + + _json_request( + driver_port, + "POST", + _webdriver_path(session_id, "/url"), + {"url": fixture_url}, + ) + surfaces = _wait_for_extension_evidence( + driver_port, + session_id, + expected_storage_persistence, + ) + click_result = _exercise_real_click(driver_port, session_id) + worker_start_count = int(surfaces["workerStartCount"]) + return { + "browser_version": browser_version, + "worker_start_count": worker_start_count, + "storage_persistence": surfaces["storagePersistence"], + "surfaces": { + "service-worker": surfaces["workerReply"] == "pong", + "content-script": surfaces["content"] == "ready", + "storage": surfaces["storage"] == "ready", + "declarative-net-request": surfaces["dnr"] == "blocked", + "tabs": surfaces["tabs"] == "ready", + "windows": surfaces["windows"] == "ready", + "scripting": surfaces["scripting"] == "ready" + and surfaces["scriptingExecuted"] == "ready", + "commands": surfaces["commands"] == "ready", + "side-panel": surfaces["sidePanel"] == "ready", + "bookmarks": surfaces["bookmarks"] == "ready", + "history": surfaces["history"] == "ready", + "real-browser-click": click_result == "clicked", + }, + } + finally: + if session_id is not None: + with contextlib.suppress(Exception): + _json_request( + driver_port, + "DELETE", + _webdriver_path(session_id, ""), + {}, + ) + driver.terminate() + try: + driver.wait(timeout=5) + except subprocess.TimeoutExpired: + driver.kill() + driver.wait(timeout=5) + + +def _run_restart_trial( + chrome_bin: pathlib.Path, + chromedriver_bin: pathlib.Path, + fixture_url: str, + trial_number: int, +) -> dict[str, Any]: + """Run one independent initial/restart pair and retain cleanup evidence on failure.""" + + trial_started = time.monotonic() + profile_path: pathlib.Path + initial: dict[str, Any] | None = None + restarted: dict[str, Any] | None = None + failure_type: str | None = None + with tempfile.TemporaryDirectory( + prefix=f"originweave-mv3-trial-{trial_number}-" + ) as profile_dir: + profile_path = pathlib.Path(profile_dir) + try: + initial = _run_browser_pass( + chrome_bin, + chromedriver_bin, + fixture_url, + profile_dir, + "initialized", + ) + restarted = _run_browser_pass( + chrome_bin, + chromedriver_bin, + fixture_url, + profile_dir, + "persisted", + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + failure_type = type(exc).__name__ + profile_cleaned = not profile_path.exists() + if not profile_cleaned: + raise RuntimeError(f"Manifest V3 profile cleanup failed in trial {trial_number}") + + duration_ms = round((time.monotonic() - trial_started) * 1000) + if failure_type is not None: + return { + "trial_number": trial_number, + "passed": False, + "failure_type": failure_type, + "profile_cleaned": True, + "duration_ms": duration_ms, + } + if initial is None or restarted is None: + raise RuntimeError("Manifest V3 restart trial returned incomplete browser evidence") + + initial_count = int(initial["worker_start_count"]) + restarted_count = int(restarted["worker_start_count"]) + surfaces = { + name: bool(initial["surfaces"][name]) and bool(restarted["surfaces"][name]) + for name in initial["surfaces"] + } + surfaces.update( + { + "restart-persistence": restarted["storage_persistence"] == "persisted", + "worker-start-count": restarted_count > initial_count, + "storage-persistence": restarted["storage_persistence"] == "persisted", + } + ) + if not all(surfaces.values()): + raise RuntimeError(f"compatibility surface failed in trial {trial_number}") + + return { + "trial_number": trial_number, + "passed": True, + "browser_version": restarted["browser_version"], + "surfaces": surfaces, + "browser_passes": [ + { + "phase": "initial", + "worker_start_count": initial_count, + "storage_persistence": initial["storage_persistence"], + }, + { + "phase": "restart", + "worker_start_count": restarted_count, + "storage_persistence": restarted["storage_persistence"], + }, + ], + "profile_cleaned": True, + "duration_ms": duration_ms, + } + + +def _run_agent_task_browser_pass( + chrome_bin: pathlib.Path, + chromedriver_bin: pathlib.Path, + fixture_url: str, + profile_dir: str, +) -> dict[str, Any]: + """Execute one synthetic Agent Task and measure bounded real-browser evidence.""" + + _require_pristine_agent_task_profile(profile_dir) + profile_pristine_before_launch = True + started = time.monotonic() + driver_port = _free_loopback_port() + session_id: str | None = None + browser_process_id: int | None = None + browser_process_start_time_ticks: int | None = None + chromium_process_identities: tuple[tuple[int, int], ...] | None = None + browser_failure_type: str | None = None + result: dict[str, Any] | None = None + driver = subprocess.Popen( + [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, + text=True, + ) + try: + _wait_for_driver(driver_port) + session = _json_request( + driver_port, + "POST", + "/session", + { + "capabilities": { + "alwaysMatch": { + "browserName": "chrome", + "goog:chromeOptions": { + "binary": str(chrome_bin), + "prefs": { + "credentials_enable_service": False, + "profile.password_manager_enabled": False, + }, + "args": [ + "--headless=new", + "--no-first-run", + "--disable-default-apps", + "--disable-component-update", + "--disable-sync", + "--disable-dev-shm-usage", + "--no-sandbox", + "--disable-extensions", + f"--user-data-dir={profile_dir}", + ], + }, + } + } + }, + ).get("value", {}) + if not isinstance(session, dict): + raise RuntimeError("ChromeDriver Agent Task session response is malformed") + raw_session_id = session.get("sessionId") + capabilities = session.get("capabilities", {}) + if not isinstance(raw_session_id, str): + raise RuntimeError("ChromeDriver did not return an Agent Task session id") + if not isinstance(capabilities, dict): + raise RuntimeError("ChromeDriver Agent Task capabilities are malformed") + session_id = _path_token(raw_session_id, "session identifier") + browser_version = capabilities.get("browserVersion") + browser_process_id = capabilities.get("goog:processID") + if browser_version != PINNED_CHROME_VERSION: + raise RuntimeError( + f"unexpected Agent Task Chrome version: expected {PINNED_CHROME_VERSION}, " + f"got {browser_version!r}" + ) + if ( + isinstance(browser_process_id, bool) + or not isinstance(browser_process_id, int) + or browser_process_id <= 0 + ): + raise RuntimeError("ChromeDriver did not return a valid browser process id") + browser_process_identity = _read_linux_proc_stat_process_identity(browser_process_id) + if browser_process_identity is None: + raise RuntimeError("Agent Task browser process identity disappeared after launch") + browser_process_start_time_ticks = browser_process_identity[1] + + _json_request( + driver_port, + "POST", + _webdriver_path(session_id, "/url"), + {"url": fixture_url}, + ) + initial_url = _json_request( + driver_port, + "GET", + _webdriver_path(session_id, "/url"), + ).get("value") + if initial_url != fixture_url: + raise RuntimeError("Agent Task did not load the requested fixture URL") + ambient_state = _probe_agent_task_ambient_state(driver_port, session_id) + + input_element = _find_element_by_accessible_role_name( + driver_port, + session_id, + "textbox", + "Task text", + ) + input_role, input_name = _get_element_semantics( + driver_port, + session_id, + input_element, + ) + if input_role != "textbox" or input_name != "Task text": + raise RuntimeError("Agent Task input semantic evidence mismatch") + submit_element = _find_element_by_accessible_role_name( + driver_port, + session_id, + "button", + "Submit task", + ) + submit_role, submit_name = _get_element_semantics( + driver_port, + session_id, + submit_element, + ) + if submit_role != "button" or submit_name != "Submit task": + raise RuntimeError("Agent Task submit semantic evidence mismatch") + semantic_observation = { + "input": {"role": input_role, "name": input_name}, + "submit": {"role": submit_role, "name": submit_name}, + } + semantic_observation_bytes = _measure_agent_task_semantic_observation_bytes( + semantic_observation + ) + + action_started = time.monotonic() + _json_request( + driver_port, + "POST", + _element_command_path(session_id, input_element, "/clear"), + {}, + ) + _json_request( + driver_port, + "POST", + _element_command_path(session_id, input_element, "/value"), + {"text": AGENT_TASK_INPUT_VALUE, "value": list(AGENT_TASK_INPUT_VALUE)}, + ) + _json_request( + driver_port, + "POST", + _element_command_path(session_id, submit_element, "/click"), + {}, + ) + action_latency_ms = round((time.monotonic() - action_started) * 1000, 3) + if action_latency_ms <= 0: + raise RuntimeError("Agent Task measured a non-positive action latency") + + post_submit_url = _json_request( + driver_port, + "GET", + _webdriver_path(session_id, "/url"), + ).get("value") + url_unchanged = post_submit_url == initial_url + if not url_unchanged: + raise RuntimeError("Agent Task URL changed during submission") + + result_element = _find_element_by_accessible_role_name( + driver_port, + session_id, + "status", + "Task result", + ) + result_role, result_name = _get_element_semantics( + driver_port, + session_id, + result_element, + ) + if result_role != "status" or result_name != "Task result": + raise RuntimeError("Agent Task result semantic evidence mismatch") + state = _json_request( + driver_port, + "GET", + _element_command_path(session_id, result_element, "/attribute/data-state"), + ).get("value") + text = _json_request( + driver_port, + "GET", + _element_command_path(session_id, result_element, "/text"), + ).get("value") + if state != "submitted": + raise RuntimeError(f"Agent Task state post-condition failed: {state!r}") + if text != AGENT_TASK_INPUT_VALUE: + raise RuntimeError("Agent Task result did not match the synthetic typed value") + structured_value_sha256 = _hash_agent_task_structured_value(text) + + process_evidence = _snapshot_linux_process_evidence() + chromium_process_ids = _discover_linux_process_tree_ids( + browser_process_id, + process_evidence, + ) + chromium_process_identities = _read_linux_process_identity_set( + chromium_process_ids + ) + browser_process_rss_bytes = _sample_linux_process_rss_bytes(browser_process_id) + chromium_process_set_rss_bytes = _sample_linux_process_set_rss_bytes( + chromium_process_ids, + process_evidence, + ) + chromium_process_count = len(chromium_process_ids) + task_duration_ms = round((time.monotonic() - started) * 1000, 3) + if task_duration_ms <= 0: + raise RuntimeError("Agent Task measured a non-positive task duration") + result = { + "browser_version": browser_version, + "post_condition": True, + "input_echo_verified": True, + "url_unchanged": url_unchanged, + "input_semantics_verified": True, + "submit_semantics_verified": True, + "result_semantics_verified": True, + "structured_value_field": "task_result", + "structured_value_sha256": structured_value_sha256, + "extensions_disabled": True, + "profile_pristine_before_launch": profile_pristine_before_launch, + "ambient_cookies_absent": ambient_state["ambient_cookies_absent"], + "ambient_web_storage_absent": ambient_state["ambient_web_storage_absent"], + "saved_credential_services_disabled": True, + "browser_process_rss_bytes": browser_process_rss_bytes, + "chromium_process_count": chromium_process_count, + "chromium_process_set_rss_bytes": chromium_process_set_rss_bytes, + "semantic_observation_bytes": semantic_observation_bytes, + "action_latency_ms": action_latency_ms, + "task_duration_ms": task_duration_ms, + "duration_ms": round(task_duration_ms), + } + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + browser_failure_type = type(exc).__name__ + if browser_process_id is None or browser_process_start_time_ticks is None: + raise + finally: + if session_id is not None: + with contextlib.suppress(Exception): + _json_request( + driver_port, + "DELETE", + _webdriver_path(session_id, ""), + {}, + ) + driver.terminate() + try: + driver.wait(timeout=5) + except subprocess.TimeoutExpired: + driver.kill() + driver.wait(timeout=5) + + if browser_process_id is None or browser_process_start_time_ticks is None: + raise RuntimeError("Agent Task browser process identity was not captured") + browser_process_terminated = _wait_for_linux_process_identity_exit( + browser_process_id, + browser_process_start_time_ticks, + ) + chromium_process_set_terminated: bool | None = None + if chromium_process_identities is not None: + chromium_process_set_terminated = _wait_for_linux_process_identity_set_exit( + chromium_process_identities + ) + if browser_failure_type is not None: + failure_evidence: dict[str, Any] = { + "failure_type": browser_failure_type, + "browser_process_terminated": browser_process_terminated, + } + if chromium_process_set_terminated is not None: + failure_evidence["chromium_process_set_terminated"] = ( + chromium_process_set_terminated + ) + return failure_evidence + if result is None: + raise RuntimeError("Agent Task browser pass returned no result after shutdown") + if chromium_process_set_terminated is None: + raise RuntimeError("Agent Task Chromium process identities were not captured") + if not browser_process_terminated: + raise RuntimeError("Agent Task browser process did not terminate") + if not chromium_process_set_terminated: + raise RuntimeError("Agent Task Chromium process set did not terminate") + result["browser_process_terminated"] = True + result["chromium_process_set_terminated"] = True + return result + + +def _run_agent_task_trial( + chrome_bin: pathlib.Path, + chromedriver_bin: pathlib.Path, + fixture_url: str, + trial_number: int, +) -> dict[str, Any]: + """Run one isolated Agent Task trial and retain cleanup evidence on failure.""" + + trial_started = time.monotonic() + profile_path: pathlib.Path + result: dict[str, Any] | None = None + failure_type: str | None = None + with tempfile.TemporaryDirectory( + prefix=f"originweave-agent-task-trial-{trial_number}-" + ) as profile_dir: + profile_path = pathlib.Path(profile_dir) + try: + result = _run_agent_task_browser_pass( + chrome_bin, + chromedriver_bin, + fixture_url, + profile_dir, + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + failure_type = type(exc).__name__ + profile_cleaned = not profile_path.exists() + if not profile_cleaned: + raise RuntimeError(f"Agent Task profile cleanup failed in trial {trial_number}") + + duration_ms = round((time.monotonic() - trial_started) * 1000) + if failure_type is not None: + return { + "trial_number": trial_number, + "passed": False, + "failure_type": failure_type, + "profile_cleaned": True, + "duration_ms": duration_ms, + } + if result is None: + raise RuntimeError("Agent Task browser pass returned no result") + returned_failure_type = result.get("failure_type") + if returned_failure_type is not None: + if not isinstance(returned_failure_type, str) or not returned_failure_type: + raise RuntimeError("Agent Task browser pass returned invalid failure evidence") + browser_process_terminated = result.get("browser_process_terminated") + if not isinstance(browser_process_terminated, bool): + raise RuntimeError("Agent Task browser pass returned invalid teardown evidence") + failure_evidence: dict[str, Any] = { + "trial_number": trial_number, + "passed": False, + "failure_type": returned_failure_type, + "browser_process_terminated": browser_process_terminated, + "profile_cleaned": True, + "duration_ms": duration_ms, + } + if "chromium_process_set_terminated" in result: + chromium_process_set_terminated = result["chromium_process_set_terminated"] + if not isinstance(chromium_process_set_terminated, bool): + raise RuntimeError( + "Agent Task browser pass returned invalid process-set teardown evidence" + ) + failure_evidence["chromium_process_set_terminated"] = ( + chromium_process_set_terminated + ) + return failure_evidence + + return { + "trial_number": trial_number, + "passed": True, + "browser_version": result["browser_version"], + "post_condition": result["post_condition"], + "input_echo_verified": result["input_echo_verified"], + "url_unchanged": result["url_unchanged"], + "input_semantics_verified": result["input_semantics_verified"], + "submit_semantics_verified": result["submit_semantics_verified"], + "result_semantics_verified": result["result_semantics_verified"], + "structured_value_field": result["structured_value_field"], + "structured_value_sha256": result["structured_value_sha256"], + "extensions_disabled": result["extensions_disabled"], + "profile_pristine_before_launch": result["profile_pristine_before_launch"], + "ambient_cookies_absent": result["ambient_cookies_absent"], + "ambient_web_storage_absent": result["ambient_web_storage_absent"], + "saved_credential_services_disabled": result[ + "saved_credential_services_disabled" + ], + "browser_process_rss_bytes": result["browser_process_rss_bytes"], + "browser_process_terminated": result["browser_process_terminated"], + "chromium_process_count": result["chromium_process_count"], + "chromium_process_set_rss_bytes": result["chromium_process_set_rss_bytes"], + "chromium_process_set_terminated": result["chromium_process_set_terminated"], + "semantic_observation_bytes": result["semantic_observation_bytes"], + "action_latency_ms": result["action_latency_ms"], + "task_duration_ms": result["task_duration_ms"], + "profile_cleaned": True, + "duration_ms": duration_ms, + } + + +def _is_no_such_window_runtime_error(error: RuntimeError) -> bool: + """Recognize only structured ChromeDriver no-such-window failure evidence.""" + + message = str(error) + direct_prefix = "WebDriver error: " + if message.startswith(direct_prefix): + code, separator, _detail = message[len(direct_prefix) :].partition(":") + return bool(separator) and code.strip().casefold() == "no such window" + + http_prefix = "WebDriver HTTP 404: " + if not message.startswith(http_prefix): + return False + try: + payload = json.loads(message[len(http_prefix) :]) + except json.JSONDecodeError: + return False + if not isinstance(payload, dict): + return False + value = payload.get("value") + return isinstance(value, dict) and value.get("error") == "no such window" + + +def _force_close_agent_task_context(driver_port: int, session_id: str) -> bool: + """Close only the current browsing context and require no-such-window evidence.""" + + closed = _json_request( + driver_port, + "DELETE", + _webdriver_path(session_id, "/window"), + ) + surviving_contexts = closed.get("value") + if not isinstance(surviving_contexts, list): + raise RuntimeError("Agent Task forced-close returned malformed surviving contexts") + if not surviving_contexts: + raise RuntimeError("Agent Task forced-close left no surviving browsing context") + if any(not isinstance(handle, str) or not handle for handle in surviving_contexts): + raise RuntimeError("Agent Task forced-close returned invalid surviving context") + + try: + _json_request( + driver_port, + "GET", + _webdriver_path(session_id, "/url"), + ) + except RuntimeError as exc: + if _is_no_such_window_runtime_error(exc): + return True + raise + raise RuntimeError("Agent Task context remained usable after forced close") + + +def _run_agent_task_forced_close_browser_pass( + chrome_bin: pathlib.Path, + chromedriver_bin: pathlib.Path, + fixture_url: str, + profile_dir: str, +) -> dict[str, Any]: + """Force-close a disposable real context while preserving the WebDriver session.""" + + driver_port = _free_loopback_port() + session_id: str | None = None + browser_process_id: int | None = None + browser_process_start_time_ticks: int | None = None + chromium_process_identities: tuple[tuple[int, int], ...] | None = None + browser_failure_type: str | None = None + result: dict[str, Any] | None = None + driver = subprocess.Popen( + [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, + text=True, + ) + try: + _wait_for_driver(driver_port) + session = _json_request( + driver_port, + "POST", + "/session", + { + "capabilities": { + "alwaysMatch": { + "browserName": "chrome", + "goog:chromeOptions": { + "binary": str(chrome_bin), + "args": [ + "--headless=new", + "--no-first-run", + "--disable-default-apps", + "--disable-component-update", + "--disable-sync", + "--disable-dev-shm-usage", + "--no-sandbox", + "--disable-extensions", + f"--user-data-dir={profile_dir}", + ], + }, + } + } + }, + ).get("value", {}) + if not isinstance(session, dict): + raise RuntimeError("ChromeDriver forced-close session response is malformed") + raw_session_id = session.get("sessionId") + capabilities = session.get("capabilities", {}) + if not isinstance(raw_session_id, str): + raise RuntimeError("ChromeDriver did not return a forced-close session id") + if not isinstance(capabilities, dict): + raise RuntimeError("ChromeDriver forced-close capabilities are malformed") + session_id = _path_token(raw_session_id, "session identifier") + browser_version = capabilities.get("browserVersion") + browser_process_id = capabilities.get("goog:processID") + if browser_version != PINNED_CHROME_VERSION: + raise RuntimeError( + f"unexpected forced-close Chrome version: expected {PINNED_CHROME_VERSION}, " + f"got {browser_version!r}" + ) + if ( + isinstance(browser_process_id, bool) + or not isinstance(browser_process_id, int) + or browser_process_id <= 0 + ): + raise RuntimeError("ChromeDriver did not return a valid forced-close browser process id") + browser_process_identity = _read_linux_proc_stat_process_identity(browser_process_id) + if browser_process_identity is None: + raise RuntimeError("Agent Task forced-close browser process identity disappeared") + browser_process_start_time_ticks = browser_process_identity[1] + + survivor_context = _json_request( + driver_port, + "GET", + _webdriver_path(session_id, "/window"), + ).get("value") + if not isinstance(survivor_context, str): + raise RuntimeError("ChromeDriver did not return the survivor context handle") + survivor_context = _path_token(survivor_context, "window handle") + + created = _json_request( + driver_port, + "POST", + _webdriver_path(session_id, "/window/new"), + {"type": "tab"}, + ).get("value", {}) + if not isinstance(created, dict): + raise RuntimeError("ChromeDriver returned malformed disposable context evidence") + disposable_context = created.get("handle") + if not isinstance(disposable_context, str): + raise RuntimeError("ChromeDriver did not return a disposable context handle") + disposable_context = _path_token(disposable_context, "window handle") + if disposable_context == survivor_context: + raise RuntimeError("ChromeDriver reused the survivor context as disposable context") + + _json_request( + driver_port, + "POST", + _webdriver_path(session_id, "/window"), + {"handle": disposable_context}, + ) + _json_request( + driver_port, + "POST", + _webdriver_path(session_id, "/url"), + {"url": fixture_url}, + ) + loaded_url = _json_request( + driver_port, + "GET", + _webdriver_path(session_id, "/url"), + ).get("value") + if loaded_url != fixture_url: + raise RuntimeError("Agent Task forced-close probe did not load its fixture URL") + + process_evidence = _snapshot_linux_process_evidence() + chromium_process_ids = _discover_linux_process_tree_ids( + browser_process_id, + process_evidence, + ) + chromium_process_identities = _read_linux_process_identity_set( + chromium_process_ids + ) + + forced_close_detected = _force_close_agent_task_context(driver_port, session_id) + if not forced_close_detected: + raise RuntimeError("Agent Task forced-close probe did not detect the close") + + _json_request( + driver_port, + "POST", + _webdriver_path(session_id, "/window"), + {"handle": survivor_context}, + ) + surviving_url = _json_request( + driver_port, + "GET", + _webdriver_path(session_id, "/url"), + ).get("value") + if not isinstance(surviving_url, str): + raise RuntimeError("Agent Task survivor context was not usable after forced close") + + result = { + "browser_version": browser_version, + "forced_close_detected": forced_close_detected, + "session_survived": True, + } + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + browser_failure_type = type(exc).__name__ + if browser_process_id is None or browser_process_start_time_ticks is None: + raise + finally: + if session_id is not None: + with contextlib.suppress(Exception): + _json_request( + driver_port, + "DELETE", + _webdriver_path(session_id, ""), + {}, + ) + driver.terminate() + try: + driver.wait(timeout=5) + except subprocess.TimeoutExpired: + driver.kill() + driver.wait(timeout=5) + + if browser_process_id is None or browser_process_start_time_ticks is None: + raise RuntimeError("Agent Task forced-close browser process identity was not captured") + full_process_set_captured = chromium_process_identities is not None + teardown_identities = ( + chromium_process_identities + if chromium_process_identities is not None + else ((browser_process_id, browser_process_start_time_ticks),) + ) + browser_process_terminated, observed_process_set_terminated = ( + _wait_for_linux_process_teardown( + browser_process_id, + browser_process_start_time_ticks, + teardown_identities, + ) + ) + chromium_process_set_terminated: bool | None = ( + observed_process_set_terminated if full_process_set_captured else None + ) + if browser_failure_type is not None: + failure_evidence: dict[str, Any] = { + "failure_type": browser_failure_type, + "browser_process_terminated": browser_process_terminated, + } + if chromium_process_set_terminated is not None: + failure_evidence["chromium_process_set_terminated"] = ( + chromium_process_set_terminated + ) + return failure_evidence + if result is None: + raise RuntimeError("Agent Task forced-close browser pass returned no result after shutdown") + if chromium_process_set_terminated is None: + raise RuntimeError("Agent Task forced-close Chromium process identities were not captured") + if not browser_process_terminated: + raise RuntimeError("Agent Task forced-close browser process did not terminate") + if not chromium_process_set_terminated: + raise RuntimeError("Agent Task forced-close Chromium process set did not terminate") + result["browser_process_terminated"] = True + result["chromium_process_set_terminated"] = True + return result + + +def _run_agent_task_forced_close_trial( + chrome_bin: pathlib.Path, + chromedriver_bin: pathlib.Path, + fixture_url: str, + trial_number: int, +) -> dict[str, Any]: + """Run one forced-close trial and retain cleanup evidence on failure.""" + + trial_started = time.monotonic() + profile_path: pathlib.Path + result: dict[str, Any] | None = None + failure_type: str | None = None + with tempfile.TemporaryDirectory( + prefix=f"originweave-agent-task-forced-close-{trial_number}-" + ) as profile_dir: + profile_path = pathlib.Path(profile_dir) + try: + result = _run_agent_task_forced_close_browser_pass( + chrome_bin, + chromedriver_bin, + fixture_url, + profile_dir, + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + failure_type = type(exc).__name__ + profile_cleaned = not profile_path.exists() + if not profile_cleaned: + raise RuntimeError( + f"Agent Task forced-close profile cleanup failed in trial {trial_number}" + ) + + duration_ms = round((time.monotonic() - trial_started) * 1000) + if failure_type is not None: + return { + "trial_number": trial_number, + "passed": False, + "failure_type": failure_type, + "profile_cleaned": True, + "duration_ms": duration_ms, + } + if result is None: + raise RuntimeError("Agent Task forced-close browser pass returned no result") + returned_failure_type = result.get("failure_type") + if returned_failure_type is not None: + if not isinstance(returned_failure_type, str) or not returned_failure_type: + raise RuntimeError("Agent Task forced-close browser pass returned invalid failure evidence") + browser_process_terminated = result.get("browser_process_terminated") + if not isinstance(browser_process_terminated, bool): + raise RuntimeError("Agent Task forced-close browser pass returned invalid teardown evidence") + failure_evidence: dict[str, Any] = { + "trial_number": trial_number, + "passed": False, + "failure_type": returned_failure_type, + "browser_process_terminated": browser_process_terminated, + "profile_cleaned": True, + "duration_ms": duration_ms, + } + if "chromium_process_set_terminated" in result: + chromium_process_set_terminated = result["chromium_process_set_terminated"] + if not isinstance(chromium_process_set_terminated, bool): + raise RuntimeError( + "Agent Task forced-close browser pass returned invalid process-set teardown evidence" + ) + failure_evidence["chromium_process_set_terminated"] = ( + chromium_process_set_terminated + ) + return failure_evidence + + return { + "trial_number": trial_number, + "passed": True, + "browser_version": result["browser_version"], + "forced_close_detected": result["forced_close_detected"], + "session_survived": result["session_survived"], + "browser_process_terminated": result["browser_process_terminated"], + "chromium_process_set_terminated": result["chromium_process_set_terminated"], + "profile_cleaned": True, + "duration_ms": duration_ms, + } + + def _cleanup_crashed_browser_session(driver_port: int, session_id: str | None) -> None: """Delete a crash session while ignoring only reviewed post-crash transport loss.""" @@ -10,10 +1826,553 @@ def _cleanup_crashed_browser_session(driver_port: int, session_id: str | None) - _webdriver_path(session_id, ""), {}, ) - except ( - OSError, - RuntimeError, - json.JSONDecodeError, - http.client.IncompleteRead, - ): + except (OSError, RuntimeError, json.JSONDecodeError): + return + + +def _stop_crashed_driver(driver: subprocess.Popen[Any]) -> None: + """Reap ChromeDriver without re-signalling a child that already exited.""" + + if driver.poll() is not None: + driver.wait(timeout=5) return + driver.terminate() + try: + driver.wait(timeout=5) + except subprocess.TimeoutExpired: + driver.kill() + driver.wait(timeout=5) + + +def _run_agent_task_browser_crash_browser_pass( + chrome_bin: pathlib.Path, + chromedriver_bin: pathlib.Path, + fixture_url: str, + profile_dir: str, +) -> dict[str, Any]: + """Kill one exact browser root and prove crash detection plus sampled teardown.""" + + _require_pristine_agent_task_profile(profile_dir) + driver_port = _free_loopback_port() + session_id: str | None = None + browser_process_id: int | None = None + browser_process_start_time_ticks: int | None = None + chromium_process_identities: tuple[tuple[int, int], ...] | None = None + browser_version: str | None = None + browser_process_crash_detected = False + driver = subprocess.Popen( + [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, + text=True, + ) + try: + _wait_for_driver(driver_port) + session = _json_request( + driver_port, + "POST", + "/session", + { + "capabilities": { + "alwaysMatch": { + "browserName": "chrome", + "goog:chromeOptions": { + "binary": str(chrome_bin), + "prefs": { + "credentials_enable_service": False, + "profile.password_manager_enabled": False, + }, + "args": [ + "--headless=new", + "--no-first-run", + "--disable-default-apps", + "--disable-component-update", + "--disable-sync", + "--disable-dev-shm-usage", + "--no-sandbox", + "--disable-extensions", + f"--user-data-dir={profile_dir}", + ], + }, + } + } + }, + ).get("value", {}) + if not isinstance(session, dict): + raise RuntimeError("ChromeDriver browser-crash session response is malformed") + raw_session_id = session.get("sessionId") + capabilities = session.get("capabilities", {}) + if not isinstance(raw_session_id, str): + raise RuntimeError("ChromeDriver did not return a browser-crash session id") + if not isinstance(capabilities, dict): + raise RuntimeError("ChromeDriver browser-crash capabilities are malformed") + session_id = _path_token(raw_session_id, "session identifier") + browser_version = capabilities.get("browserVersion") + browser_process_id = capabilities.get("goog:processID") + if browser_version != PINNED_CHROME_VERSION: + raise RuntimeError( + f"unexpected browser-crash Chrome version: expected {PINNED_CHROME_VERSION}, " + f"got {browser_version!r}" + ) + if ( + isinstance(browser_process_id, bool) + or not isinstance(browser_process_id, int) + or browser_process_id <= 0 + ): + raise RuntimeError("ChromeDriver did not return a valid browser-crash process id") + browser_process_identity = _read_linux_proc_stat_process_identity(browser_process_id) + if browser_process_identity is None: + raise RuntimeError("Agent Task browser-crash process identity disappeared") + browser_process_start_time_ticks = browser_process_identity[1] + + _json_request( + driver_port, + "POST", + _webdriver_path(session_id, "/url"), + {"url": fixture_url}, + ) + loaded_url = _json_request( + driver_port, + "GET", + _webdriver_path(session_id, "/url"), + ).get("value") + if loaded_url != fixture_url: + raise RuntimeError("Agent Task browser-crash probe did not load its fixture URL") + + process_evidence = _snapshot_linux_process_evidence() + chromium_process_ids = _discover_linux_process_tree_ids( + browser_process_id, + process_evidence, + ) + chromium_process_identities = _read_linux_process_identity_set( + chromium_process_ids + ) + if not _signal_linux_process_identity(browser_process_identity, signal.SIGKILL): + raise RuntimeError("Agent Task browser process identity changed before crash signal") + + deadline = time.monotonic() + PROCESS_EXIT_TIMEOUT_SECONDS + while True: + try: + _json_request( + driver_port, + "GET", + _webdriver_path(session_id, "/url"), + timeout=1.0, + ) + except (OSError, RuntimeError, json.JSONDecodeError): + browser_process_crash_detected = True + break + remaining_seconds = deadline - time.monotonic() + if remaining_seconds <= 0: + raise RuntimeError("Agent Task browser remained usable after SIGKILL") + time.sleep(min(0.05, remaining_seconds)) + finally: + _cleanup_crashed_browser_session(driver_port, session_id) + _stop_crashed_driver(driver) + + if ( + browser_process_id is None + or browser_process_start_time_ticks is None + or chromium_process_identities is None + or browser_version is None + ): + raise RuntimeError("Agent Task browser-crash teardown identities were not captured") + browser_process_terminated, chromium_process_set_terminated = ( + _wait_for_linux_process_teardown( + browser_process_id, + browser_process_start_time_ticks, + chromium_process_identities, + ) + ) + if not browser_process_crash_detected: + raise RuntimeError("Agent Task browser-process crash was not detected") + if not browser_process_terminated: + raise RuntimeError("Agent Task browser-crash root process did not terminate") + if not chromium_process_set_terminated: + raise RuntimeError("Agent Task browser-crash Chromium process set did not terminate") + return { + "browser_version": browser_version, + "browser_process_crash_detected": True, + "browser_process_terminated": True, + "chromium_process_set_terminated": True, + } + + +def _run_agent_task_browser_crash_trial( + chrome_bin: pathlib.Path, + chromedriver_bin: pathlib.Path, + fixture_url: str, + trial_number: int, +) -> dict[str, Any]: + """Run one isolated browser-root crash trial and retain cleanup evidence.""" + + trial_started = time.monotonic() + profile_path: pathlib.Path + result: dict[str, Any] | None = None + failure_type: str | None = None + with tempfile.TemporaryDirectory( + prefix=f"originweave-agent-task-browser-crash-{trial_number}-" + ) as profile_dir: + profile_path = pathlib.Path(profile_dir) + try: + result = _run_agent_task_browser_crash_browser_pass( + chrome_bin, + chromedriver_bin, + fixture_url, + profile_dir, + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + failure_type = type(exc).__name__ + profile_cleaned = not profile_path.exists() + if not profile_cleaned: + raise RuntimeError( + f"Agent Task browser-crash profile cleanup failed in trial {trial_number}" + ) + + duration_ms = round((time.monotonic() - trial_started) * 1000) + if failure_type is not None: + return { + "trial_number": trial_number, + "passed": False, + "failure_type": failure_type, + "profile_cleaned": True, + "duration_ms": duration_ms, + } + if result is None: + raise RuntimeError("Agent Task browser-crash browser pass returned no result") + return { + "trial_number": trial_number, + "passed": True, + "browser_version": result["browser_version"], + "browser_process_crash_detected": result["browser_process_crash_detected"], + "browser_process_terminated": result["browser_process_terminated"], + "chromium_process_set_terminated": result["chromium_process_set_terminated"], + "profile_cleaned": True, + "duration_ms": duration_ms, + } + + +def _start_fixture_server( + directory: pathlib.Path, +) -> tuple[http.server.ThreadingHTTPServer, threading.Thread]: + """Start one loopback-only static fixture server for a bounded browser lane.""" + + server = http.server.ThreadingHTTPServer( + ("127.0.0.1", 0), + lambda *args, **kwargs: QuietFixtureHandler( + *args, + directory=str(directory), + **kwargs, + ), + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread + + +def _stop_fixture_server( + server: http.server.ThreadingHTTPServer, + thread: threading.Thread, +) -> None: + """Stop one bounded fixture server and join its helper thread.""" + + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def main() -> int: + """Run bounded MV3 and Agent Task trials and emit credential-free evidence.""" + + chrome_bin = pathlib.Path(os.environ.get("CHROME_BIN", "")) + chromedriver_bin = pathlib.Path(os.environ.get("CHROMEDRIVER_BIN", "")) + if not chrome_bin.is_file(): + raise SystemExit("CHROME_BIN must point to the pinned Chrome for Testing executable") + if not chromedriver_bin.is_file(): + raise SystemExit("CHROMEDRIVER_BIN must point to the matching pinned ChromeDriver") + if not (FIXTURE / "manifest.json").is_file(): + raise SystemExit("MV3 fixture manifest is missing") + if not (AGENT_TASK_FIXTURE / "index.html").is_file(): + raise SystemExit("Agent Task fixture is missing") + + fixture_server, fixture_thread = _start_fixture_server(FIXTURE) + agent_task_server, agent_task_thread = _start_fixture_server(AGENT_TASK_FIXTURE) + started = time.monotonic() + + try: + fixture_url = f"http://127.0.0.1:{fixture_server.server_port}/page.html" + trial_results: list[dict[str, Any]] = [] + for trial_number in range(1, REPEATABILITY_TRIALS + 1): + try: + trial_results.append( + _run_restart_trial( + chrome_bin, + chromedriver_bin, + fixture_url, + trial_number, + ) + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + trial_results.append( + { + "trial_number": trial_number, + "passed": False, + "failure_type": type(exc).__name__, + } + ) + + successful_trials = sum( + 1 for trial in trial_results if trial.get("passed") is True + ) + trial_pass_rate = successful_trials / REPEATABILITY_TRIALS + mv3_profiles_cleaned = all( + trial.get("profile_cleaned") is True for trial in trial_results + ) + successful_results = [ + trial for trial in trial_results if trial.get("passed") is True + ] + common_surfaces: dict[str, bool] = {} + if successful_results: + first_surfaces = successful_results[0].get("surfaces", {}) + if isinstance(first_surfaces, dict): + common_surfaces = { + str(name): all( + isinstance(trial.get("surfaces"), dict) + and trial["surfaces"].get(name) is True + for trial in successful_results + ) + for name in first_surfaces + } + + agent_task_url = ( + f"http://127.0.0.1:{agent_task_server.server_port}/index.html" + ) + agent_task_trials: list[dict[str, Any]] = [] + for trial_number in range(1, AGENT_TASK_REPEATABILITY_TRIALS + 1): + try: + agent_task_trials.append( + _run_agent_task_trial( + chrome_bin, + chromedriver_bin, + agent_task_url, + trial_number, + ) + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + agent_task_trials.append( + { + "trial_number": trial_number, + "passed": False, + "failure_type": type(exc).__name__, + } + ) + + forced_close_trials: list[dict[str, Any]] = [] + for trial_number in range(1, AGENT_TASK_REPEATABILITY_TRIALS + 1): + try: + forced_close_trials.append( + _run_agent_task_forced_close_trial( + chrome_bin, + chromedriver_bin, + agent_task_url, + trial_number, + ) + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + forced_close_trials.append( + { + "trial_number": trial_number, + "passed": False, + "failure_type": type(exc).__name__, + } + ) + + browser_crash_trials: list[dict[str, Any]] = [] + for trial_number in range(1, AGENT_TASK_REPEATABILITY_TRIALS + 1): + try: + browser_crash_trials.append( + _run_agent_task_browser_crash_trial( + chrome_bin, + chromedriver_bin, + agent_task_url, + trial_number, + ) + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + browser_crash_trials.append( + { + "trial_number": trial_number, + "passed": False, + "failure_type": type(exc).__name__, + } + ) + + agent_task_successful_trials = sum( + 1 for trial in agent_task_trials if trial.get("passed") is True + ) + agent_task_trial_pass_rate = ( + agent_task_successful_trials / AGENT_TASK_REPEATABILITY_TRIALS + ) + agent_task_profiles_cleaned = all( + trial.get("profile_cleaned") is True for trial in agent_task_trials + ) + agent_task_isolation_complete = all( + trial.get("profile_pristine_before_launch") is True + and trial.get("ambient_cookies_absent") is True + and trial.get("ambient_web_storage_absent") is True + and trial.get("saved_credential_services_disabled") is True + and trial.get("extensions_disabled") is True + and trial.get("profile_cleaned") is True + for trial in agent_task_trials + if trial.get("passed") is True + ) + agent_task_surfaces_complete = all( + trial.get("post_condition") is True + and trial.get("input_echo_verified") is True + and trial.get("url_unchanged") is True + and trial.get("input_semantics_verified") is True + and trial.get("submit_semantics_verified") is True + and trial.get("result_semantics_verified") is True + and trial.get("structured_value_field") == "task_result" + and isinstance(trial.get("structured_value_sha256"), str) + and len(trial["structured_value_sha256"]) == len("sha256:") + 64 + and trial["structured_value_sha256"].startswith("sha256:") + and trial.get("extensions_disabled") is True + and trial.get("profile_cleaned") is True + and trial.get("browser_process_terminated") is True + and trial.get("chromium_process_set_terminated") is True + and isinstance(trial.get("browser_process_rss_bytes"), int) + and trial["browser_process_rss_bytes"] > 0 + and isinstance(trial.get("chromium_process_count"), int) + and 0 < trial["chromium_process_count"] <= MAX_BROWSER_PROCESS_TREE_SIZE + and isinstance(trial.get("chromium_process_set_rss_bytes"), int) + and trial["chromium_process_set_rss_bytes"] > 0 + and isinstance(trial.get("semantic_observation_bytes"), int) + and 0 + < trial["semantic_observation_bytes"] + <= MAX_AGENT_TASK_SEMANTIC_OBSERVATION_BYTES + and isinstance(trial.get("action_latency_ms"), (int, float)) + and trial["action_latency_ms"] > 0 + and isinstance(trial.get("task_duration_ms"), (int, float)) + and trial["task_duration_ms"] >= trial["action_latency_ms"] + for trial in agent_task_trials + if trial.get("passed") is True + ) + forced_close_successful_trials = sum( + 1 for trial in forced_close_trials if trial.get("passed") is True + ) + forced_close_profiles_cleaned = all( + trial.get("profile_cleaned") is True for trial in forced_close_trials + ) + forced_close_surfaces_complete = all( + trial.get("forced_close_detected") is True + and trial.get("session_survived") is True + and trial.get("browser_process_terminated") is True + and trial.get("chromium_process_set_terminated") is True + and trial.get("profile_cleaned") is True + for trial in forced_close_trials + if trial.get("passed") is True + ) + browser_crash_successful_trials = sum( + 1 for trial in browser_crash_trials if trial.get("passed") is True + ) + browser_crash_profiles_cleaned = all( + trial.get("profile_cleaned") is True for trial in browser_crash_trials + ) + browser_crash_surfaces_complete = all( + trial.get("browser_process_crash_detected") is True + and trial.get("browser_process_terminated") is True + and trial.get("chromium_process_set_terminated") is True + and trial.get("profile_cleaned") is True + for trial in browser_crash_trials + if trial.get("passed") is True + ) + + evidence = { + "chrome_version": PINNED_CHROME_VERSION, + "chrome_revision": PINNED_CHROME_REVISION, + "repeatability_trials": REPEATABILITY_TRIALS, + "successful_trials": successful_trials, + "trial_pass_rate": trial_pass_rate, + "profiles_cleaned": mv3_profiles_cleaned, + "surfaces": common_surfaces, + "trial_results": trial_results, + "browser_passes": ( + successful_results[-1].get("browser_passes", []) + if successful_results + else [] + ), + "agent_task": { + "repeatability_trials": AGENT_TASK_REPEATABILITY_TRIALS, + "successful_trials": agent_task_successful_trials, + "trial_pass_rate": agent_task_trial_pass_rate, + "profiles_cleaned": agent_task_profiles_cleaned, + "isolation_complete": agent_task_isolation_complete, + "trial_results": agent_task_trials, + "forced_close": { + "repeatability_trials": AGENT_TASK_REPEATABILITY_TRIALS, + "successful_trials": forced_close_successful_trials, + "profiles_cleaned": forced_close_profiles_cleaned, + "trial_results": forced_close_trials, + }, + "browser_crash": { + "repeatability_trials": AGENT_TASK_REPEATABILITY_TRIALS, + "successful_trials": browser_crash_successful_trials, + "profiles_cleaned": browser_crash_profiles_cleaned, + "trial_results": browser_crash_trials, + }, + }, + "duration_ms": round((time.monotonic() - started) * 1000), + } + print(json.dumps(evidence, sort_keys=True)) + if not mv3_profiles_cleaned: + raise RuntimeError("Manifest V3 profile cleanup gate failed") + if successful_trials != REPEATABILITY_TRIALS: + raise RuntimeError( + "Manifest V3 repeatability gate failed: " + f"{successful_trials}/{REPEATABILITY_TRIALS} trials passed" + ) + if not common_surfaces or not all(common_surfaces.values()): + raise RuntimeError("Manifest V3 repeatability surfaces were incomplete") + if not agent_task_profiles_cleaned: + raise RuntimeError("Agent Task profile cleanup gate failed") + if agent_task_successful_trials != AGENT_TASK_REPEATABILITY_TRIALS: + raise RuntimeError( + "Agent Task repeatability gate failed: " + f"{agent_task_successful_trials}/{AGENT_TASK_REPEATABILITY_TRIALS} " + "trials passed" + ) + if not agent_task_isolation_complete: + raise RuntimeError("Agent Task isolation gate failed") + if not agent_task_surfaces_complete: + raise RuntimeError("Agent Task repeatability surfaces were incomplete") + if not forced_close_profiles_cleaned: + raise RuntimeError("Agent Task forced-close profile cleanup gate failed") + if ( + forced_close_successful_trials != AGENT_TASK_REPEATABILITY_TRIALS + or not forced_close_surfaces_complete + ): + raise RuntimeError( + "Agent Task forced-close recovery gate failed: " + f"{forced_close_successful_trials}/{AGENT_TASK_REPEATABILITY_TRIALS} " + "trials passed" + ) + if not browser_crash_profiles_cleaned: + raise RuntimeError("Agent Task browser-crash profile cleanup gate failed") + if ( + browser_crash_successful_trials != AGENT_TASK_REPEATABILITY_TRIALS + or not browser_crash_surfaces_complete + ): + raise RuntimeError( + "Agent Task browser-crash recovery gate failed: " + f"{browser_crash_successful_trials}/{AGENT_TASK_REPEATABILITY_TRIALS} " + "trials passed" + ) + return 0 + finally: + _stop_fixture_server(agent_task_server, agent_task_thread) + _stop_fixture_server(fixture_server, fixture_thread) + + +if __name__ == "__main__": + raise SystemExit(main()) From dd45158f230420b7582d220390d5979a0e0f916a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 23:21:33 -0700 Subject: [PATCH 21/50] fix(browser): tolerate truncated post-crash cleanup response --- scripts/ci/run_mv3_compatibility.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index d96369f29..f613c30e9 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -1826,7 +1826,7 @@ def _cleanup_crashed_browser_session(driver_port: int, session_id: str | None) - _webdriver_path(session_id, ""), {}, ) - except (OSError, RuntimeError, json.JSONDecodeError): + except (OSError, RuntimeError, json.JSONDecodeError, http.client.IncompleteRead): return From 6f6291a2d57fdf73865ec62e36533c6b05069a60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 05:00:42 -0700 Subject: [PATCH 22/50] test(browser): retain terminal startup fail-closed contract --- ...chromedriver_startup_exception_contract.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/test_chromedriver_startup_exception_contract.py diff --git a/tests/test_chromedriver_startup_exception_contract.py b/tests/test_chromedriver_startup_exception_contract.py new file mode 100644 index 000000000..3ae9d4e8b --- /dev/null +++ b/tests/test_chromedriver_startup_exception_contract.py @@ -0,0 +1,36 @@ +"""Fail-closed exception contract for bounded ChromeDriver startup probing.""" + +from __future__ import annotations + +import pathlib +import runpy +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class ChromeDriverStartupExceptionContractTests(unittest.TestCase): + """Keep recoverable startup transport faults separate from terminal failures.""" + + def test_runner_startup_does_not_retry_terminal_runtime_failure(self) -> None: + """A terminal WebDriver/runtime failure must fail closed before a later success.""" + + namespace = runpy.run_path(str(RUNNER), run_name="chromedriver_terminal_startup_failure") + wait_for_driver = namespace["_wait_for_driver"] + attempts = [0] + + def terminal_then_ready(*_args: object, **_kwargs: object) -> dict[str, object]: + attempts[0] += 1 + if attempts[0] == 1: + raise RuntimeError("WebDriver HTTP 403: forbidden") + return {"value": {"ready": True}} + + wait_for_driver.__globals__["_json_request"] = terminal_then_ready + with self.assertRaisesRegex(RuntimeError, "HTTP 403"): + wait_for_driver(9515) + self.assertEqual(attempts[0], 1) + + +if __name__ == "__main__": + unittest.main() From e0e449dd36081a9b73fab0369de6725c281f99ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 05:06:07 -0700 Subject: [PATCH 23/50] test(browser): bind crash sampling to exact root identity --- ...est_agent_task_browser_crash_recovery_contract.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_agent_task_browser_crash_recovery_contract.py b/tests/test_agent_task_browser_crash_recovery_contract.py index 7c1efce1d..a15c2f271 100644 --- a/tests/test_agent_task_browser_crash_recovery_contract.py +++ b/tests/test_agent_task_browser_crash_recovery_contract.py @@ -199,6 +199,18 @@ def unexpected_request(*_args: object, **_kwargs: object) -> object: cleanup_session.__globals__["_json_request"] = unexpected_request cleanup_session(9222, None) + def test_crash_lane_binds_sampled_process_set_to_exact_root_identity(self) -> None: + """Crash sampling must preserve the prerequisite root-identity integrity contract.""" + + runner = RUNNER.read_text(encoding="utf-8") + crash_lane = runner.split( + "def _run_agent_task_browser_crash_browser_pass", 1 + )[1].split("def _run_agent_task_browser_crash_trial", 1)[0] + self.assertIn( + "required_root_identity=browser_process_identity", + crash_lane, + ) + def test_crash_lane_is_required_for_success_evidence(self) -> None: """The real-browser evidence must retain deterministic crash and teardown proof.""" From 239a7b25662df18493d76119eb35a203a43d2fe7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 05:16:03 -0700 Subject: [PATCH 24/50] fix(browser): preserve root identity across crash sampling --- scripts/ci/run_mv3_compatibility.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 4c9b8bf87..5d83d73de 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -2172,8 +2172,11 @@ def _run_agent_task_browser_crash_browser_pass( browser_process_id, process_evidence, ) - chromium_process_identities = _read_linux_process_identity_set( - chromium_process_ids + chromium_process_identities, _pre_shutdown_exit_count = ( + _read_linux_process_identity_set( + chromium_process_ids, + required_root_identity=browser_process_identity, + ) ) if not _signal_linux_process_identity(browser_process_identity, signal.SIGKILL): raise RuntimeError("Agent Task browser process identity changed before crash signal") From d8b37807b015290b71f775de5c8728e34f736f99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 07:09:20 -0700 Subject: [PATCH 25/50] test(browser): reproduce crash driver teardown timeout escape --- ...nt_task_browser_crash_recovery_contract.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_agent_task_browser_crash_recovery_contract.py b/tests/test_agent_task_browser_crash_recovery_contract.py index a15c2f271..b77386190 100644 --- a/tests/test_agent_task_browser_crash_recovery_contract.py +++ b/tests/test_agent_task_browser_crash_recovery_contract.py @@ -6,6 +6,7 @@ import pathlib import runpy import signal +import subprocess import unittest from unittest import mock @@ -157,6 +158,29 @@ def kill(self) -> None: cleanup(ExitedDriver()) self.assertEqual(events, ["poll", ("wait", 5)]) + def test_crash_trial_records_driver_teardown_timeout_as_failed_trial(self) -> None: + """A bounded ChromeDriver teardown timeout must fail one trial without aborting the run.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_browser_crash_timeout") + run_trial = namespace["_run_agent_task_browser_crash_trial"] + + def driver_teardown_timeout(*_args: object, **_kwargs: object) -> dict[str, object]: + raise subprocess.TimeoutExpired(cmd="chromedriver", timeout=5) + + run_trial.__globals__["_run_agent_task_browser_crash_browser_pass"] = ( + driver_teardown_timeout + ) + result = run_trial( + pathlib.Path("/controlled/chrome"), + pathlib.Path("/controlled/chromedriver"), + "http://127.0.0.1/agent-task", + 1, + ) + + self.assertFalse(result["passed"]) + self.assertEqual(result["failure_type"], "TimeoutExpired") + self.assertTrue(result["profile_cleaned"]) + def test_crash_session_cleanup_ignores_only_reviewed_transport_failures(self) -> None: """Expected post-crash transport loss is bounded, while programming failures propagate.""" From b1cdf304666c79aa7a95a3983a54920a2f249e76 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 07:15:26 -0700 Subject: [PATCH 26/50] fix(browser): retain crash driver teardown timeout evidence --- scripts/ci/run_mv3_compatibility.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 5d83d73de..ea8d1a00f 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -2252,7 +2252,13 @@ def _run_agent_task_browser_crash_trial( fixture_url, profile_dir, ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except ( + OSError, + ValueError, + RuntimeError, + json.JSONDecodeError, + subprocess.TimeoutExpired, + ) as exc: failure_type = type(exc).__name__ profile_cleaned = not profile_path.exists() if not profile_cleaned: From 633796c9a4358fe9e250478670cd5ebddce2be62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 07:21:05 -0700 Subject: [PATCH 27/50] test(browser): reproduce process-wide pidfd mock leakage --- ...t_agent_task_browser_crash_recovery_contract.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_agent_task_browser_crash_recovery_contract.py b/tests/test_agent_task_browser_crash_recovery_contract.py index b77386190..642e8c0fc 100644 --- a/tests/test_agent_task_browser_crash_recovery_contract.py +++ b/tests/test_agent_task_browser_crash_recovery_contract.py @@ -3,6 +3,7 @@ from __future__ import annotations import http.client +import os import pathlib import runpy import signal @@ -12,6 +13,9 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" +ORIGINAL_OS_CLOSE = os.close +ORIGINAL_PIDFD_OPEN = getattr(os, "pidfd_open", None) +ORIGINAL_PIDFD_SEND_SIGNAL = getattr(signal, "pidfd_send_signal", None) class AgentTaskBrowserCrashRecoveryContractTests(unittest.TestCase): @@ -249,6 +253,16 @@ def test_crash_lane_is_required_for_success_evidence(self) -> None: with self.subTest(expected=expected): self.assertIn(expected, runner) + def test_zz_signal_boundary_tests_restore_process_wide_modules(self) -> None: + """Mocked pidfd helpers must not leak process-wide module mutations.""" + + self.assertIs(os.close, ORIGINAL_OS_CLOSE) + self.assertIs(getattr(os, "pidfd_open", None), ORIGINAL_PIDFD_OPEN) + self.assertIs( + getattr(signal, "pidfd_send_signal", None), + ORIGINAL_PIDFD_SEND_SIGNAL, + ) + if __name__ == "__main__": unittest.main() From 73ea49269a9362352be7d210832a51618f3faa95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 07:23:09 -0700 Subject: [PATCH 28/50] fix(browser): restore process-wide pidfd mocks --- ...nt_task_browser_crash_recovery_contract.py | 104 +++++++++++++----- 1 file changed, 78 insertions(+), 26 deletions(-) diff --git a/tests/test_agent_task_browser_crash_recovery_contract.py b/tests/test_agent_task_browser_crash_recovery_contract.py index 642e8c0fc..440d91e02 100644 --- a/tests/test_agent_task_browser_crash_recovery_contract.py +++ b/tests/test_agent_task_browser_crash_recovery_contract.py @@ -44,13 +44,26 @@ def test_signal_boundary_rejects_pid_reuse_before_open(self) -> None: signal_identity.__globals__["_read_linux_proc_stat_process_identity"] = ( lambda _process_id: (777, 99) ) - signal_identity.__globals__["os"].pidfd_open = lambda process_id, _flags=0: opened.append(process_id) or 12 - signal_identity.__globals__["signal"].pidfd_send_signal = ( - lambda pidfd, sig, *_args, **_kwargs: signalled.append((pidfd, sig)) - ) - signal_identity.__globals__["os"].close = lambda _fd: None - - self.assertFalse(signal_identity((777, 42), signal.SIGKILL)) + os_module = signal_identity.__globals__["os"] + signal_module = signal_identity.__globals__["signal"] + with ( + mock.patch.object( + os_module, + "pidfd_open", + side_effect=lambda process_id, _flags=0: opened.append(process_id) or 12, + create=True, + ), + mock.patch.object( + signal_module, + "pidfd_send_signal", + side_effect=lambda pidfd, sig, *_args, **_kwargs: signalled.append( + (pidfd, sig) + ), + create=True, + ), + mock.patch.object(os_module, "close", side_effect=lambda _fd: None), + ): + self.assertFalse(signal_identity((777, 42), signal.SIGKILL)) self.assertEqual(opened, []) self.assertEqual(signalled, []) @@ -66,13 +79,26 @@ def test_signal_boundary_rechecks_identity_after_pidfd_open(self) -> None: signal_identity.__globals__["_read_linux_proc_stat_process_identity"] = ( lambda _process_id: next(identities) ) - signal_identity.__globals__["os"].pidfd_open = lambda _process_id, _flags=0: 12 - signal_identity.__globals__["signal"].pidfd_send_signal = ( - lambda pidfd, sig, *_args, **_kwargs: signalled.append((pidfd, sig)) - ) - signal_identity.__globals__["os"].close = closed.append - - self.assertFalse(signal_identity((777, 42), signal.SIGKILL)) + os_module = signal_identity.__globals__["os"] + signal_module = signal_identity.__globals__["signal"] + with ( + mock.patch.object( + os_module, + "pidfd_open", + side_effect=lambda _process_id, _flags=0: 12, + create=True, + ), + mock.patch.object( + signal_module, + "pidfd_send_signal", + side_effect=lambda pidfd, sig, *_args, **_kwargs: signalled.append( + (pidfd, sig) + ), + create=True, + ), + mock.patch.object(os_module, "close", side_effect=closed.append), + ): + self.assertFalse(signal_identity((777, 42), signal.SIGKILL)) self.assertEqual(signalled, []) self.assertEqual(closed, [12]) @@ -87,13 +113,26 @@ def test_signal_boundary_targets_only_exact_open_identity(self) -> None: signal_identity.__globals__["_read_linux_proc_stat_process_identity"] = ( lambda _process_id: (777, 42) ) - signal_identity.__globals__["os"].pidfd_open = lambda _process_id, _flags=0: 12 - signal_identity.__globals__["signal"].pidfd_send_signal = ( - lambda pidfd, sig, *_args, **_kwargs: signalled.append((pidfd, sig)) - ) - signal_identity.__globals__["os"].close = closed.append - - self.assertTrue(signal_identity((777, 42), signal.SIGKILL)) + os_module = signal_identity.__globals__["os"] + signal_module = signal_identity.__globals__["signal"] + with ( + mock.patch.object( + os_module, + "pidfd_open", + side_effect=lambda _process_id, _flags=0: 12, + create=True, + ), + mock.patch.object( + signal_module, + "pidfd_send_signal", + side_effect=lambda pidfd, sig, *_args, **_kwargs: signalled.append( + (pidfd, sig) + ), + create=True, + ), + mock.patch.object(os_module, "close", side_effect=closed.append), + ): + self.assertTrue(signal_identity((777, 42), signal.SIGKILL)) self.assertEqual(signalled, [(12, signal.SIGKILL)]) self.assertEqual(closed, [12]) @@ -107,15 +146,28 @@ def test_signal_boundary_handles_exit_before_pidfd_signal(self) -> None: signal_identity.__globals__["_read_linux_proc_stat_process_identity"] = ( lambda _process_id: (777, 42) ) - signal_identity.__globals__["os"].pidfd_open = lambda _process_id, _flags=0: 12 def exited_before_signal(*_args: object, **_kwargs: object) -> None: raise ProcessLookupError("process exited before pidfd signal") - signal_identity.__globals__["signal"].pidfd_send_signal = exited_before_signal - signal_identity.__globals__["os"].close = closed.append - - self.assertFalse(signal_identity((777, 42), signal.SIGKILL)) + os_module = signal_identity.__globals__["os"] + signal_module = signal_identity.__globals__["signal"] + with ( + mock.patch.object( + os_module, + "pidfd_open", + side_effect=lambda _process_id, _flags=0: 12, + create=True, + ), + mock.patch.object( + signal_module, + "pidfd_send_signal", + side_effect=exited_before_signal, + create=True, + ), + mock.patch.object(os_module, "close", side_effect=closed.append), + ): + self.assertFalse(signal_identity((777, 42), signal.SIGKILL)) self.assertEqual(closed, [12]) def test_proc_stat_identity_treats_read_time_esrch_as_process_exit(self) -> None: From 33e46d56f82e2ba636c1835e677a0f8fb4419822 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 08:09:47 -0700 Subject: [PATCH 29/50] test(browser): fail closed on unknown crash cleanup errors --- ..._browser_crash_cleanup_runtime_contract.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/test_agent_task_browser_crash_cleanup_runtime_contract.py diff --git a/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py b/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py new file mode 100644 index 000000000..58cbd01ee --- /dev/null +++ b/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py @@ -0,0 +1,33 @@ +"""Fail-closed contract for unexpected browser-crash cleanup runtime failures.""" + +from __future__ import annotations + +import pathlib +import runpy +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class AgentTaskBrowserCrashCleanupRuntimeContractTests(unittest.TestCase): + """Keep unknown WebDriver/runtime cleanup failures visible after a browser crash.""" + + def test_unknown_runtime_error_is_not_suppressed(self) -> None: + """Only reviewed post-crash transport loss may be converted to cleanup success.""" + + namespace = runpy.run_path( + str(RUNNER), run_name="agent_task_browser_crash_cleanup_runtime_contract" + ) + cleanup_session = namespace["_cleanup_crashed_browser_session"] + + def unexpected_runtime_failure(*_args: object, **_kwargs: object) -> object: + raise RuntimeError("unexpected WebDriver protocol failure") + + cleanup_session.__globals__["_json_request"] = unexpected_runtime_failure + with self.assertRaisesRegex(RuntimeError, "unexpected WebDriver protocol failure"): + cleanup_session(9222, "session-1") + + +if __name__ == "__main__": + unittest.main() From e37dccc1aa0bfbe79cb4fe3a72e94437cfca8e97 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 08:21:17 -0700 Subject: [PATCH 30/50] fix(browser): preserve fail-closed crash cleanup errors --- scripts/ci/run_mv3_compatibility.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 2553e54a4..b7a0c7f50 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -58,6 +58,10 @@ PATH_TOKEN_CHARACTERS = frozenset(string.ascii_letters + string.digits + "-_.") +class _WebDriverNoSuchWindowError(RuntimeError): + """Identify reviewed ChromeDriver no-such-window evidence without masking other failures.""" + + class QuietFixtureHandler(http.server.SimpleHTTPRequestHandler): """Serve only the controlled local fixture without noisy access logging.""" @@ -141,7 +145,7 @@ def _json_request( isinstance(error_value, dict) and error_value.get("error") == "no such window" ): - raise RuntimeError( + raise _WebDriverNoSuchWindowError( "WebDriver error: no such window: response details redacted" ) raise RuntimeError(f"WebDriver HTTP {response.status}") @@ -154,7 +158,7 @@ def _json_request( value = decoded.get("value") if isinstance(value, dict) and value.get("error"): if value.get("error") == "no such window": - raise RuntimeError( + raise _WebDriverNoSuchWindowError( "WebDriver error: no such window: response details redacted" ) raise RuntimeError("WebDriver returned an error response") @@ -2137,7 +2141,12 @@ def _cleanup_crashed_browser_session(driver_port: int, session_id: str | None) - _webdriver_path(session_id, ""), {}, ) - except (OSError, RuntimeError, json.JSONDecodeError, http.client.IncompleteRead): + except ( + OSError, + _WebDriverNoSuchWindowError, + json.JSONDecodeError, + http.client.IncompleteRead, + ): return From 5742d00d958c4069f66d931412f6a2d2c13edb1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:10:11 +0900 Subject: [PATCH 31/50] test(browser): expose bounded crash failure stage --- tests/test_agent_task_browser_crash_recovery_contract.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_agent_task_browser_crash_recovery_contract.py b/tests/test_agent_task_browser_crash_recovery_contract.py index 40e9929f8..9f8edd260 100644 --- a/tests/test_agent_task_browser_crash_recovery_contract.py +++ b/tests/test_agent_task_browser_crash_recovery_contract.py @@ -265,6 +265,10 @@ def test_crash_startup_keeps_sandbox_and_fails_without_fallback(self) -> None: self.assertFalse(result["passed"]) self.assertEqual(result["failure_type"], "RuntimeError") + self.assertEqual(result["failure_stage"], "session_create") + self.assertEqual(result["reason_code"], "runtime_error") + self.assertNotIn("failure_message", result) + self.assertNotIn("sandbox unavailable", repr(result)) self.assertTrue(result["profile_cleaned"]) launch.assert_called_once() driver.wait.assert_called_once_with(timeout=5) From bf5adac58318bdb1f32c35771b7741c175eb1400 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:17:15 +0900 Subject: [PATCH 32/50] fix(browser): retain bounded crash failure diagnostics --- scripts/ci/run_mv3_compatibility.py | 72 +++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index c95c1f290..8331d87f9 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -2319,6 +2319,70 @@ def _run_agent_task_browser_crash_browser_pass( } +def _classify_agent_task_browser_crash_reason(error: BaseException) -> str: + """Map crash failures onto a closed reason vocabulary without retaining messages.""" + + if isinstance(error, subprocess.TimeoutExpired): + return "timeout" + if isinstance(error, json.JSONDecodeError): + return "invalid_json" + if isinstance(error, ValueError): + return "invalid_value" + if isinstance(error, OSError): + return "os_error" + return "runtime_error" + + +def _classify_agent_task_browser_crash_stage(error: BaseException) -> str: + """Classify one crash failure from bounded traceback structure and pass state.""" + + traceback_cursor = error.__traceback__ + pass_locals: dict[str, Any] | None = None + function_names: set[str] = set() + while traceback_cursor is not None: + frame = traceback_cursor.tb_frame + function_name = frame.f_code.co_name + function_names.add(function_name) + if function_name == "_run_agent_task_browser_crash_browser_pass": + pass_locals = dict(frame.f_locals) + traceback_cursor = traceback_cursor.tb_next + + if "_wait_for_driver" in function_names: + return "driver_ready" + if "_cleanup_crashed_browser_session" in function_names: + return "session_cleanup" + if "_stop_crashed_driver" in function_names: + return "driver_teardown" + if "_signal_and_wait_for_linux_process_identity_termination" in function_names: + return "crash_signal" + if "_wait_for_linux_process_teardown" in function_names: + return "post_crash_teardown" + if function_names.intersection( + { + "_snapshot_linux_process_evidence", + "_discover_linux_process_tree_ids", + "_read_linux_process_identity_set", + } + ): + return "process_tree_capture" + + if pass_locals is None: + return "browser_pass" + if "driver" not in pass_locals: + return "driver_start" + if pass_locals.get("session_id") is None: + return "session_create" + if pass_locals.get("browser_process_id") is None: + return "session_identity" + if pass_locals.get("browser_process_start_time_ticks") is None: + return "browser_identity" + if pass_locals.get("chromium_process_identities") is None: + return "fixture_navigation" + if pass_locals.get("browser_process_crash_detected") is not True: + return "crash_signal" + return "post_crash_teardown" + + def _run_agent_task_browser_crash_trial( chrome_bin: pathlib.Path, chromedriver_bin: pathlib.Path, @@ -2331,6 +2395,8 @@ def _run_agent_task_browser_crash_trial( profile_path: pathlib.Path result: dict[str, Any] | None = None failure_type: str | None = None + failure_stage: str | None = None + reason_code: str | None = None with tempfile.TemporaryDirectory( prefix=f"originweave-agent-task-browser-crash-{trial_number}-" ) as profile_dir: @@ -2350,6 +2416,8 @@ def _run_agent_task_browser_crash_trial( subprocess.TimeoutExpired, ) as exc: failure_type = type(exc).__name__ + failure_stage = _classify_agent_task_browser_crash_stage(exc) + reason_code = _classify_agent_task_browser_crash_reason(exc) profile_cleaned = not profile_path.exists() if not profile_cleaned: raise RuntimeError( @@ -2358,10 +2426,14 @@ def _run_agent_task_browser_crash_trial( duration_ms = round((time.monotonic() - trial_started) * 1000) if failure_type is not None: + if failure_stage is None or reason_code is None: + raise RuntimeError("Agent Task browser-crash failure classification was incomplete") return { "trial_number": trial_number, "passed": False, "failure_type": failure_type, + "failure_stage": failure_stage, + "reason_code": reason_code, "profile_cleaned": True, "duration_ms": duration_ms, } From 08eab999e63deefce08a7349ac9c2b2dc642449d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:21:26 +0900 Subject: [PATCH 33/50] test(browser): lock crash reason vocabulary --- ...gent_task_browser_crash_recovery_contract.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_agent_task_browser_crash_recovery_contract.py b/tests/test_agent_task_browser_crash_recovery_contract.py index 9f8edd260..b313ac03d 100644 --- a/tests/test_agent_task_browser_crash_recovery_contract.py +++ b/tests/test_agent_task_browser_crash_recovery_contract.py @@ -3,6 +3,7 @@ from __future__ import annotations import http.client +import json import os import pathlib import runpy @@ -237,6 +238,22 @@ def driver_teardown_timeout(*_args: object, **_kwargs: object) -> dict[str, obje self.assertEqual(result["failure_type"], "TimeoutExpired") self.assertTrue(result["profile_cleaned"]) + def test_crash_reason_codes_are_closed_and_type_stable(self) -> None: + """Diagnostic reason codes must distinguish reviewed exception classes without messages.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_browser_crash_reasons") + classify = namespace["_classify_agent_task_browser_crash_reason"] + cases = ( + (subprocess.TimeoutExpired(cmd="chromedriver", timeout=5), "timeout"), + (json.JSONDecodeError("redacted", "{}", 0), "invalid_json"), + (ValueError("redacted"), "invalid_value"), + (OSError("redacted"), "os_error"), + (RuntimeError("redacted"), "runtime_error"), + ) + for error, expected in cases: + with self.subTest(error_type=type(error).__name__): + self.assertEqual(classify(error), expected) + def test_crash_startup_keeps_sandbox_and_fails_without_fallback(self) -> None: """A rejected sandboxed session must fail once, reap its driver and remove its profile.""" From b4a07979f86528192e4ae9040afd2c742595a645 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 02:26:00 +0900 Subject: [PATCH 34/50] fix(browser): keep JSON crash reason distinct From 4b763f08a9b63f3be28ff16865cf68c7e0296f3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:00:03 +0900 Subject: [PATCH 35/50] test(mv3): preserve primary crash failure evidence --- ..._browser_crash_cleanup_runtime_contract.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py b/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py index 58cbd01ee..1cd7b403d 100644 --- a/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py +++ b/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py @@ -5,6 +5,7 @@ import pathlib import runpy import unittest +from unittest import mock ROOT = pathlib.Path(__file__).resolve().parents[1] RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" @@ -28,6 +29,72 @@ def unexpected_runtime_failure(*_args: object, **_kwargs: object) -> object: with self.assertRaisesRegex(RuntimeError, "unexpected WebDriver protocol failure"): cleanup_session(9222, "session-1") + def test_primary_failure_survives_secondary_session_cleanup_failure(self) -> None: + """Cleanup diagnostics must not replace the first browser failure stage.""" + + namespace = runpy.run_path( + str(RUNNER), run_name="agent_task_browser_crash_primary_failure_contract" + ) + run_pass = namespace["_run_agent_task_browser_crash_browser_pass"] + pinned_version = namespace["PINNED_CHROME_VERSION"] + driver = mock.Mock() + driver.poll.return_value = 0 + + def request( + _driver_port: int, + method: str, + path: str, + _payload: object, + ) -> dict[str, object]: + if method == "POST" and path == "/session": + return { + "value": { + "sessionId": "session-1", + "capabilities": { + "browserVersion": pinned_version, + "goog:processID": 777, + }, + } + } + if method == "POST" and path.endswith("/url"): + raise RuntimeError("primary fixture navigation failure") + if method == "DELETE": + raise RuntimeError("secondary cleanup failure") + raise AssertionError(f"unexpected WebDriver request: {method} {path}") + + with ( + mock.patch.dict( + run_pass.__globals__, + { + "_free_loopback_port": lambda: 9222, + "_wait_for_driver": lambda _port: None, + "_json_request": request, + "_read_linux_proc_stat_process_identity": lambda _pid: (777, 42), + }, + ), + mock.patch.object( + run_pass.__globals__["subprocess"], + "Popen", + return_value=driver, + ), + ): + result = namespace["_run_agent_task_browser_crash_trial"]( + pathlib.Path("/controlled/chrome"), + pathlib.Path("/controlled/chromedriver"), + "http://127.0.0.1/agent-task", + 1, + ) + + self.assertFalse(result["passed"]) + self.assertEqual(result["failure_type"], "RuntimeError") + self.assertEqual(result["failure_stage"], "fixture_navigation") + self.assertEqual(result["reason_code"], "runtime_error") + self.assertEqual(result["session_cleanup_failure_type"], "RuntimeError") + self.assertNotIn("primary fixture navigation failure", repr(result)) + self.assertNotIn("secondary cleanup failure", repr(result)) + self.assertTrue(result["profile_cleaned"]) + driver.wait.assert_called_once_with(timeout=5) + if __name__ == "__main__": unittest.main() From c81732af40a2725b2ef18a2e52a1c1a5b783cc84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:13:38 +0900 Subject: [PATCH 36/50] fix(mv3): preserve primary browser crash diagnostics --- scripts/ci/run_mv3_compatibility.py | 55 +++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 8331d87f9..747aa171f 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -2288,8 +2288,10 @@ def _run_agent_task_browser_crash_browser_pass( ) browser_process_crash_detected = True finally: - _cleanup_crashed_browser_session(driver_port, session_id) - _stop_crashed_driver(driver) + try: + _cleanup_crashed_browser_session(driver_port, session_id) + finally: + _stop_crashed_driver(driver) if ( browser_process_id is None @@ -2383,6 +2385,33 @@ def _classify_agent_task_browser_crash_stage(error: BaseException) -> str: return "post_crash_teardown" +def _partition_agent_task_browser_crash_failure( + error: BaseException, +) -> tuple[BaseException, str | None, str | None]: + """Preserve the first crash failure while retaining typed secondary cleanup evidence.""" + + primary_error = error + session_cleanup_failure_type: str | None = None + driver_cleanup_failure_type: str | None = None + visited: set[int] = set() + while id(primary_error) not in visited: + visited.add(id(primary_error)) + stage = _classify_agent_task_browser_crash_stage(primary_error) + context = primary_error.__context__ + if context is None: + break + if stage == "driver_teardown": + driver_cleanup_failure_type = type(primary_error).__name__ + primary_error = context + continue + if stage == "session_cleanup": + session_cleanup_failure_type = type(primary_error).__name__ + primary_error = context + continue + break + return primary_error, session_cleanup_failure_type, driver_cleanup_failure_type + + def _run_agent_task_browser_crash_trial( chrome_bin: pathlib.Path, chromedriver_bin: pathlib.Path, @@ -2397,6 +2426,8 @@ def _run_agent_task_browser_crash_trial( failure_type: str | None = None failure_stage: str | None = None reason_code: str | None = None + session_cleanup_failure_type: str | None = None + cleanup_failure_type: str | None = None with tempfile.TemporaryDirectory( prefix=f"originweave-agent-task-browser-crash-{trial_number}-" ) as profile_dir: @@ -2415,9 +2446,14 @@ def _run_agent_task_browser_crash_trial( json.JSONDecodeError, subprocess.TimeoutExpired, ) as exc: - failure_type = type(exc).__name__ - failure_stage = _classify_agent_task_browser_crash_stage(exc) - reason_code = _classify_agent_task_browser_crash_reason(exc) + ( + primary_error, + session_cleanup_failure_type, + cleanup_failure_type, + ) = _partition_agent_task_browser_crash_failure(exc) + failure_type = type(primary_error).__name__ + failure_stage = _classify_agent_task_browser_crash_stage(primary_error) + reason_code = _classify_agent_task_browser_crash_reason(primary_error) profile_cleaned = not profile_path.exists() if not profile_cleaned: raise RuntimeError( @@ -2428,7 +2464,7 @@ def _run_agent_task_browser_crash_trial( if failure_type is not None: if failure_stage is None or reason_code is None: raise RuntimeError("Agent Task browser-crash failure classification was incomplete") - return { + failure_evidence: dict[str, Any] = { "trial_number": trial_number, "passed": False, "failure_type": failure_type, @@ -2437,6 +2473,13 @@ def _run_agent_task_browser_crash_trial( "profile_cleaned": True, "duration_ms": duration_ms, } + if session_cleanup_failure_type is not None: + failure_evidence["session_cleanup_failure_type"] = ( + session_cleanup_failure_type + ) + if cleanup_failure_type is not None: + failure_evidence["cleanup_failure_type"] = cleanup_failure_type + return failure_evidence if result is None: raise RuntimeError("Agent Task browser-crash browser pass returned no result") return { From 2d0b3c9fae5d76bed2c73ab276734e4a89dbb154 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:18:08 +0900 Subject: [PATCH 37/50] test(mv3): cover secondary crash teardown evidence --- ..._browser_crash_cleanup_runtime_contract.py | 98 ++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py b/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py index 1cd7b403d..8e930e1a3 100644 --- a/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py +++ b/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py @@ -30,7 +30,7 @@ def unexpected_runtime_failure(*_args: object, **_kwargs: object) -> object: cleanup_session(9222, "session-1") def test_primary_failure_survives_secondary_session_cleanup_failure(self) -> None: - """Cleanup diagnostics must not replace the first browser failure stage.""" + """Session cleanup diagnostics must not replace the first browser failure stage.""" namespace = runpy.run_path( str(RUNNER), run_name="agent_task_browser_crash_primary_failure_contract" @@ -95,6 +95,102 @@ def request( self.assertTrue(result["profile_cleaned"]) driver.wait.assert_called_once_with(timeout=5) + def test_primary_failure_survives_secondary_driver_teardown_failure(self) -> None: + """Driver teardown diagnostics must not replace the first browser failure stage.""" + + namespace = runpy.run_path( + str(RUNNER), run_name="agent_task_browser_crash_driver_failure_contract" + ) + run_pass = namespace["_run_agent_task_browser_crash_browser_pass"] + pinned_version = namespace["PINNED_CHROME_VERSION"] + driver = mock.Mock() + + def request( + _driver_port: int, + method: str, + path: str, + _payload: object, + ) -> dict[str, object]: + if method == "POST" and path == "/session": + return { + "value": { + "sessionId": "session-1", + "capabilities": { + "browserVersion": pinned_version, + "goog:processID": 777, + }, + } + } + if method == "POST" and path.endswith("/url"): + raise RuntimeError("primary fixture navigation failure") + if method == "DELETE": + return {"value": None} + raise AssertionError(f"unexpected WebDriver request: {method} {path}") + + def fail_driver_teardown(_driver: object) -> None: + raise RuntimeError("secondary driver teardown failure") + + with ( + mock.patch.dict( + run_pass.__globals__, + { + "_free_loopback_port": lambda: 9222, + "_wait_for_driver": lambda _port: None, + "_json_request": request, + "_read_linux_proc_stat_process_identity": lambda _pid: (777, 42), + "_stop_crashed_driver": fail_driver_teardown, + }, + ), + mock.patch.object( + run_pass.__globals__["subprocess"], + "Popen", + return_value=driver, + ), + ): + result = namespace["_run_agent_task_browser_crash_trial"]( + pathlib.Path("/controlled/chrome"), + pathlib.Path("/controlled/chromedriver"), + "http://127.0.0.1/agent-task", + 1, + ) + + self.assertFalse(result["passed"]) + self.assertEqual(result["failure_type"], "RuntimeError") + self.assertEqual(result["failure_stage"], "fixture_navigation") + self.assertEqual(result["reason_code"], "runtime_error") + self.assertEqual(result["cleanup_failure_type"], "RuntimeError") + self.assertNotIn("primary fixture navigation failure", repr(result)) + self.assertNotIn("secondary driver teardown failure", repr(result)) + self.assertTrue(result["profile_cleaned"]) + + def test_cleanup_only_failure_remains_primary(self) -> None: + """Do not demote cleanup failure when no earlier browser failure exists.""" + + namespace = runpy.run_path( + str(RUNNER), run_name="agent_task_browser_crash_cleanup_only_contract" + ) + cleanup_session = namespace["_cleanup_crashed_browser_session"] + partition_failure = namespace["_partition_agent_task_browser_crash_failure"] + classify_stage = namespace["_classify_agent_task_browser_crash_stage"] + + def unexpected_runtime_failure(*_args: object, **_kwargs: object) -> object: + raise RuntimeError("cleanup-only failure") + + cleanup_session.__globals__["_json_request"] = unexpected_runtime_failure + try: + cleanup_session(9222, "session-1") + except RuntimeError as error: + primary_error, session_failure_type, driver_failure_type = partition_failure( + error + ) + else: + self.fail("cleanup-only RuntimeError was unexpectedly suppressed") + + self.assertIs(primary_error, error) + self.assertEqual(classify_stage(primary_error), "session_cleanup") + self.assertIsNone(session_failure_type) + self.assertIsNone(driver_failure_type) + if __name__ == "__main__": unittest.main() From 3d11ddde3352238e23ea5a2847fa206c34022d00 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:18:34 +0900 Subject: [PATCH 38/50] fix(test): retain cleanup-only exception identity --- ...test_agent_task_browser_crash_cleanup_runtime_contract.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py b/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py index 8e930e1a3..e305c7e20 100644 --- a/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py +++ b/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py @@ -177,16 +177,19 @@ def unexpected_runtime_failure(*_args: object, **_kwargs: object) -> object: raise RuntimeError("cleanup-only failure") cleanup_session.__globals__["_json_request"] = unexpected_runtime_failure + captured_error: RuntimeError | None = None try: cleanup_session(9222, "session-1") except RuntimeError as error: + captured_error = error primary_error, session_failure_type, driver_failure_type = partition_failure( error ) else: self.fail("cleanup-only RuntimeError was unexpectedly suppressed") - self.assertIs(primary_error, error) + self.assertIsNotNone(captured_error) + self.assertIs(primary_error, captured_error) self.assertEqual(classify_stage(primary_error), "session_cleanup") self.assertIsNone(session_failure_type) self.assertIsNone(driver_failure_type) From 8f34ad949b58b1db0d905b51b19c6d2e819c7037 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:20:33 +0900 Subject: [PATCH 39/50] fix(test): exercise real driver teardown classifier --- ...st_agent_task_browser_crash_cleanup_runtime_contract.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py b/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py index e305c7e20..ba5ba260c 100644 --- a/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py +++ b/tests/test_agent_task_browser_crash_cleanup_runtime_contract.py @@ -104,6 +104,8 @@ def test_primary_failure_survives_secondary_driver_teardown_failure(self) -> Non run_pass = namespace["_run_agent_task_browser_crash_browser_pass"] pinned_version = namespace["PINNED_CHROME_VERSION"] driver = mock.Mock() + driver.poll.return_value = 0 + driver.wait.side_effect = RuntimeError("secondary driver teardown failure") def request( _driver_port: int, @@ -127,9 +129,6 @@ def request( return {"value": None} raise AssertionError(f"unexpected WebDriver request: {method} {path}") - def fail_driver_teardown(_driver: object) -> None: - raise RuntimeError("secondary driver teardown failure") - with ( mock.patch.dict( run_pass.__globals__, @@ -138,7 +137,6 @@ def fail_driver_teardown(_driver: object) -> None: "_wait_for_driver": lambda _port: None, "_json_request": request, "_read_linux_proc_stat_process_identity": lambda _pid: (777, 42), - "_stop_crashed_driver": fail_driver_teardown, }, ), mock.patch.object( @@ -162,6 +160,7 @@ def fail_driver_teardown(_driver: object) -> None: self.assertNotIn("primary fixture navigation failure", repr(result)) self.assertNotIn("secondary driver teardown failure", repr(result)) self.assertTrue(result["profile_cleaned"]) + driver.wait.assert_called_once_with(timeout=5) def test_cleanup_only_failure_remains_primary(self) -> None: """Do not demote cleanup failure when no earlier browser failure exists.""" From e1f4e76df66d27e25d38b4005019258e9c3bbafa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:21:53 +0900 Subject: [PATCH 40/50] docs(changelog): preserve primary crash diagnostics --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af674edc8..1cc6d58d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] - Browser-crash trials no longer disable Chromium's sandbox. Rejected session startup remains one failed attempt with driver and temporary-profile cleanup, without an unsandboxed retry; live pinned-browser acceptance is still required. +- Browser-crash failure evidence now preserves the first causal browser failure when session cleanup or ChromeDriver teardown also fails, retains secondary cleanup only as bounded exception-type fields, and excludes raw exception text from the emitted artifact. Cleanup-only failures remain fail-closed as primary failures. ### Added From ce6a26059cfeee54e1222343a51f5cde07c84d8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:28:18 +0900 Subject: [PATCH 41/50] test(browser): require sandboxed real-browser evidence paths --- tests/test_mv3_browser_sandbox_contract.py | 33 ++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/test_mv3_browser_sandbox_contract.py diff --git a/tests/test_mv3_browser_sandbox_contract.py b/tests/test_mv3_browser_sandbox_contract.py new file mode 100644 index 000000000..c77bd0a52 --- /dev/null +++ b/tests/test_mv3_browser_sandbox_contract.py @@ -0,0 +1,33 @@ +"""Regression contract for preserving Chromium sandboxing in real-browser evidence.""" + +from __future__ import annotations + +import inspect +import pathlib +import runpy +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class BrowserSandboxContractTests(unittest.TestCase): + """Require every real-Chromium evidence path to keep process sandboxing enabled.""" + + def test_real_browser_passes_do_not_disable_chromium_sandbox(self) -> None: + """No real-browser evidence lane may launch Chrome with ``--no-sandbox``.""" + + namespace = runpy.run_path(str(RUNNER), run_name="browser_sandbox_contract") + for function_name in ( + "_run_browser_pass", + "_run_agent_task_browser_pass", + "_run_agent_task_forced_close_browser_pass", + "_run_agent_task_browser_crash_browser_pass", + ): + with self.subTest(function_name=function_name): + source = inspect.getsource(namespace[function_name]) + self.assertNotIn('"--no-sandbox"', source) + + +if __name__ == "__main__": + unittest.main() From 0135984f1bc1f68d89d7777f49c4999474105a12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 05:34:06 +0900 Subject: [PATCH 42/50] fix(browser): keep every real-browser evidence lane sandboxed --- scripts/ci/run_mv3_compatibility.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 747aa171f..b9b9db81b 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -1097,7 +1097,6 @@ def _run_browser_pass( "--disable-component-update", "--disable-sync", "--disable-dev-shm-usage", - "--no-sandbox", f"--user-data-dir={profile_dir}", f"--disable-extensions-except={FIXTURE}", f"--load-extension={FIXTURE}", @@ -1355,7 +1354,6 @@ def _run_agent_task_browser_pass( "--disable-component-update", "--disable-sync", "--disable-dev-shm-usage", - "--no-sandbox", "--disable-extensions", f"--user-data-dir={profile_dir}", ], @@ -1825,7 +1823,6 @@ def _run_agent_task_forced_close_browser_pass( "--disable-component-update", "--disable-sync", "--disable-dev-shm-usage", - "--no-sandbox", "--disable-extensions", f"--user-data-dir={profile_dir}", ], From 2323ff54e7bcad4c37eddfeb9274562982d52ac5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 8 Sep 2026 22:32:26 +0900 Subject: [PATCH 43/50] fix(browser): retain bounded startup failure evidence --- CHANGELOG.md | 1 + docs/doctoring.md | 2 + scripts/ci/run_mv3_compatibility.py | 41 +++++++++++++++ ...nt_task_browser_crash_recovery_contract.py | 10 ++-- ..._chromedriver_error_diagnostic_contract.py | 51 ++++++++++++++++++- 5 files changed, 100 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cc6d58d3..5ddfa2130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- ChromeDriver `session not created` responses now retain a typed `session_not_created` failure and only the allowlisted `sandbox_unavailable` or `unknown` startup reason; raw driver-controlled response text remains excluded from browser-crash evidence. - Browser-crash trials no longer disable Chromium's sandbox. Rejected session startup remains one failed attempt with driver and temporary-profile cleanup, without an unsandboxed retry; live pinned-browser acceptance is still required. - Browser-crash failure evidence now preserves the first causal browser failure when session cleanup or ChromeDriver teardown also fails, retains secondary cleanup only as bounded exception-type fields, and excludes raw exception text from the emitted artifact. Cleanup-only failures remain fail-closed as primary failures. diff --git a/docs/doctoring.md b/docs/doctoring.md index eedc9795c..4d4b5fd2b 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -130,6 +130,8 @@ PR #148 adopts the current #147 parent `3dff28d9bf2dd27b72507e39979d51b8bf140fb4 The same regression requires one failed startup attempt, driver reaping and actual temporary-profile removal. It preserves browser executable selection and never credits the failed startup as a successful crash trial. The existing PID-safe signal/exit observers, sampled-process checks and trial denominator are unchanged. Other launch lanes retain their separate owner repairs; this child must not be interpreted as complete runner-wide sandbox integration. Mocked launch requests prove the control-flow contract, not that a real Chromium binary started with its sandbox active. Linux pidfd tests and exact-head pinned-Chromium compatibility remain separate acceptance evidence; macOS skips are not passes. +ChromeDriver session creation is also an evidence boundary. A structured `session not created` response is retained as a typed `session_not_created` failure. Only the reviewed `No usable sandbox` diagnostic maps to `sandbox_unavailable`; every other driver-controlled message maps to `unknown`. Raw response text, executable/profile paths and arbitrary diagnostics remain excluded, so the next pinned-browser run can distinguish the sandbox-helper case without admitting untrusted ChromeDriver prose into CI evidence. This classification does not install the helper, retry startup, disable the sandbox or make cleanup equivalent to browser success. + Supplemental verification used the existing Colima Linux kernel `6.8.0-117-generic` and Python `3.12.3`, without installing dependencies or changing VM configuration. All 247 Python contracts execute there with no skips, including the three real pidfd cases skipped on macOS: killed-but-unreaped child, non-terminating signal and stale identity. Initial host-path discovery failed because this VM does not expose the host worktree. The first streamed archive then added AppleDouble `._*.rs` files, causing two TLS source-read errors. A fresh export of only Git-tracked paths with `COPYFILE_DISABLE=1 tar --no-xattrs --no-acls --no-fflags` removes that packaging artifact at its producer; no test filter or source exception was added. Source and regression-file SHA-256 hashes match across hosts. This proves the supplemental Linux contracts, not pinned-Chromium execution, hosted approval or release acceptance. ## References diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index b9b9db81b..d92b2a960 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -62,6 +62,20 @@ class _WebDriverNoSuchWindowError(RuntimeError): """Identify reviewed ChromeDriver no-such-window evidence without masking other failures.""" +class _WebDriverSessionNotCreatedError(RuntimeError): + """Retain only closed ChromeDriver startup evidence from a rejected session.""" + + error_code = "session_not_created" + + def __init__(self, startup_reason: str) -> None: + """Build one redacted typed failure with an allowlisted startup reason.""" + + if startup_reason not in {"sandbox_unavailable", "unknown"}: + raise ValueError("unsupported WebDriver session startup reason") + self.startup_reason = startup_reason + super().__init__("WebDriver error: session not created: response details redacted") + + class QuietFixtureHandler(http.server.SimpleHTTPRequestHandler): """Serve only the controlled local fixture without noisy access logging.""" @@ -101,6 +115,15 @@ def _webdriver_path(session_id: str, suffix: str) -> str: return f"/session/{safe_session}{suffix}" +def _classify_webdriver_session_startup_reason(error_value: dict[str, Any]) -> str: + """Map reviewed ChromeDriver startup text to a closed credential-safe reason.""" + + message = error_value.get("message") + if isinstance(message, str) and "no usable sandbox" in message.casefold(): + return "sandbox_unavailable" + return "unknown" + + def _json_request( driver_port: int, method: str, @@ -148,6 +171,13 @@ def _json_request( raise _WebDriverNoSuchWindowError( "WebDriver error: no such window: response details redacted" ) + if ( + isinstance(error_value, dict) + and error_value.get("error") == "session not created" + ): + raise _WebDriverSessionNotCreatedError( + _classify_webdriver_session_startup_reason(error_value) + ) raise RuntimeError(f"WebDriver HTTP {response.status}") finally: connection.close() @@ -161,6 +191,10 @@ def _json_request( raise _WebDriverNoSuchWindowError( "WebDriver error: no such window: response details redacted" ) + if value.get("error") == "session not created": + raise _WebDriverSessionNotCreatedError( + _classify_webdriver_session_startup_reason(value) + ) raise RuntimeError("WebDriver returned an error response") return decoded @@ -2321,6 +2355,8 @@ def _run_agent_task_browser_crash_browser_pass( def _classify_agent_task_browser_crash_reason(error: BaseException) -> str: """Map crash failures onto a closed reason vocabulary without retaining messages.""" + if isinstance(error, _WebDriverSessionNotCreatedError): + return error.error_code if isinstance(error, subprocess.TimeoutExpired): return "timeout" if isinstance(error, json.JSONDecodeError): @@ -2423,6 +2459,7 @@ def _run_agent_task_browser_crash_trial( failure_type: str | None = None failure_stage: str | None = None reason_code: str | None = None + startup_reason: str | None = None session_cleanup_failure_type: str | None = None cleanup_failure_type: str | None = None with tempfile.TemporaryDirectory( @@ -2451,6 +2488,8 @@ def _run_agent_task_browser_crash_trial( failure_type = type(primary_error).__name__ failure_stage = _classify_agent_task_browser_crash_stage(primary_error) reason_code = _classify_agent_task_browser_crash_reason(primary_error) + if isinstance(primary_error, _WebDriverSessionNotCreatedError): + startup_reason = primary_error.startup_reason profile_cleaned = not profile_path.exists() if not profile_cleaned: raise RuntimeError( @@ -2476,6 +2515,8 @@ def _run_agent_task_browser_crash_trial( ) if cleanup_failure_type is not None: failure_evidence["cleanup_failure_type"] = cleanup_failure_type + if startup_reason is not None: + failure_evidence["startup_reason"] = startup_reason return failure_evidence if result is None: raise RuntimeError("Agent Task browser-crash browser pass returned no result") diff --git a/tests/test_agent_task_browser_crash_recovery_contract.py b/tests/test_agent_task_browser_crash_recovery_contract.py index b313ac03d..aaafc0237 100644 --- a/tests/test_agent_task_browser_crash_recovery_contract.py +++ b/tests/test_agent_task_browser_crash_recovery_contract.py @@ -259,7 +259,8 @@ def test_crash_startup_keeps_sandbox_and_fails_without_fallback(self) -> None: namespace = runpy.run_path(str(RUNNER), run_name="agent_task_browser_crash_sandbox") run_pass = namespace["_run_agent_task_browser_crash_browser_pass"] - request = mock.Mock(side_effect=RuntimeError("sandbox unavailable")) + session_error = namespace["_WebDriverSessionNotCreatedError"] + request = mock.Mock(side_effect=session_error("sandbox_unavailable")) driver = mock.Mock() driver.poll.return_value = 0 with ( @@ -281,11 +282,12 @@ def test_crash_startup_keeps_sandbox_and_fails_without_fallback(self) -> None: ) self.assertFalse(result["passed"]) - self.assertEqual(result["failure_type"], "RuntimeError") + self.assertEqual(result["failure_type"], "_WebDriverSessionNotCreatedError") self.assertEqual(result["failure_stage"], "session_create") - self.assertEqual(result["reason_code"], "runtime_error") + self.assertEqual(result["reason_code"], "session_not_created") + self.assertEqual(result["startup_reason"], "sandbox_unavailable") self.assertNotIn("failure_message", result) - self.assertNotIn("sandbox unavailable", repr(result)) + self.assertNotIn("response details", repr(result)) self.assertTrue(result["profile_cleaned"]) launch.assert_called_once() driver.wait.assert_called_once_with(timeout=5) diff --git a/tests/test_chromedriver_error_diagnostic_contract.py b/tests/test_chromedriver_error_diagnostic_contract.py index f9f0f9734..5a5369d6f 100644 --- a/tests/test_chromedriver_error_diagnostic_contract.py +++ b/tests/test_chromedriver_error_diagnostic_contract.py @@ -37,13 +37,21 @@ def log_message(self, _format: str, *args: object) -> None: class ChromeDriverErrorDiagnosticContractTests(unittest.TestCase): """ChromeDriver-controlled response bytes must not be reflected into CI errors.""" - def _request_against(self, *, status: int) -> RuntimeError: + def _request_against( + self, + *, + status: int, + response_body: bytes | None = None, + ) -> RuntimeError: namespace = runpy.run_path(str(RUNNER), run_name="chromedriver_error_diagnostic_contract") json_request = namespace["_json_request"] class Handler(_ErrorResponseHandler): response_status = status + if response_body is not None: + Handler.response_body = response_body + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() @@ -70,6 +78,47 @@ def test_webdriver_error_does_not_reflect_response_message(self) -> None: self.assertIn("WebDriver", str(error)) self.assertNotIn(SECRET_MARKER, str(error)) + def test_session_not_created_retains_only_allowlisted_startup_reason(self) -> None: + """A sandbox startup failure must keep typed safe evidence without raw detail.""" + + sandbox_response = ( + b'{"value":{"error":"session not created","message":"' + b'session not created: Chrome failed to start: No usable sandbox! ' + + SECRET_MARKER.encode("ascii") + + b'"}}' + ) + for status in (500, 200): + with self.subTest(status=status): + error = self._request_against( + status=status, + response_body=sandbox_response, + ) + + self.assertEqual(type(error).__name__, "_WebDriverSessionNotCreatedError") + self.assertEqual(getattr(error, "error_code", None), "session_not_created") + self.assertEqual( + getattr(error, "startup_reason", None), + "sandbox_unavailable", + ) + self.assertNotIn(SECRET_MARKER, str(error)) + self.assertNotIn(SECRET_MARKER, repr(error)) + + def test_unrecognized_session_startup_detail_remains_unknown_and_redacted(self) -> None: + """Unreviewed ChromeDriver prose must not become evidence or a new reason code.""" + + unknown_response = ( + b'{"value":{"error":"session not created","message":"' + + SECRET_MARKER.encode("ascii") + + b'"}}' + ) + error = self._request_against(status=500, response_body=unknown_response) + + self.assertEqual(type(error).__name__, "_WebDriverSessionNotCreatedError") + self.assertEqual(getattr(error, "error_code", None), "session_not_created") + self.assertEqual(getattr(error, "startup_reason", None), "unknown") + self.assertNotIn(SECRET_MARKER, str(error)) + self.assertNotIn(SECRET_MARKER, repr(error)) + if __name__ == "__main__": unittest.main() From 5a86b28bb84643c3296c937a0480d70c9229ed6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 01:08:05 +0900 Subject: [PATCH 44/50] test(browser): require bounded ChromeDriver process diagnostics --- ...hromedriver_process_diagnostic_contract.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/test_chromedriver_process_diagnostic_contract.py diff --git a/tests/test_chromedriver_process_diagnostic_contract.py b/tests/test_chromedriver_process_diagnostic_contract.py new file mode 100644 index 000000000..3d6cfc191 --- /dev/null +++ b/tests/test_chromedriver_process_diagnostic_contract.py @@ -0,0 +1,96 @@ +"""Contract tests for credential-safe ChromeDriver process-start diagnostics.""" + +from __future__ import annotations + +import io +import os +import pathlib +import runpy +import threading +import unittest + + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER_PATH = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class ChromeDriverProcessDiagnosticContractTests(unittest.TestCase): + """Keep process diagnostics bounded to reviewed reason codes and drained continuously.""" + + @classmethod + def setUpClass(cls) -> None: + cls.runner = runpy.run_path( + str(RUNNER_PATH), + run_name="originweave_mv3_process_diagnostic_contract", + ) + + def test_split_sandbox_marker_is_classified_without_raw_retention(self) -> None: + diagnostic_type = self.runner["_ChromeDriverStartupDiagnostic"] + diagnostic = diagnostic_type() + + diagnostic.feed(b"prefix /tmp/private-profile token=do-not-retain No usable sand") + self.assertEqual(diagnostic.startup_reason, "unknown") + diagnostic.feed(b"box! suffix secret-shaped-value") + + self.assertEqual(diagnostic.startup_reason, "sandbox_unavailable") + rendered_state = repr(diagnostic) + self.assertNotIn("private-profile", rendered_state) + self.assertNotIn("do-not-retain", rendered_state) + self.assertNotIn("secret-shaped-value", rendered_state) + self.assertFalse(hasattr(diagnostic, "__dict__")) + + def test_unknown_process_diagnostic_stays_unknown(self) -> None: + diagnostic_type = self.runner["_ChromeDriverStartupDiagnostic"] + diagnostic = diagnostic_type() + + diagnostic.feed(b"session not created: /tmp/private-profile bearer-secret") + + self.assertEqual(diagnostic.startup_reason, "unknown") + self.assertNotIn("private-profile", repr(diagnostic)) + self.assertNotIn("bearer-secret", repr(diagnostic)) + + def test_drain_consumes_large_pipe_without_retaining_payload(self) -> None: + diagnostic_type = self.runner["_ChromeDriverStartupDiagnostic"] + drain = self.runner["_drain_chromedriver_diagnostics"] + diagnostic = diagnostic_type() + read_fd, write_fd = os.pipe() + reader = os.fdopen(read_fd, "rb", buffering=0) + writer = os.fdopen(write_fd, "wb", buffering=0) + thread = threading.Thread(target=drain, args=(reader, diagnostic), daemon=True) + thread.start() + try: + writer.write(b"x" * 262_144) + writer.write(b"No usable sand") + writer.write(b"box!") + finally: + writer.close() + thread.join(timeout=2.0) + reader.close() + + self.assertFalse(thread.is_alive(), "ChromeDriver diagnostic pipe drain deadlocked") + self.assertEqual(diagnostic.startup_reason, "sandbox_unavailable") + self.assertNotIn("x" * 32, repr(diagnostic)) + + def test_bytesio_drain_keeps_unreviewed_text_out_of_state(self) -> None: + diagnostic_type = self.runner["_ChromeDriverStartupDiagnostic"] + drain = self.runner["_drain_chromedriver_diagnostics"] + diagnostic = diagnostic_type() + sensitive = b"/home/runner/private-profile bearer-super-secret" + + drain(io.BytesIO(sensitive), diagnostic) + + self.assertEqual(diagnostic.startup_reason, "unknown") + self.assertNotIn("private-profile", repr(diagnostic)) + self.assertNotIn("super-secret", repr(diagnostic)) + + def test_all_chromedriver_launches_stream_instead_of_discarding_output(self) -> None: + source = RUNNER_PATH.read_text(encoding="utf-8") + + self.assertNotIn("stdout=subprocess.DEVNULL", source) + self.assertIn("stdout=subprocess.PIPE", source) + self.assertEqual(source.count("_start_chromedriver("), 5) + self.assertEqual(source.count("_create_chromedriver_session("), 5) + + +if __name__ == "__main__": + unittest.main() From 19998d64e51fcdcb4e0f4f5ee06a0e464ffaaccb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 02:18:21 +0900 Subject: [PATCH 45/50] fix(browser): retain bounded ChromeDriver startup diagnostics --- scripts/ci/run_mv3_compatibility.py | 141 ++++++++++++++++++++-------- 1 file changed, 101 insertions(+), 40 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index d92b2a960..afc08a576 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -76,6 +76,37 @@ def __init__(self, startup_reason: str) -> None: super().__init__("WebDriver error: session not created: response details redacted") +class _ChromeDriverStartupDiagnostic: + """Retain only a closed startup reason while continuously discarding process output.""" + + __slots__ = ("_marker_index", "startup_reason") + _MARKER = b"no usable sandbox" + + def __init__(self) -> None: + """Start with no reviewed process-level startup reason.""" + + self._marker_index = 0 + self.startup_reason = "unknown" + + def feed(self, chunk: bytes) -> None: + """Scan one output chunk without retaining raw ChromeDriver-controlled bytes.""" + + if not isinstance(chunk, bytes): + raise TypeError("ChromeDriver diagnostic chunks must be bytes") + if self.startup_reason == "sandbox_unavailable": + return + for raw_byte in chunk: + byte = raw_byte + 32 if 65 <= raw_byte <= 90 else raw_byte + if byte == self._MARKER[self._marker_index]: + self._marker_index += 1 + if self._marker_index == len(self._MARKER): + self.startup_reason = "sandbox_unavailable" + self._marker_index = 0 + return + else: + self._marker_index = 1 if byte == self._MARKER[0] else 0 + + class QuietFixtureHandler(http.server.SimpleHTTPRequestHandler): """Serve only the controlled local fixture without noisy access logging.""" @@ -220,6 +251,64 @@ def _wait_for_driver(driver_port: int) -> None: raise RuntimeError(f"ChromeDriver did not become ready: {last_error}") +def _drain_chromedriver_diagnostics( + stream: Any, + diagnostic: _ChromeDriverStartupDiagnostic, +) -> None: + """Continuously drain ChromeDriver output while retaining only reviewed reason state.""" + + while True: + chunk = stream.read(8_192) + if not chunk: + return + if not isinstance(chunk, bytes): + raise TypeError("ChromeDriver diagnostic stream must be binary") + diagnostic.feed(chunk) + + +def _start_chromedriver( + chromedriver_bin: pathlib.Path, + driver_port: int, +) -> tuple[subprocess.Popen[Any], _ChromeDriverStartupDiagnostic]: + """Start one local ChromeDriver and continuously drain its credential-bearing output.""" + + diagnostic = _ChromeDriverStartupDiagnostic() + driver = subprocess.Popen( + [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + if driver.stdout is None: + driver.terminate() + driver.wait(timeout=PROCESS_EXIT_TIMEOUT_SECONDS) + raise RuntimeError("ChromeDriver diagnostic pipe was unavailable") + threading.Thread( + target=_drain_chromedriver_diagnostics, + args=(driver.stdout, diagnostic), + daemon=True, + ).start() + return driver, diagnostic + + +def _create_chromedriver_session( + driver_port: int, + payload: dict[str, Any], + diagnostic: _ChromeDriverStartupDiagnostic, +) -> dict[str, Any]: + """Create one session while allowing only reviewed process startup evidence to refine errors.""" + + _wait_for_driver(driver_port) + try: + return _json_request(driver_port, "POST", "/session", payload) + except _WebDriverSessionNotCreatedError as error: + if ( + error.startup_reason == "unknown" + and diagnostic.startup_reason == "sandbox_unavailable" + ): + raise _WebDriverSessionNotCreatedError("sandbox_unavailable") from None + raise + + def _execute(driver_port: int, session_id: str, script: str) -> Any: """Run fixture-only JavaScript through the test WebDriver session.""" @@ -1106,18 +1195,10 @@ def _run_browser_pass( driver_port = _free_loopback_port() session_id: str | None = None primary_error: BaseException | None = None - driver = subprocess.Popen( - [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], - stdout=subprocess.DEVNULL, - stderr=subprocess.STDOUT, - text=True, - ) + driver, startup_diagnostic = _start_chromedriver(chromedriver_bin, driver_port) try: - _wait_for_driver(driver_port) - session = _json_request( + session = _create_chromedriver_session( driver_port, - "POST", - "/session", { "capabilities": { "alwaysMatch": { @@ -1139,6 +1220,7 @@ def _run_browser_pass( } } }, + startup_diagnostic, ).get("value", {}) if not isinstance(session, dict): raise RuntimeError("ChromeDriver session response is malformed") @@ -1359,18 +1441,10 @@ def _run_agent_task_browser_pass( driver_cleanup_failure_type: str | None = None driver_kill_fallback_used = False result: dict[str, Any] | None = None - driver = subprocess.Popen( - [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], - stdout=subprocess.DEVNULL, - stderr=subprocess.STDOUT, - text=True, - ) + driver, startup_diagnostic = _start_chromedriver(chromedriver_bin, driver_port) try: - _wait_for_driver(driver_port) - session = _json_request( + session = _create_chromedriver_session( driver_port, - "POST", - "/session", { "capabilities": { "alwaysMatch": { @@ -1395,6 +1469,7 @@ def _run_agent_task_browser_pass( } } }, + startup_diagnostic, ).get("value", {}) if not isinstance(session, dict): raise RuntimeError("ChromeDriver Agent Task session response is malformed") @@ -1832,18 +1907,10 @@ def _run_agent_task_forced_close_browser_pass( driver_cleanup_failure_type: str | None = None driver_kill_fallback_used = False result: dict[str, Any] | None = None - driver = subprocess.Popen( - [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], - stdout=subprocess.DEVNULL, - stderr=subprocess.STDOUT, - text=True, - ) + driver, startup_diagnostic = _start_chromedriver(chromedriver_bin, driver_port) try: - _wait_for_driver(driver_port) - session = _json_request( + session = _create_chromedriver_session( driver_port, - "POST", - "/session", { "capabilities": { "alwaysMatch": { @@ -1864,6 +1931,7 @@ def _run_agent_task_forced_close_browser_pass( } } }, + startup_diagnostic, ).get("value", {}) if not isinstance(session, dict): raise RuntimeError("ChromeDriver forced-close session response is malformed") @@ -2221,18 +2289,10 @@ def _run_agent_task_browser_crash_browser_pass( chromium_process_identities: tuple[tuple[int, int], ...] | None = None browser_version: str | None = None browser_process_crash_detected = False - driver = subprocess.Popen( - [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], - stdout=subprocess.DEVNULL, - stderr=subprocess.STDOUT, - text=True, - ) + driver, startup_diagnostic = _start_chromedriver(chromedriver_bin, driver_port) try: - _wait_for_driver(driver_port) - session = _json_request( + session = _create_chromedriver_session( driver_port, - "POST", - "/session", { "capabilities": { "alwaysMatch": { @@ -2257,6 +2317,7 @@ def _run_agent_task_browser_crash_browser_pass( } } }, + startup_diagnostic, ).get("value", {}) if not isinstance(session, dict): raise RuntimeError("ChromeDriver browser-crash session response is malformed") From 2b8e0e263fc4b4865ef570df0ee36c52f561fbf9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 02:21:54 +0900 Subject: [PATCH 46/50] test(browser): require verbose ChromeDriver diagnostics --- tests/test_chromedriver_process_diagnostic_contract.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_chromedriver_process_diagnostic_contract.py b/tests/test_chromedriver_process_diagnostic_contract.py index 3d6cfc191..6aca70be2 100644 --- a/tests/test_chromedriver_process_diagnostic_contract.py +++ b/tests/test_chromedriver_process_diagnostic_contract.py @@ -91,6 +91,12 @@ def test_all_chromedriver_launches_stream_instead_of_discarding_output(self) -> self.assertEqual(source.count("_start_chromedriver("), 5) self.assertEqual(source.count("_create_chromedriver_session("), 5) + def test_shared_launch_enables_verbose_diagnostics_without_log_file(self) -> None: + source = RUNNER_PATH.read_text(encoding="utf-8") + + self.assertIn('"--verbose"', source) + self.assertNotIn("--log-path", source) + if __name__ == "__main__": unittest.main() From 0e320aad591ea78cc4775f5a451aeb15ee063214 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 02:23:05 +0900 Subject: [PATCH 47/50] test(browser): cover text-mode diagnostic doubles --- .../test_chromedriver_process_diagnostic_contract.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_chromedriver_process_diagnostic_contract.py b/tests/test_chromedriver_process_diagnostic_contract.py index 6aca70be2..8adf9c936 100644 --- a/tests/test_chromedriver_process_diagnostic_contract.py +++ b/tests/test_chromedriver_process_diagnostic_contract.py @@ -83,6 +83,18 @@ def test_bytesio_drain_keeps_unreviewed_text_out_of_state(self) -> None: self.assertNotIn("private-profile", repr(diagnostic)) self.assertNotIn("super-secret", repr(diagnostic)) + def test_text_stream_drain_supports_existing_process_doubles_without_retention(self) -> None: + diagnostic_type = self.runner["_ChromeDriverStartupDiagnostic"] + drain = self.runner["_drain_chromedriver_diagnostics"] + diagnostic = diagnostic_type() + sensitive = "prefix /tmp/private-profile No usable sandbox! bearer-super-secret" + + drain(io.StringIO(sensitive), diagnostic) + + self.assertEqual(diagnostic.startup_reason, "sandbox_unavailable") + self.assertNotIn("private-profile", repr(diagnostic)) + self.assertNotIn("super-secret", repr(diagnostic)) + def test_all_chromedriver_launches_stream_instead_of_discarding_output(self) -> None: source = RUNNER_PATH.read_text(encoding="utf-8") From 45b283d84d95b8d32552a53419bfbc0e5a4307f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 15:16:43 +0900 Subject: [PATCH 48/50] fix(browser): drain owned chromedriver diagnostics --- AGENTS.md | 2 ++ CHANGELOG.md | 1 + CLAUDE.md | 1 + scripts/ci/run_mv3_compatibility.py | 13 +++++++++--- ...hromedriver_process_diagnostic_contract.py | 20 +++++++++++++++++++ 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6f747c38e..b1b13502e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,8 @@ For every change: Do not bypass required checks, branch protection, or any review authority actually required by current GitHub rules or an explicit operationally satisfiable OriginWeave/CWL governance rule. Waiting checks are not permission to weaken tests; continue with a non-conflicting next task. +- ChromeDriver diagnostic drains must continuously discard bytes, accept existing text-mode process doubles by immediate UTF-8 replacement encoding, and silently stop on an unrecognized chunk; only the reviewed startup reason may survive. Shared ChromeDriver launch owns `--verbose` and never writes a diagnostic log file. + ## Work-conserving autonomous maintenance **A completed action is an intermediate state**, not an implicit end of a maintenance invocation. “One bounded slice” means **one write-active slice at a time**, not one slice, pull request, RCA, check, review request, documentation update, or merge per run. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ddfa2130..3ea680a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- ChromeDriver's shared diagnostic drain now accepts existing text-mode process doubles and discards unrecognized chunks without a background thread error; the shared launch retains verbose process evidence without a log file. - ChromeDriver `session not created` responses now retain a typed `session_not_created` failure and only the allowlisted `sandbox_unavailable` or `unknown` startup reason; raw driver-controlled response text remains excluded from browser-crash evidence. - Browser-crash trials no longer disable Chromium's sandbox. Rejected session startup remains one failed attempt with driver and temporary-profile cleanup, without an unsandboxed retry; live pinned-browser acceptance is still required. - Browser-crash failure evidence now preserves the first causal browser failure when session cleanup or ChromeDriver teardown also fails, retains secondary cleanup only as bounded exception-type fields, and excludes raw exception text from the emitted artifact. Cleanup-only failures remain fail-closed as primary failures. diff --git a/CLAUDE.md b/CLAUDE.md index ab08bc16f..5ac87efd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,4 +11,5 @@ Additional constraints: - Do not merge logical origin, destination authorization, direct TCP peer proof, TLS service identity, proxy routing, or HTTP resource policy into one ambient authority. - Do not add hostname reconnect, proxy-environment inheritance, dangerous certificate-verifier hooks, Common Name fallback, TLS 0-RTT, key logging, or secret extraction to a production TLS path. - Keep changes bounded to one product gap and preserve modular crate boundaries. +- ChromeDriver process output is a redaction boundary: support text-mode test doubles by encoding and discard unrecognized chunks without retaining them; emit only the closed diagnostic reason. - Never claim a test, benchmark, browser integration, TLS identity, GPU execution, release, or merge succeeded without current exact-head evidence. diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index afc08a576..b49e95336 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -261,8 +261,10 @@ def _drain_chromedriver_diagnostics( chunk = stream.read(8_192) if not chunk: return - if not isinstance(chunk, bytes): - raise TypeError("ChromeDriver diagnostic stream must be binary") + if isinstance(chunk, str): + chunk = chunk.encode("utf-8", "replace") + elif not isinstance(chunk, bytes): + return diagnostic.feed(chunk) @@ -274,7 +276,12 @@ def _start_chromedriver( diagnostic = _ChromeDriverStartupDiagnostic() driver = subprocess.Popen( - [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], + [ + str(chromedriver_bin), + f"--port={driver_port}", + "--allowed-ips=127.0.0.1", + "--verbose", + ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) diff --git a/tests/test_chromedriver_process_diagnostic_contract.py b/tests/test_chromedriver_process_diagnostic_contract.py index 8adf9c936..f4da00495 100644 --- a/tests/test_chromedriver_process_diagnostic_contract.py +++ b/tests/test_chromedriver_process_diagnostic_contract.py @@ -14,6 +14,15 @@ RUNNER_PATH = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" +class _UnrecognizedStream: + """Simulate a legacy process double with no usable diagnostic bytes.""" + + def read(self, _size: int) -> object: + """Return one deliberately unrecognized value.""" + + return object() + + class ChromeDriverProcessDiagnosticContractTests(unittest.TestCase): """Keep process diagnostics bounded to reviewed reason codes and drained continuously.""" @@ -95,6 +104,17 @@ def test_text_stream_drain_supports_existing_process_doubles_without_retention(s self.assertNotIn("private-profile", repr(diagnostic)) self.assertNotIn("super-secret", repr(diagnostic)) + def test_unrecognized_stream_chunk_is_discarded_without_thread_failure(self) -> None: + """Existing process doubles cannot turn discarded diagnostics into a thread error.""" + + diagnostic_type = self.runner["_ChromeDriverStartupDiagnostic"] + drain = self.runner["_drain_chromedriver_diagnostics"] + diagnostic = diagnostic_type() + + drain(_UnrecognizedStream(), diagnostic) + + self.assertEqual(diagnostic.startup_reason, "unknown") + def test_all_chromedriver_launches_stream_instead_of_discarding_output(self) -> None: source = RUNNER_PATH.read_text(encoding="utf-8") From 349fe818c49c8e0b92c890ee99c14e09d7d6f501 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 15:19:22 +0900 Subject: [PATCH 49/50] fix(browser): synchronize startup diagnostic handoff --- AGENTS.md | 1 + CHANGELOG.md | 1 + CLAUDE.md | 1 + scripts/ci/run_mv3_compatibility.py | 12 ++++++- ...hromedriver_process_diagnostic_contract.py | 32 +++++++++++++++++++ 5 files changed, 46 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b1b13502e..7f23f4d0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ For every change: Do not bypass required checks, branch protection, or any review authority actually required by current GitHub rules or an explicit operationally satisfiable OriginWeave/CWL governance rule. Waiting checks are not permission to weaken tests; continue with a non-conflicting next task. - ChromeDriver diagnostic drains must continuously discard bytes, accept existing text-mode process doubles by immediate UTF-8 replacement encoding, and silently stop on an unrecognized chunk; only the reviewed startup reason may survive. Shared ChromeDriver launch owns `--verbose` and never writes a diagnostic log file. +- A `session not created` result may race the asynchronous ChromeDriver drain. Wait only for the bounded diagnostic-handoff event before choosing a closed startup reason; process exit and arbitrary sleeps are not acceptable handoff signals. ## Work-conserving autonomous maintenance diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea680a5e..0a2c93ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +- ChromeDriver session-creation failures now wait for one bounded process-diagnostic handoff before selecting a closed startup reason, preventing a delayed reviewed sandbox marker from being recorded as `unknown` without retaining output or waiting for process exit. - ChromeDriver's shared diagnostic drain now accepts existing text-mode process doubles and discards unrecognized chunks without a background thread error; the shared launch retains verbose process evidence without a log file. - ChromeDriver `session not created` responses now retain a typed `session_not_created` failure and only the allowlisted `sandbox_unavailable` or `unknown` startup reason; raw driver-controlled response text remains excluded from browser-crash evidence. - Browser-crash trials no longer disable Chromium's sandbox. Rejected session startup remains one failed attempt with driver and temporary-profile cleanup, without an unsandboxed retry; live pinned-browser acceptance is still required. diff --git a/CLAUDE.md b/CLAUDE.md index 5ac87efd1..4a334f2ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,4 +12,5 @@ Additional constraints: - Do not add hostname reconnect, proxy-environment inheritance, dangerous certificate-verifier hooks, Common Name fallback, TLS 0-RTT, key logging, or secret extraction to a production TLS path. - Keep changes bounded to one product gap and preserve modular crate boundaries. - ChromeDriver process output is a redaction boundary: support text-mode test doubles by encoding and discard unrecognized chunks without retaining them; emit only the closed diagnostic reason. +- For concurrent session failure, use the bounded diagnostic-observation event rather than an arbitrary sleep or process-exit wait before reading the closed reason. - Never claim a test, benchmark, browser integration, TLS identity, GPU execution, release, or merge succeeded without current exact-head evidence. diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index b49e95336..830a1f649 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -45,6 +45,7 @@ STARTUP_TIMEOUT_SECONDS = 20.0 FIXTURE_TIMEOUT_SECONDS = 20.0 PROCESS_EXIT_TIMEOUT_SECONDS = 5.0 +DIAGNOSTIC_HANDOFF_TIMEOUT_SECONDS = 0.25 MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576 MAX_PROC_STATUS_CHARACTERS = 65_536 MAX_PROC_STAT_CHARACTERS = 65_536 @@ -79,13 +80,14 @@ def __init__(self, startup_reason: str) -> None: class _ChromeDriverStartupDiagnostic: """Retain only a closed startup reason while continuously discarding process output.""" - __slots__ = ("_marker_index", "startup_reason") + __slots__ = ("_marker_index", "_observed", "startup_reason") _MARKER = b"no usable sandbox" def __init__(self) -> None: """Start with no reviewed process-level startup reason.""" self._marker_index = 0 + self._observed = threading.Event() self.startup_reason = "unknown" def feed(self, chunk: bytes) -> None: @@ -93,6 +95,7 @@ def feed(self, chunk: bytes) -> None: if not isinstance(chunk, bytes): raise TypeError("ChromeDriver diagnostic chunks must be bytes") + self._observed.set() if self.startup_reason == "sandbox_unavailable": return for raw_byte in chunk: @@ -106,6 +109,11 @@ def feed(self, chunk: bytes) -> None: else: self._marker_index = 1 if byte == self._MARKER[0] else 0 + def wait_for_observation(self) -> None: + """Bound the handoff from asynchronous process draining to session classification.""" + + self._observed.wait(timeout=DIAGNOSTIC_HANDOFF_TIMEOUT_SECONDS) + class QuietFixtureHandler(http.server.SimpleHTTPRequestHandler): """Serve only the controlled local fixture without noisy access logging.""" @@ -308,6 +316,8 @@ def _create_chromedriver_session( try: return _json_request(driver_port, "POST", "/session", payload) except _WebDriverSessionNotCreatedError as error: + if error.startup_reason == "unknown": + diagnostic.wait_for_observation() if ( error.startup_reason == "unknown" and diagnostic.startup_reason == "sandbox_unavailable" diff --git a/tests/test_chromedriver_process_diagnostic_contract.py b/tests/test_chromedriver_process_diagnostic_contract.py index f4da00495..016dac23f 100644 --- a/tests/test_chromedriver_process_diagnostic_contract.py +++ b/tests/test_chromedriver_process_diagnostic_contract.py @@ -7,7 +7,9 @@ import pathlib import runpy import threading +import time import unittest +from unittest.mock import patch ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -115,6 +117,36 @@ def test_unrecognized_stream_chunk_is_discarded_without_thread_failure(self) -> self.assertEqual(diagnostic.startup_reason, "unknown") + def test_session_creation_waits_for_bounded_diagnostic_handoff(self) -> None: + """A delayed reviewed diagnostic refines a concurrent session-creation error.""" + + diagnostic_type = self.runner["_ChromeDriverStartupDiagnostic"] + create_session = self.runner["_create_chromedriver_session"] + session_error_type = self.runner["_WebDriverSessionNotCreatedError"] + diagnostic = diagnostic_type() + session_started = threading.Event() + + def delayed_diagnostic() -> None: + session_started.wait(timeout=1.0) + time.sleep(0.01) + diagnostic.feed(b"No usable sandbox") + + def rejected_session(*_args: object, **_kwargs: object) -> dict[str, object]: + session_started.set() + raise session_error_type("unknown") + + feeder = threading.Thread(target=delayed_diagnostic) + feeder.start() + with patch.dict( + create_session.__globals__, + {"_wait_for_driver": lambda _port: None, "_json_request": rejected_session}, + ), self.assertRaises(session_error_type) as captured: + create_session(9515, {}, diagnostic) + feeder.join(timeout=1.0) + + self.assertFalse(feeder.is_alive()) + self.assertEqual(captured.exception.startup_reason, "sandbox_unavailable") + def test_all_chromedriver_launches_stream_instead_of_discarding_output(self) -> None: source = RUNNER_PATH.read_text(encoding="utf-8") From 884190f05bcb3ba19dcc11c950ac00c9e7abc17e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 9 Sep 2026 16:10:17 +0900 Subject: [PATCH 50/50] fix(browser): wait for reviewed startup diagnostics --- AGENTS.md | 2 +- CHANGELOG.md | 2 ++ CLAUDE.md | 2 +- scripts/ci/run_mv3_compatibility.py | 8 ++--- ...hromedriver_process_diagnostic_contract.py | 31 +++++++++++++++++++ 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7f23f4d0c..be69de4f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ For every change: Do not bypass required checks, branch protection, or any review authority actually required by current GitHub rules or an explicit operationally satisfiable OriginWeave/CWL governance rule. Waiting checks are not permission to weaken tests; continue with a non-conflicting next task. - ChromeDriver diagnostic drains must continuously discard bytes, accept existing text-mode process doubles by immediate UTF-8 replacement encoding, and silently stop on an unrecognized chunk; only the reviewed startup reason may survive. Shared ChromeDriver launch owns `--verbose` and never writes a diagnostic log file. -- A `session not created` result may race the asynchronous ChromeDriver drain. Wait only for the bounded diagnostic-handoff event before choosing a closed startup reason; process exit and arbitrary sleeps are not acceptable handoff signals. +- A `session not created` result may race the asynchronous ChromeDriver drain. Wait only for a reviewed startup-reason event or bounded handoff expiry before choosing a closed reason; unreviewed output, process exit, and arbitrary sleeps are not acceptable handoff signals. ## Work-conserving autonomous maintenance diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a2c93ea0..600c522d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Kept ChromeDriver startup diagnosis fail-closed when verbose output races session creation: only a reviewed startup marker can complete the bounded handoff early, while ordinary process output waits for the existing expiry before retaining `unknown`. + - Controlled browser-crash compatibility evidence now credits a crash only after the exact PID/start-time identity is signalled through a revalidated Linux pidfd and that same pidfd becomes readable within the bounded deadline; generic WebDriver transport failures no longer substitute for process-termination proof, while sampled Chromium process-set teardown remains a separate recovery boundary and the pidfd runtime contract remains mandatory on Linux CI. - Separated logical origin authority from resolved network destination authority; an origin grant no longer implies permission to connect to every resolver result. - Separated resolved-address authorization from direct transport evidence; an approved IP now becomes a usable stream only after the operating system reports the exact requested IP and port. diff --git a/CLAUDE.md b/CLAUDE.md index 4a334f2ac..c3bef2033 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,5 +12,5 @@ Additional constraints: - Do not add hostname reconnect, proxy-environment inheritance, dangerous certificate-verifier hooks, Common Name fallback, TLS 0-RTT, key logging, or secret extraction to a production TLS path. - Keep changes bounded to one product gap and preserve modular crate boundaries. - ChromeDriver process output is a redaction boundary: support text-mode test doubles by encoding and discard unrecognized chunks without retaining them; emit only the closed diagnostic reason. -- For concurrent session failure, use the bounded diagnostic-observation event rather than an arbitrary sleep or process-exit wait before reading the closed reason. +- For concurrent session failure, use a reviewed startup-reason event or bounded handoff expiry rather than arbitrary output, a sleep, or process-exit wait before reading the closed reason. - Never claim a test, benchmark, browser integration, TLS identity, GPU execution, release, or merge succeeded without current exact-head evidence. diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 830a1f649..c2636e729 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -80,14 +80,14 @@ def __init__(self, startup_reason: str) -> None: class _ChromeDriverStartupDiagnostic: """Retain only a closed startup reason while continuously discarding process output.""" - __slots__ = ("_marker_index", "_observed", "startup_reason") + __slots__ = ("_marker_index", "_reviewed_reason", "startup_reason") _MARKER = b"no usable sandbox" def __init__(self) -> None: """Start with no reviewed process-level startup reason.""" self._marker_index = 0 - self._observed = threading.Event() + self._reviewed_reason = threading.Event() self.startup_reason = "unknown" def feed(self, chunk: bytes) -> None: @@ -95,7 +95,6 @@ def feed(self, chunk: bytes) -> None: if not isinstance(chunk, bytes): raise TypeError("ChromeDriver diagnostic chunks must be bytes") - self._observed.set() if self.startup_reason == "sandbox_unavailable": return for raw_byte in chunk: @@ -104,6 +103,7 @@ def feed(self, chunk: bytes) -> None: self._marker_index += 1 if self._marker_index == len(self._MARKER): self.startup_reason = "sandbox_unavailable" + self._reviewed_reason.set() self._marker_index = 0 return else: @@ -112,7 +112,7 @@ def feed(self, chunk: bytes) -> None: def wait_for_observation(self) -> None: """Bound the handoff from asynchronous process draining to session classification.""" - self._observed.wait(timeout=DIAGNOSTIC_HANDOFF_TIMEOUT_SECONDS) + self._reviewed_reason.wait(timeout=DIAGNOSTIC_HANDOFF_TIMEOUT_SECONDS) class QuietFixtureHandler(http.server.SimpleHTTPRequestHandler): diff --git a/tests/test_chromedriver_process_diagnostic_contract.py b/tests/test_chromedriver_process_diagnostic_contract.py index 016dac23f..aff383553 100644 --- a/tests/test_chromedriver_process_diagnostic_contract.py +++ b/tests/test_chromedriver_process_diagnostic_contract.py @@ -147,6 +147,37 @@ def rejected_session(*_args: object, **_kwargs: object) -> dict[str, object]: self.assertFalse(feeder.is_alive()) self.assertEqual(captured.exception.startup_reason, "sandbox_unavailable") + def test_session_creation_waits_past_unreviewed_output_for_delayed_marker(self) -> None: + """An earlier verbose chunk cannot end the reviewed-reason handoff.""" + + diagnostic_type = self.runner["_ChromeDriverStartupDiagnostic"] + create_session = self.runner["_create_chromedriver_session"] + session_error_type = self.runner["_WebDriverSessionNotCreatedError"] + diagnostic = diagnostic_type() + diagnostic.feed(b"ordinary ChromeDriver startup line") + session_started = threading.Event() + + def delayed_marker() -> None: + session_started.wait(timeout=1.0) + time.sleep(0.01) + diagnostic.feed(b"No usable sandbox") + + def rejected_session(*_args: object, **_kwargs: object) -> dict[str, object]: + session_started.set() + raise session_error_type("unknown") + + feeder = threading.Thread(target=delayed_marker) + feeder.start() + with patch.dict( + create_session.__globals__, + {"_wait_for_driver": lambda _port: None, "_json_request": rejected_session}, + ), self.assertRaises(session_error_type) as captured: + create_session(9515, {}, diagnostic) + feeder.join(timeout=1.0) + + self.assertFalse(feeder.is_alive()) + self.assertEqual(captured.exception.startup_reason, "sandbox_unavailable") + def test_all_chromedriver_launches_stream_instead_of_discarding_output(self) -> None: source = RUNNER_PATH.read_text(encoding="utf-8")