From d20fd0f0d02e4c9d58a5c471b25b13e82d9ddac3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 14:15:27 +0900 Subject: [PATCH 1/8] test(browser): require Chromium process-set termination evidence --- ...t_task_process_set_termination_contract.py | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/test_agent_task_process_set_termination_contract.py diff --git a/tests/test_agent_task_process_set_termination_contract.py b/tests/test_agent_task_process_set_termination_contract.py new file mode 100644 index 000000000..9b7dc32cd --- /dev/null +++ b/tests/test_agent_task_process_set_termination_contract.py @@ -0,0 +1,107 @@ +"""Contract for proving the controlled Agent Task Chromium process set terminates.""" + +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 AgentTaskProcessSetTerminationContractTests(unittest.TestCase): + """Keep descendant cleanup evidence bounded, PID-reuse-safe, and fail closed.""" + + def test_runner_exposes_bounded_process_set_identity_and_exit_helpers(self) -> None: + """A sampled Chromium tree needs exact PID/start-time identities before shutdown.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_set_termination") + for expected in ( + "_read_linux_process_identity_set", + "_wait_for_linux_process_identity_set_exit", + ): + with self.subTest(expected=expected): + self.assertIn(expected, namespace) + + def test_process_identity_set_reader_preserves_order_and_rejects_ambiguity(self) -> None: + """Every sampled process must bind to one exact start time before shutdown.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_set_reader") + reader = namespace["_read_linux_process_identity_set"] + original_reader = reader.__globals__["_read_linux_proc_stat_process_identity"] + try: + identities = {10: (10, 101), 20: (20, 202), 30: (30, 303)} + reader.__globals__["_read_linux_proc_stat_process_identity"] = identities.get + self.assertEqual( + reader((10, 20, 30)), + ((10, 101), (20, 202), (30, 303)), + ) + + reader.__globals__["_read_linux_proc_stat_process_identity"] = ( + lambda process_id: None if process_id == 20 else identities[process_id] + ) + with self.assertRaisesRegex(RuntimeError, "disappeared before shutdown"): + reader((10, 20, 30)) + finally: + reader.__globals__["_read_linux_proc_stat_process_identity"] = original_reader + + for process_ids in ((), (10, 10)): + with self.subTest(process_ids=process_ids): + with self.assertRaises(ValueError): + reader(process_ids) + + def test_process_set_exit_waiter_uses_one_deadline_and_detects_any_live_identity(self) -> None: + """A reused PID is exited evidence, but any exact surviving identity fails closed.""" + + namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_set_exit_waiter") + waiter = namespace["_wait_for_linux_process_identity_set_exit"] + original_reader = waiter.__globals__["_read_linux_proc_stat_process_identity"] + identities = ((10, 101), (20, 202), (30, 303)) + try: + waiter.__globals__["_read_linux_proc_stat_process_identity"] = ( + lambda _process_id: None + ) + self.assertTrue(waiter(identities, timeout_seconds=0.0)) + + waiter.__globals__["_read_linux_proc_stat_process_identity"] = ( + lambda process_id: (process_id, {10: 111, 20: 222, 30: 333}[process_id]) + ) + self.assertTrue(waiter(identities, timeout_seconds=0.0)) + + waiter.__globals__["_read_linux_proc_stat_process_identity"] = ( + lambda process_id: (20, 202) if process_id == 20 else None + ) + self.assertFalse(waiter(identities, timeout_seconds=0.0)) + finally: + waiter.__globals__["_read_linux_proc_stat_process_identity"] = original_reader + + for process_identities, timeout_seconds in ( + ((), 0.0), + (((10, 101), (10, 102)), 0.0), + (((10, 101),), -0.1), + ): + with self.subTest( + process_identities=process_identities, + timeout_seconds=timeout_seconds, + ): + with self.assertRaises(ValueError): + waiter(process_identities, timeout_seconds=timeout_seconds) + + def test_successful_agent_task_requires_entire_sampled_process_set_to_terminate(self) -> None: + """Successful acceptance must not stop at proof for the Chrome root process alone.""" + + runner = RUNNER.read_text(encoding="utf-8") + for expected in ( + "chromium_process_identities", + '"chromium_process_set_terminated"', + 'result["chromium_process_set_terminated"]', + 'trial.get("chromium_process_set_terminated") is True', + "Agent Task Chromium process set did not terminate", + ): + with self.subTest(expected=expected): + self.assertIn(expected, runner) + + +if __name__ == "__main__": + unittest.main() From 9c3af05a889160844e487710e4fa37bad1d89a6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 15:26:50 +0900 Subject: [PATCH 2/8] feat(browser): prove sampled Chromium process-set termination --- scripts/ci/run_mv3_compatibility.py | 85 +++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 230f97534..61cf90374 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -502,6 +502,77 @@ def _wait_for_linux_process_identity_exit( 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 _sample_linux_process_rss_bytes(process_id: int) -> int: """Read one attributed Linux process RSS through a bounded ``/proc`` status file.""" @@ -917,6 +988,7 @@ def _run_agent_task_browser_pass( 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( @@ -1099,6 +1171,9 @@ def _run_agent_task_browser_pass( 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, @@ -1164,9 +1239,17 @@ def _run_agent_task_browser_pass( } if result is None: raise RuntimeError("Agent Task browser pass returned no result after shutdown") + if chromium_process_identities is None: + raise RuntimeError("Agent Task Chromium process identities were not captured") + chromium_process_set_terminated = _wait_for_linux_process_identity_set_exit( + chromium_process_identities + ) 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 @@ -1249,6 +1332,7 @@ def _run_agent_task_trial( "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"], @@ -1674,6 +1758,7 @@ def main() -> int: 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) From fea7bec6a3ef3f378884b717d36028d9103bde92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 16:12:47 +0900 Subject: [PATCH 3/8] docs(changelog): record sampled Chromium process-set teardown proof --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d295e158a..0a60660f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added -- Controlled pinned-Chromium Agent Task success now binds the ChromeDriver browser PID to its Linux `/proc//stat` start-time identity and fails closed unless that exact root process terminates after session/driver shutdown; PID reuse counts only as termination of the original identity, and this does not yet prove termination of every Chromium descendant or process ownership outside the controlled runner. +- Controlled pinned-Chromium Agent Task success now binds the ChromeDriver browser root and every PID in the already sampled bounded Chromium root-plus-descendant process set to exact Linux `/proc//stat` start-time identities before shutdown and fails closed unless those exact identities terminate after session/driver shutdown; PID reuse counts only as termination of the original identity, and this does not attest cgroup/task ownership, processes appearing only after the sample, or OS-wide orphan absence. - Failed ordinary and forced-close Agent Task browser trials now retain credential-free temporary-profile cleanup evidence after bounded browser errors, and separate aggregate compatibility gates require cleanup proof from every trial rather than filtering unsuccessful trials out; this does not attest adversarial filesystem erasure, process termination, or arbitrary browser recovery. - Failed Manifest V3 restart trials now retain credential-free temporary-profile cleanup evidence after bounded browser errors, successful trials record the same cleanup fact, and an aggregate compatibility gate requires teardown proof from every MV3 trial before repeatability acceptance without retaining exception messages; this does not attest adversarial filesystem erasure, browser-process termination, or cleanup outside the controlled temporary profile. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules. From ea2ca405237e87da7609a234e62773f1054cf8ab Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:00:30 -0700 Subject: [PATCH 4/8] test(browser): retain process-set teardown evidence on failure --- ...t_task_process_set_termination_contract.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/test_agent_task_process_set_termination_contract.py b/tests/test_agent_task_process_set_termination_contract.py index 9b7dc32cd..621f859c0 100644 --- a/tests/test_agent_task_process_set_termination_contract.py +++ b/tests/test_agent_task_process_set_termination_contract.py @@ -102,6 +102,82 @@ def test_successful_agent_task_requires_entire_sampled_process_set_to_terminate( with self.subTest(expected=expected): self.assertIn(expected, runner) + def test_failure_after_identity_capture_retains_process_set_teardown_evidence(self) -> None: + """Late browser failures must not discard already captured descendant evidence.""" + + runner = RUNNER.read_text(encoding="utf-8") + start = runner.index("def _run_agent_task_browser_pass(") + end = runner.index("\ndef _run_agent_task_trial(", start) + browser_pass = runner[start:end] + for expected in ( + "if chromium_process_identities is not None:", + 'failure_evidence["chromium_process_set_terminated"] =', + ): + with self.subTest(expected=expected): + self.assertIn(expected, browser_pass) + + def test_failed_trial_preserves_process_set_termination_evidence(self) -> None: + """A failed task must retain a sampled-set survival result after profile cleanup.""" + + namespace = runpy.run_path( + str(RUNNER), + run_name="agent_task_failure_process_set_termination_trial", + ) + run_trial = namespace["_run_agent_task_trial"] + + def fail_with_process_set_evidence( + *_args: object, **_kwargs: object + ) -> dict[str, object]: + return { + "failure_type": "RuntimeError", + "browser_process_terminated": True, + "chromium_process_set_terminated": False, + } + + run_trial.__globals__["_run_agent_task_browser_pass"] = fail_with_process_set_evidence + result = run_trial( + pathlib.Path("controlled-chrome"), + pathlib.Path("controlled-chromedriver"), + "http://127.0.0.1/controlled-fixture", + 13, + ) + + self.assertEqual(result["trial_number"], 13) + self.assertIs(result["passed"], False) + self.assertEqual(result["failure_type"], "RuntimeError") + self.assertIs(result["browser_process_terminated"], True) + self.assertIs(result["chromium_process_set_terminated"], False) + self.assertIs(result["profile_cleaned"], True) + + def test_failed_trial_rejects_malformed_process_set_termination_evidence(self) -> None: + """Failure evidence must fail closed instead of normalizing a non-boolean result.""" + + namespace = runpy.run_path( + str(RUNNER), + run_name="agent_task_failure_process_set_validation_trial", + ) + run_trial = namespace["_run_agent_task_trial"] + + def fail_with_malformed_process_set_evidence( + *_args: object, **_kwargs: object + ) -> dict[str, object]: + return { + "failure_type": "RuntimeError", + "browser_process_terminated": True, + "chromium_process_set_terminated": "true", + } + + run_trial.__globals__["_run_agent_task_browser_pass"] = ( + fail_with_malformed_process_set_evidence + ) + with self.assertRaisesRegex(RuntimeError, "process-set teardown evidence"): + run_trial( + pathlib.Path("controlled-chrome"), + pathlib.Path("controlled-chromedriver"), + "http://127.0.0.1/controlled-fixture", + 14, + ) + if __name__ == "__main__": unittest.main() From c60bafb0822abfe90f879f55b6439ba554996546 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:03:07 -0700 Subject: [PATCH 5/8] revert: keep late-failure teardown repair on canonical descendant --- ...t_task_process_set_termination_contract.py | 76 ------------------- 1 file changed, 76 deletions(-) diff --git a/tests/test_agent_task_process_set_termination_contract.py b/tests/test_agent_task_process_set_termination_contract.py index 621f859c0..9b7dc32cd 100644 --- a/tests/test_agent_task_process_set_termination_contract.py +++ b/tests/test_agent_task_process_set_termination_contract.py @@ -102,82 +102,6 @@ def test_successful_agent_task_requires_entire_sampled_process_set_to_terminate( with self.subTest(expected=expected): self.assertIn(expected, runner) - def test_failure_after_identity_capture_retains_process_set_teardown_evidence(self) -> None: - """Late browser failures must not discard already captured descendant evidence.""" - - runner = RUNNER.read_text(encoding="utf-8") - start = runner.index("def _run_agent_task_browser_pass(") - end = runner.index("\ndef _run_agent_task_trial(", start) - browser_pass = runner[start:end] - for expected in ( - "if chromium_process_identities is not None:", - 'failure_evidence["chromium_process_set_terminated"] =', - ): - with self.subTest(expected=expected): - self.assertIn(expected, browser_pass) - - def test_failed_trial_preserves_process_set_termination_evidence(self) -> None: - """A failed task must retain a sampled-set survival result after profile cleanup.""" - - namespace = runpy.run_path( - str(RUNNER), - run_name="agent_task_failure_process_set_termination_trial", - ) - run_trial = namespace["_run_agent_task_trial"] - - def fail_with_process_set_evidence( - *_args: object, **_kwargs: object - ) -> dict[str, object]: - return { - "failure_type": "RuntimeError", - "browser_process_terminated": True, - "chromium_process_set_terminated": False, - } - - run_trial.__globals__["_run_agent_task_browser_pass"] = fail_with_process_set_evidence - result = run_trial( - pathlib.Path("controlled-chrome"), - pathlib.Path("controlled-chromedriver"), - "http://127.0.0.1/controlled-fixture", - 13, - ) - - self.assertEqual(result["trial_number"], 13) - self.assertIs(result["passed"], False) - self.assertEqual(result["failure_type"], "RuntimeError") - self.assertIs(result["browser_process_terminated"], True) - self.assertIs(result["chromium_process_set_terminated"], False) - self.assertIs(result["profile_cleaned"], True) - - def test_failed_trial_rejects_malformed_process_set_termination_evidence(self) -> None: - """Failure evidence must fail closed instead of normalizing a non-boolean result.""" - - namespace = runpy.run_path( - str(RUNNER), - run_name="agent_task_failure_process_set_validation_trial", - ) - run_trial = namespace["_run_agent_task_trial"] - - def fail_with_malformed_process_set_evidence( - *_args: object, **_kwargs: object - ) -> dict[str, object]: - return { - "failure_type": "RuntimeError", - "browser_process_terminated": True, - "chromium_process_set_terminated": "true", - } - - run_trial.__globals__["_run_agent_task_browser_pass"] = ( - fail_with_malformed_process_set_evidence - ) - with self.assertRaisesRegex(RuntimeError, "process-set teardown evidence"): - run_trial( - pathlib.Path("controlled-chrome"), - pathlib.Path("controlled-chromedriver"), - "http://127.0.0.1/controlled-fixture", - 14, - ) - if __name__ == "__main__": unittest.main() From 4a75e5ac43898c54ff6020e70dd44968ecfbee92 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 14:39:05 -0700 Subject: [PATCH 6/8] test(browser): reproduce short-lived Chromium child teardown race --- ...t_task_process_set_termination_contract.py | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/tests/test_agent_task_process_set_termination_contract.py b/tests/test_agent_task_process_set_termination_contract.py index 9b7dc32cd..f3a7e65be 100644 --- a/tests/test_agent_task_process_set_termination_contract.py +++ b/tests/test_agent_task_process_set_termination_contract.py @@ -24,32 +24,49 @@ def test_runner_exposes_bounded_process_set_identity_and_exit_helpers(self) -> N with self.subTest(expected=expected): self.assertIn(expected, namespace) - def test_process_identity_set_reader_preserves_order_and_rejects_ambiguity(self) -> None: - """Every sampled process must bind to one exact start time before shutdown.""" + def test_process_identity_set_reader_preserves_root_and_tolerates_exited_children(self) -> None: + """Short-lived descendants may exit after the snapshot, but root identity stays exact.""" namespace = runpy.run_path(str(RUNNER), run_name="agent_task_process_set_reader") reader = namespace["_read_linux_process_identity_set"] original_reader = reader.__globals__["_read_linux_proc_stat_process_identity"] + identities = {10: (10, 101), 20: (20, 202), 30: (30, 303)} try: - identities = {10: (10, 101), 20: (20, 202), 30: (30, 303)} reader.__globals__["_read_linux_proc_stat_process_identity"] = identities.get self.assertEqual( - reader((10, 20, 30)), - ((10, 101), (20, 202), (30, 303)), + reader((10, 20, 30), required_root_identity=(10, 101)), + (((10, 101), (20, 202), (30, 303)), 0), ) reader.__globals__["_read_linux_proc_stat_process_identity"] = ( lambda process_id: None if process_id == 20 else identities[process_id] ) - with self.assertRaisesRegex(RuntimeError, "disappeared before shutdown"): - reader((10, 20, 30)) + self.assertEqual( + reader((10, 20, 30), required_root_identity=(10, 101)), + (((10, 101), (30, 303)), 1), + ) + + reader.__globals__["_read_linux_proc_stat_process_identity"] = ( + lambda process_id: None if process_id == 10 else identities[process_id] + ) + with self.assertRaisesRegex(RuntimeError, "root process identity disappeared"): + reader((10, 20, 30), required_root_identity=(10, 101)) + + reader.__globals__["_read_linux_proc_stat_process_identity"] = identities.get + with self.assertRaisesRegex(RuntimeError, "root process identity changed"): + reader((10, 20, 30), required_root_identity=(10, 999)) finally: reader.__globals__["_read_linux_proc_stat_process_identity"] = original_reader - for process_ids in ((), (10, 10)): - with self.subTest(process_ids=process_ids): + for process_ids, root_identity in ( + ((), (10, 101)), + ((10, 10), (10, 101)), + ((10, 20), (20, 202)), + ((10, 20), (10, 0)), + ): + with self.subTest(process_ids=process_ids, root_identity=root_identity): with self.assertRaises(ValueError): - reader(process_ids) + reader(process_ids, required_root_identity=root_identity) def test_process_set_exit_waiter_uses_one_deadline_and_detects_any_live_identity(self) -> None: """A reused PID is exited evidence, but any exact surviving identity fails closed.""" @@ -89,11 +106,12 @@ def test_process_set_exit_waiter_uses_one_deadline_and_detects_any_live_identity waiter(process_identities, timeout_seconds=timeout_seconds) def test_successful_agent_task_requires_entire_sampled_process_set_to_terminate(self) -> None: - """Successful acceptance must not stop at proof for the Chrome root process alone.""" + """Successful acceptance must preserve already-exited descendants as explicit evidence.""" runner = RUNNER.read_text(encoding="utf-8") for expected in ( "chromium_process_identities", + "chromium_process_pre_shutdown_exit_count", '"chromium_process_set_terminated"', 'result["chromium_process_set_terminated"]', 'trial.get("chromium_process_set_terminated") is True', From d5c239c31f38d8364f138fd4527b216194f63b61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 14:45:39 -0700 Subject: [PATCH 7/8] fix(browser): tolerate already-exited sampled Chromium descendants --- scripts/ci/run_mv3_compatibility.py | 62 +++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 61cf90374..98459d407 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -504,23 +504,48 @@ def _wait_for_linux_process_identity_exit( 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.""" + *, + required_root_identity: tuple[int, int], +) -> tuple[tuple[tuple[int, int], ...], int]: + """Bind live sampled PIDs while explicitly accounting for already-exited descendants.""" 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") + if not isinstance(required_root_identity, tuple) or len(required_root_identity) != 2: + raise ValueError("invalid Linux root process identity") + root_process_id, root_start_time_ticks = required_root_identity + if ( + isinstance(root_process_id, bool) + or not isinstance(root_process_id, int) + or root_process_id <= 0 + or 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 identity") + if process_ids[0] != root_process_id: + raise ValueError("Linux process identity set must start with the required root PID") identities: list[tuple[int, int]] = [] - for process_id in process_ids: + pre_shutdown_exit_count = 0 + for index, process_id in enumerate(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 index == 0: + if identity is None: + raise RuntimeError("Linux Chromium root process identity disappeared before shutdown capture") + if identity != required_root_identity: + raise RuntimeError("Linux Chromium root process identity changed before shutdown capture") + identities.append(identity) + continue if identity is None: - raise RuntimeError("Linux Chromium process disappeared before shutdown identity capture") + pre_shutdown_exit_count += 1 + continue identities.append(identity) - return tuple(identities) + return tuple(identities), pre_shutdown_exit_count def _wait_for_linux_process_identity_set_exit( @@ -989,6 +1014,7 @@ def _run_agent_task_browser_pass( browser_process_id: int | None = None browser_process_start_time_ticks: int | None = None chromium_process_identities: tuple[tuple[int, int], ...] | None = None + chromium_process_pre_shutdown_exit_count: int | None = None browser_failure_type: str | None = None result: dict[str, Any] | None = None driver = subprocess.Popen( @@ -1171,8 +1197,15 @@ def _run_agent_task_browser_pass( browser_process_id, process_evidence, ) - chromium_process_identities = _read_linux_process_identity_set( - chromium_process_ids + ( + chromium_process_identities, + chromium_process_pre_shutdown_exit_count, + ) = _read_linux_process_identity_set( + chromium_process_ids, + required_root_identity=( + browser_process_id, + browser_process_start_time_ticks, + ), ) browser_process_rss_bytes = _sample_linux_process_rss_bytes(browser_process_id) chromium_process_set_rss_bytes = _sample_linux_process_set_rss_bytes( @@ -1200,6 +1233,9 @@ def _run_agent_task_browser_pass( "saved_credential_services_disabled": True, "browser_process_rss_bytes": browser_process_rss_bytes, "chromium_process_count": chromium_process_count, + "chromium_process_pre_shutdown_exit_count": ( + chromium_process_pre_shutdown_exit_count + ), "chromium_process_set_rss_bytes": chromium_process_set_rss_bytes, "semantic_observation_bytes": semantic_observation_bytes, "action_latency_ms": action_latency_ms, @@ -1241,6 +1277,8 @@ def _run_agent_task_browser_pass( raise RuntimeError("Agent Task browser pass returned no result after shutdown") if chromium_process_identities is None: raise RuntimeError("Agent Task Chromium process identities were not captured") + if chromium_process_pre_shutdown_exit_count is None: + raise RuntimeError("Agent Task Chromium pre-shutdown exit count was not captured") chromium_process_set_terminated = _wait_for_linux_process_identity_set_exit( chromium_process_identities ) @@ -1331,6 +1369,9 @@ def _run_agent_task_trial( "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_pre_shutdown_exit_count": result[ + "chromium_process_pre_shutdown_exit_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"], @@ -1763,6 +1804,11 @@ def main() -> 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_pre_shutdown_exit_count"), int) + and not isinstance(trial["chromium_process_pre_shutdown_exit_count"], bool) + and 0 + <= trial["chromium_process_pre_shutdown_exit_count"] + < trial["chromium_process_count"] 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) @@ -1860,4 +1906,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) From cde4204e81a4496e9f1013487f9a0a0c3f830fdb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 14:49:12 -0700 Subject: [PATCH 8/8] docs(changelog): record Chromium pre-shutdown child exits --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a60660f9..fb6640d94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added -- Controlled pinned-Chromium Agent Task success now binds the ChromeDriver browser root and every PID in the already sampled bounded Chromium root-plus-descendant process set to exact Linux `/proc//stat` start-time identities before shutdown and fails closed unless those exact identities terminate after session/driver shutdown; PID reuse counts only as termination of the original identity, and this does not attest cgroup/task ownership, processes appearing only after the sample, or OS-wide orphan absence. +- Controlled pinned-Chromium Agent Task success now binds the ChromeDriver browser root to its exact Linux `/proc//stat` start-time identity, binds every still-live PID from the already sampled bounded Chromium root-plus-descendant set before shutdown, explicitly records descendants that already exited between the `/proc` lineage snapshot and identity capture, and fails closed unless every retained exact identity terminates after session/driver shutdown; root disappearance or identity change remains an error, PID reuse counts only as termination of the original identity, and this does not attest cgroup/task ownership, processes appearing only after the sample, or OS-wide orphan absence. - Failed ordinary and forced-close Agent Task browser trials now retain credential-free temporary-profile cleanup evidence after bounded browser errors, and separate aggregate compatibility gates require cleanup proof from every trial rather than filtering unsuccessful trials out; this does not attest adversarial filesystem erasure, process termination, or arbitrary browser recovery. - Failed Manifest V3 restart trials now retain credential-free temporary-profile cleanup evidence after bounded browser errors, successful trials record the same cleanup fact, and an aggregate compatibility gate requires teardown proof from every MV3 trial before repeatability acceptance without retaining exception messages; this does not attest adversarial filesystem erasure, browser-process termination, or cleanup outside the controlled temporary profile. - Rust workspace for independently reusable core, policy, destination, network, TLS, resource, and evidence modules.