diff --git a/CHANGELOG.md b/CHANGELOG.md index d73c09ad9..c6f25d650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Added - 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. - Canonical HTTPS and loopback-origin boundary with case-normalized schemes and hosts, default-port normalization, IPv4/IPv6 handling, browser-special numeric-host rejection, and explicit malformed-input errors. - Typed browser actions, capabilities, risk classes, execution modes, robots decisions, secret-delivery contracts, immutable canonical action-intent digests, and intent-bound approval scopes. diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index b19850d82..9f87e1e84 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -723,26 +723,55 @@ def _run_restart_trial( fixture_url: str, trial_number: int, ) -> dict[str, Any]: - """Run one independent initial/restart pair and return credential-free evidence.""" + """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: - 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", - ) + 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, + subprocess.TimeoutExpired, + ) 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"]) @@ -758,7 +787,14 @@ def _run_restart_trial( } ) if not all(surfaces.values()): - raise RuntimeError(f"compatibility surface failed in trial {trial_number}") + return { + "trial_number": trial_number, + "passed": False, + "failure_type": "CompatibilitySurfaceFailure", + "surfaces": surfaces, + "profile_cleaned": True, + "duration_ms": duration_ms, + } return { "trial_number": trial_number, @@ -777,7 +813,8 @@ def _run_restart_trial( "storage_persistence": restarted["storage_persistence"], }, ], - "duration_ms": round((time.monotonic() - trial_started) * 1000), + "profile_cleaned": True, + "duration_ms": duration_ms, } @@ -1424,6 +1461,9 @@ def main() -> int: 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 ] @@ -1552,6 +1592,7 @@ def main() -> int: "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": ( @@ -1576,6 +1617,8 @@ def main() -> int: "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: " diff --git a/tests/test_mv3_failure_profile_cleanup_contract.py b/tests/test_mv3_failure_profile_cleanup_contract.py new file mode 100644 index 000000000..759214d17 --- /dev/null +++ b/tests/test_mv3_failure_profile_cleanup_contract.py @@ -0,0 +1,88 @@ +"""Contract for MV3 restart-trial profile cleanup evidence on browser failure.""" + +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 Mv3FailureProfileCleanupContractTests(unittest.TestCase): + """Require failed extension-compatibility trials to retain teardown evidence.""" + + def _namespace(self, name: str) -> dict[str, object]: + return runpy.run_path(str(RUNNER), run_name=name) + + def test_failed_restart_pass_returns_profile_cleanup_evidence(self) -> None: + """A failed initial or restarted browser pass must retain profile cleanup proof.""" + + namespace = self._namespace("mv3_failure_cleanup_behavior") + run_trial = namespace["_run_restart_trial"] + + def fail_browser_pass(*_args: object, **_kwargs: object) -> dict[str, object]: + raise RuntimeError("synthetic MV3 browser failure") + + run_trial.__globals__["_run_browser_pass"] = fail_browser_pass + result = run_trial( + pathlib.Path("controlled-chrome"), + pathlib.Path("controlled-chromedriver"), + "http://127.0.0.1/controlled-fixture", + 11, + ) + + self.assertEqual(result["trial_number"], 11) + self.assertIs(result["passed"], False) + self.assertEqual(result["failure_type"], "RuntimeError") + self.assertIs(result["profile_cleaned"], True) + self.assertNotIn("synthetic MV3 browser failure", repr(result)) + + def test_surface_failure_preserves_cleanup_and_surface_evidence(self) -> None: + """A compatibility regression must not be mislabeled as profile cleanup failure.""" + + namespace = self._namespace("mv3_surface_failure_cleanup_behavior") + run_trial = namespace["_run_restart_trial"] + browser_pass_number = 0 + + def browser_pass(*_args: object, **_kwargs: object) -> dict[str, object]: + nonlocal browser_pass_number + browser_pass_number += 1 + persisted = browser_pass_number == 2 + return { + "browser_version": "controlled-browser", + "worker_start_count": browser_pass_number, + "storage_persistence": "persisted" if persisted else "initialized", + "surfaces": { + "service-worker": True, + "real-browser-click": not persisted, + }, + } + + run_trial.__globals__["_run_browser_pass"] = browser_pass + result = run_trial( + pathlib.Path("controlled-chrome"), + pathlib.Path("controlled-chromedriver"), + "http://127.0.0.1/controlled-fixture", + 12, + ) + + self.assertEqual(result["trial_number"], 12) + self.assertIs(result["passed"], False) + self.assertEqual(result["failure_type"], "CompatibilitySurfaceFailure") + self.assertIs(result["profile_cleaned"], True) + self.assertIs(result["surfaces"]["real-browser-click"], False) + + def test_acceptance_gate_requires_cleanup_evidence_for_every_mv3_trial(self) -> None: + """Failed MV3 trials must not be filtered out of the profile-cleanup gate.""" + + runner = RUNNER.read_text(encoding="utf-8") + self.assertIn("mv3_profiles_cleaned = all(", runner) + self.assertIn('trial.get("profile_cleaned") is True', runner) + self.assertIn('"profiles_cleaned": mv3_profiles_cleaned', runner) + self.assertIn("Manifest V3 profile cleanup gate failed", runner) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mv3_restart_timeout_cleanup_contract.py b/tests/test_mv3_restart_timeout_cleanup_contract.py new file mode 100644 index 000000000..22eef6292 --- /dev/null +++ b/tests/test_mv3_restart_timeout_cleanup_contract.py @@ -0,0 +1,45 @@ +"""Regression contract for MV3 restart teardown timeout cleanup evidence.""" + +from __future__ import annotations + +import pathlib +import runpy +import subprocess +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class Mv3RestartTimeoutCleanupContractTests(unittest.TestCase): + """Require reviewed ChromeDriver teardown timeouts to retain MV3 cleanup proof.""" + + def test_teardown_timeout_returns_profile_cleanup_evidence(self) -> None: + """A bounded process teardown timeout must become failed-trial evidence.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_restart_timeout_cleanup") + run_trial = namespace["_run_restart_trial"] + + def timeout_browser_pass(*_args: object, **_kwargs: object) -> dict[str, object]: + raise subprocess.TimeoutExpired( + cmd="private-controlled-chromedriver-path", + timeout=5, + ) + + run_trial.__globals__["_run_browser_pass"] = timeout_browser_pass + result = run_trial( + pathlib.Path("controlled-chrome"), + pathlib.Path("controlled-chromedriver"), + "http://127.0.0.1/controlled-fixture", + 12, + ) + + self.assertEqual(result["trial_number"], 12) + self.assertIs(result["passed"], False) + self.assertEqual(result["failure_type"], "TimeoutExpired") + self.assertIs(result["profile_cleaned"], True) + self.assertNotIn("private-controlled-chromedriver-path", repr(result)) + + +if __name__ == "__main__": + unittest.main()