diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a66260a0..04bc41cd3 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 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, including reviewed ChromeDriver process-teardown `TimeoutExpired` failures; 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 or command paths; 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. diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 73880874b..ce4b0b098 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -502,6 +502,102 @@ def _wait_for_linux_process_identity_exit( time.sleep(min(0.05, remaining_seconds)) +def _read_linux_process_identity_set( + process_ids: tuple[int, ...], + *, + 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]] = [] + 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: + pre_shutdown_exit_count += 1 + continue + identities.append(identity) + return tuple(identities), pre_shutdown_exit_count + + +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.""" @@ -923,6 +1019,8 @@ 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 + chromium_process_pre_shutdown_exit_count: int | None = None browser_failure_type: str | None = None result: dict[str, Any] | None = None driver = subprocess.Popen( @@ -1105,6 +1203,16 @@ def _run_agent_task_browser_pass( browser_process_id, process_evidence, ) + ( + 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( chromium_process_ids, @@ -1131,6 +1239,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, @@ -1170,9 +1281,19 @@ 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") + 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 + ) 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 @@ -1260,7 +1381,11 @@ 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"], "action_latency_ms": result["action_latency_ms"], "task_duration_ms": result["task_duration_ms"], @@ -1692,10 +1817,16 @@ 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) 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) @@ -1793,4 +1924,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) 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..f3a7e65be --- /dev/null +++ b/tests/test_agent_task_process_set_termination_contract.py @@ -0,0 +1,125 @@ +"""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_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: + reader.__globals__["_read_linux_proc_stat_process_identity"] = identities.get + self.assertEqual( + 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] + ) + 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, 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, 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.""" + + 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 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', + "Agent Task Chromium process set did not terminate", + ): + with self.subTest(expected=expected): + self.assertIn(expected, runner) + + +if __name__ == "__main__": + unittest.main()