From 8f87e85bd77ea63bbef63259c6e6432311478eee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 08:29:53 +0900 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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.