From 39c4544eec5ce9a0ad6ab4d5ec1bfdfd318149a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 06:17:44 +0900 Subject: [PATCH 01/95] test(mv3): define real downloads compatibility contract --- tests/test_mv3_downloads_contract.py | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/test_mv3_downloads_contract.py diff --git a/tests/test_mv3_downloads_contract.py b/tests/test_mv3_downloads_contract.py new file mode 100644 index 000000000..3910766a7 --- /dev/null +++ b/tests/test_mv3_downloads_contract.py @@ -0,0 +1,49 @@ +"""Fail-first contract for real Manifest V3 downloads compatibility.""" + +from __future__ import annotations + +import json +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests" / "fixtures" / "mv3_basic" +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class ManifestV3DownloadsContractTests(unittest.TestCase): + """Require the real Chrome downloads API in every pinned-browser trial.""" + + def test_fixture_declares_downloads_permission_and_local_resource(self) -> None: + """The controlled extension must request downloads and own its test payload.""" + + manifest = json.loads((FIXTURE / "manifest.json").read_text(encoding="utf-8")) + self.assertIn("downloads", manifest["permissions"]) + payload = (FIXTURE / "download.txt").read_bytes() + self.assertEqual(payload, b"OriginWeave deterministic MV3 download fixture.\n") + + def test_service_worker_executes_and_verifies_a_real_local_download(self) -> None: + """Evidence must originate from a real download followed by bounded inspection.""" + + worker = (FIXTURE / "service_worker.js").read_text(encoding="utf-8") + for expected in ( + "chrome.downloads.download", + "chrome.downloads.search", + "chrome.runtime.getURL(\"download.txt\")", + "downloadsReady", + ): + with self.subTest(expected=expected): + self.assertIn(expected, worker) + + def test_content_script_and_runner_require_downloads_on_every_pass(self) -> None: + """The compatibility report must fail closed when downloads evidence is missing.""" + + content = (FIXTURE / "content_script.js").read_text(encoding="utf-8") + runner = RUNNER.read_text(encoding="utf-8") + self.assertIn("originweaveDownloads", content) + self.assertIn('"downloads": surfaces["downloads"] == "ready"', runner) + self.assertIn('"downloads": "ready"', runner) + + +if __name__ == "__main__": + unittest.main() From 50138f12860089ed8f5ba19cc6c0841659d57613 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 06:41:36 +0900 Subject: [PATCH 02/95] feat(mv3): declare downloads compatibility permission --- tests/fixtures/mv3_basic/manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/mv3_basic/manifest.json b/tests/fixtures/mv3_basic/manifest.json index f366329ac..960fee780 100644 --- a/tests/fixtures/mv3_basic/manifest.json +++ b/tests/fixtures/mv3_basic/manifest.json @@ -11,7 +11,8 @@ "scripting", "sidePanel", "bookmarks", - "history" + "history", + "downloads" ], "host_permissions": [ "http://127.0.0.1/*" From b98c2ca1952133d421686f03d8d55f961a65acbe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 06:41:48 +0900 Subject: [PATCH 03/95] test(mv3): add deterministic local download fixture --- tests/fixtures/mv3_basic/download.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 tests/fixtures/mv3_basic/download.txt diff --git a/tests/fixtures/mv3_basic/download.txt b/tests/fixtures/mv3_basic/download.txt new file mode 100644 index 000000000..c6cde1c6a --- /dev/null +++ b/tests/fixtures/mv3_basic/download.txt @@ -0,0 +1 @@ +OriginWeave deterministic MV3 download fixture. From bedc4307aa7f36d7931a735d12ba80b4a3a3f023 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 06:42:13 +0900 Subject: [PATCH 04/95] feat(mv3): exercise bounded local downloads API --- tests/fixtures/mv3_basic/service_worker.js | 45 ++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/fixtures/mv3_basic/service_worker.js b/tests/fixtures/mv3_basic/service_worker.js index 70687838a..6815c87e4 100644 --- a/tests/fixtures/mv3_basic/service_worker.js +++ b/tests/fixtures/mv3_basic/service_worker.js @@ -1,5 +1,9 @@ "use strict"; +const DOWNLOAD_PAYLOAD = "OriginWeave deterministic MV3 download fixture.\n"; +const DOWNLOAD_POLL_ATTEMPTS = 100; +const DOWNLOAD_POLL_INTERVAL_MS = 50; + const workerStartPromise = (async () => { const values = await chrome.storage.local.get("originweave_worker_start_count"); const previous = Number(values.originweave_worker_start_count ?? 0); @@ -16,6 +20,43 @@ async function ensureWorkerState() { return "installed"; } +async function waitForDownload(downloadId, expectedUrl) { + const expectedBytes = new TextEncoder().encode(DOWNLOAD_PAYLOAD).byteLength; + for (let attempt = 0; attempt < DOWNLOAD_POLL_ATTEMPTS; attempt += 1) { + const items = await chrome.downloads.search({ id: downloadId, limit: 1 }); + if (Array.isArray(items) && items.length === 1) { + const item = items[0]; + if (item.state === "interrupted") { + return false; + } + if (item.state === "complete") { + return ( + item.url === expectedUrl && + item.bytesReceived === expectedBytes && + item.totalBytes === expectedBytes && + item.exists !== false + ); + } + } + await new Promise((resolve) => setTimeout(resolve, DOWNLOAD_POLL_INTERVAL_MS)); + } + return false; +} + +async function exerciseDownload() { + const url = chrome.runtime.getURL("download.txt"); + const downloadId = await chrome.downloads.download({ + url, + filename: "originweave-mv3/download.txt", + conflictAction: "overwrite", + saveAs: false, + }); + if (!Number.isInteger(downloadId)) { + return false; + } + return waitForDownload(downloadId, url); +} + async function exerciseCoreApis(sender) { const tabId = sender?.tab?.id; if (!Number.isInteger(tabId)) { @@ -56,6 +97,8 @@ async function exerciseCoreApis(sender) { }); const historyReady = Array.isArray(historyItems); + const downloadsReady = await exerciseDownload(); + return { tabs: tabReady ? "ready" : "missing", windows: windowReady ? "ready" : "missing", @@ -64,6 +107,7 @@ async function exerciseCoreApis(sender) { sidePanel: sidePanelReady ? "ready" : "missing", bookmarks: bookmarksReady ? "ready" : "missing", history: historyReady ? "ready" : "missing", + downloads: downloadsReady ? "ready" : "missing", }; } @@ -91,6 +135,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { sidePanel: "missing", bookmarks: "missing", history: "missing", + downloads: "missing", }); } ); From 7d25a50afa8fc3ba1cccf7c08eedfbed27528c7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 06:42:28 +0900 Subject: [PATCH 05/95] test(mv3): propagate downloads compatibility evidence --- tests/fixtures/mv3_basic/content_script.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/fixtures/mv3_basic/content_script.js b/tests/fixtures/mv3_basic/content_script.js index 8b6af5314..a99867f97 100644 --- a/tests/fixtures/mv3_basic/content_script.js +++ b/tests/fixtures/mv3_basic/content_script.js @@ -27,4 +27,5 @@ document.documentElement.dataset.originweaveSidePanel = response?.sidePanel ?? "missing"; document.documentElement.dataset.originweaveBookmarks = response?.bookmarks ?? "missing"; document.documentElement.dataset.originweaveHistory = response?.history ?? "missing"; + document.documentElement.dataset.originweaveDownloads = response?.downloads ?? "missing"; })(); From 054b32a8a9e1d74ab9afcac623600a43d24e1d1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 06:43:24 +0900 Subject: [PATCH 06/95] test(mv3): require downloads evidence every browser pass --- scripts/ci/run_mv3_compatibility.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 28a3fb1e2..7ced6a047 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -5,7 +5,7 @@ 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 +commands, side-panel, bookmarks, history, downloads, real browser-click, and restart-persistence behavior. """ @@ -178,7 +178,8 @@ def _wait_for_extension_evidence( commands: document.documentElement.dataset.originweaveCommands || "missing", sidePanel: document.documentElement.dataset.originweaveSidePanel || "missing", bookmarks: document.documentElement.dataset.originweaveBookmarks || "missing", - history: document.documentElement.dataset.originweaveHistory || "missing" + history: document.documentElement.dataset.originweaveHistory || "missing", + downloads: document.documentElement.dataset.originweaveDownloads || "missing" }; """ expected = { @@ -196,6 +197,7 @@ def _wait_for_extension_evidence( "sidePanel": "ready", "bookmarks": "ready", "history": "ready", + "downloads": "ready", } deadline = time.monotonic() + FIXTURE_TIMEOUT_SECONDS latest: dict[str, str] = {} @@ -268,6 +270,8 @@ def _run_browser_pass( driver_port = _free_loopback_port() session_id: str | None = None + download_dir = pathlib.Path(profile_dir) / "downloads" + download_dir.mkdir(mode=0o700, parents=True, exist_ok=True) driver = subprocess.Popen( [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], stdout=subprocess.DEVNULL, @@ -298,6 +302,11 @@ def _run_browser_pass( f"--disable-extensions-except={FIXTURE}", f"--load-extension={FIXTURE}", ], + "prefs": { + "download.default_directory": str(download_dir), + "download.prompt_for_download": False, + "download.directory_upgrade": True, + }, }, } } @@ -349,6 +358,7 @@ def _run_browser_pass( "side-panel": surfaces["sidePanel"] == "ready", "bookmarks": surfaces["bookmarks"] == "ready", "history": surfaces["history"] == "ready", + "downloads": surfaces["downloads"] == "ready", "real-browser-click": click_result == "clicked", }, } From 5c5f9d0ca4b7f5a4409cac89d4e62964e99a86d0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 06:47:33 +0900 Subject: [PATCH 07/95] test(mv3): require bounded surface failure diagnostics --- tests/test_mv3_compatibility_contract.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_mv3_compatibility_contract.py b/tests/test_mv3_compatibility_contract.py index 10872ddac..6c92afd4d 100644 --- a/tests/test_mv3_compatibility_contract.py +++ b/tests/test_mv3_compatibility_contract.py @@ -162,6 +162,23 @@ def test_runner_reports_repeated_trial_pass_rate(self) -> None: with self.subTest(expected=expected): self.assertIn(expected, runner) + def test_runner_preserves_safe_surface_failure_evidence(self) -> None: + """A failed trial must identify the bounded fixture surface without leaking raw errors.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_contract") + surface_error = namespace["CompatibilitySurfaceError"] + failure_evidence = namespace["_failure_evidence"] + + observed = {"downloads": "missing", "storage": "ready"} + diagnostic = failure_evidence(surface_error(observed)) + self.assertEqual(diagnostic["failure_kind"], "surface_mismatch") + self.assertEqual(diagnostic["observed"], observed) + + generic = failure_evidence( + RuntimeError("secret-token https://example.invalid /home/runner/private") + ) + self.assertEqual(generic, {"failure_kind": "runtime_error"}) + def test_workflow_runs_the_real_browser_lane_without_model_credentials(self) -> None: """Compatibility evidence must execute Chromium and never require LLM secrets.""" From 9259e287f38b0dc0d84dea9615c3fdfa083f581e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 06:48:49 +0900 Subject: [PATCH 08/95] fix(mv3): retain bounded surface failure evidence --- scripts/ci/run_mv3_compatibility.py | 73 +++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 10 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 7ced6a047..e051f2807 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -36,6 +36,39 @@ MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576 W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" PATH_TOKEN_CHARACTERS = frozenset(string.ascii_letters + string.digits + "-_.") +SURFACE_EVIDENCE_KEYS = ( + "content", + "storage", + "storagePersistence", + "workerReply", + "workerState", + "workerStartCount", + "dnr", + "tabs", + "windows", + "scripting", + "scriptingExecuted", + "commands", + "sidePanel", + "bookmarks", + "history", + "downloads", +) +SURFACE_EVIDENCE_VALUES = frozenset( + {"ready", "missing", "initialized", "persisted", "pong", "installed", "blocked"} +) + + +class CompatibilitySurfaceError(RuntimeError): + """Report only bounded fixture-surface state when real-browser evidence does not converge.""" + + def __init__(self, observed: dict[str, str]) -> None: + self.observed = { + key: _safe_surface_value(key, observed[key]) + for key in SURFACE_EVIDENCE_KEYS + if key in observed + } + super().__init__("Manifest V3 fixture surfaces did not converge") class QuietFixtureHandler(http.server.SimpleHTTPRequestHandler): @@ -45,6 +78,28 @@ def log_message(self, _format: str, *args: object) -> None: """Suppress request logs because the fixture contains no diagnostic value.""" +def _safe_surface_value(key: str, value: str) -> str: + """Reduce one controlled DOM evidence value to a non-sensitive diagnostic token.""" + + if key == "workerStartCount": + return value if value.isdecimal() and len(value) <= 20 else "invalid" + return value if value in SURFACE_EVIDENCE_VALUES else "unexpected" + + +def _failure_evidence(error: BaseException) -> dict[str, Any]: + """Classify one browser-trial failure without retaining raw exception text.""" + + if isinstance(error, CompatibilitySurfaceError): + return {"failure_kind": "surface_mismatch", "observed": error.observed} + if isinstance(error, json.JSONDecodeError): + return {"failure_kind": "json_decode_error"} + if isinstance(error, OSError): + return {"failure_kind": "io_error"} + if isinstance(error, ValueError): + return {"failure_kind": "value_error"} + return {"failure_kind": "runtime_error"} + + def _free_loopback_port() -> int: """Reserve and release one loopback TCP port for a short-lived local service.""" @@ -214,9 +269,7 @@ def _wait_for_extension_evidence( ): return latest time.sleep(0.1) - raise RuntimeError( - f"MV3 fixture did not converge: expected={expected!r}, observed={latest!r}" - ) + raise CompatibilitySurfaceError(latest) def _exercise_real_click(driver_port: int, session_id: str) -> str: @@ -478,13 +531,13 @@ def main() -> int: trial_number, ) ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError): - trial_results.append( - { - "trial_number": trial_number, - "passed": False, - } - ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + failed_trial: dict[str, Any] = { + "trial_number": trial_number, + "passed": False, + } + failed_trial.update(_failure_evidence(exc)) + trial_results.append(failed_trial) successful_trials = sum( 1 for trial in trial_results if trial.get("passed") is True From b10e3cc9493b1ec681cf727f06adbb20e011cded Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 06:54:15 +0900 Subject: [PATCH 09/95] test(mv3): require bounded downloads failure stages --- tests/test_mv3_downloads_contract.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_mv3_downloads_contract.py b/tests/test_mv3_downloads_contract.py index 3910766a7..a44979703 100644 --- a/tests/test_mv3_downloads_contract.py +++ b/tests/test_mv3_downloads_contract.py @@ -35,6 +35,31 @@ def test_service_worker_executes_and_verifies_a_real_local_download(self) -> Non with self.subTest(expected=expected): self.assertIn(expected, worker) + def test_download_failures_emit_only_bounded_stage_diagnostics(self) -> None: + """A real-browser failure must identify its reviewed download stage without raw paths.""" + + worker = (FIXTURE / "service_worker.js").read_text(encoding="utf-8") + content = (FIXTURE / "content_script.js").read_text(encoding="utf-8") + runner = RUNNER.read_text(encoding="utf-8") + for expected in ( + "download-start-rejected", + "download-search-missing", + "download-interrupted", + "download-url-mismatch", + "download-byte-count-mismatch", + "download-exists-false", + "download-timeout", + "download-complete-ready", + "downloadsDiagnostic", + ): + with self.subTest(expected=expected): + self.assertIn(expected, worker) + self.assertIn("originweaveDownloadsDiagnostic", content) + self.assertIn("downloadsDiagnostic", runner) + self.assertIn("DOWNLOAD_DIAGNOSTIC_VALUES", runner) + self.assertNotIn("download.default_directory", worker) + self.assertNotIn("item.filename", worker) + def test_content_script_and_runner_require_downloads_on_every_pass(self) -> None: """The compatibility report must fail closed when downloads evidence is missing.""" From 20f1d83a63733eb9beb8af016ef99983f1fa9069 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 07:31:10 +0900 Subject: [PATCH 10/95] fix(mv3): classify bounded download failure stages --- tests/fixtures/mv3_basic/service_worker.js | 67 +++++++++++++++------- 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/tests/fixtures/mv3_basic/service_worker.js b/tests/fixtures/mv3_basic/service_worker.js index 6815c87e4..9086ede47 100644 --- a/tests/fixtures/mv3_basic/service_worker.js +++ b/tests/fixtures/mv3_basic/service_worker.js @@ -22,37 +22,58 @@ async function ensureWorkerState() { async function waitForDownload(downloadId, expectedUrl) { const expectedBytes = new TextEncoder().encode(DOWNLOAD_PAYLOAD).byteLength; + let observedDownload = false; for (let attempt = 0; attempt < DOWNLOAD_POLL_ATTEMPTS; attempt += 1) { - const items = await chrome.downloads.search({ id: downloadId, limit: 1 }); - if (Array.isArray(items) && items.length === 1) { - const item = items[0]; - if (item.state === "interrupted") { - return false; + let items; + try { + items = await chrome.downloads.search({ id: downloadId, limit: 1 }); + } catch (_error) { + return { ready: false, diagnostic: "download-search-missing" }; + } + if (!Array.isArray(items) || items.length !== 1) { + await new Promise((resolve) => setTimeout(resolve, DOWNLOAD_POLL_INTERVAL_MS)); + continue; + } + observedDownload = true; + const item = items[0]; + if (item.state === "interrupted") { + return { ready: false, diagnostic: "download-interrupted" }; + } + if (item.state === "complete") { + if (item.url !== expectedUrl) { + return { ready: false, diagnostic: "download-url-mismatch" }; } - if (item.state === "complete") { - return ( - item.url === expectedUrl && - item.bytesReceived === expectedBytes && - item.totalBytes === expectedBytes && - item.exists !== false - ); + if (item.bytesReceived !== expectedBytes || item.totalBytes !== expectedBytes) { + return { ready: false, diagnostic: "download-byte-count-mismatch" }; } + if (item.exists === false) { + return { ready: false, diagnostic: "download-exists-false" }; + } + return { ready: true, diagnostic: "download-complete-ready" }; } await new Promise((resolve) => setTimeout(resolve, DOWNLOAD_POLL_INTERVAL_MS)); } - return false; + return { + ready: false, + diagnostic: observedDownload ? "download-timeout" : "download-search-missing", + }; } async function exerciseDownload() { const url = chrome.runtime.getURL("download.txt"); - const downloadId = await chrome.downloads.download({ - url, - filename: "originweave-mv3/download.txt", - conflictAction: "overwrite", - saveAs: false, - }); + let downloadId; + try { + downloadId = await chrome.downloads.download({ + url, + filename: "originweave-mv3/download.txt", + conflictAction: "overwrite", + saveAs: false, + }); + } catch (_error) { + return { ready: false, diagnostic: "download-start-rejected" }; + } if (!Number.isInteger(downloadId)) { - return false; + return { ready: false, diagnostic: "download-start-rejected" }; } return waitForDownload(downloadId, url); } @@ -97,7 +118,7 @@ async function exerciseCoreApis(sender) { }); const historyReady = Array.isArray(historyItems); - const downloadsReady = await exerciseDownload(); + const downloadResult = await exerciseDownload(); return { tabs: tabReady ? "ready" : "missing", @@ -107,7 +128,8 @@ async function exerciseCoreApis(sender) { sidePanel: sidePanelReady ? "ready" : "missing", bookmarks: bookmarksReady ? "ready" : "missing", history: historyReady ? "ready" : "missing", - downloads: downloadsReady ? "ready" : "missing", + downloads: downloadResult.ready ? "ready" : "missing", + downloadsDiagnostic: downloadResult.diagnostic, }; } @@ -136,6 +158,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { bookmarks: "missing", history: "missing", downloads: "missing", + downloadsDiagnostic: "download-not-evaluated", }); } ); From dd942669727c769c3862fc73aab564478cb23ca7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 07:31:29 +0900 Subject: [PATCH 11/95] fix(mv3): propagate bounded download diagnostics --- tests/fixtures/mv3_basic/content_script.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/fixtures/mv3_basic/content_script.js b/tests/fixtures/mv3_basic/content_script.js index a99867f97..b70d1a27f 100644 --- a/tests/fixtures/mv3_basic/content_script.js +++ b/tests/fixtures/mv3_basic/content_script.js @@ -28,4 +28,6 @@ document.documentElement.dataset.originweaveBookmarks = response?.bookmarks ?? "missing"; document.documentElement.dataset.originweaveHistory = response?.history ?? "missing"; document.documentElement.dataset.originweaveDownloads = response?.downloads ?? "missing"; + document.documentElement.dataset.originweaveDownloadsDiagnostic = + response?.downloadsDiagnostic ?? "download-not-evaluated"; })(); From a38d56d33ac76aa28d2b6d1fe2c76d3c6eb964b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 11:22:23 +0900 Subject: [PATCH 12/95] test(mv3): name bounded downloads readiness evidence --- tests/fixtures/mv3_basic/service_worker.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/mv3_basic/service_worker.js b/tests/fixtures/mv3_basic/service_worker.js index 9086ede47..226f0166c 100644 --- a/tests/fixtures/mv3_basic/service_worker.js +++ b/tests/fixtures/mv3_basic/service_worker.js @@ -119,6 +119,7 @@ async function exerciseCoreApis(sender) { const historyReady = Array.isArray(historyItems); const downloadResult = await exerciseDownload(); + const downloadsReady = downloadResult.ready; return { tabs: tabReady ? "ready" : "missing", @@ -128,7 +129,7 @@ async function exerciseCoreApis(sender) { sidePanel: sidePanelReady ? "ready" : "missing", bookmarks: bookmarksReady ? "ready" : "missing", history: historyReady ? "ready" : "missing", - downloads: downloadResult.ready ? "ready" : "missing", + downloads: downloadsReady ? "ready" : "missing", downloadsDiagnostic: downloadResult.diagnostic, }; } From 643298d937637b7a0cdb6d5ea7d935450c1e1092 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 12:00:48 +0900 Subject: [PATCH 13/95] test(mv3): require loopback downloads evidence --- tests/test_mv3_downloads_contract.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/test_mv3_downloads_contract.py b/tests/test_mv3_downloads_contract.py index a44979703..c8e04906d 100644 --- a/tests/test_mv3_downloads_contract.py +++ b/tests/test_mv3_downloads_contract.py @@ -15,25 +15,28 @@ class ManifestV3DownloadsContractTests(unittest.TestCase): """Require the real Chrome downloads API in every pinned-browser trial.""" def test_fixture_declares_downloads_permission_and_local_resource(self) -> None: - """The controlled extension must request downloads and own its test payload.""" + """The controlled extension must request downloads and serve its test payload locally.""" manifest = json.loads((FIXTURE / "manifest.json").read_text(encoding="utf-8")) self.assertIn("downloads", manifest["permissions"]) payload = (FIXTURE / "download.txt").read_bytes() self.assertEqual(payload, b"OriginWeave deterministic MV3 download fixture.\n") - def test_service_worker_executes_and_verifies_a_real_local_download(self) -> None: - """Evidence must originate from a real download followed by bounded inspection.""" + def test_service_worker_executes_and_verifies_a_real_loopback_download(self) -> None: + """Evidence must originate from the controlled fixture origin and bounded inspection.""" worker = (FIXTURE / "service_worker.js").read_text(encoding="utf-8") for expected in ( "chrome.downloads.download", "chrome.downloads.search", - "chrome.runtime.getURL(\"download.txt\")", + 'new URL("download.txt", sourceUrl).href', + 'parsed.hostname !== "127.0.0.1"', + 'parsed.protocol !== "http:"', "downloadsReady", ): with self.subTest(expected=expected): self.assertIn(expected, worker) + self.assertNotIn('chrome.runtime.getURL("download.txt")', worker) def test_download_failures_emit_only_bounded_stage_diagnostics(self) -> None: """A real-browser failure must identify its reviewed download stage without raw paths.""" @@ -42,6 +45,7 @@ def test_download_failures_emit_only_bounded_stage_diagnostics(self) -> None: content = (FIXTURE / "content_script.js").read_text(encoding="utf-8") runner = RUNNER.read_text(encoding="utf-8") for expected in ( + "download-source-rejected", "download-start-rejected", "download-search-missing", "download-interrupted", @@ -68,6 +72,7 @@ def test_content_script_and_runner_require_downloads_on_every_pass(self) -> None self.assertIn("originweaveDownloads", content) self.assertIn('"downloads": surfaces["downloads"] == "ready"', runner) self.assertIn('"downloads": "ready"', runner) + self.assertIn('"downloadsDiagnostic": "download-complete-ready"', runner) if __name__ == "__main__": From 61a5a640333e199bd94d92be26d5fdb7960e10a3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 12:02:30 +0900 Subject: [PATCH 14/95] fix(mv3): download from controlled fixture origin --- tests/fixtures/mv3_basic/service_worker.js | 27 +++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/mv3_basic/service_worker.js b/tests/fixtures/mv3_basic/service_worker.js index 226f0166c..37e8129d5 100644 --- a/tests/fixtures/mv3_basic/service_worker.js +++ b/tests/fixtures/mv3_basic/service_worker.js @@ -59,8 +59,29 @@ async function waitForDownload(downloadId, expectedUrl) { }; } -async function exerciseDownload() { - const url = chrome.runtime.getURL("download.txt"); +async function exerciseDownload(sender) { + const sourceUrl = sender?.tab?.url; + if (typeof sourceUrl !== "string") { + return { ready: false, diagnostic: "download-source-rejected" }; + } + + let parsed; + try { + parsed = new URL(sourceUrl); + } catch (_error) { + return { ready: false, diagnostic: "download-source-rejected" }; + } + if ( + parsed.protocol !== "http:" || + parsed.hostname !== "127.0.0.1" || + parsed.pathname !== "/page.html" || + parsed.username !== "" || + parsed.password !== "" + ) { + return { ready: false, diagnostic: "download-source-rejected" }; + } + + const url = new URL("download.txt", sourceUrl).href; let downloadId; try { downloadId = await chrome.downloads.download({ @@ -118,7 +139,7 @@ async function exerciseCoreApis(sender) { }); const historyReady = Array.isArray(historyItems); - const downloadResult = await exerciseDownload(); + const downloadResult = await exerciseDownload(sender); const downloadsReady = downloadResult.ready; return { From c58bde7b20bdb5cc9c9f54f215dcf96f50bb524d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 12:03:44 +0900 Subject: [PATCH 15/95] test(mv3): keep download diagnostics fixture-bounded --- tests/test_mv3_downloads_contract.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_mv3_downloads_contract.py b/tests/test_mv3_downloads_contract.py index c8e04906d..8173da362 100644 --- a/tests/test_mv3_downloads_contract.py +++ b/tests/test_mv3_downloads_contract.py @@ -39,11 +39,10 @@ def test_service_worker_executes_and_verifies_a_real_loopback_download(self) -> self.assertNotIn('chrome.runtime.getURL("download.txt")', worker) def test_download_failures_emit_only_bounded_stage_diagnostics(self) -> None: - """A real-browser failure must identify its reviewed download stage without raw paths.""" + """Fixture diagnostics must name a reviewed stage without retaining raw browser errors.""" worker = (FIXTURE / "service_worker.js").read_text(encoding="utf-8") content = (FIXTURE / "content_script.js").read_text(encoding="utf-8") - runner = RUNNER.read_text(encoding="utf-8") for expected in ( "download-source-rejected", "download-start-rejected", @@ -59,10 +58,10 @@ def test_download_failures_emit_only_bounded_stage_diagnostics(self) -> None: with self.subTest(expected=expected): self.assertIn(expected, worker) self.assertIn("originweaveDownloadsDiagnostic", content) - self.assertIn("downloadsDiagnostic", runner) - self.assertIn("DOWNLOAD_DIAGNOSTIC_VALUES", runner) self.assertNotIn("download.default_directory", worker) self.assertNotIn("item.filename", worker) + self.assertNotIn("_error.message", worker) + self.assertNotIn("String(_error)", worker) def test_content_script_and_runner_require_downloads_on_every_pass(self) -> None: """The compatibility report must fail closed when downloads evidence is missing.""" @@ -72,7 +71,6 @@ def test_content_script_and_runner_require_downloads_on_every_pass(self) -> None self.assertIn("originweaveDownloads", content) self.assertIn('"downloads": surfaces["downloads"] == "ready"', runner) self.assertIn('"downloads": "ready"', runner) - self.assertIn('"downloadsDiagnostic": "download-complete-ready"', runner) if __name__ == "__main__": From 669e308358f7488e6899c9fb288c795562aa7f84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 13:27:08 +0900 Subject: [PATCH 16/95] test(mv3): require bounded download diagnostics in runner evidence --- tests/test_mv3_downloads_contract.py | 61 ++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/test_mv3_downloads_contract.py b/tests/test_mv3_downloads_contract.py index 8173da362..89d95778c 100644 --- a/tests/test_mv3_downloads_contract.py +++ b/tests/test_mv3_downloads_contract.py @@ -2,6 +2,7 @@ from __future__ import annotations +import importlib.util import json import pathlib import unittest @@ -11,6 +12,17 @@ RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" +def _load_runner_module(): + """Load the compatibility runner without invoking its command-line entry point.""" + + spec = importlib.util.spec_from_file_location("originweave_mv3_runner", RUNNER) + if spec is None or spec.loader is None: + raise AssertionError("unable to load the MV3 compatibility runner") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + class ManifestV3DownloadsContractTests(unittest.TestCase): """Require the real Chrome downloads API in every pinned-browser trial.""" @@ -72,6 +84,55 @@ def test_content_script_and_runner_require_downloads_on_every_pass(self) -> None self.assertIn('"downloads": surfaces["downloads"] == "ready"', runner) self.assertIn('"downloads": "ready"', runner) + def test_runner_preserves_only_reviewed_download_diagnostic_tokens(self) -> None: + """Runner failure evidence must retain stage tokens while rejecting raw diagnostics.""" + + runner = _load_runner_module() + approved = { + "download-source-rejected", + "download-start-rejected", + "download-search-missing", + "download-interrupted", + "download-url-mismatch", + "download-byte-count-mismatch", + "download-exists-false", + "download-timeout", + "download-complete-ready", + "download-not-evaluated", + } + self.assertIn("downloadsDiagnostic", runner.SURFACE_EVIDENCE_KEYS) + self.assertEqual(runner.DOWNLOAD_DIAGNOSTIC_VALUES, frozenset(approved)) + for token in approved: + with self.subTest(token=token): + self.assertEqual( + runner._safe_surface_value("downloadsDiagnostic", token), token + ) + for raw in ("/tmp/private/download.txt", "Error: secret browser failure"): + with self.subTest(raw=raw): + self.assertEqual( + runner._safe_surface_value("downloadsDiagnostic", raw), "unexpected" + ) + + error = runner.CompatibilitySurfaceError( + { + "downloads": "missing", + "downloadsDiagnostic": "download-source-rejected", + } + ) + evidence = runner._failure_evidence(error) + self.assertEqual( + evidence["observed"]["downloadsDiagnostic"], "download-source-rejected" + ) + self.assertNotIn("/tmp/private", repr(evidence)) + self.assertNotIn("secret browser failure", repr(evidence)) + + def test_runner_collects_download_diagnostic_from_fixture_dataset(self) -> None: + """The WebDriver evidence script must collect the bounded fixture diagnostic field.""" + + runner = RUNNER.read_text(encoding="utf-8") + self.assertIn("originweaveDownloadsDiagnostic", runner) + self.assertIn('"downloadsDiagnostic": "download-complete-ready"', runner) + if __name__ == "__main__": unittest.main() From 27ce89066ed1473dcd66eb26a2f91becf9df5424 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 13:28:47 +0900 Subject: [PATCH 17/95] fix(mv3): preserve bounded download stage diagnostics --- scripts/ci/run_mv3_compatibility.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index e051f2807..4cb3c732a 100755 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -53,10 +53,25 @@ "bookmarks", "history", "downloads", + "downloadsDiagnostic", ) SURFACE_EVIDENCE_VALUES = frozenset( {"ready", "missing", "initialized", "persisted", "pong", "installed", "blocked"} ) +DOWNLOAD_DIAGNOSTIC_VALUES = frozenset( + { + "download-source-rejected", + "download-start-rejected", + "download-search-missing", + "download-interrupted", + "download-url-mismatch", + "download-byte-count-mismatch", + "download-exists-false", + "download-timeout", + "download-complete-ready", + "download-not-evaluated", + } +) class CompatibilitySurfaceError(RuntimeError): @@ -83,6 +98,8 @@ def _safe_surface_value(key: str, value: str) -> str: if key == "workerStartCount": return value if value.isdecimal() and len(value) <= 20 else "invalid" + if key == "downloadsDiagnostic": + return value if value in DOWNLOAD_DIAGNOSTIC_VALUES else "unexpected" return value if value in SURFACE_EVIDENCE_VALUES else "unexpected" @@ -234,7 +251,9 @@ def _wait_for_extension_evidence( sidePanel: document.documentElement.dataset.originweaveSidePanel || "missing", bookmarks: document.documentElement.dataset.originweaveBookmarks || "missing", history: document.documentElement.dataset.originweaveHistory || "missing", - downloads: document.documentElement.dataset.originweaveDownloads || "missing" + downloads: document.documentElement.dataset.originweaveDownloads || "missing", + downloadsDiagnostic: + document.documentElement.dataset.originweaveDownloadsDiagnostic || "download-not-evaluated" }; """ expected = { @@ -253,6 +272,7 @@ def _wait_for_extension_evidence( "bookmarks": "ready", "history": "ready", "downloads": "ready", + "downloadsDiagnostic": "download-complete-ready", } deadline = time.monotonic() + FIXTURE_TIMEOUT_SECONDS latest: dict[str, str] = {} From 7bd2d433adc567bc97409e13b7c1daeaed0536c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:01:26 +0900 Subject: [PATCH 18/95] test(mv3): exercise raw diagnostic sanitization --- tests/test_mv3_downloads_contract.py | 30 ++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/tests/test_mv3_downloads_contract.py b/tests/test_mv3_downloads_contract.py index 89d95778c..94696376c 100644 --- a/tests/test_mv3_downloads_contract.py +++ b/tests/test_mv3_downloads_contract.py @@ -107,24 +107,34 @@ def test_runner_preserves_only_reviewed_download_diagnostic_tokens(self) -> None self.assertEqual( runner._safe_surface_value("downloadsDiagnostic", token), token ) - for raw in ("/tmp/private/download.txt", "Error: secret browser failure"): - with self.subTest(raw=raw): - self.assertEqual( - runner._safe_surface_value("downloadsDiagnostic", raw), "unexpected" - ) - error = runner.CompatibilitySurfaceError( + approved_error = runner.CompatibilitySurfaceError( { "downloads": "missing", "downloadsDiagnostic": "download-source-rejected", } ) - evidence = runner._failure_evidence(error) + approved_evidence = runner._failure_evidence(approved_error) self.assertEqual( - evidence["observed"]["downloadsDiagnostic"], "download-source-rejected" + approved_evidence["observed"]["downloadsDiagnostic"], + "download-source-rejected", ) - self.assertNotIn("/tmp/private", repr(evidence)) - self.assertNotIn("secret browser failure", repr(evidence)) + + raw_download_path = str(ROOT / "private" / "download.txt") + raw_browser_error = "Error: secret browser failure" + for raw in (raw_download_path, raw_browser_error): + with self.subTest(raw=raw): + error = runner.CompatibilitySurfaceError( + { + "downloads": "missing", + "downloadsDiagnostic": raw, + } + ) + evidence = runner._failure_evidence(error) + self.assertEqual( + evidence["observed"]["downloadsDiagnostic"], "unexpected" + ) + self.assertNotIn(raw, repr(evidence)) def test_runner_collects_download_diagnostic_from_fixture_dataset(self) -> None: """The WebDriver evidence script must collect the bounded fixture diagnostic field.""" From 7d219459a7cf0918763db9ecda21fbc56f1a2230 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:46:33 +0900 Subject: [PATCH 19/95] test(mv3): reproduce restart download overwrite race --- tests/test_mv3_downloads_contract.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_mv3_downloads_contract.py b/tests/test_mv3_downloads_contract.py index 94696376c..53218b965 100644 --- a/tests/test_mv3_downloads_contract.py +++ b/tests/test_mv3_downloads_contract.py @@ -50,6 +50,13 @@ def test_service_worker_executes_and_verifies_a_real_loopback_download(self) -> self.assertIn(expected, worker) self.assertNotIn('chrome.runtime.getURL("download.txt")', worker) + def test_restart_pair_never_overwrites_the_previous_controlled_download(self) -> None: + """Restart evidence must not race Chrome while replacing the first pass's file.""" + + worker = (FIXTURE / "service_worker.js").read_text(encoding="utf-8") + self.assertIn('conflictAction: "uniquify"', worker) + self.assertNotIn('conflictAction: "overwrite"', worker) + def test_download_failures_emit_only_bounded_stage_diagnostics(self) -> None: """Fixture diagnostics must name a reviewed stage without retaining raw browser errors.""" From 806f571fd7a549f232752f8f9a14af6e7785906e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:49:38 +0900 Subject: [PATCH 20/95] fix(mv3): avoid restart download overwrite race --- tests/fixtures/mv3_basic/service_worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/mv3_basic/service_worker.js b/tests/fixtures/mv3_basic/service_worker.js index 37e8129d5..33ac59efd 100644 --- a/tests/fixtures/mv3_basic/service_worker.js +++ b/tests/fixtures/mv3_basic/service_worker.js @@ -87,7 +87,7 @@ async function exerciseDownload(sender) { downloadId = await chrome.downloads.download({ url, filename: "originweave-mv3/download.txt", - conflictAction: "overwrite", + conflictAction: "uniquify", saveAs: false, }); } catch (_error) { From 6ffb02e10ecd10c374e8b9bc8d2c79779cc2d54c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:16:14 +0900 Subject: [PATCH 21/95] test(mv3): expose swallowed session cleanup failures --- ..._mv3_session_cleanup_exception_contract.py | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/test_mv3_session_cleanup_exception_contract.py diff --git a/tests/test_mv3_session_cleanup_exception_contract.py b/tests/test_mv3_session_cleanup_exception_contract.py new file mode 100644 index 000000000..b1037696a --- /dev/null +++ b/tests/test_mv3_session_cleanup_exception_contract.py @@ -0,0 +1,128 @@ +"""Regression contract for fail-closed WebDriver session cleanup.""" + +from __future__ import annotations + +import pathlib +import runpy +import tempfile +import unittest +from unittest import mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class _UnexpectedCleanupFailure(Exception): + """Model an unreviewed programming/integration failure during session deletion.""" + + +class _FakeDriver: + """Record process cleanup without launching ChromeDriver.""" + + def __init__(self) -> None: + self.terminated = False + self.killed = False + + def terminate(self) -> None: + """Record the graceful process-termination fallback.""" + + self.terminated = True + + def kill(self) -> None: + """Record the bounded hard-kill fallback when requested.""" + + self.killed = True + + def wait(self, timeout: float) -> int: + """Model an immediately reaped process.""" + + if timeout <= 0: + raise AssertionError("timeout must remain positive") + return 0 + + +class ManifestV3SessionCleanupExceptionTests(unittest.TestCase): + """Unexpected cleanup failures must remain visible after process teardown.""" + + def test_unreviewed_session_cleanup_exception_is_not_silently_suppressed(self) -> None: + """A new exception class must propagate while ChromeDriver is still terminated.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_cleanup_contract") + run_browser_pass = namespace["_run_browser_pass"] + globals_ = run_browser_pass.__globals__ + fake_driver = _FakeDriver() + + def fake_json_request( + _driver_port: int, + method: str, + path: str, + _payload=None, + *, + timeout: float = 5.0, + ): + if timeout <= 0: + raise AssertionError("timeout must remain positive") + if method == "POST" and path == "/session": + return { + "value": { + "sessionId": "session-1", + "capabilities": { + "browserVersion": namespace["PINNED_CHROME_VERSION"] + }, + } + } + if method == "POST" and path.endswith("/url"): + return {"value": None} + if method == "DELETE" and path.endswith("/session/session-1"): + raise _UnexpectedCleanupFailure("must not be normalized") + raise AssertionError(f"unexpected WebDriver request: {method} {path}") + + surfaces = { + "workerStartCount": "1", + "storagePersistence": "initialized", + "workerReply": "pong", + "content": "ready", + "storage": "ready", + "dnr": "blocked", + "tabs": "ready", + "windows": "ready", + "scripting": "ready", + "scriptingExecuted": "ready", + "commands": "ready", + "sidePanel": "ready", + "bookmarks": "ready", + "history": "ready", + "downloads": "ready", + } + + with tempfile.TemporaryDirectory(prefix="originweave-cleanup-contract-") as profile_dir: + with ( + mock.patch.object(globals_["subprocess"], "Popen", return_value=fake_driver), + mock.patch.dict( + globals_, + { + "_free_loopback_port": lambda: 43123, + "_wait_for_driver": lambda _port: None, + "_json_request": fake_json_request, + "_wait_for_extension_evidence": ( + lambda _port, _session, _expected: surfaces + ), + "_exercise_real_click": lambda _port, _session: "clicked", + }, + ), + ): + with self.assertRaises(_UnexpectedCleanupFailure): + run_browser_pass( + pathlib.Path("/controlled/chrome"), + pathlib.Path("/controlled/chromedriver"), + "http://127.0.0.1:8080/page.html", + profile_dir, + "initialized", + ) + + self.assertTrue(fake_driver.terminated) + self.assertFalse(fake_driver.killed) + + +if __name__ == "__main__": + unittest.main() From 1d391df8eec16bbbbd6f34ab50548daf6af8321b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:19:32 +0900 Subject: [PATCH 22/95] fix(mv3): fail closed on unexpected session cleanup --- scripts/ci/run_mv3_compatibility.py | 40 +++++++++++++++++++---------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 4cb3c732a..100827c58 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -11,7 +11,6 @@ from __future__ import annotations -import contextlib import http.client import http.server import json @@ -86,6 +85,10 @@ def __init__(self, observed: dict[str, str]) -> None: super().__init__("Manifest V3 fixture surfaces did not converge") +class WebDriverSessionCleanupError(RuntimeError): + """Report a reviewed WebDriver session-delete failure after process teardown.""" + + class QuietFixtureHandler(http.server.SimpleHTTPRequestHandler): """Serve only the controlled local fixture without noisy access logging.""" @@ -436,20 +439,29 @@ def _run_browser_pass( }, } finally: - if session_id is not None: - with contextlib.suppress(Exception): - _json_request( - driver_port, - "DELETE", - _webdriver_path(session_id, ""), - {}, - ) - driver.terminate() + cleanup_error: Exception | None = None try: - driver.wait(timeout=5) - except subprocess.TimeoutExpired: - driver.kill() - driver.wait(timeout=5) + if session_id is not None: + try: + _json_request( + driver_port, + "DELETE", + _webdriver_path(session_id, ""), + {}, + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as error: + cleanup_error = error + finally: + driver.terminate() + try: + driver.wait(timeout=5) + except subprocess.TimeoutExpired: + driver.kill() + driver.wait(timeout=5) + if cleanup_error is not None: + raise WebDriverSessionCleanupError( + "WebDriver session cleanup failed after bounded process teardown" + ) from cleanup_error def _run_restart_trial( From 8759518d919fab576c584e3849d17c1fae81c282 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 22:07:19 +0900 Subject: [PATCH 23/95] test(mv3): normalize unittest mock imports --- tests/test_mv3_session_cleanup_exception_contract.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_mv3_session_cleanup_exception_contract.py b/tests/test_mv3_session_cleanup_exception_contract.py index b1037696a..d9afb366b 100644 --- a/tests/test_mv3_session_cleanup_exception_contract.py +++ b/tests/test_mv3_session_cleanup_exception_contract.py @@ -6,7 +6,7 @@ import runpy import tempfile import unittest -from unittest import mock +import unittest.mock ROOT = pathlib.Path(__file__).resolve().parents[1] RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" @@ -97,8 +97,10 @@ def fake_json_request( with tempfile.TemporaryDirectory(prefix="originweave-cleanup-contract-") as profile_dir: with ( - mock.patch.object(globals_["subprocess"], "Popen", return_value=fake_driver), - mock.patch.dict( + unittest.mock.patch.object( + globals_["subprocess"], "Popen", return_value=fake_driver + ), + unittest.mock.patch.dict( globals_, { "_free_loopback_port": lambda: 43123, From 02e4550b08bf3007688b42a1355248dedc0289d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:37:34 +0900 Subject: [PATCH 24/95] test(mv3): reject untrusted browser binary overrides --- tests/test_mv3_binary_authority_contract.py | 108 ++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/test_mv3_binary_authority_contract.py diff --git a/tests/test_mv3_binary_authority_contract.py b/tests/test_mv3_binary_authority_contract.py new file mode 100644 index 000000000..68fedbf9f --- /dev/null +++ b/tests/test_mv3_binary_authority_contract.py @@ -0,0 +1,108 @@ +"""Security contract for pinned Manifest V3 browser executable authority.""" + +from __future__ import annotations + +import os +import pathlib +import runpy +import tempfile +import unittest +import unittest.mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class ManifestV3BinaryAuthorityContractTests(unittest.TestCase): + """Prevent environment variables from selecting arbitrary executable code.""" + + def setUp(self) -> None: + """Load the production runner without executing its command-line entrypoint.""" + + self.namespace = runpy.run_path(str(RUNNER), run_name="mv3_binary_authority") + self.validate = self.namespace["_pinned_workspace_binary"] + + @staticmethod + def _make_executable(path: pathlib.Path) -> None: + """Create one inert executable fixture without ever executing it.""" + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + path.chmod(0o755) + + def test_untrusted_environment_override_is_rejected_before_execution(self) -> None: + """An existing executable outside the pinned workspace path must fail closed.""" + + with tempfile.TemporaryDirectory(prefix="originweave-binary-authority-") as temp_dir: + root = pathlib.Path(temp_dir) + expected = root / ".mv3-browser" / "chromedriver-linux64" / "chromedriver" + attacker = root / "attacker-controlled" / "chromedriver" + self._make_executable(expected) + self._make_executable(attacker) + + with unittest.mock.patch.dict( + os.environ, + {"CHROMEDRIVER_BIN": str(attacker)}, + clear=False, + ): + with self.assertRaisesRegex(SystemExit, "pinned workspace executable"): + self.validate( + "CHROMEDRIVER_BIN", + pathlib.PurePosixPath( + ".mv3-browser/chromedriver-linux64/chromedriver" + ), + "ChromeDriver", + root=root, + ) + + def test_exact_pinned_workspace_executable_is_accepted(self) -> None: + """The exact executable provisioned by the pinned workflow remains usable.""" + + with tempfile.TemporaryDirectory(prefix="originweave-binary-authority-") as temp_dir: + root = pathlib.Path(temp_dir) + expected = root / ".mv3-browser" / "chrome-linux64" / "chrome" + self._make_executable(expected) + + with unittest.mock.patch.dict( + os.environ, + {"CHROME_BIN": str(expected)}, + clear=False, + ): + actual = self.validate( + "CHROME_BIN", + pathlib.PurePosixPath(".mv3-browser/chrome-linux64/chrome"), + "Chrome for Testing", + root=root, + ) + + self.assertEqual(actual, expected) + + def test_symlink_at_pinned_executable_path_is_rejected(self) -> None: + """A matching pathname must not authorize a symlink to foreign executable code.""" + + with tempfile.TemporaryDirectory(prefix="originweave-binary-authority-") as temp_dir: + root = pathlib.Path(temp_dir) + expected = root / ".mv3-browser" / "chromedriver-linux64" / "chromedriver" + attacker = root / "attacker-controlled" / "chromedriver" + self._make_executable(attacker) + expected.parent.mkdir(parents=True, exist_ok=True) + expected.symlink_to(attacker) + + with unittest.mock.patch.dict( + os.environ, + {"CHROMEDRIVER_BIN": str(expected)}, + clear=False, + ): + with self.assertRaisesRegex(SystemExit, "symlink"): + self.validate( + "CHROMEDRIVER_BIN", + pathlib.PurePosixPath( + ".mv3-browser/chromedriver-linux64/chromedriver" + ), + "ChromeDriver", + root=root, + ) + + +if __name__ == "__main__": + unittest.main() From 7cbc2fa5017a4a884645839661a74af982b50c14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:38:33 +0900 Subject: [PATCH 25/95] fix(mv3): bind browser executables to pinned workspace paths --- scripts/ci/run_mv3_compatibility.py | 69 ++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 6 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 100827c58..d271cefb2 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -28,6 +28,12 @@ FIXTURE = ROOT / "tests" / "fixtures" / "mv3_basic" PINNED_CHROME_VERSION = "150.0.7871.129" PINNED_CHROME_REVISION = "r1639810" +PINNED_CHROME_RELATIVE_PATH = pathlib.PurePosixPath( + ".mv3-browser/chrome-linux64/chrome" +) +PINNED_CHROMEDRIVER_RELATIVE_PATH = pathlib.PurePosixPath( + ".mv3-browser/chromedriver-linux64/chromedriver" +) REPEATABILITY_TRIALS = 3 REQUEST_TIMEOUT_SECONDS = 5.0 STARTUP_TIMEOUT_SECONDS = 20.0 @@ -528,15 +534,66 @@ def _run_restart_trial( } +def _pinned_workspace_binary( + env_name: str, + relative_path: pathlib.PurePosixPath, + label: str, + *, + root: pathlib.Path = ROOT, +) -> pathlib.Path: + """Authorize only the exact non-symlink executable provisioned under the workspace. + + Environment variables remain compatibility inputs for the workflow, but they + cannot redirect execution. The release lane has one reviewed path for each + pinned Chrome-for-Testing artifact, and any other executable fails closed. + """ + + if relative_path.is_absolute() or ".." in relative_path.parts: + raise SystemExit(f"{label} pinned workspace path is invalid") + + trusted_root = pathlib.Path(os.path.abspath(root)) + expected = pathlib.Path(os.path.abspath(trusted_root.joinpath(*relative_path.parts))) + configured = os.environ.get(env_name) + if configured: + configured_path = pathlib.Path(configured) + if not configured_path.is_absolute(): + raise SystemExit(f"{env_name} must name the pinned workspace executable") + if pathlib.Path(os.path.abspath(configured_path)) != expected: + raise SystemExit(f"{env_name} must name the pinned workspace executable") + + current = expected + while current != trusted_root: + if current.is_symlink(): + raise SystemExit(f"{label} pinned workspace executable path contains a symlink") + parent = current.parent + if parent == current: + raise SystemExit(f"{label} pinned workspace executable escaped the workspace") + current = parent + + try: + expected.relative_to(trusted_root) + except ValueError as exc: + raise SystemExit(f"{label} pinned workspace executable escaped the workspace") from exc + if not expected.is_file(): + raise SystemExit(f"{label} pinned workspace executable is missing") + if not os.access(expected, os.X_OK): + raise SystemExit(f"{label} pinned workspace executable is not executable") + return expected + + def main() -> int: """Run three independent restart trials and emit bounded repeatability 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") + chrome_bin = _pinned_workspace_binary( + "CHROME_BIN", + PINNED_CHROME_RELATIVE_PATH, + "Chrome for Testing", + ) + chromedriver_bin = _pinned_workspace_binary( + "CHROMEDRIVER_BIN", + PINNED_CHROMEDRIVER_RELATIVE_PATH, + "ChromeDriver", + ) if not (FIXTURE / "manifest.json").is_file(): raise SystemExit("MV3 fixture manifest is missing") From f410460247c97d262635096e054a50983f3e1315 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:41:20 +0900 Subject: [PATCH 26/95] test(mv3): preserve session cleanup failure over teardown errors --- ..._mv3_session_cleanup_exception_contract.py | 83 +++++++++++++------ 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/tests/test_mv3_session_cleanup_exception_contract.py b/tests/test_mv3_session_cleanup_exception_contract.py index d9afb366b..9e9a640b9 100644 --- a/tests/test_mv3_session_cleanup_exception_contract.py +++ b/tests/test_mv3_session_cleanup_exception_contract.py @@ -19,14 +19,17 @@ class _UnexpectedCleanupFailure(Exception): class _FakeDriver: """Record process cleanup without launching ChromeDriver.""" - def __init__(self) -> None: + def __init__(self, *, terminate_error: OSError | None = None) -> None: self.terminated = False self.killed = False + self.terminate_error = terminate_error def terminate(self) -> None: """Record the graceful process-termination fallback.""" self.terminated = True + if self.terminate_error is not None: + raise self.terminate_error def kill(self) -> None: """Record the bounded hard-kill fallback when requested.""" @@ -44,13 +47,38 @@ def wait(self, timeout: float) -> int: class ManifestV3SessionCleanupExceptionTests(unittest.TestCase): """Unexpected cleanup failures must remain visible after process teardown.""" - def test_unreviewed_session_cleanup_exception_is_not_silently_suppressed(self) -> None: - """A new exception class must propagate while ChromeDriver is still terminated.""" + @staticmethod + def _surfaces() -> dict[str, str]: + """Return one fully passing controlled compatibility surface set.""" + + return { + "workerStartCount": "1", + "storagePersistence": "initialized", + "workerReply": "pong", + "content": "ready", + "storage": "ready", + "dnr": "blocked", + "tabs": "ready", + "windows": "ready", + "scripting": "ready", + "scriptingExecuted": "ready", + "commands": "ready", + "sidePanel": "ready", + "bookmarks": "ready", + "history": "ready", + "downloads": "ready", + } + + def _run_with_cleanup_failure( + self, + cleanup_failure: Exception, + fake_driver: _FakeDriver, + ) -> tuple[object, object]: + """Run the production browser-pass boundary with controlled cleanup failures.""" namespace = runpy.run_path(str(RUNNER), run_name="mv3_cleanup_contract") run_browser_pass = namespace["_run_browser_pass"] globals_ = run_browser_pass.__globals__ - fake_driver = _FakeDriver() def fake_json_request( _driver_port: int, @@ -74,27 +102,9 @@ def fake_json_request( if method == "POST" and path.endswith("/url"): return {"value": None} if method == "DELETE" and path.endswith("/session/session-1"): - raise _UnexpectedCleanupFailure("must not be normalized") + raise cleanup_failure raise AssertionError(f"unexpected WebDriver request: {method} {path}") - surfaces = { - "workerStartCount": "1", - "storagePersistence": "initialized", - "workerReply": "pong", - "content": "ready", - "storage": "ready", - "dnr": "blocked", - "tabs": "ready", - "windows": "ready", - "scripting": "ready", - "scriptingExecuted": "ready", - "commands": "ready", - "sidePanel": "ready", - "bookmarks": "ready", - "history": "ready", - "downloads": "ready", - } - with tempfile.TemporaryDirectory(prefix="originweave-cleanup-contract-") as profile_dir: with ( unittest.mock.patch.object( @@ -107,13 +117,13 @@ def fake_json_request( "_wait_for_driver": lambda _port: None, "_json_request": fake_json_request, "_wait_for_extension_evidence": ( - lambda _port, _session, _expected: surfaces + lambda _port, _session, _expected: self._surfaces() ), "_exercise_real_click": lambda _port, _session: "clicked", }, ), ): - with self.assertRaises(_UnexpectedCleanupFailure): + try: run_browser_pass( pathlib.Path("/controlled/chrome"), pathlib.Path("/controlled/chromedriver"), @@ -121,10 +131,33 @@ def fake_json_request( profile_dir, "initialized", ) + except Exception as error: # noqa: BLE001 - the test returns the exact boundary error. + return namespace, error + self.fail("cleanup failure unexpectedly became success") + + def test_unreviewed_session_cleanup_exception_is_not_silently_suppressed(self) -> None: + """A new exception class must propagate while ChromeDriver is still terminated.""" + fake_driver = _FakeDriver() + expected = _UnexpectedCleanupFailure("must not be normalized") + _namespace, error = self._run_with_cleanup_failure(expected, fake_driver) + + self.assertIs(error, expected) self.assertTrue(fake_driver.terminated) self.assertFalse(fake_driver.killed) + def test_reviewed_session_cleanup_error_survives_teardown_failure(self) -> None: + """The causal session failure must not be replaced by a later terminate error.""" + + fake_driver = _FakeDriver(terminate_error=OSError("terminate failed")) + session_error = RuntimeError("session delete failed") + namespace, error = self._run_with_cleanup_failure(session_error, fake_driver) + + self.assertIsInstance(error, namespace["WebDriverSessionCleanupError"]) + self.assertIs(error.__cause__, session_error) + self.assertTrue(fake_driver.terminated) + self.assertTrue(fake_driver.killed) + if __name__ == "__main__": unittest.main() From 319f5b5e8796b8e502b47b41f9b5693f2c62aa89 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:42:30 +0900 Subject: [PATCH 27/95] fix(mv3): preserve cleanup cause across process teardown --- scripts/ci/run_mv3_compatibility.py | 34 ++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index d271cefb2..bb964b0ef 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -341,6 +341,31 @@ def _exercise_real_click(driver_port: int, session_id: str) -> str: return str(text) +def _teardown_driver_process(driver: subprocess.Popen[str]) -> Exception | None: + """Best-effort reap ChromeDriver while preserving the first reviewed process error.""" + + teardown_error: Exception | None = None + try: + driver.terminate() + except OSError as error: + teardown_error = error + + if teardown_error is None: + try: + driver.wait(timeout=5) + return None + except subprocess.TimeoutExpired as error: + teardown_error = error + + try: + driver.kill() + driver.wait(timeout=5) + except (OSError, subprocess.TimeoutExpired) as error: + if teardown_error is None: + teardown_error = error + return teardown_error + + def _run_browser_pass( chrome_bin: pathlib.Path, chromedriver_bin: pathlib.Path, @@ -458,16 +483,13 @@ def _run_browser_pass( except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as error: cleanup_error = error finally: - driver.terminate() - try: - driver.wait(timeout=5) - except subprocess.TimeoutExpired: - driver.kill() - driver.wait(timeout=5) + teardown_error = _teardown_driver_process(driver) if cleanup_error is not None: raise WebDriverSessionCleanupError( "WebDriver session cleanup failed after bounded process teardown" ) from cleanup_error + if teardown_error is not None: + raise teardown_error def _run_restart_trial( From 6a1ace6f5d81eeb784d52ec9423086d043c11b9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:43:04 +0900 Subject: [PATCH 28/95] test(mv3): keep timeout kill fallback non-failing --- ..._mv3_session_cleanup_exception_contract.py | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/tests/test_mv3_session_cleanup_exception_contract.py b/tests/test_mv3_session_cleanup_exception_contract.py index 9e9a640b9..ef85ad8cc 100644 --- a/tests/test_mv3_session_cleanup_exception_contract.py +++ b/tests/test_mv3_session_cleanup_exception_contract.py @@ -4,6 +4,7 @@ import pathlib import runpy +import subprocess import tempfile import unittest import unittest.mock @@ -19,10 +20,17 @@ class _UnexpectedCleanupFailure(Exception): class _FakeDriver: """Record process cleanup without launching ChromeDriver.""" - def __init__(self, *, terminate_error: OSError | None = None) -> None: + def __init__( + self, + *, + terminate_error: OSError | None = None, + wait_timeout_once: bool = False, + ) -> None: self.terminated = False self.killed = False self.terminate_error = terminate_error + self.wait_timeout_once = wait_timeout_once + self.wait_calls = 0 def terminate(self) -> None: """Record the graceful process-termination fallback.""" @@ -37,10 +45,13 @@ def kill(self) -> None: self.killed = True def wait(self, timeout: float) -> int: - """Model an immediately reaped process.""" + """Model either an immediately reaped process or one bounded timeout.""" if timeout <= 0: raise AssertionError("timeout must remain positive") + self.wait_calls += 1 + if self.wait_timeout_once and self.wait_calls == 1: + raise subprocess.TimeoutExpired("controlled-chromedriver", timeout) return 0 @@ -131,7 +142,7 @@ def fake_json_request( profile_dir, "initialized", ) - except Exception as error: # noqa: BLE001 - the test returns the exact boundary error. + except Exception as error: # noqa: BLE001 - return exact boundary error. return namespace, error self.fail("cleanup failure unexpectedly became success") @@ -158,6 +169,19 @@ def test_reviewed_session_cleanup_error_survives_teardown_failure(self) -> None: self.assertTrue(fake_driver.terminated) self.assertTrue(fake_driver.killed) + def test_successful_kill_after_wait_timeout_is_normal_cleanup(self) -> None: + """A bounded wait timeout must remain a successful fallback when kill reaps the process.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_teardown_contract") + fake_driver = _FakeDriver(wait_timeout_once=True) + + error = namespace["_teardown_driver_process"](fake_driver) + + self.assertIsNone(error) + self.assertTrue(fake_driver.terminated) + self.assertTrue(fake_driver.killed) + self.assertEqual(fake_driver.wait_calls, 2) + if __name__ == "__main__": unittest.main() From d60f30504e6bf68f2a7d1430a0258c0738e912b8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:44:10 +0900 Subject: [PATCH 29/95] fix(mv3): keep bounded wait timeout fallback successful --- scripts/ci/run_mv3_compatibility.py | 30 ++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index bb964b0ef..8d48b1cc2 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -342,28 +342,28 @@ def _exercise_real_click(driver_port: int, session_id: str) -> str: def _teardown_driver_process(driver: subprocess.Popen[str]) -> Exception | None: - """Best-effort reap ChromeDriver while preserving the first reviewed process error.""" + """Best-effort reap ChromeDriver while preserving reviewed process failures.""" - teardown_error: Exception | None = None try: driver.terminate() - except OSError as error: - teardown_error = error - - if teardown_error is None: + except OSError as terminate_error: try: + driver.kill() driver.wait(timeout=5) - return None - except subprocess.TimeoutExpired as error: - teardown_error = error + except (OSError, subprocess.TimeoutExpired): + pass + return terminate_error try: - driver.kill() driver.wait(timeout=5) - except (OSError, subprocess.TimeoutExpired) as error: - if teardown_error is None: - teardown_error = error - return teardown_error + return None + except subprocess.TimeoutExpired: + try: + driver.kill() + driver.wait(timeout=5) + except (OSError, subprocess.TimeoutExpired) as fallback_error: + return fallback_error + return None def _run_browser_pass( @@ -566,7 +566,7 @@ def _pinned_workspace_binary( """Authorize only the exact non-symlink executable provisioned under the workspace. Environment variables remain compatibility inputs for the workflow, but they - cannot redirect execution. The release lane has one reviewed path for each + cannot redirect execution. The release lane has one reviewed path for each pinned Chrome-for-Testing artifact, and any other executable fails closed. """ From ac7f1f59c663dab72991c2633feb44a9076f3910 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:44:37 +0900 Subject: [PATCH 30/95] test(mv3): retain bounded fallback failure evidence --- ..._mv3_session_cleanup_exception_contract.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_mv3_session_cleanup_exception_contract.py b/tests/test_mv3_session_cleanup_exception_contract.py index ef85ad8cc..6ba9e3301 100644 --- a/tests/test_mv3_session_cleanup_exception_contract.py +++ b/tests/test_mv3_session_cleanup_exception_contract.py @@ -24,11 +24,13 @@ def __init__( self, *, terminate_error: OSError | None = None, + kill_error: OSError | None = None, wait_timeout_once: bool = False, ) -> None: self.terminated = False self.killed = False self.terminate_error = terminate_error + self.kill_error = kill_error self.wait_timeout_once = wait_timeout_once self.wait_calls = 0 @@ -43,6 +45,8 @@ def kill(self) -> None: """Record the bounded hard-kill fallback when requested.""" self.killed = True + if self.kill_error is not None: + raise self.kill_error def wait(self, timeout: float) -> int: """Model either an immediately reaped process or one bounded timeout.""" @@ -182,6 +186,25 @@ def test_successful_kill_after_wait_timeout_is_normal_cleanup(self) -> None: self.assertTrue(fake_driver.killed) self.assertEqual(fake_driver.wait_calls, 2) + def test_failed_kill_fallback_is_recorded_on_the_primary_teardown_error(self) -> None: + """A secondary fallback failure must not disappear while the first error stays causal.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_teardown_contract") + terminate_error = OSError("terminate failed") + fake_driver = _FakeDriver( + terminate_error=terminate_error, + kill_error=PermissionError("kill denied"), + ) + + error = namespace["_teardown_driver_process"](fake_driver) + + self.assertIs(error, terminate_error) + self.assertTrue(fake_driver.killed) + self.assertIn( + "bounded ChromeDriver kill fallback also failed: PermissionError", + getattr(error, "__notes__", []), + ) + if __name__ == "__main__": unittest.main() From 7fc08e51b019f4be2f181d7ea04e5e2dcb859e64 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:45:42 +0900 Subject: [PATCH 31/95] fix(mv3): retain fallback teardown diagnostics --- 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 8d48b1cc2..fcb7d17e5 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -350,8 +350,11 @@ def _teardown_driver_process(driver: subprocess.Popen[str]) -> Exception | None: try: driver.kill() driver.wait(timeout=5) - except (OSError, subprocess.TimeoutExpired): - pass + except (OSError, subprocess.TimeoutExpired) as fallback_error: + terminate_error.add_note( + "bounded ChromeDriver kill fallback also failed: " + f"{type(fallback_error).__name__}" + ) return terminate_error try: From ab8a6e999f3f304307653ff02e9445a4c9cf9099 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:57:43 +0900 Subject: [PATCH 32/95] test(mv3): reject raw webdriver error retention --- tests/test_mv3_compatibility_contract.py | 58 ++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/test_mv3_compatibility_contract.py b/tests/test_mv3_compatibility_contract.py index 6c92afd4d..515866e7c 100644 --- a/tests/test_mv3_compatibility_contract.py +++ b/tests/test_mv3_compatibility_contract.py @@ -6,6 +6,7 @@ import pathlib import runpy import unittest +import unittest.mock ROOT = pathlib.Path(__file__).resolve().parents[1] FIXTURE = ROOT / "tests" / "fixtures" / "mv3_basic" @@ -179,6 +180,63 @@ def test_runner_preserves_safe_surface_failure_evidence(self) -> None: ) self.assertEqual(generic, {"failure_kind": "runtime_error"}) + def test_webdriver_errors_do_not_retain_raw_response_payloads(self) -> None: + """WebDriver protocol failures must stay useful without copying raw browser text.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_contract") + json_request = namespace["_json_request"] + http_module = namespace["http"] + + class FakeResponse: + def __init__(self, status: int, body: bytes) -> None: + self.status = status + self.body = body + + def read(self, _limit: int) -> bytes: + return self.body + + class FakeConnection: + def __init__(self, response: FakeResponse) -> None: + self.response = response + + def request(self, *_args: object, **_kwargs: object) -> None: + return None + + def getresponse(self) -> FakeResponse: + return self.response + + def close(self) -> None: + return None + + raw_secret = "secret-token /home/runner/private https://example.invalid" + cases = ( + FakeResponse(500, raw_secret.encode("utf-8")), + FakeResponse( + 200, + json.dumps( + { + "value": { + "error": "unknown error", + "message": raw_secret, + } + } + ).encode("utf-8"), + ), + ) + for response in cases: + with self.subTest(status=response.status): + with unittest.mock.patch.object( + http_module.client, + "HTTPConnection", + return_value=FakeConnection(response), + ): + with self.assertRaises(RuntimeError) as raised: + json_request(9515, "GET", "/status") + rendered = str(raised.exception) + self.assertNotIn("secret-token", rendered) + self.assertNotIn("/home/runner/private", rendered) + self.assertNotIn("example.invalid", rendered) + def test_workflow_runs_the_real_browser_lane_without_model_credentials(self) -> None: """Compatibility evidence must execute Chromium and never require LLM secrets.""" From f6f307febfe752ed0b93f8d885450fff06ab1868 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:01:17 +0900 Subject: [PATCH 33/95] fix(mv3): sanitize webdriver protocol errors --- scripts/ci/run_mv3_compatibility.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index fcb7d17e5..148009a63 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -189,8 +189,7 @@ def _json_request( 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}") + raise RuntimeError(f"WebDriver HTTP {response.status} error") finally: connection.close() @@ -199,7 +198,7 @@ def _json_request( 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')}") + raise RuntimeError("WebDriver returned a protocol error") return decoded @@ -704,4 +703,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file From 4e24f4140ac728846c9a3129f892ecfaae17eb60 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:05:05 +0000 Subject: [PATCH 34/95] test(mv3): require chrome.downloads primary citation The downloads lane must record the current Chrome Extensions Downloads API reference instead of inferring compatibility from the matrix row. Co-authored-by: Seongho Bae --- tests/test_mv3_compatibility_contract.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_mv3_compatibility_contract.py b/tests/test_mv3_compatibility_contract.py index 515866e7c..33b4eea81 100644 --- a/tests/test_mv3_compatibility_contract.py +++ b/tests/test_mv3_compatibility_contract.py @@ -268,6 +268,8 @@ def test_doctoring_records_primary_chromium_evidence(self) -> None: "not claim 100% Chrome extension compatibility", "Chrome for Developers", "Google Chrome Labs", + "chrome.downloads", + "https://developer.chrome.com/docs/extensions/reference/api/downloads", ): with self.subTest(expected=expected): self.assertIn(expected, doctoring) From 11411038c3d9a47f82100b22d79bcc8c4a05f7fe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:05:05 +0000 Subject: [PATCH 35/95] docs(mv3): record chrome.downloads APA evidence Cite the current vendor Downloads API, bound the active loopback proof, and restore the runner trailing newline after the sanitization change. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + docs/doctoring.md | 6 ++++++ docs/doctoring/mv3-compatibility.md | 8 +++++++- scripts/ci/run_mv3_compatibility.py | 2 +- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4bd39ca..069faa4fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Recorded the current Chrome Extensions `chrome.downloads` primary reference in APA 7th form and stated that the active downloads lane proves one controlled loopback payload in pinned Chromium, not Agent filesystem authority. - 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. diff --git a/docs/doctoring.md b/docs/doctoring.md index 75c107ef0..27e43483a 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,6 +8,10 @@ This document records external evidence that changes OriginWeave architecture, t The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-control protocol, events, commands, and user contexts. Because it remains a W3C Working Draft, OriginWeave places BiDi behind a versioned adapter and Web Platform Tests-derived contract tests rather than make it the internal authority model. +### Manifest V3 downloads compatibility + +The current Chrome Extensions Downloads API documents the `downloads` manifest permission and `chrome.downloads` methods that initiate, monitor, search, and inspect downloads. That living vendor reference is API semantics only. OriginWeave treats a successful controlled loopback download in pinned Chromium as compatibility evidence for one declared surface, not as Agent filesystem authority, general download persistence, or a claim that every Downloads method is supported. + ### Browser origin equivalence The WHATWG URL host parser and Chromium canonicalizer classify shortened decimal, integer, hexadecimal, legacy octal-looking, and mixed-component numeric hosts as IPv4 or broken IPv4 candidates rather than ordinary DNS names. Chromium's regression suite includes values such as `192`, `0xC0a80001`, `030052000001`, and mixed hexadecimal components. A non-final empty `0x` component can participate in Chromium's multi-part IPv4 truncation behavior, but a final `0x` label does not produce an IPv4 number because stripping its prefix leaves no digits; it remains a domain label. Chromium also warns that broken IP-like hosts must not be connected because another resolver could accept them. OriginWeave therefore admits only canonical dotted-decimal IPv4 into its policy origin type, rejects browser-special numeric spellings before DNS validation, and preserves final non-numeric DNS labels such as `0x`. @@ -98,6 +102,8 @@ Autio, C., Schwartz, R., Dunietz, J., Jain, S., Stanley, M., Tabassi, E., Hall, Bonica, R., Cotton, M., Haberman, B., & Vegoda, L. (2017). *Updates to the special-purpose IP address registries* (RFC 8190). Internet Engineering Task Force. https://doi.org/10.17487/RFC8190 +Chrome for Developers. (n.d.). *chrome.downloads*. Google. Retrieved August 16, 2026, from https://developer.chrome.com/docs/extensions/reference/api/downloads + Chromium Authors. (n.d.). *Proxy support in Chrome* [Source documentation]. Chromium. https://chromium.googlesource.com/chromium/src/+/a3e71ebfa307d8760eb68b777e2998a869940092/net/docs/proxy.md Chromium Authors. (2026). *URL canonicalizer unit tests* [Source code]. Chromium. https://chromium.googlesource.com/chromium/src/+/446d05d21720f0b3505ec21057b3e9f909784262/url/url_canon_unittest.cc diff --git a/docs/doctoring/mv3-compatibility.md b/docs/doctoring/mv3-compatibility.md index 571c49329..5522c9f55 100644 --- a/docs/doctoring/mv3-compatibility.md +++ b/docs/doctoring/mv3-compatibility.md @@ -1,7 +1,7 @@ # Manifest V3 compatibility evidence baseline - **Status:** Active implementation evidence for issue #27 -- **Reviewed:** 2026-08-11 +- **Reviewed:** 2026-08-16 - **Pinned browser:** Chrome for Testing `150.0.7871.129`, Chromium revision `r1639810` OriginWeave uses Chromium as its compatibility kernel, so browser-extension compatibility must be demonstrated with executable Chromium evidence rather than inferred from architecture alone. The protected-main lane exercises a controlled unpacked Manifest V3 extension against one exact Chrome for Testing build and proves service-worker, content-script, storage, declarative-network-request, tabs, windows, scripting, commands, side-panel, bookmarks/history read compatibility, restart persistence, repeatability, and one real WebDriver click/post-condition. Active stacked compatibility work adds downloads, bounded bookmark/history mutation, profile isolation, explicit extension update/version-migration evidence, and an exact content-script isolated-world check. OriginWeave does **not claim 100% Chrome extension compatibility**. @@ -38,6 +38,10 @@ The release-quality capability matrix must remain coupled to executable evidence For history compatibility specifically, the current official Chrome Extensions API documents the `history` manifest permission and Promise-returning `chrome.history.addUrl`, `chrome.history.search`, and `chrome.history.deleteUrl` methods. This living vendor reference establishes API semantics only. OriginWeave release evidence continues to depend on the exact pinned Chromium fixture and exact-head CI result rather than inferring compatibility from documentation. +## Downloads API primary evidence + +For downloads compatibility specifically, the current official Chrome Extensions API documents the `downloads` manifest permission and the `chrome.downloads` methods that initiate, monitor, search, and inspect downloads. This living vendor reference establishes API semantics only. Active PR #43 exercises one controlled loopback payload through pinned Chromium and retains only allow-listed stage diagnostics. That proof is not Agent filesystem authority, general download persistence, unsafe-filename handling, or a release claim that every `chrome.downloads` method works. + ## Update-migration evidence boundary Restart persistence and extension update migration are separate compatibility claims. A successful restart proves only that state survives a new browser process. The active update-migration lane additionally uses a trial-local copy of the checked-in fixture, preserves the same extension path and ephemeral profile across passes, changes only the controlled manifest version from `1.0.0` to `1.0.1`, observes `chrome.runtime.getManifest().version`, and requires the fixture schema marker to migrate from version 1 to version 2. The checked-in fixture is not rewritten by the test. This establishes one deterministic unpacked-extension version transition; it does not establish Chrome Web Store update behavior, enterprise rollout semantics, downgrade behavior, or arbitrary third-party extension migration safety. @@ -60,6 +64,8 @@ Chrome for Developers. (2023, May 2). *The extension service worker lifecycle*. Chrome for Developers. (n.d.). *chrome.declarativeNetRequest*. Google. Retrieved August 9, 2026, from https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest +Chrome for Developers. (n.d.). *chrome.downloads*. Google. Retrieved August 16, 2026, from https://developer.chrome.com/docs/extensions/reference/api/downloads + Chrome for Developers. (n.d.). *chrome.history*. Google. Retrieved August 11, 2026, from https://developer.chrome.com/docs/extensions/reference/api/history Chrome for Developers. (n.d.). *Manifest file format*. Google. Retrieved August 9, 2026, from https://developer.chrome.com/docs/extensions/reference/manifest diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 148009a63..a8ad75a4b 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -703,4 +703,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) From d9914c62a4d60e4dd73e95545a3c99362358dbe8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:08:42 +0900 Subject: [PATCH 36/95] test(mv3): reject raw ChromeDriver startup errors --- tests/test_mv3_compatibility_contract.py | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/test_mv3_compatibility_contract.py b/tests/test_mv3_compatibility_contract.py index 33b4eea81..afa0d8511 100644 --- a/tests/test_mv3_compatibility_contract.py +++ b/tests/test_mv3_compatibility_contract.py @@ -237,6 +237,36 @@ def close(self) -> None: self.assertNotIn("/home/runner/private", rendered) self.assertNotIn("example.invalid", rendered) + def test_chromedriver_startup_timeout_does_not_retain_raw_last_error(self) -> None: + """Startup timeout diagnostics must classify transient errors without copying raw text.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_contract") + wait_for_driver = namespace["_wait_for_driver"] + time_module = namespace["time"] + raw_error = "secret-token /home/runner/private https://example.invalid" + + with ( + unittest.mock.patch.dict( + wait_for_driver.__globals__, + {"_json_request": unittest.mock.Mock(side_effect=OSError(raw_error))}, + ), + unittest.mock.patch.object( + time_module, + "monotonic", + side_effect=(0.0, 0.0, 99.0), + ), + unittest.mock.patch.object(time_module, "sleep", return_value=None), + ): + with self.assertRaises(RuntimeError) as raised: + wait_for_driver(9515) + + rendered = str(raised.exception) + self.assertIn("ChromeDriver did not become ready", rendered) + self.assertIn("io_error", rendered) + self.assertNotIn("secret-token", rendered) + self.assertNotIn("/home/runner/private", rendered) + self.assertNotIn("example.invalid", rendered) + def test_workflow_runs_the_real_browser_lane_without_model_credentials(self) -> None: """Compatibility evidence must execute Chromium and never require LLM secrets.""" From 3a35b7866185d14c395fdea615d841a4f2092958 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:11:13 +0900 Subject: [PATCH 37/95] fix(mv3): classify ChromeDriver startup failures --- scripts/ci/run_mv3_compatibility.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index a8ad75a4b..31a41c2a4 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -203,19 +203,21 @@ def _json_request( def _wait_for_driver(driver_port: int) -> None: - """Wait for the exact local ChromeDriver process to become ready.""" + """Wait for local ChromeDriver readiness while retaining only a safe failure class.""" deadline = time.monotonic() + STARTUP_TIMEOUT_SECONDS - last_error: Exception | None = None + last_failure_kind = "not_observed" 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 + last_failure_kind = str(_failure_evidence(exc)["failure_kind"]) time.sleep(0.1) - raise RuntimeError(f"ChromeDriver did not become ready: {last_error}") + raise RuntimeError( + f"ChromeDriver did not become ready ({last_failure_kind})" + ) def _execute(driver_port: int, session_id: str, script: str) -> Any: From 9c29a087148a67fe908cacfff323b4f15605798a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:18:23 +0900 Subject: [PATCH 38/95] test(mv3): reject raw click postcondition text --- tests/test_mv3_click_diagnostic_contract.py | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/test_mv3_click_diagnostic_contract.py diff --git a/tests/test_mv3_click_diagnostic_contract.py b/tests/test_mv3_click_diagnostic_contract.py new file mode 100644 index 000000000..e0e28931a --- /dev/null +++ b/tests/test_mv3_click_diagnostic_contract.py @@ -0,0 +1,48 @@ +"""Fail-closed contract for real-click diagnostic handling in the MV3 runner.""" + +from __future__ import annotations + +import pathlib +import runpy +import unittest +import unittest.mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class ManifestV3ClickDiagnosticContractTests(unittest.TestCase): + """Keep browser-controlled click postconditions out of exception text.""" + + def test_click_mismatch_does_not_retain_raw_browser_text(self) -> None: + """A failed click must classify the mismatch without copying page-controlled text.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_click_contract") + exercise = namespace["_exercise_real_click"] + element_key = namespace["W3C_ELEMENT_KEY"] + raw_text = "secret-token /home/runner/private https://example.invalid" + responses = iter( + ( + {"value": {element_key: "f.1.d.2.e.3"}}, + {"value": {}}, + {"value": {element_key: "f.4.d.5.e.6"}}, + {"value": raw_text}, + ) + ) + + with unittest.mock.patch.dict( + exercise.__globals__, + {"_json_request": unittest.mock.Mock(side_effect=lambda *_a, **_k: next(responses))}, + ): + with self.assertRaises(RuntimeError) as raised: + exercise(9515, "session.1") + + rendered = str(raised.exception) + self.assertEqual(rendered, "real click post-condition mismatch") + self.assertNotIn("secret-token", rendered) + self.assertNotIn("/home/runner/private", rendered) + self.assertNotIn("example.invalid", rendered) + + +if __name__ == "__main__": + unittest.main() From e129c28b9ff1e5285520e878b1da43d604282a99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:34:45 +0900 Subject: [PATCH 39/95] fix(mv3): bound click mismatch diagnostics --- 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 31a41c2a4..e616eade9 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -338,7 +338,7 @@ def _exercise_real_click(driver_port: int, session_id: str) -> str: _webdriver_path(session_id, f"/element/{safe_output}/text"), ).get("value") if text != "clicked": - raise RuntimeError(f"real click post-condition failed: {text!r}") + raise RuntimeError("real click post-condition mismatch") return str(text) From cbd5d8cf3b18036c1ca0368761e232a158e71f11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:39:10 +0900 Subject: [PATCH 40/95] docs(mv3): consolidate click diagnostic boundary --- CHANGELOG.md | 1 + docs/doctoring.md | 6 ++++++ docs/doctoring/mv3-compatibility.md | 6 ++++++ scripts/ci/run_mv3_compatibility.py | 2 +- 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 069faa4fe..b52bada3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Classified Manifest V3 real-click post-condition failures as a fixed mismatch token so page-controlled WebDriver text cannot enter runner exception text. - Recorded the current Chrome Extensions `chrome.downloads` primary reference in APA 7th form and stated that the active downloads lane proves one controlled loopback payload in pinned Chromium, not Agent filesystem authority. - 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/docs/doctoring.md b/docs/doctoring.md index 27e43483a..8030e1156 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -12,6 +12,10 @@ The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-cont The current Chrome Extensions Downloads API documents the `downloads` manifest permission and `chrome.downloads` methods that initiate, monitor, search, and inspect downloads. That living vendor reference is API semantics only. OriginWeave treats a successful controlled loopback download in pinned Chromium as compatibility evidence for one declared surface, not as Agent filesystem authority, general download persistence, or a claim that every Downloads method is supported. +### Manifest V3 click post-condition diagnostics + +W3C WebDriver Get Element Text returns the rendered text content of a located element. That value is page-controlled data, not a trusted diagnostic token. The Manifest V3 compatibility runner therefore compares the fixture output against the exact expected `clicked` token and, on mismatch, raises only the classified message `real click post-condition mismatch`. Raw element text must not enter exception text, trial evidence, or logs. + ### Browser origin equivalence The WHATWG URL host parser and Chromium canonicalizer classify shortened decimal, integer, hexadecimal, legacy octal-looking, and mixed-component numeric hosts as IPv4 or broken IPv4 candidates rather than ordinary DNS names. Chromium's regression suite includes values such as `192`, `0xC0a80001`, `030052000001`, and mixed hexadecimal components. A non-final empty `0x` component can participate in Chromium's multi-part IPv4 truncation behavior, but a final `0x` label does not produce an IPv4 number because stripping its prefix leaves no digits; it remains a domain label. Chromium also warns that broken IP-like hosts must not be connected because another resolver could accept them. OriginWeave therefore admits only canonical dotted-decimal IPv4 into its policy origin type, rejects browser-special numeric spellings before DNS validation, and preserves final non-numeric DNS labels such as `0x`. @@ -160,6 +164,8 @@ Web Hypertext Application Technology Working Group. (2026). *URL standard*. http World Wide Web Consortium. (2013). *PROV-O: The PROV ontology*. https://www.w3.org/TR/prov-o/ +World Wide Web Consortium. (2018, June 5). *WebDriver* (W3C Recommendation). https://www.w3.org/TR/2018/REC-webdriver1-20180605/ + World Wide Web Consortium. (2026, June 1). *WebDriver BiDi* (W3C Working Draft). https://www.w3.org/TR/2026/WD-webdriver-bidi-20260601/ Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2025). *TRINITY: An evolved LLM coordinator* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04695 diff --git a/docs/doctoring/mv3-compatibility.md b/docs/doctoring/mv3-compatibility.md index 5522c9f55..7fe360bec 100644 --- a/docs/doctoring/mv3-compatibility.md +++ b/docs/doctoring/mv3-compatibility.md @@ -42,6 +42,10 @@ For history compatibility specifically, the current official Chrome Extensions A For downloads compatibility specifically, the current official Chrome Extensions API documents the `downloads` manifest permission and the `chrome.downloads` methods that initiate, monitor, search, and inspect downloads. This living vendor reference establishes API semantics only. Active PR #43 exercises one controlled loopback payload through pinned Chromium and retains only allow-listed stage diagnostics. That proof is not Agent filesystem authority, general download persistence, unsafe-filename handling, or a release claim that every `chrome.downloads` method works. +## Click post-condition diagnostic boundary + +W3C WebDriver Get Element Text returns rendered element text. That value is page-controlled data. The compatibility runner compares the fixture output against the exact expected `clicked` token and, on mismatch, retains only the classified message `real click post-condition mismatch`. Raw element text must not enter exception text or trial evidence. + ## Update-migration evidence boundary Restart persistence and extension update migration are separate compatibility claims. A successful restart proves only that state survives a new browser process. The active update-migration lane additionally uses a trial-local copy of the checked-in fixture, preserves the same extension path and ephemeral profile across passes, changes only the controlled manifest version from `1.0.0` to `1.0.1`, observes `chrome.runtime.getManifest().version`, and requires the fixture schema marker to migrate from version 1 to version 2. The checked-in fixture is not rewritten by the test. This establishes one deterministic unpacked-extension version transition; it does not establish Chrome Web Store update behavior, enterprise rollout semantics, downgrade behavior, or arbitrary third-party extension migration safety. @@ -73,3 +77,5 @@ Chrome for Developers. (n.d.). *Manifest file format*. Google. Retrieved August Bynens, M. (2023, June 12). *Chrome for Testing*. Chrome for Developers. https://developer.chrome.com/docs/automation-and-testing/chrome-for-testing Google Chrome Labs. (2026, July 21). *Chrome for Testing availability*. https://googlechromelabs.github.io/chrome-for-testing/ + +World Wide Web Consortium. (2018, June 5). *WebDriver* (W3C Recommendation). https://www.w3.org/TR/2018/REC-webdriver1-20180605/ diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index e616eade9..614c0e736 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -303,7 +303,7 @@ def _wait_for_extension_evidence( def _exercise_real_click(driver_port: int, session_id: str) -> str: - """Use the WebDriver element-click command and verify the DOM post-condition.""" + """Use the WebDriver element-click command and classify DOM post-condition mismatches.""" found = _json_request( driver_port, From c5e13a1283b279bcb9a9b9014a4891f60d9b18a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 01:46:01 +0900 Subject: [PATCH 41/95] test(mv3): expose unclassified transport protocol exceptions --- ...3_transport_protocol_exception_contract.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/test_mv3_transport_protocol_exception_contract.py diff --git a/tests/test_mv3_transport_protocol_exception_contract.py b/tests/test_mv3_transport_protocol_exception_contract.py new file mode 100644 index 000000000..f8a6d9963 --- /dev/null +++ b/tests/test_mv3_transport_protocol_exception_contract.py @@ -0,0 +1,70 @@ +"""Regression contract for bounded WebDriver transport-protocol failures.""" + +from __future__ import annotations + +import pathlib +import runpy +import unittest +import unittest.mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class ManifestV3TransportProtocolExceptionContractTests(unittest.TestCase): + """Keep recoverable HTTP parser failures inside the typed runner boundary.""" + + def test_http_protocol_exceptions_are_classified_without_raw_transport_text(self) -> None: + """BadStatusLine and IncompleteRead must become one bounded RuntimeError.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_transport_contract") + json_request = namespace["_json_request"] + http_module = namespace["http"] + raw_secret = "secret-token /home/runner/private https://example.invalid" + + class BadStatusConnection: + def request(self, *_args: object, **_kwargs: object) -> None: + return None + + def getresponse(self) -> object: + raise http_module.client.BadStatusLine(raw_secret) + + def close(self) -> None: + return None + + class IncompleteReadResponse: + status = 200 + + def read(self, _limit: int) -> bytes: + partial = raw_secret.encode("utf-8") + raise http_module.client.IncompleteRead(partial, len(partial) + 10) + + class IncompleteReadConnection: + def request(self, *_args: object, **_kwargs: object) -> None: + return None + + def getresponse(self) -> IncompleteReadResponse: + return IncompleteReadResponse() + + def close(self) -> None: + return None + + for connection in (BadStatusConnection(), IncompleteReadConnection()): + with self.subTest(connection=type(connection).__name__): + with unittest.mock.patch.object( + http_module.client, + "HTTPConnection", + return_value=connection, + ): + with self.assertRaises(RuntimeError) as raised: + json_request(9515, "GET", "/status") + + rendered = str(raised.exception) + self.assertEqual(rendered, "WebDriver transport protocol failure") + self.assertNotIn("secret-token", rendered) + self.assertNotIn("/home/runner/private", rendered) + self.assertNotIn("example.invalid", rendered) + + +if __name__ == "__main__": + unittest.main() From 5b123d9924610f85412cad6b27ed8bee2fc45313 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:48:38 +0000 Subject: [PATCH 42/95] fix(mv3): classify WebDriver transport protocol failures Convert HTTP/1.1 parser exceptions such as BadStatusLine and IncompleteRead into a fixed RuntimeError so trial evidence can record the failure without retaining raw status-line or partial-body text. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + docs/doctoring.md | 6 ++++++ docs/doctoring/mv3-compatibility.md | 6 ++++++ scripts/ci/run_mv3_compatibility.py | 27 ++++++++++++++++++--------- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b52bada3e..d0ed25711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Classified Manifest V3 WebDriver HTTP/1.1 parser failures as a fixed transport-protocol token so a malformed status-line or incomplete message body cannot enter runner exception text. - Classified Manifest V3 real-click post-condition failures as a fixed mismatch token so page-controlled WebDriver text cannot enter runner exception text. - Recorded the current Chrome Extensions `chrome.downloads` primary reference in APA 7th form and stated that the active downloads lane proves one controlled loopback payload in pinned Chromium, not Agent filesystem authority. - Separated logical origin authority from resolved network destination authority; an origin grant no longer implies permission to connect to every resolver result. diff --git a/docs/doctoring.md b/docs/doctoring.md index 8030e1156..f6b4ede10 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -12,6 +12,10 @@ The 1 June 2026 WebDriver BiDi Working Draft defines a bidirectional remote-cont The current Chrome Extensions Downloads API documents the `downloads` manifest permission and `chrome.downloads` methods that initiate, monitor, search, and inspect downloads. That living vendor reference is API semantics only. OriginWeave treats a successful controlled loopback download in pinned Chromium as compatibility evidence for one declared surface, not as Agent filesystem authority, general download persistence, or a claim that every Downloads method is supported. +### Manifest V3 WebDriver transport-protocol diagnostics + +RFC 9112 defines the HTTP/1.1 status-line and the requirement that a message body match the announced framing. A malformed status-line or an incomplete body is a recoverable parser failure, not a trusted diagnostic payload. W3C WebDriver carries commands over that HTTP transport. The Manifest V3 compatibility runner therefore converts `http.client.HTTPException` subclasses such as `BadStatusLine` and `IncompleteRead` into the classified message `WebDriver transport protocol failure`. Raw status-line text, partial body bytes, paths, URLs, or tokens must not enter exception text, trial evidence, or logs. + ### Manifest V3 click post-condition diagnostics W3C WebDriver Get Element Text returns the rendered text content of a located element. That value is page-controlled data, not a trusted diagnostic token. The Manifest V3 compatibility runner therefore compares the fixture output against the exact expected `clicked` token and, on mismatch, raises only the classified message `real click post-condition mismatch`. Raw element text must not enter exception text, trial evidence, or logs. @@ -124,6 +128,8 @@ Evtimov, I., Zharmagambetov, A., Grattafiori, A., Guo, C., & Chaudhuri, K. (2025 Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). Internet Engineering Task Force. https://doi.org/10.17487/RFC9110 +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP/1.1* (RFC 9112). Internet Engineering Task Force. https://doi.org/10.17487/RFC9112 + Fugu Team, Sakana AI. (2026). *Sakana Fugu technical report* [Technical report]. arXiv. https://doi.org/10.48550/arXiv.2606.21228 Huston, G., & Buraglio, N. (2024). *Expanding the IPv6 documentation space* (RFC 9637). Internet Engineering Task Force. https://doi.org/10.17487/RFC9637 diff --git a/docs/doctoring/mv3-compatibility.md b/docs/doctoring/mv3-compatibility.md index 7fe360bec..6dc9c4d05 100644 --- a/docs/doctoring/mv3-compatibility.md +++ b/docs/doctoring/mv3-compatibility.md @@ -42,6 +42,10 @@ For history compatibility specifically, the current official Chrome Extensions A For downloads compatibility specifically, the current official Chrome Extensions API documents the `downloads` manifest permission and the `chrome.downloads` methods that initiate, monitor, search, and inspect downloads. This living vendor reference establishes API semantics only. Active PR #43 exercises one controlled loopback payload through pinned Chromium and retains only allow-listed stage diagnostics. That proof is not Agent filesystem authority, general download persistence, unsafe-filename handling, or a release claim that every `chrome.downloads` method works. +## WebDriver transport-protocol diagnostic boundary + +RFC 9112 requires a well-formed HTTP/1.1 status-line and a message body that matches the announced framing. W3C WebDriver sends commands over that HTTP transport. When ChromeDriver returns a malformed status-line or an incomplete body, the compatibility runner raises only `WebDriver transport protocol failure`. Raw status-line text, partial body bytes, paths, URLs, or tokens must not enter exception text or trial evidence. This classification lets `main` record the failure in `trial_results` instead of aborting the compatibility run with an unclassified parser exception. + ## Click post-condition diagnostic boundary W3C WebDriver Get Element Text returns rendered element text. That value is page-controlled data. The compatibility runner compares the fixture output against the exact expected `clicked` token and, on mismatch, retains only the classified message `real click post-condition mismatch`. Raw element text must not enter exception text or trial evidence. @@ -78,4 +82,6 @@ Bynens, M. (2023, June 12). *Chrome for Testing*. Chrome for Developers. https:/ Google Chrome Labs. (2026, July 21). *Chrome for Testing availability*. https://googlechromelabs.github.io/chrome-for-testing/ +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP/1.1* (RFC 9112). Internet Engineering Task Force. https://doi.org/10.17487/RFC9112 + World Wide Web Consortium. (2018, June 5). *WebDriver* (W3C Recommendation). https://www.w3.org/TR/2018/REC-webdriver1-20180605/ diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 614c0e736..482d82c2e 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -166,7 +166,13 @@ def _json_request( *, timeout: float = REQUEST_TIMEOUT_SECONDS, ) -> dict[str, Any]: - """Issue one bounded JSON request to the fixed loopback ChromeDriver authority.""" + """Issue one bounded JSON request to the fixed loopback ChromeDriver authority. + + Recoverable HTTP/1.1 parser failures, including a malformed status-line or an + incomplete message body, become `RuntimeError("WebDriver transport protocol + failure")` so trial evidence can record a classified outcome without retaining + raw transport text. + """ if not 1 <= driver_port <= 65_535: raise ValueError("invalid ChromeDriver port") @@ -178,14 +184,17 @@ def _json_request( 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) + try: + connection.request( + method, + path, + body=body, + headers={"Content-Type": "application/json"}, + ) + response = connection.getresponse() + raw = response.read(MAX_WEBDRIVER_RESPONSE_BYTES + 1) + except http.client.HTTPException: + raise RuntimeError("WebDriver transport protocol failure") from None if len(raw) > MAX_WEBDRIVER_RESPONSE_BYTES: raise RuntimeError("WebDriver response exceeded the bounded JSON limit") if response.status >= 400: From 1c63849f6fdf543b23608bf7271f4197b942a347 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:06:15 +0900 Subject: [PATCH 43/95] test(mv3): reject raw browser version diagnostics --- ...mv3_browser_version_diagnostic_contract.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/test_mv3_browser_version_diagnostic_contract.py diff --git a/tests/test_mv3_browser_version_diagnostic_contract.py b/tests/test_mv3_browser_version_diagnostic_contract.py new file mode 100644 index 000000000..c79da5ac8 --- /dev/null +++ b/tests/test_mv3_browser_version_diagnostic_contract.py @@ -0,0 +1,110 @@ +"""Regression contract for classified Chrome capability version diagnostics.""" + +from __future__ import annotations + +import pathlib +import runpy +import tempfile +import unittest +import unittest.mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class _FakeDriver: + """Model bounded ChromeDriver process cleanup without launching a process.""" + + def __init__(self) -> None: + self.terminated = False + + def terminate(self) -> None: + """Record graceful teardown.""" + + self.terminated = True + + def kill(self) -> None: + """Fail if the normal teardown unexpectedly needs hard-kill fallback.""" + + raise AssertionError("unexpected ChromeDriver hard-kill fallback") + + def wait(self, timeout: float) -> int: + """Model an immediately reaped ChromeDriver process.""" + + if timeout <= 0: + raise AssertionError("timeout must remain positive") + return 0 + + +class ManifestV3BrowserVersionDiagnosticTests(unittest.TestCase): + """Keep browser-reported capability text out of runner diagnostics.""" + + def test_browser_version_mismatch_does_not_retain_raw_capability_text(self) -> None: + """An unexpected browser version must fail closed with a classified safe message.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_browser_version_contract") + run_browser_pass = namespace["_run_browser_pass"] + globals_ = run_browser_pass.__globals__ + fake_driver = _FakeDriver() + raw_version = "151.0 secret-token /home/runner/private https://example.invalid" + + def fake_json_request( + _driver_port: int, + method: str, + path: str, + _payload=None, + *, + timeout: float = 5.0, + ): + if timeout <= 0: + raise AssertionError("timeout must remain positive") + if method == "POST" and path == "/session": + return { + "value": { + "sessionId": "session-1", + "capabilities": {"browserVersion": raw_version}, + } + } + if method == "DELETE" and path.endswith("/session/session-1"): + return {"value": None} + raise AssertionError(f"unexpected WebDriver request: {method} {path}") + + with tempfile.TemporaryDirectory( + prefix="originweave-browser-version-contract-" + ) as profile_dir: + with ( + unittest.mock.patch.object( + globals_["subprocess"], "Popen", return_value=fake_driver + ), + unittest.mock.patch.dict( + globals_, + { + "_free_loopback_port": lambda: 43123, + "_wait_for_driver": lambda _port: None, + "_json_request": fake_json_request, + }, + ), + ): + with self.assertRaises(RuntimeError) as raised: + run_browser_pass( + pathlib.Path("/controlled/chrome"), + pathlib.Path("/controlled/chromedriver"), + "http://127.0.0.1:8080/page.html", + profile_dir, + "initialized", + ) + + rendered = str(raised.exception) + self.assertEqual( + rendered, + f"unexpected Chrome version; expected {namespace['PINNED_CHROME_VERSION']}", + ) + self.assertNotIn("secret-token", rendered) + self.assertNotIn("/home/runner/private", rendered) + self.assertNotIn("example.invalid", rendered) + self.assertIsNone(raised.exception.__cause__) + self.assertTrue(fake_driver.terminated) + + +if __name__ == "__main__": + unittest.main() From 6a6e5f72b53fc6c68a459c078dc859e6b9652f46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:09:26 +0900 Subject: [PATCH 44/95] fix(mv3): classify unexpected browser version --- scripts/ci/run_mv3_compatibility.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 482d82c2e..8f8a05439 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -444,8 +444,7 @@ def _run_browser_pass( ) if browser_version != PINNED_CHROME_VERSION: raise RuntimeError( - f"unexpected Chrome version: expected {PINNED_CHROME_VERSION}, " - f"got {browser_version!r}" + f"unexpected Chrome version; expected {PINNED_CHROME_VERSION}" ) _json_request( From c457f1d908bfea7dcf3b6f5f18ee28e8e6c69df8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:11:06 +0900 Subject: [PATCH 45/95] docs(changelog): record classified browser version diagnostics --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ed25711..c28bd7f5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Classified a mismatched Chrome `browserVersion` capability as an expected-only diagnostic so browser-reported capability text cannot enter Manifest V3 runner exception output. - Classified Manifest V3 WebDriver HTTP/1.1 parser failures as a fixed transport-protocol token so a malformed status-line or incomplete message body cannot enter runner exception text. - Classified Manifest V3 real-click post-condition failures as a fixed mismatch token so page-controlled WebDriver text cannot enter runner exception text. - Recorded the current Chrome Extensions `chrome.downloads` primary reference in APA 7th form and stated that the active downloads lane proves one controlled loopback payload in pinned Chromium, not Agent filesystem authority. From 58b3d08487a7999822ee38f6fcc2c135b89b387e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:30:00 +0900 Subject: [PATCH 46/95] test(mv3): expose recovered terminate cleanup failure --- .../test_mv3_session_cleanup_exception_contract.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_mv3_session_cleanup_exception_contract.py b/tests/test_mv3_session_cleanup_exception_contract.py index 6ba9e3301..b009f8c04 100644 --- a/tests/test_mv3_session_cleanup_exception_contract.py +++ b/tests/test_mv3_session_cleanup_exception_contract.py @@ -186,6 +186,19 @@ def test_successful_kill_after_wait_timeout_is_normal_cleanup(self) -> None: self.assertTrue(fake_driver.killed) self.assertEqual(fake_driver.wait_calls, 2) + def test_successful_kill_after_terminate_error_is_normal_cleanup(self) -> None: + """A recoverable terminate error must not fail cleanup after bounded kill succeeds.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_teardown_contract") + fake_driver = _FakeDriver(terminate_error=OSError("terminate failed")) + + error = namespace["_teardown_driver_process"](fake_driver) + + self.assertIsNone(error) + self.assertTrue(fake_driver.terminated) + self.assertTrue(fake_driver.killed) + self.assertEqual(fake_driver.wait_calls, 1) + def test_failed_kill_fallback_is_recorded_on_the_primary_teardown_error(self) -> None: """A secondary fallback failure must not disappear while the first error stays causal.""" From 1db35c97184ac7f91ebda57bf09ed5ed6c39d4d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:32:36 +0900 Subject: [PATCH 47/95] fix(mv3): accept successful bounded kill fallback --- scripts/ci/run_mv3_compatibility.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 8f8a05439..3e8bad2c7 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -352,7 +352,7 @@ def _exercise_real_click(driver_port: int, session_id: str) -> str: def _teardown_driver_process(driver: subprocess.Popen[str]) -> Exception | None: - """Best-effort reap ChromeDriver while preserving reviewed process failures.""" + """Best-effort reap ChromeDriver while preserving unrecovered process failures.""" try: driver.terminate() @@ -365,7 +365,8 @@ def _teardown_driver_process(driver: subprocess.Popen[str]) -> Exception | None: "bounded ChromeDriver kill fallback also failed: " f"{type(fallback_error).__name__}" ) - return terminate_error + return terminate_error + return None try: driver.wait(timeout=5) From 484c74df6073d6c0163d73d79734f4a1dd0d662a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:34:04 +0900 Subject: [PATCH 48/95] docs(changelog): record bounded teardown fallback recovery --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c28bd7f5e..bad94d11a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Treated a failed graceful ChromeDriver termination as recoverable when the bounded hard-kill fallback successfully reaps the process, while preserving unrecovered fallback failures as teardown errors. - Classified a mismatched Chrome `browserVersion` capability as an expected-only diagnostic so browser-reported capability text cannot enter Manifest V3 runner exception output. - Classified Manifest V3 WebDriver HTTP/1.1 parser failures as a fixed transport-protocol token so a malformed status-line or incomplete message body cannot enter runner exception text. - Classified Manifest V3 real-click post-condition failures as a fixed mismatch token so page-controlled WebDriver text cannot enter runner exception text. From 0a43e09a7035c79d7c991bc716931751ae2acfd4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:38:59 +0900 Subject: [PATCH 49/95] test(mv3): make cleanup helper failure explicit --- tests/test_mv3_session_cleanup_exception_contract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_mv3_session_cleanup_exception_contract.py b/tests/test_mv3_session_cleanup_exception_contract.py index b009f8c04..0106438d9 100644 --- a/tests/test_mv3_session_cleanup_exception_contract.py +++ b/tests/test_mv3_session_cleanup_exception_contract.py @@ -148,7 +148,7 @@ def fake_json_request( ) except Exception as error: # noqa: BLE001 - return exact boundary error. return namespace, error - self.fail("cleanup failure unexpectedly became success") + raise AssertionError("cleanup failure unexpectedly became success") def test_unreviewed_session_cleanup_exception_is_not_silently_suppressed(self) -> None: """A new exception class must propagate while ChromeDriver is still terminated.""" From 8ba95b90358f3b0a1747053c4ac2835ea519a42b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:09:36 +0900 Subject: [PATCH 50/95] test(mv3): expose teardown exit race --- ..._mv3_session_cleanup_exception_contract.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_mv3_session_cleanup_exception_contract.py b/tests/test_mv3_session_cleanup_exception_contract.py index 0106438d9..9c8d6d3b0 100644 --- a/tests/test_mv3_session_cleanup_exception_contract.py +++ b/tests/test_mv3_session_cleanup_exception_contract.py @@ -26,12 +26,14 @@ def __init__( terminate_error: OSError | None = None, kill_error: OSError | None = None, wait_timeout_once: bool = False, + poll_return_code: int | None = None, ) -> None: self.terminated = False self.killed = False self.terminate_error = terminate_error self.kill_error = kill_error self.wait_timeout_once = wait_timeout_once + self.poll_return_code = poll_return_code self.wait_calls = 0 def terminate(self) -> None: @@ -48,6 +50,11 @@ def kill(self) -> None: if self.kill_error is not None: raise self.kill_error + def poll(self) -> int | None: + """Return a controlled process state for teardown race regressions.""" + + return self.poll_return_code + def wait(self, timeout: float) -> int: """Model either an immediately reaped process or one bounded timeout.""" @@ -199,6 +206,23 @@ def test_successful_kill_after_terminate_error_is_normal_cleanup(self) -> None: self.assertTrue(fake_driver.killed) self.assertEqual(fake_driver.wait_calls, 1) + def test_disappeared_process_after_terminate_race_is_normal_cleanup(self) -> None: + """An already-reaped process must not become a false cleanup failure.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_teardown_contract") + fake_driver = _FakeDriver( + terminate_error=ProcessLookupError("process already exited"), + kill_error=ProcessLookupError("process already exited"), + poll_return_code=0, + ) + + error = namespace["_teardown_driver_process"](fake_driver) + + self.assertIsNone(error) + self.assertTrue(fake_driver.terminated) + self.assertFalse(fake_driver.killed) + self.assertEqual(fake_driver.wait_calls, 0) + def test_failed_kill_fallback_is_recorded_on_the_primary_teardown_error(self) -> None: """A secondary fallback failure must not disappear while the first error stays causal.""" From 208447f71e463aa78f5b0fd8cab61109a72695b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:12:48 +0900 Subject: [PATCH 51/95] test(mv3): leave exit-race repair to canonical owner --- ..._mv3_session_cleanup_exception_contract.py | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/tests/test_mv3_session_cleanup_exception_contract.py b/tests/test_mv3_session_cleanup_exception_contract.py index 9c8d6d3b0..0106438d9 100644 --- a/tests/test_mv3_session_cleanup_exception_contract.py +++ b/tests/test_mv3_session_cleanup_exception_contract.py @@ -26,14 +26,12 @@ def __init__( terminate_error: OSError | None = None, kill_error: OSError | None = None, wait_timeout_once: bool = False, - poll_return_code: int | None = None, ) -> None: self.terminated = False self.killed = False self.terminate_error = terminate_error self.kill_error = kill_error self.wait_timeout_once = wait_timeout_once - self.poll_return_code = poll_return_code self.wait_calls = 0 def terminate(self) -> None: @@ -50,11 +48,6 @@ def kill(self) -> None: if self.kill_error is not None: raise self.kill_error - def poll(self) -> int | None: - """Return a controlled process state for teardown race regressions.""" - - return self.poll_return_code - def wait(self, timeout: float) -> int: """Model either an immediately reaped process or one bounded timeout.""" @@ -206,23 +199,6 @@ def test_successful_kill_after_terminate_error_is_normal_cleanup(self) -> None: self.assertTrue(fake_driver.killed) self.assertEqual(fake_driver.wait_calls, 1) - def test_disappeared_process_after_terminate_race_is_normal_cleanup(self) -> None: - """An already-reaped process must not become a false cleanup failure.""" - - namespace = runpy.run_path(str(RUNNER), run_name="mv3_teardown_contract") - fake_driver = _FakeDriver( - terminate_error=ProcessLookupError("process already exited"), - kill_error=ProcessLookupError("process already exited"), - poll_return_code=0, - ) - - error = namespace["_teardown_driver_process"](fake_driver) - - self.assertIsNone(error) - self.assertTrue(fake_driver.terminated) - self.assertFalse(fake_driver.killed) - self.assertEqual(fake_driver.wait_calls, 0) - def test_failed_kill_fallback_is_recorded_on_the_primary_teardown_error(self) -> None: """A secondary fallback failure must not disappear while the first error stays causal.""" From b299f76236e74bcdd584f63d553e9fe40019370f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:31:54 +0900 Subject: [PATCH 52/95] test(mv3): preserve primary failure through cleanup --- ...st_mv3_primary_failure_cleanup_contract.py | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/test_mv3_primary_failure_cleanup_contract.py diff --git a/tests/test_mv3_primary_failure_cleanup_contract.py b/tests/test_mv3_primary_failure_cleanup_contract.py new file mode 100644 index 000000000..133e7159f --- /dev/null +++ b/tests/test_mv3_primary_failure_cleanup_contract.py @@ -0,0 +1,108 @@ +"""Regression contract for preserving a primary browser-pass failure through cleanup.""" + +from __future__ import annotations + +import pathlib +import runpy +import tempfile +import unittest +import unittest.mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class _FakeDriver: + """Model a ChromeDriver process that tears down successfully.""" + + def __init__(self) -> None: + self.terminated = False + self.wait_calls = 0 + + def terminate(self) -> None: + """Record graceful termination.""" + + self.terminated = True + + def kill(self) -> None: + """Fail if the hard-kill fallback is unexpectedly required.""" + + raise AssertionError("hard-kill fallback was not expected") + + def wait(self, timeout: float) -> int: + """Model an immediately reaped process.""" + + if timeout <= 0: + raise AssertionError("timeout must remain positive") + self.wait_calls += 1 + return 0 + + +class ManifestV3PrimaryFailureCleanupTests(unittest.TestCase): + """Cleanup failures must not replace the causal browser-pass failure.""" + + def test_primary_browser_failure_survives_reviewed_session_cleanup_failure(self) -> None: + """A later reviewed cleanup error must remain secondary to the primary failure.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_primary_cleanup_contract") + run_browser_pass = namespace["_run_browser_pass"] + globals_ = run_browser_pass.__globals__ + fake_driver = _FakeDriver() + primary_error = RuntimeError("controlled primary browser-pass failure") + cleanup_error = OSError("controlled session cleanup failure") + + def fake_json_request( + _driver_port: int, + method: str, + path: str, + _payload=None, + *, + timeout: float = 5.0, + ): + if timeout <= 0: + raise AssertionError("timeout must remain positive") + if method == "POST" and path == "/session": + return { + "value": { + "sessionId": "session-1", + "capabilities": { + "browserVersion": namespace["PINNED_CHROME_VERSION"] + }, + } + } + if method == "POST" and path == "/session/session-1/url": + raise primary_error + if method == "DELETE" and path == "/session/session-1": + raise cleanup_error + raise AssertionError(f"unexpected WebDriver request: {method} {path}") + + with tempfile.TemporaryDirectory(prefix="originweave-primary-cleanup-") as profile_dir: + with ( + unittest.mock.patch.object( + globals_["subprocess"], "Popen", return_value=fake_driver + ), + unittest.mock.patch.dict( + globals_, + { + "_free_loopback_port": lambda: 43123, + "_wait_for_driver": lambda _port: None, + "_json_request": fake_json_request, + }, + ), + ): + with self.assertRaises(RuntimeError) as raised: + run_browser_pass( + pathlib.Path("/controlled/chrome"), + pathlib.Path("/controlled/chromedriver"), + "http://127.0.0.1:8080/page.html", + profile_dir, + "initialized", + ) + + self.assertIs(raised.exception, primary_error) + self.assertTrue(fake_driver.terminated) + self.assertEqual(fake_driver.wait_calls, 1) + + +if __name__ == "__main__": + unittest.main() From c5e33b47b7a2ade47ecf962a41220b62c96a88db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:34:50 +0900 Subject: [PATCH 53/95] fix(mv3): preserve primary browser-pass failures --- scripts/ci/run_mv3_compatibility.py | 31 ++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 3e8bad2c7..1cc9a25bb 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -399,6 +399,7 @@ def _run_browser_pass( stderr=subprocess.STDOUT, text=True, ) + primary_error: BaseException | None = None try: _wait_for_driver(driver_port) session = _json_request( @@ -482,6 +483,9 @@ def _run_browser_pass( "real-browser-click": click_result == "clicked", }, } + except BaseException as error: # noqa: BLE001 - re-raised unchanged after cleanup. + primary_error = error + raise finally: cleanup_error: Exception | None = None try: @@ -497,11 +501,28 @@ def _run_browser_pass( cleanup_error = error finally: teardown_error = _teardown_driver_process(driver) - if cleanup_error is not None: - raise WebDriverSessionCleanupError( + if primary_error is not None: + if cleanup_error is not None: + primary_error.add_note( + "WebDriver session cleanup also failed after the primary browser-pass " + f"failure: {type(cleanup_error).__name__}" + ) + if teardown_error is not None: + primary_error.add_note( + "ChromeDriver process teardown also failed after the primary browser-pass " + f"failure: {type(teardown_error).__name__}" + ) + elif cleanup_error is not None: + cleanup_failure = WebDriverSessionCleanupError( "WebDriver session cleanup failed after bounded process teardown" - ) from cleanup_error - if teardown_error is not None: + ) + if teardown_error is not None: + cleanup_failure.add_note( + "ChromeDriver process teardown also failed: " + f"{type(teardown_error).__name__}" + ) + raise cleanup_failure from cleanup_error + elif teardown_error is not None: raise teardown_error @@ -714,4 +735,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file From b01e9732fed7d986fc817b0b2d515d0f0333d3fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 16:06:33 -0700 Subject: [PATCH 54/95] test(mv3): require bounded ChromeDriver status authority --- ...st_mv3_driver_status_authority_contract.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/test_mv3_driver_status_authority_contract.py diff --git a/tests/test_mv3_driver_status_authority_contract.py b/tests/test_mv3_driver_status_authority_contract.py new file mode 100644 index 000000000..f57509d0a --- /dev/null +++ b/tests/test_mv3_driver_status_authority_contract.py @@ -0,0 +1,87 @@ +"""Regression contracts for bounded ChromeDriver startup/status authority.""" + +from __future__ import annotations + +import pathlib +import runpy +import unittest +import unittest.mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class ManifestV3DriverStatusAuthorityTests(unittest.TestCase): + """Startup evidence must come from the pinned ChromeDriver status shape.""" + + def test_ready_status_rejects_a_different_chromedriver_build(self) -> None: + """A ready loopback endpoint cannot impersonate the pinned driver build.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_driver_status_authority") + wait_for_driver = namespace["_wait_for_driver"] + globals_ = wait_for_driver.__globals__ + + def mismatched_status( + _driver_port: int, + method: str, + path: str, + _payload=None, + *, + timeout: float = 5.0, + ) -> dict[str, object]: + self.assertEqual(method, "GET") + self.assertEqual(path, "/status") + self.assertGreater(timeout, 0) + return { + "value": { + "ready": True, + "build": {"version": "149.0.0.0 (controlled-mismatch)"}, + } + } + + with unittest.mock.patch.dict(globals_, {"_json_request": mismatched_status}): + with self.assertRaisesRegex(RuntimeError, "ChromeDriver status identity mismatch"): + wait_for_driver(43123) + + def test_malformed_status_value_is_bounded_and_retried(self) -> None: + """Malformed external status JSON must not escape as AttributeError.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_driver_status_protocol") + wait_for_driver = namespace["_wait_for_driver"] + globals_ = wait_for_driver.__globals__ + monotonic_values = iter((0.0, 0.0, 21.0)) + status_calls = 0 + + def malformed_status( + _driver_port: int, + method: str, + path: str, + _payload=None, + *, + timeout: float = 5.0, + ) -> dict[str, object]: + nonlocal status_calls + status_calls += 1 + self.assertEqual(method, "GET") + self.assertEqual(path, "/status") + self.assertGreater(timeout, 0) + return {"value": []} + + with ( + unittest.mock.patch.dict(globals_, {"_json_request": malformed_status}), + unittest.mock.patch.object( + globals_["time"], "monotonic", side_effect=lambda: next(monotonic_values) + ), + unittest.mock.patch.object(globals_["time"], "sleep", return_value=None), + ): + with self.assertRaisesRegex( + RuntimeError, + r"ChromeDriver did not become ready \(status_protocol_error\)", + ): + wait_for_driver(43123) + + self.assertEqual(status_calls, 1) + + +if __name__ == "__main__": + unittest.main() From 4be3b77b0652a389fc67637e89562aeccedfff20 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 16:11:25 -0700 Subject: [PATCH 55/95] fix(mv3): verify pinned ChromeDriver status authority --- scripts/ci/run_mv3_compatibility.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 1cc9a25bb..6dc421a43 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -212,17 +212,37 @@ def _json_request( def _wait_for_driver(driver_port: int) -> None: - """Wait for local ChromeDriver readiness while retaining only a safe failure class.""" + """Wait for the exact pinned local ChromeDriver and reject foreign ready endpoints.""" deadline = time.monotonic() + STARTUP_TIMEOUT_SECONDS last_failure_kind = "not_observed" 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_failure_kind = str(_failure_evidence(exc)["failure_kind"]) + time.sleep(0.1) + continue + + status_value = status.get("value") + if not isinstance(status_value, dict): + last_failure_kind = "status_protocol_error" + time.sleep(0.1) + continue + + ready = status_value.get("ready") + if ready is True: + build = status_value.get("build") + build_version = build.get("version") if isinstance(build, dict) else None + expected_prefix = f"{PINNED_CHROME_VERSION} (" + if not isinstance(build_version, str) or not ( + build_version == PINNED_CHROME_VERSION + or build_version.startswith(expected_prefix) + ): + raise RuntimeError("ChromeDriver status identity mismatch") + return + if ready is not False: + last_failure_kind = "status_protocol_error" time.sleep(0.1) raise RuntimeError( f"ChromeDriver did not become ready ({last_failure_kind})" From 93e4be4ede73490bd3566b80150d08e7c7036f5e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 02:33:33 -0700 Subject: [PATCH 56/95] test(mv3): reject ChromeDriver port release-bind race --- tests/test_mv3_driver_status_authority_contract.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_mv3_driver_status_authority_contract.py b/tests/test_mv3_driver_status_authority_contract.py index f57509d0a..f7bc97bf4 100644 --- a/tests/test_mv3_driver_status_authority_contract.py +++ b/tests/test_mv3_driver_status_authority_contract.py @@ -14,6 +14,14 @@ class ManifestV3DriverStatusAuthorityTests(unittest.TestCase): """Startup evidence must come from the pinned ChromeDriver status shape.""" + def test_driver_owns_ephemeral_port_selection_without_a_release_bind_race(self) -> None: + """ChromeDriver itself must bind port zero instead of racing on a released probe port.""" + + source = RUNNER.read_text(encoding="utf-8") + self.assertNotIn("def _free_loopback_port", source) + self.assertNotIn("driver_port = _free_loopback_port()", source) + self.assertIn('"--port=0"', source) + def test_ready_status_rejects_a_different_chromedriver_build(self) -> None: """A ready loopback endpoint cannot impersonate the pinned driver build.""" From 41462e483985aba957046c78c3737897fba1c038 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 02:42:43 -0700 Subject: [PATCH 57/95] fix(mv3): let ChromeDriver own ephemeral port binding --- scripts/ci/run_mv3_compatibility.py | 106 +++++++++++++++++++++++----- 1 file changed, 89 insertions(+), 17 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 6dc421a43..d1613c02b 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -16,7 +16,7 @@ import json import os import pathlib -import socket +import queue import string import subprocess import tempfile @@ -39,6 +39,8 @@ STARTUP_TIMEOUT_SECONDS = 20.0 FIXTURE_TIMEOUT_SECONDS = 20.0 MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576 +MAX_CHROMEDRIVER_STARTUP_LINE_BYTES = 512 +CHROMEDRIVER_BOUND_PORT_PREFIX = "ChromeDriver was started successfully on port " W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" PATH_TOKEN_CHARACTERS = frozenset(string.ascii_letters + string.digits + "-_.") SURFACE_EVIDENCE_KEYS = ( @@ -126,14 +128,6 @@ def _failure_evidence(error: BaseException) -> dict[str, Any]: return {"failure_kind": "runtime_error"} -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.""" @@ -400,6 +394,90 @@ def _teardown_driver_process(driver: subprocess.Popen[str]) -> Exception | None: return None +def _start_chromedriver( + chromedriver_bin: pathlib.Path, +) -> tuple[subprocess.Popen[str], int]: + """Let ChromeDriver atomically bind an ephemeral port and report the bound authority. + + The process owns port allocation by binding port zero itself. Its combined output is + continuously drained so the pipe cannot become a back-pressure failure, but only the + reviewed startup-port record is retained. Raw ChromeDriver output never enters evidence. + """ + + driver = subprocess.Popen( + [str(chromedriver_bin), "--port=0", "--allowed-ips=127.0.0.1"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + ) + if driver.stdout is None: + teardown_error = _teardown_driver_process(driver) + startup_error = RuntimeError("ChromeDriver startup output pipe was unavailable") + if teardown_error is not None: + startup_error.add_note( + "ChromeDriver process teardown also failed: " + f"{type(teardown_error).__name__}" + ) + raise startup_error + + startup_events: queue.Queue[tuple[str, int | None]] = queue.Queue(maxsize=1) + + def publish(event: tuple[str, int | None]) -> None: + try: + startup_events.put_nowait(event) + except queue.Full: + return + + def drain_output() -> None: + for raw_line in driver.stdout: + if not raw_line.startswith(CHROMEDRIVER_BOUND_PORT_PREFIX): + continue + if len(raw_line.encode("utf-8")) > MAX_CHROMEDRIVER_STARTUP_LINE_BYTES: + publish(("invalid", None)) + continue + + line = raw_line.rstrip("\r\n") + if not line.endswith("."): + publish(("invalid", None)) + continue + port_text = line[len(CHROMEDRIVER_BOUND_PORT_PREFIX) : -1] + if not port_text.isdecimal(): + publish(("invalid", None)) + continue + port = int(port_text) + if not 1 <= port <= 65_535: + publish(("invalid", None)) + continue + publish(("ready", port)) + publish(("eof", None)) + + threading.Thread( + target=drain_output, + name="originweave-chromedriver-output-drain", + daemon=True, + ).start() + + try: + event_kind, bound_port = startup_events.get(timeout=STARTUP_TIMEOUT_SECONDS) + except queue.Empty: + event_kind, bound_port = "timeout", None + + if event_kind == "ready" and bound_port is not None: + return driver, bound_port + + teardown_error = _teardown_driver_process(driver) + startup_error = RuntimeError("ChromeDriver did not publish a valid bound port") + if teardown_error is not None: + startup_error.add_note( + "ChromeDriver process teardown also failed: " + f"{type(teardown_error).__name__}" + ) + raise startup_error + + def _run_browser_pass( chrome_bin: pathlib.Path, chromedriver_bin: pathlib.Path, @@ -409,16 +487,10 @@ def _run_browser_pass( ) -> dict[str, Any]: """Run one fresh browser process against a shared bounded compatibility profile.""" - driver_port = _free_loopback_port() session_id: str | None = None download_dir = pathlib.Path(profile_dir) / "downloads" download_dir.mkdir(mode=0o700, parents=True, exist_ok=True) - 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, driver_port = _start_chromedriver(chromedriver_bin) primary_error: BaseException | None = None try: _wait_for_driver(driver_port) @@ -755,4 +827,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) From 3ad1978505a4a5751db9c85790553d71dce21c82 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 02:47:15 -0700 Subject: [PATCH 58/95] test(mv3): mock the ChromeDriver startup boundary --- tests/test_mv3_browser_version_diagnostic_contract.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/test_mv3_browser_version_diagnostic_contract.py b/tests/test_mv3_browser_version_diagnostic_contract.py index c79da5ac8..c2f78060b 100644 --- a/tests/test_mv3_browser_version_diagnostic_contract.py +++ b/tests/test_mv3_browser_version_diagnostic_contract.py @@ -73,13 +73,10 @@ def fake_json_request( prefix="originweave-browser-version-contract-" ) as profile_dir: with ( - unittest.mock.patch.object( - globals_["subprocess"], "Popen", return_value=fake_driver - ), unittest.mock.patch.dict( globals_, { - "_free_loopback_port": lambda: 43123, + "_start_chromedriver": lambda _binary: (fake_driver, 43123), "_wait_for_driver": lambda _port: None, "_json_request": fake_json_request, }, From 4f36c70e329bd10701a2f81d7e0e09663852c922 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 02:47:40 -0700 Subject: [PATCH 59/95] test(mv3): adapt primary cleanup contract to startup owner --- ...st_mv3_primary_failure_cleanup_contract.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/tests/test_mv3_primary_failure_cleanup_contract.py b/tests/test_mv3_primary_failure_cleanup_contract.py index 133e7159f..8b1644fa3 100644 --- a/tests/test_mv3_primary_failure_cleanup_contract.py +++ b/tests/test_mv3_primary_failure_cleanup_contract.py @@ -77,18 +77,13 @@ def fake_json_request( raise AssertionError(f"unexpected WebDriver request: {method} {path}") with tempfile.TemporaryDirectory(prefix="originweave-primary-cleanup-") as profile_dir: - with ( - unittest.mock.patch.object( - globals_["subprocess"], "Popen", return_value=fake_driver - ), - unittest.mock.patch.dict( - globals_, - { - "_free_loopback_port": lambda: 43123, - "_wait_for_driver": lambda _port: None, - "_json_request": fake_json_request, - }, - ), + with unittest.mock.patch.dict( + globals_, + { + "_start_chromedriver": lambda _binary: (fake_driver, 43123), + "_wait_for_driver": lambda _port: None, + "_json_request": fake_json_request, + }, ): with self.assertRaises(RuntimeError) as raised: run_browser_pass( From 656d1bd3cde4a6cc30fb77dd9a33b727904e19a0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 02:48:14 -0700 Subject: [PATCH 60/95] test(mv3): preserve cleanup contracts after startup hardening --- ..._mv3_session_cleanup_exception_contract.py | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/tests/test_mv3_session_cleanup_exception_contract.py b/tests/test_mv3_session_cleanup_exception_contract.py index 0106438d9..09767d452 100644 --- a/tests/test_mv3_session_cleanup_exception_contract.py +++ b/tests/test_mv3_session_cleanup_exception_contract.py @@ -121,22 +121,17 @@ def fake_json_request( raise AssertionError(f"unexpected WebDriver request: {method} {path}") with tempfile.TemporaryDirectory(prefix="originweave-cleanup-contract-") as profile_dir: - with ( - unittest.mock.patch.object( - globals_["subprocess"], "Popen", return_value=fake_driver - ), - unittest.mock.patch.dict( - globals_, - { - "_free_loopback_port": lambda: 43123, - "_wait_for_driver": lambda _port: None, - "_json_request": fake_json_request, - "_wait_for_extension_evidence": ( - lambda _port, _session, _expected: self._surfaces() - ), - "_exercise_real_click": lambda _port, _session: "clicked", - }, - ), + with unittest.mock.patch.dict( + globals_, + { + "_start_chromedriver": lambda _binary: (fake_driver, 43123), + "_wait_for_driver": lambda _port: None, + "_json_request": fake_json_request, + "_wait_for_extension_evidence": ( + lambda _port, _session, _expected: self._surfaces() + ), + "_exercise_real_click": lambda _port, _session: "clicked", + }, ): try: run_browser_pass( From 61929f045d8d70b1c77482f50a57a5ed6e67e9ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 03:47:30 -0700 Subject: [PATCH 61/95] test(mv3): reproduce Popen compatibility gate failure --- ...t_mv3_subprocess_compatibility_contract.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/test_mv3_subprocess_compatibility_contract.py diff --git a/tests/test_mv3_subprocess_compatibility_contract.py b/tests/test_mv3_subprocess_compatibility_contract.py new file mode 100644 index 000000000..166c471c1 --- /dev/null +++ b/tests/test_mv3_subprocess_compatibility_contract.py @@ -0,0 +1,44 @@ +"""Regression contract for portable, explicit ChromeDriver output decoding.""" + +from __future__ import annotations + +import ast +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class ChromeDriverSubprocessCompatibilityContractTests(unittest.TestCase): + """Keep subprocess decoding explicit instead of version-gated Popen kwargs.""" + + def test_chromedriver_popen_uses_binary_pipe_with_explicit_utf8_decode(self) -> None: + """Popen must avoid text-decoding kwargs and decode bounded output explicitly.""" + + source = RUNNER.read_text(encoding="utf-8") + tree = ast.parse(source) + start = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "_start_chromedriver" + ) + popen_calls = [ + node + for node in ast.walk(start) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "subprocess" + and node.func.attr == "Popen" + ] + self.assertEqual(len(popen_calls), 1) + keyword_names = {keyword.arg for keyword in popen_calls[0].keywords} + self.assertTrue({"text", "encoding", "errors"}.isdisjoint(keyword_names)) + + self.assertIn('raw_line_bytes.decode("utf-8", errors="replace")', source) + self.assertIn("len(raw_line_bytes) > MAX_CHROMEDRIVER_STARTUP_LINE_BYTES", source) + + +if __name__ == "__main__": + unittest.main() From 93d36efec072d1125825aed70a7900b60ef4f3d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:23:11 -0700 Subject: [PATCH 62/95] fix(mv3): decode ChromeDriver startup output explicitly --- scripts/ci/run_mv3_compatibility.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index d1613c02b..b7c253add 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -365,7 +365,7 @@ def _exercise_real_click(driver_port: int, session_id: str) -> str: return str(text) -def _teardown_driver_process(driver: subprocess.Popen[str]) -> Exception | None: +def _teardown_driver_process(driver: subprocess.Popen[bytes]) -> Exception | None: """Best-effort reap ChromeDriver while preserving unrecovered process failures.""" try: @@ -396,7 +396,7 @@ def _teardown_driver_process(driver: subprocess.Popen[str]) -> Exception | None: def _start_chromedriver( chromedriver_bin: pathlib.Path, -) -> tuple[subprocess.Popen[str], int]: +) -> tuple[subprocess.Popen[bytes], int]: """Let ChromeDriver atomically bind an ephemeral port and report the bound authority. The process owns port allocation by binding port zero itself. Its combined output is @@ -408,10 +408,6 @@ def _start_chromedriver( [str(chromedriver_bin), "--port=0", "--allowed-ips=127.0.0.1"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, - encoding="utf-8", - errors="replace", - bufsize=1, ) if driver.stdout is None: teardown_error = _teardown_driver_process(driver) @@ -424,6 +420,7 @@ def _start_chromedriver( raise startup_error startup_events: queue.Queue[tuple[str, int | None]] = queue.Queue(maxsize=1) + port_prefix_bytes = CHROMEDRIVER_BOUND_PORT_PREFIX.encode("ascii") def publish(event: tuple[str, int | None]) -> None: try: @@ -432,13 +429,14 @@ def publish(event: tuple[str, int | None]) -> None: return def drain_output() -> None: - for raw_line in driver.stdout: - if not raw_line.startswith(CHROMEDRIVER_BOUND_PORT_PREFIX): + for raw_line_bytes in driver.stdout: + if not raw_line_bytes.startswith(port_prefix_bytes): continue - if len(raw_line.encode("utf-8")) > MAX_CHROMEDRIVER_STARTUP_LINE_BYTES: + if len(raw_line_bytes) > MAX_CHROMEDRIVER_STARTUP_LINE_BYTES: publish(("invalid", None)) continue + raw_line = raw_line_bytes.decode("utf-8", errors="replace") line = raw_line.rstrip("\r\n") if not line.endswith("."): publish(("invalid", None)) From 6963e4d4828f6a60db0bb9eabd0c4357dabc074f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:42:23 -0700 Subject: [PATCH 63/95] test(mv3): bound chromedriver startup line reads --- ...t_mv3_subprocess_compatibility_contract.py | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/test_mv3_subprocess_compatibility_contract.py b/tests/test_mv3_subprocess_compatibility_contract.py index 166c471c1..020c5fdb4 100644 --- a/tests/test_mv3_subprocess_compatibility_contract.py +++ b/tests/test_mv3_subprocess_compatibility_contract.py @@ -1,9 +1,11 @@ -"""Regression contract for portable, explicit ChromeDriver output decoding.""" +"""Regression contract for portable, bounded ChromeDriver output decoding.""" from __future__ import annotations import ast +import io import pathlib +import runpy import unittest ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -11,7 +13,7 @@ class ChromeDriverSubprocessCompatibilityContractTests(unittest.TestCase): - """Keep subprocess decoding explicit instead of version-gated Popen kwargs.""" + """Keep subprocess decoding explicit and bound retained startup-line memory.""" def test_chromedriver_popen_uses_binary_pipe_with_explicit_utf8_decode(self) -> None: """Popen must avoid text-decoding kwargs and decode bounded output explicitly.""" @@ -39,6 +41,32 @@ def test_chromedriver_popen_uses_binary_pipe_with_explicit_utf8_decode(self) -> self.assertIn('raw_line_bytes.decode("utf-8", errors="replace")', source) self.assertIn("len(raw_line_bytes) > MAX_CHROMEDRIVER_STARTUP_LINE_BYTES", source) + def test_startup_output_reader_never_requests_an_unbounded_line(self) -> None: + """A newline-free subprocess record must be drained in bounded reads.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_startup_output_bound") + read_line = namespace["_read_chromedriver_startup_line"] + maximum = namespace["MAX_CHROMEDRIVER_STARTUP_LINE_BYTES"] + + class RecordingStream(io.BytesIO): + def __init__(self, initial_bytes: bytes) -> None: + super().__init__(initial_bytes) + self.requested_sizes: list[int] = [] + + def readline(self, size: int = -1) -> bytes: + self.requested_sizes.append(size) + return super().readline(size) + + stream = RecordingStream(b"x" * (maximum * 4) + b"\nnext\n") + raw_line, oversized = read_line(stream) + + self.assertTrue(oversized) + self.assertLessEqual(len(raw_line), maximum + 1) + self.assertTrue(stream.requested_sizes) + self.assertNotIn(-1, stream.requested_sizes) + self.assertLessEqual(max(stream.requested_sizes), maximum + 1) + self.assertEqual(read_line(stream), (b"next\n", False)) + if __name__ == "__main__": unittest.main() From ecaac90f9d6e0380e98a5e32d2e6ab6f3ecaad8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 20:45:52 -0700 Subject: [PATCH 64/95] fix(mv3): bound chromedriver startup output reads --- scripts/ci/run_mv3_compatibility.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index b7c253add..67f0bbec6 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -394,6 +394,22 @@ def _teardown_driver_process(driver: subprocess.Popen[bytes]) -> Exception | Non return None +def _read_chromedriver_startup_line(stream: Any) -> tuple[bytes, bool]: + """Read and drain one ChromeDriver startup record using only bounded reads.""" + + raw_line_bytes = stream.readline(MAX_CHROMEDRIVER_STARTUP_LINE_BYTES + 1) + if not raw_line_bytes: + return b"", False + + oversized = len(raw_line_bytes) > MAX_CHROMEDRIVER_STARTUP_LINE_BYTES + if oversized and not raw_line_bytes.endswith(b"\n"): + while True: + remainder = stream.readline(MAX_CHROMEDRIVER_STARTUP_LINE_BYTES + 1) + if not remainder or remainder.endswith(b"\n"): + break + return raw_line_bytes, oversized + + def _start_chromedriver( chromedriver_bin: pathlib.Path, ) -> tuple[subprocess.Popen[bytes], int]: @@ -429,10 +445,13 @@ def publish(event: tuple[str, int | None]) -> None: return def drain_output() -> None: - for raw_line_bytes in driver.stdout: + while True: + raw_line_bytes, oversized = _read_chromedriver_startup_line(driver.stdout) + if not raw_line_bytes: + break if not raw_line_bytes.startswith(port_prefix_bytes): continue - if len(raw_line_bytes) > MAX_CHROMEDRIVER_STARTUP_LINE_BYTES: + if oversized: publish(("invalid", None)) continue From 089116bc7b1b4af230da6afd0631d8ccd56484b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 04:29:24 -0700 Subject: [PATCH 65/95] test(mv3): require recoverable startup candidate parsing --- ...est_mv3_subprocess_compatibility_contract.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_mv3_subprocess_compatibility_contract.py b/tests/test_mv3_subprocess_compatibility_contract.py index 020c5fdb4..453aaa36f 100644 --- a/tests/test_mv3_subprocess_compatibility_contract.py +++ b/tests/test_mv3_subprocess_compatibility_contract.py @@ -67,6 +67,23 @@ def readline(self, size: int = -1) -> bytes: self.assertLessEqual(max(stream.requested_sizes), maximum + 1) self.assertEqual(read_line(stream), (b"next\n", False)) + def test_startup_port_parser_treats_malformed_candidates_as_non_authoritative(self) -> None: + """Malformed candidate records must be ignorable while a later valid record can win.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_startup_port_parser") + parse_bound_port = namespace["_parse_chromedriver_bound_port"] + prefix = namespace["CHROMEDRIVER_BOUND_PORT_PREFIX"].encode("ascii") + maximum = namespace["MAX_CHROMEDRIVER_STARTUP_LINE_BYTES"] + + self.assertIsNone(parse_bound_port(prefix + b"not-a-port.\n", False)) + self.assertIsNone(parse_bound_port(prefix + b"9515\n", False)) + self.assertIsNone(parse_bound_port(prefix + b"9515.\n", True)) + self.assertIsNone(parse_bound_port(b"ordinary ChromeDriver diagnostic\n", False)) + self.assertIsNone(parse_bound_port(prefix + b"0.\n", False)) + self.assertIsNone(parse_bound_port(prefix + b"65536.\n", False)) + self.assertEqual(parse_bound_port(prefix + b"9515.\n", False), 9515) + self.assertLess(len(prefix) + len(b"9515.\n"), maximum) + if __name__ == "__main__": unittest.main() From 31488765c093105fda94e563d82d3351cd434e3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 04:34:54 -0700 Subject: [PATCH 66/95] fix(mv3): ignore malformed startup port candidates --- scripts/ci/run_mv3_compatibility.py | 42 +++++++++++++++-------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 67f0bbec6..99aaaf0e9 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -410,6 +410,25 @@ def _read_chromedriver_startup_line(stream: Any) -> tuple[bytes, bool]: return raw_line_bytes, oversized +def _parse_chromedriver_bound_port(raw_line_bytes: bytes, oversized: bool) -> int | None: + """Return one bounded authoritative startup port or ignore a malformed candidate.""" + + if oversized or not raw_line_bytes.startswith( + CHROMEDRIVER_BOUND_PORT_PREFIX.encode("ascii") + ): + return None + + raw_line = raw_line_bytes.decode("utf-8", errors="replace") + line = raw_line.rstrip("\r\n") + if not line.endswith("."): + return None + port_text = line[len(CHROMEDRIVER_BOUND_PORT_PREFIX) : -1] + if not port_text.isdecimal(): + return None + port = int(port_text) + return port if 1 <= port <= 65_535 else None + + def _start_chromedriver( chromedriver_bin: pathlib.Path, ) -> tuple[subprocess.Popen[bytes], int]: @@ -436,7 +455,6 @@ def _start_chromedriver( raise startup_error startup_events: queue.Queue[tuple[str, int | None]] = queue.Queue(maxsize=1) - port_prefix_bytes = CHROMEDRIVER_BOUND_PORT_PREFIX.encode("ascii") def publish(event: tuple[str, int | None]) -> None: try: @@ -449,24 +467,8 @@ def drain_output() -> None: raw_line_bytes, oversized = _read_chromedriver_startup_line(driver.stdout) if not raw_line_bytes: break - if not raw_line_bytes.startswith(port_prefix_bytes): - continue - if oversized: - publish(("invalid", None)) - continue - - raw_line = raw_line_bytes.decode("utf-8", errors="replace") - line = raw_line.rstrip("\r\n") - if not line.endswith("."): - publish(("invalid", None)) - continue - port_text = line[len(CHROMEDRIVER_BOUND_PORT_PREFIX) : -1] - if not port_text.isdecimal(): - publish(("invalid", None)) - continue - port = int(port_text) - if not 1 <= port <= 65_535: - publish(("invalid", None)) + port = _parse_chromedriver_bound_port(raw_line_bytes, oversized) + if port is None: continue publish(("ready", port)) publish(("eof", None)) @@ -844,4 +846,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file From fab11f64d696693744d08b749b032051c4d4bf67 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 04:36:42 -0700 Subject: [PATCH 67/95] docs(mv3): record startup recovery authority boundary --- docs/doctoring/mv3-compatibility.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/mv3-compatibility.md b/docs/doctoring/mv3-compatibility.md index 6dc9c4d05..18274829b 100644 --- a/docs/doctoring/mv3-compatibility.md +++ b/docs/doctoring/mv3-compatibility.md @@ -1,7 +1,7 @@ # Manifest V3 compatibility evidence baseline - **Status:** Active implementation evidence for issue #27 -- **Reviewed:** 2026-08-16 +- **Reviewed:** 2026-08-20 - **Pinned browser:** Chrome for Testing `150.0.7871.129`, Chromium revision `r1639810` OriginWeave uses Chromium as its compatibility kernel, so browser-extension compatibility must be demonstrated with executable Chromium evidence rather than inferred from architecture alone. The protected-main lane exercises a controlled unpacked Manifest V3 extension against one exact Chrome for Testing build and proves service-worker, content-script, storage, declarative-network-request, tabs, windows, scripting, commands, side-panel, bookmarks/history read compatibility, restart persistence, repeatability, and one real WebDriver click/post-condition. Active stacked compatibility work adds downloads, bounded bookmark/history mutation, profile isolation, explicit extension update/version-migration evidence, and an exact content-script isolated-world check. OriginWeave does **not claim 100% Chrome extension compatibility**. @@ -46,6 +46,12 @@ For downloads compatibility specifically, the current official Chrome Extensions RFC 9112 requires a well-formed HTTP/1.1 status-line and a message body that matches the announced framing. W3C WebDriver sends commands over that HTTP transport. When ChromeDriver returns a malformed status-line or an incomplete body, the compatibility runner raises only `WebDriver transport protocol failure`. Raw status-line text, partial body bytes, paths, URLs, or tokens must not enter exception text or trial evidence. This classification lets `main` record the failure in `trial_results` instead of aborting the compatibility run with an unclassified parser exception. +## ChromeDriver startup-record robustness boundary + +ChromeDriver startup stdout is diagnostic input, not authority. The compatibility runner retains at most `MAX_CHROMEDRIVER_STARTUP_LINE_BYTES + 1` bytes from one record and drains the remainder through bounded reads. A prefixed record that is oversized, lacks the required terminal period, carries a non-decimal port, or names a port outside `1..65535` is treated as non-authoritative and ignored while the existing bounded startup wait continues. A later well-formed candidate can therefore recover from malformed-but-expected startup diagnostics without turning the malformed record into success. + +A syntactically valid reported port is still insufficient authority. Before a WebDriver session is created, the loopback `/status` endpoint must identify the exact pinned ChromeDriver build. If no valid candidate appears before EOF or the startup deadline, or if the status endpoint identifies a foreign build, startup still fails closed and the process is reaped through the reviewed bounded teardown path. + ## Click post-condition diagnostic boundary W3C WebDriver Get Element Text returns rendered element text. That value is page-controlled data. The compatibility runner compares the fixture output against the exact expected `clicked` token and, on mismatch, retains only the classified message `real click post-condition mismatch`. Raw element text must not enter exception text or trial evidence. @@ -84,4 +90,4 @@ Google Chrome Labs. (2026, July 21). *Chrome for Testing availability*. https:// Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP/1.1* (RFC 9112). Internet Engineering Task Force. https://doi.org/10.17487/RFC9112 -World Wide Web Consortium. (2018, June 5). *WebDriver* (W3C Recommendation). https://www.w3.org/TR/2018/REC-webdriver1-20180605/ +World Wide Web Consortium. (2018, June 5). *WebDriver* (W3C Recommendation). https://www.w3.org/TR/2018/REC-webdriver1-20180605/ \ No newline at end of file From e434ac98f8b3b93068009bff2a3da92f0be296b4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 04:37:21 -0700 Subject: [PATCH 68/95] docs(changelog): record bounded ChromeDriver startup recovery --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee593faf..f70f24374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Made malformed or oversized ChromeDriver startup-port candidate records non-authoritative within the existing bounded startup wait, so later valid startup output may recover while exact pinned-build `/status` identity remains mandatory before session creation. - Treated a failed graceful ChromeDriver termination as recoverable when the bounded hard-kill fallback successfully reaps the process, while preserving unrecovered fallback failures as teardown errors. - Classified a mismatched Chrome `browserVersion` capability as an expected-only diagnostic so browser-reported capability text cannot enter Manifest V3 runner exception output. - Classified Manifest V3 WebDriver HTTP/1.1 parser failures as a fixed transport-protocol token so a malformed status-line or incomplete message body cannot enter runner exception text. @@ -80,4 +81,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file From e501797d03f51ae80e6f041b326b8ca0ce5ca1d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 04:40:22 -0700 Subject: [PATCH 69/95] test(mv3): prove malformed startup candidate recovery --- ...t_mv3_subprocess_compatibility_contract.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_mv3_subprocess_compatibility_contract.py b/tests/test_mv3_subprocess_compatibility_contract.py index 453aaa36f..74737401d 100644 --- a/tests/test_mv3_subprocess_compatibility_contract.py +++ b/tests/test_mv3_subprocess_compatibility_contract.py @@ -84,6 +84,50 @@ def test_startup_port_parser_treats_malformed_candidates_as_non_authoritative(se self.assertEqual(parse_bound_port(prefix + b"9515.\n", False), 9515) self.assertLess(len(prefix) + len(b"9515.\n"), maximum) + def test_chromedriver_startup_recovers_after_malformed_candidate(self) -> None: + """One malformed candidate must not prevent a later valid bound-port record.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_startup_recovery") + start_chromedriver = namespace["_start_chromedriver"] + prefix = namespace["CHROMEDRIVER_BOUND_PORT_PREFIX"].encode("ascii") + subprocess_module = namespace["subprocess"] + + class FakeDriver: + def __init__(self) -> None: + self.stdout = io.BytesIO( + prefix + b"not-a-port.\n" + prefix + b"9515.\n" + ) + self.terminate_calls = 0 + self.kill_calls = 0 + self.wait_calls = 0 + + def terminate(self) -> None: + self.terminate_calls += 1 + + def kill(self) -> None: + self.kill_calls += 1 + + def wait(self, timeout: float | None = None) -> int: + del timeout + self.wait_calls += 1 + return 0 + + fake_driver = FakeDriver() + original_popen = subprocess_module.Popen + subprocess_module.Popen = lambda *args, **kwargs: fake_driver + try: + returned_driver, bound_port = start_chromedriver( + pathlib.Path("/reviewed/chromedriver") + ) + finally: + subprocess_module.Popen = original_popen + + self.assertIs(returned_driver, fake_driver) + self.assertEqual(bound_port, 9515) + self.assertEqual(fake_driver.terminate_calls, 0) + self.assertEqual(fake_driver.kill_calls, 0) + self.assertEqual(fake_driver.wait_calls, 0) + if __name__ == "__main__": unittest.main() From a45c83e4d8988fe89920ecb6a9eac469815f5b9b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 06:59:35 +0900 Subject: [PATCH 70/95] fix(mv3): preserve chromium sandbox --- CHANGELOG.md | 3 ++- docs/doctoring/mv3-compatibility.md | 4 +++- scripts/ci/run_mv3_compatibility.py | 3 +-- tests/test_mv3_compatibility_contract.py | 6 ++++++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f70f24374..d06116ce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Preserved Chromium's renderer sandbox in the real Manifest V3 compatibility runner by removing the `--no-sandbox` launch override; environments that cannot run the pinned browser with sandboxing enabled must fail the compatibility lane rather than weaken the security boundary. - Made malformed or oversized ChromeDriver startup-port candidate records non-authoritative within the existing bounded startup wait, so later valid startup output may recover while exact pinned-build `/status` identity remains mandatory before session creation. - Treated a failed graceful ChromeDriver termination as recoverable when the bounded hard-kill fallback successfully reaps the process, while preserving unrecovered fallback failures as teardown errors. - Classified a mismatched Chrome `browserVersion` capability as an expected-only diagnostic so browser-reported capability text cannot enter Manifest V3 runner exception output. @@ -81,4 +82,4 @@ All notable changes to OriginWeave are documented in this file. The format follo - The hourly product agent has no Git metadata or repository authority. A separate post-verification publisher opens one PR and cannot approve or merge it. - The unprivileged OpenCode user is restricted to loopback egress during model execution, preventing runner-wide allow-listed endpoints from becoming direct source-exfiltration channels. -[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/ContextualWisdomLab/OriginWeave/compare/main...HEAD diff --git a/docs/doctoring/mv3-compatibility.md b/docs/doctoring/mv3-compatibility.md index 18274829b..1a5a0dfd4 100644 --- a/docs/doctoring/mv3-compatibility.md +++ b/docs/doctoring/mv3-compatibility.md @@ -8,6 +8,8 @@ OriginWeave uses Chromium as its compatibility kernel, so browser-extension comp The checked-in fixture is intentionally local-only. Its host permission is limited to loopback HTTP used by the deterministic test server. It contains no remote code, user credential, model call, external content, native-messaging host, or production PII. Chrome permissions remain distinct from the explicit OriginWeave extension-to-Agent grant implemented in `originweave-core`. Compatibility mutation tests create only controlled synthetic state inside the ephemeral test profile and must clean it up; successful API compatibility never grants the OriginWeave Agent ambient bookmarks/history/downloads authority. +The compatibility runner preserves Chromium's renderer sandbox and does not pass `--no-sandbox`. A runner environment that cannot start the pinned browser with sandboxing enabled is an infrastructure failure to repair or report, not a reason to weaken the browser security boundary. + ## Supported-capability evidence matrix This matrix separates protected-main executable evidence from active, non-shipped evidence and from genuinely unproven surfaces. A row marked **ACTIVE_PR** is never a release claim; exact head/run provenance belongs in `docs/evidence/2026-08-10-active-pr-maturity.md` and must be refreshed when the branch changes. @@ -90,4 +92,4 @@ Google Chrome Labs. (2026, July 21). *Chrome for Testing availability*. https:// Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP/1.1* (RFC 9112). Internet Engineering Task Force. https://doi.org/10.17487/RFC9112 -World Wide Web Consortium. (2018, June 5). *WebDriver* (W3C Recommendation). https://www.w3.org/TR/2018/REC-webdriver1-20180605/ \ No newline at end of file +World Wide Web Consortium. (2018, June 5). *WebDriver* (W3C Recommendation). https://www.w3.org/TR/2018/REC-webdriver1-20180605/ diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 99aaaf0e9..3b512f8e5 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -530,7 +530,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}", @@ -846,4 +845,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/tests/test_mv3_compatibility_contract.py b/tests/test_mv3_compatibility_contract.py index afa0d8511..c075a2d78 100644 --- a/tests/test_mv3_compatibility_contract.py +++ b/tests/test_mv3_compatibility_contract.py @@ -121,6 +121,12 @@ def test_runner_transport_cannot_follow_dynamic_url_schemes(self) -> None: self.assertNotIn("urllib.request", runner) self.assertNotIn("urllib.error", runner) + def test_runner_preserves_chromium_sandbox(self) -> None: + """The real-browser compatibility lane must not disable Chromium sandboxing.""" + + runner = RUNNER.read_text(encoding="utf-8") + self.assertNotIn('"--no-sandbox"', runner) + def test_runner_accepts_real_chromedriver_element_ids_without_path_injection(self) -> None: """ChromeDriver dotted element IDs must work while path syntax stays fail-closed.""" From bf3a2b05c854394b59b3f03732207c848b1d1479 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:05:54 +0900 Subject: [PATCH 71/95] fix(mv3): install chromium sandbox helper --- .github/workflows/mv3-compatibility.yml | 2 ++ CHANGELOG.md | 4 ++++ docs/doctoring/mv3-compatibility.md | 4 ++-- tests/test_mv3_compatibility_contract.py | 14 ++++++++++++++ 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mv3-compatibility.yml b/.github/workflows/mv3-compatibility.yml index 13a9eec6c..fd6558af9 100644 --- a/.github/workflows/mv3-compatibility.yml +++ b/.github/workflows/mv3-compatibility.yml @@ -63,6 +63,8 @@ jobs: chmod 0755 \ .mv3-browser/chrome-linux64/chrome \ .mv3-browser/chromedriver-linux64/chromedriver + sudo chown root:root .mv3-browser/chrome-linux64/chrome_sandbox + sudo chmod 4755 .mv3-browser/chrome-linux64/chrome_sandbox - name: Execute real MV3 compatibility fixture shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index d06116ce3..a452a88dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] +### Changed + +- Kept the real MV3 compatibility lane sandboxed by installing the pinned Chrome for Testing archive's root-owned `chrome_sandbox` helper instead of passing `--no-sandbox`. + ### Added - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. diff --git a/docs/doctoring/mv3-compatibility.md b/docs/doctoring/mv3-compatibility.md index 1a5a0dfd4..cfadbc588 100644 --- a/docs/doctoring/mv3-compatibility.md +++ b/docs/doctoring/mv3-compatibility.md @@ -1,14 +1,14 @@ # Manifest V3 compatibility evidence baseline - **Status:** Active implementation evidence for issue #27 -- **Reviewed:** 2026-08-20 +- **Reviewed:** 2026-08-21 - **Pinned browser:** Chrome for Testing `150.0.7871.129`, Chromium revision `r1639810` OriginWeave uses Chromium as its compatibility kernel, so browser-extension compatibility must be demonstrated with executable Chromium evidence rather than inferred from architecture alone. The protected-main lane exercises a controlled unpacked Manifest V3 extension against one exact Chrome for Testing build and proves service-worker, content-script, storage, declarative-network-request, tabs, windows, scripting, commands, side-panel, bookmarks/history read compatibility, restart persistence, repeatability, and one real WebDriver click/post-condition. Active stacked compatibility work adds downloads, bounded bookmark/history mutation, profile isolation, explicit extension update/version-migration evidence, and an exact content-script isolated-world check. OriginWeave does **not claim 100% Chrome extension compatibility**. The checked-in fixture is intentionally local-only. Its host permission is limited to loopback HTTP used by the deterministic test server. It contains no remote code, user credential, model call, external content, native-messaging host, or production PII. Chrome permissions remain distinct from the explicit OriginWeave extension-to-Agent grant implemented in `originweave-core`. Compatibility mutation tests create only controlled synthetic state inside the ephemeral test profile and must clean it up; successful API compatibility never grants the OriginWeave Agent ambient bookmarks/history/downloads authority. -The compatibility runner preserves Chromium's renderer sandbox and does not pass `--no-sandbox`. A runner environment that cannot start the pinned browser with sandboxing enabled is an infrastructure failure to repair or report, not a reason to weaken the browser security boundary. +The compatibility runner preserves Chromium's renderer sandbox and does not pass `--no-sandbox`. Because the Chrome for Testing archive does not carry setuid ownership through extraction, the workflow installs its pinned `chrome_sandbox` helper as root-owned mode `4755` before execution. A runner environment that cannot start the pinned browser with sandboxing enabled is an infrastructure failure to repair or report, not a reason to weaken the browser security boundary. ## Supported-capability evidence matrix diff --git a/tests/test_mv3_compatibility_contract.py b/tests/test_mv3_compatibility_contract.py index c075a2d78..eb5f46729 100644 --- a/tests/test_mv3_compatibility_contract.py +++ b/tests/test_mv3_compatibility_contract.py @@ -127,6 +127,20 @@ def test_runner_preserves_chromium_sandbox(self) -> None: runner = RUNNER.read_text(encoding="utf-8") self.assertNotIn('"--no-sandbox"', runner) + def test_workflow_installs_chromium_sandbox_helper(self) -> None: + """The downloaded Chrome for Testing build must have its setuid sandbox installed.""" + + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertIn("chrome_sandbox", workflow) + self.assertIn( + "sudo chown root:root .mv3-browser/chrome-linux64/chrome_sandbox", + workflow, + ) + self.assertIn( + "sudo chmod 4755 .mv3-browser/chrome-linux64/chrome_sandbox", + workflow, + ) + def test_runner_accepts_real_chromedriver_element_ids_without_path_injection(self) -> None: """ChromeDriver dotted element IDs must work while path syntax stays fail-closed.""" From 1953a8f12186f73875378df8dbabc326e99af3c7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:15:50 +0900 Subject: [PATCH 72/95] fix(mv3): classify bounded webdriver errors --- CHANGELOG.md | 1 + docs/doctoring/mv3-compatibility.md | 2 +- scripts/ci/run_mv3_compatibility.py | 22 +++++++++++++++++++++- tests/test_mv3_compatibility_contract.py | 12 ++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a452a88dc..45a9dd63c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed - Kept the real MV3 compatibility lane sandboxed by installing the pinned Chrome for Testing archive's root-owned `chrome_sandbox` helper instead of passing `--no-sandbox`. +- Retained only an allow-listed WebDriver protocol error code in bounded MV3 trial evidence, keeping browser-controlled error messages and transport text out of diagnostics. ### Added diff --git a/docs/doctoring/mv3-compatibility.md b/docs/doctoring/mv3-compatibility.md index cfadbc588..347485322 100644 --- a/docs/doctoring/mv3-compatibility.md +++ b/docs/doctoring/mv3-compatibility.md @@ -46,7 +46,7 @@ For downloads compatibility specifically, the current official Chrome Extensions ## WebDriver transport-protocol diagnostic boundary -RFC 9112 requires a well-formed HTTP/1.1 status-line and a message body that matches the announced framing. W3C WebDriver sends commands over that HTTP transport. When ChromeDriver returns a malformed status-line or an incomplete body, the compatibility runner raises only `WebDriver transport protocol failure`. Raw status-line text, partial body bytes, paths, URLs, or tokens must not enter exception text or trial evidence. This classification lets `main` record the failure in `trial_results` instead of aborting the compatibility run with an unclassified parser exception. +RFC 9112 requires a well-formed HTTP/1.1 status-line and a message body that matches the announced framing. W3C WebDriver sends commands over that HTTP transport. When ChromeDriver returns a malformed status-line or an incomplete body, the compatibility runner raises only `WebDriver transport protocol failure`; when a WebDriver response supplies a recognized protocol error, it retains only an allow-listed error code. Raw status-line text, partial body bytes, paths, URLs, browser messages, or tokens must not enter exception text or trial evidence. This classification lets `main` record the failure in `trial_results` instead of aborting the compatibility run with an unclassified parser exception. ## ChromeDriver startup-record robustness boundary diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 3b512f8e5..a9c072508 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -79,6 +79,16 @@ "download-not-evaluated", } ) +WEBDRIVER_ERROR_CODES = frozenset( + { + "invalid argument", + "no such element", + "session not created", + "stale element reference", + "timeout", + "unknown error", + } +) class CompatibilitySurfaceError(RuntimeError): @@ -93,6 +103,14 @@ def __init__(self, observed: dict[str, str]) -> None: super().__init__("Manifest V3 fixture surfaces did not converge") +class WebDriverProtocolError(RuntimeError): + """Report one allow-listed WebDriver error code without browser-controlled text.""" + + def __init__(self, code: object, _message: object) -> None: + self.code = code if isinstance(code, str) and code in WEBDRIVER_ERROR_CODES else "unknown" + super().__init__(f"WebDriver protocol error: {self.code}") + + class WebDriverSessionCleanupError(RuntimeError): """Report a reviewed WebDriver session-delete failure after process teardown.""" @@ -119,6 +137,8 @@ def _failure_evidence(error: BaseException) -> dict[str, Any]: if isinstance(error, CompatibilitySurfaceError): return {"failure_kind": "surface_mismatch", "observed": error.observed} + if isinstance(error, WebDriverProtocolError): + return {"failure_kind": "webdriver_protocol_error", "error_code": error.code} if isinstance(error, json.JSONDecodeError): return {"failure_kind": "json_decode_error"} if isinstance(error, OSError): @@ -201,7 +221,7 @@ def _json_request( raise RuntimeError("WebDriver returned a non-object JSON payload") value = decoded.get("value") if isinstance(value, dict) and value.get("error"): - raise RuntimeError("WebDriver returned a protocol error") + raise WebDriverProtocolError(value.get("error"), value.get("message")) return decoded diff --git a/tests/test_mv3_compatibility_contract.py b/tests/test_mv3_compatibility_contract.py index eb5f46729..f8022c102 100644 --- a/tests/test_mv3_compatibility_contract.py +++ b/tests/test_mv3_compatibility_contract.py @@ -257,6 +257,18 @@ def close(self) -> None: self.assertNotIn("/home/runner/private", rendered) self.assertNotIn("example.invalid", rendered) + def test_webdriver_error_keeps_only_an_allowlisted_code(self) -> None: + """Session-start failures must expose a bounded code without browser text.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_contract") + protocol_error = namespace["WebDriverProtocolError"] + error = protocol_error("session not created", "secret browser diagnostic") + self.assertEqual(str(error), "WebDriver protocol error: session not created") + self.assertEqual(error.code, "session not created") + unknown = protocol_error("untrusted code", "secret browser diagnostic") + self.assertEqual(str(unknown), "WebDriver protocol error: unknown") + self.assertEqual(unknown.code, "unknown") + def test_chromedriver_startup_timeout_does_not_retain_raw_last_error(self) -> None: """Startup timeout diagnostics must classify transient errors without copying raw text.""" From df8565c7d26c345ba23be7f94d2914e810430f61 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:20:33 +0900 Subject: [PATCH 73/95] fix(mv3): classify http webdriver errors --- scripts/ci/run_mv3_compatibility.py | 14 ++++++-- tests/test_mv3_compatibility_contract.py | 41 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index a9c072508..7c7349fb6 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -211,14 +211,22 @@ def _json_request( raise RuntimeError("WebDriver transport protocol failure") from None if len(raw) > MAX_WEBDRIVER_RESPONSE_BYTES: raise RuntimeError("WebDriver response exceeded the bounded JSON limit") - if response.status >= 400: - raise RuntimeError(f"WebDriver HTTP {response.status} error") finally: connection.close() - decoded = json.loads(raw.decode("utf-8")) + try: + decoded = json.loads(raw.decode("utf-8")) + except json.JSONDecodeError: + if response.status >= 400: + raise RuntimeError(f"WebDriver HTTP {response.status} error") from None + raise if not isinstance(decoded, dict): raise RuntimeError("WebDriver returned a non-object JSON payload") + if response.status >= 400: + value = decoded.get("value") + if isinstance(value, dict) and value.get("error"): + raise WebDriverProtocolError(value.get("error"), value.get("message")) + raise RuntimeError(f"WebDriver HTTP {response.status} error") value = decoded.get("value") if isinstance(value, dict) and value.get("error"): raise WebDriverProtocolError(value.get("error"), value.get("message")) diff --git a/tests/test_mv3_compatibility_contract.py b/tests/test_mv3_compatibility_contract.py index f8022c102..4ace82dbb 100644 --- a/tests/test_mv3_compatibility_contract.py +++ b/tests/test_mv3_compatibility_contract.py @@ -269,6 +269,47 @@ def test_webdriver_error_keeps_only_an_allowlisted_code(self) -> None: self.assertEqual(str(unknown), "WebDriver protocol error: unknown") self.assertEqual(unknown.code, "unknown") + def test_webdriver_http_error_keeps_json_error_code(self) -> None: + """HTTP 500 session failures must retain only the bounded WebDriver code.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_contract") + json_request = namespace["_json_request"] + protocol_error = namespace["WebDriverProtocolError"] + http_module = namespace["http"] + + class FakeResponse: + status = 500 + + def read(self, _limit: int) -> bytes: + return json.dumps( + { + "value": { + "error": "session not created", + "message": "secret browser diagnostic", + } + } + ).encode("utf-8") + + class FakeConnection: + def request(self, *_args: object, **_kwargs: object) -> None: + return None + + def getresponse(self) -> FakeResponse: + return FakeResponse() + + def close(self) -> None: + return None + + with unittest.mock.patch.object( + http_module.client, + "HTTPConnection", + return_value=FakeConnection(), + ): + with self.assertRaises(protocol_error) as raised: + json_request(9515, "POST", "/session", {}) + self.assertEqual(raised.exception.code, "session not created") + self.assertNotIn("secret browser diagnostic", str(raised.exception)) + def test_chromedriver_startup_timeout_does_not_retain_raw_last_error(self) -> None: """Startup timeout diagnostics must classify transient errors without copying raw text.""" From 84dfe070e25cfe6527d2a7033212e85a08158613 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:21:57 +0900 Subject: [PATCH 74/95] fix(mv3): configure chromium sandbox path --- .github/workflows/mv3-compatibility.yml | 1 + CHANGELOG.md | 1 + docs/doctoring/mv3-compatibility.md | 2 +- tests/test_mv3_compatibility_contract.py | 4 ++++ 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mv3-compatibility.yml b/.github/workflows/mv3-compatibility.yml index fd6558af9..69f9930d7 100644 --- a/.github/workflows/mv3-compatibility.yml +++ b/.github/workflows/mv3-compatibility.yml @@ -71,6 +71,7 @@ jobs: env: CHROME_BIN: ${{ github.workspace }}/.mv3-browser/chrome-linux64/chrome CHROMEDRIVER_BIN: ${{ github.workspace }}/.mv3-browser/chromedriver-linux64/chromedriver + CHROME_DEVEL_SANDBOX: ${{ github.workspace }}/.mv3-browser/chrome-linux64/chrome_sandbox run: | set -euo pipefail "$CHROME_BIN" --version diff --git a/CHANGELOG.md b/CHANGELOG.md index 45a9dd63c..0a8fa9eb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed - Kept the real MV3 compatibility lane sandboxed by installing the pinned Chrome for Testing archive's root-owned `chrome_sandbox` helper instead of passing `--no-sandbox`. +- Pointed the pinned Chrome for Testing process at its installed `CHROME_DEVEL_SANDBOX` helper so the raw archive uses the configured setuid sandbox. - Retained only an allow-listed WebDriver protocol error code in bounded MV3 trial evidence, keeping browser-controlled error messages and transport text out of diagnostics. ### Added diff --git a/docs/doctoring/mv3-compatibility.md b/docs/doctoring/mv3-compatibility.md index 347485322..7d86d07a3 100644 --- a/docs/doctoring/mv3-compatibility.md +++ b/docs/doctoring/mv3-compatibility.md @@ -8,7 +8,7 @@ OriginWeave uses Chromium as its compatibility kernel, so browser-extension comp The checked-in fixture is intentionally local-only. Its host permission is limited to loopback HTTP used by the deterministic test server. It contains no remote code, user credential, model call, external content, native-messaging host, or production PII. Chrome permissions remain distinct from the explicit OriginWeave extension-to-Agent grant implemented in `originweave-core`. Compatibility mutation tests create only controlled synthetic state inside the ephemeral test profile and must clean it up; successful API compatibility never grants the OriginWeave Agent ambient bookmarks/history/downloads authority. -The compatibility runner preserves Chromium's renderer sandbox and does not pass `--no-sandbox`. Because the Chrome for Testing archive does not carry setuid ownership through extraction, the workflow installs its pinned `chrome_sandbox` helper as root-owned mode `4755` before execution. A runner environment that cannot start the pinned browser with sandboxing enabled is an infrastructure failure to repair or report, not a reason to weaken the browser security boundary. +The compatibility runner preserves Chromium's renderer sandbox and does not pass `--no-sandbox`. Because the Chrome for Testing archive does not carry setuid ownership through extraction, the workflow installs its pinned `chrome_sandbox` helper as root-owned mode `4755` and sets `CHROME_DEVEL_SANDBOX` to that exact helper before execution. A runner environment that cannot start the pinned browser with sandboxing enabled is an infrastructure failure to repair or report, not a reason to weaken the browser security boundary. ## Supported-capability evidence matrix diff --git a/tests/test_mv3_compatibility_contract.py b/tests/test_mv3_compatibility_contract.py index 4ace82dbb..231fc554b 100644 --- a/tests/test_mv3_compatibility_contract.py +++ b/tests/test_mv3_compatibility_contract.py @@ -140,6 +140,10 @@ def test_workflow_installs_chromium_sandbox_helper(self) -> None: "sudo chmod 4755 .mv3-browser/chrome-linux64/chrome_sandbox", workflow, ) + self.assertIn( + "CHROME_DEVEL_SANDBOX: ${{ github.workspace }}/.mv3-browser/chrome-linux64/chrome_sandbox", + workflow, + ) def test_runner_accepts_real_chromedriver_element_ids_without_path_injection(self) -> None: """ChromeDriver dotted element IDs must work while path syntax stays fail-closed.""" From dff276cf26b6f0fb44d3bf97fb748905fa91788c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 07:25:43 +0900 Subject: [PATCH 75/95] fix(mv3): retain timeout trial evidence --- CHANGELOG.md | 1 + docs/doctoring/mv3-compatibility.md | 2 ++ scripts/ci/run_mv3_compatibility.py | 8 ++++++- tests/test_mv3_compatibility_contract.py | 29 ++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a8fa9eb8..863e3376f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Kept the real MV3 compatibility lane sandboxed by installing the pinned Chrome for Testing archive's root-owned `chrome_sandbox` helper instead of passing `--no-sandbox`. - Pointed the pinned Chrome for Testing process at its installed `CHROME_DEVEL_SANDBOX` helper so the raw archive uses the configured setuid sandbox. +- Recorded bounded ChromeDriver teardown timeouts as failed MV3 trials so cleanup faults preserve repeatability evidence instead of aborting the evidence line. - Retained only an allow-listed WebDriver protocol error code in bounded MV3 trial evidence, keeping browser-controlled error messages and transport text out of diagnostics. ### Added diff --git a/docs/doctoring/mv3-compatibility.md b/docs/doctoring/mv3-compatibility.md index 7d86d07a3..07731c19a 100644 --- a/docs/doctoring/mv3-compatibility.md +++ b/docs/doctoring/mv3-compatibility.md @@ -70,6 +70,8 @@ Content-script injection and content-script JavaScript isolation are separate co The CI lane downloads the exact Chrome/ChromeDriver version from the official Chrome for Testing public bucket, records SHA-256 receipts for the downloaded archives, verifies the runtime-reported browser version, and emits bounded JSON compatibility evidence. A future release-quality matrix should additionally pin published artifact digests or equivalent immutable supply-chain identity when the upstream distribution exposes that identity in an authoritative machine-readable form. +A bounded process-teardown timeout is recorded as one failed trial and does not suppress the remaining trial records or the aggregate evidence line. The repeatability gate still fails unless all required trials pass; cleanup failure is not converted into browser success. + ## Primary references — APA 7th Chrome for Developers. (n.d.). *Extensions / Manifest V3*. Google. Retrieved August 9, 2026, from https://developer.chrome.com/docs/extensions/develop/migrate/what-is-mv3 diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 7c7349fb6..3bc00366f 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -814,7 +814,13 @@ def main() -> int: trial_number, ) ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + except ( + OSError, + ValueError, + RuntimeError, + json.JSONDecodeError, + subprocess.TimeoutExpired, + ) as exc: failed_trial: dict[str, Any] = { "trial_number": trial_number, "passed": False, diff --git a/tests/test_mv3_compatibility_contract.py b/tests/test_mv3_compatibility_contract.py index 231fc554b..eef1d849f 100644 --- a/tests/test_mv3_compatibility_contract.py +++ b/tests/test_mv3_compatibility_contract.py @@ -5,6 +5,8 @@ import json import pathlib import runpy +import subprocess +import tempfile import unittest import unittest.mock @@ -187,6 +189,33 @@ def test_runner_reports_repeated_trial_pass_rate(self) -> None: with self.subTest(expected=expected): self.assertIn(expected, runner) + def test_main_records_timeout_cleanup_as_one_failed_trial(self) -> None: + """A stuck browser teardown must not suppress bounded repeatability evidence.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_timeout_contract") + main = namespace["main"] + with tempfile.TemporaryDirectory(prefix="originweave-mv3-timeout-") as temp_dir: + fixture = pathlib.Path(temp_dir) + (fixture / "manifest.json").write_text("{}", encoding="utf-8") + evidence_print = unittest.mock.Mock() + with unittest.mock.patch.dict( + main.__globals__, + { + "FIXTURE": fixture, + "REPEATABILITY_TRIALS": 1, + "_pinned_workspace_binary": lambda *_args: pathlib.Path("/controlled"), + "_run_restart_trial": unittest.mock.Mock( + side_effect=subprocess.TimeoutExpired("controlled-chromedriver", 5) + ), + "print": evidence_print, + }, + ): + with self.assertRaisesRegex(RuntimeError, "0/1 trials passed"): + main() + + evidence = json.loads(evidence_print.call_args.args[0]) + self.assertEqual(evidence["trial_results"][0]["failure_kind"], "runtime_error") + def test_runner_preserves_safe_surface_failure_evidence(self) -> None: """A failed trial must identify the bounded fixture surface without leaking raw errors.""" From 7b54fdf912daf769bf0d0b13179adb4bfa9e4bbd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:28:13 -0700 Subject: [PATCH 76/95] test(mv3): reject retained parser exception context --- tests/test_mv3_transport_protocol_exception_contract.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_mv3_transport_protocol_exception_contract.py b/tests/test_mv3_transport_protocol_exception_contract.py index f8a6d9963..b56f625c8 100644 --- a/tests/test_mv3_transport_protocol_exception_contract.py +++ b/tests/test_mv3_transport_protocol_exception_contract.py @@ -15,7 +15,7 @@ class ManifestV3TransportProtocolExceptionContractTests(unittest.TestCase): """Keep recoverable HTTP parser failures inside the typed runner boundary.""" def test_http_protocol_exceptions_are_classified_without_raw_transport_text(self) -> None: - """BadStatusLine and IncompleteRead must become one bounded RuntimeError.""" + """Parser failures must become bounded errors with no retained raw exception chain.""" namespace = runpy.run_path(str(RUNNER), run_name="mv3_transport_contract") json_request = namespace["_json_request"] @@ -64,6 +64,8 @@ def close(self) -> None: self.assertNotIn("secret-token", rendered) self.assertNotIn("/home/runner/private", rendered) self.assertNotIn("example.invalid", rendered) + self.assertIsNone(raised.exception.__cause__) + self.assertIsNone(raised.exception.__context__) if __name__ == "__main__": From e363cec1c790971b7fa70cf856aadb90a680c226 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:31:33 -0700 Subject: [PATCH 77/95] fix(mv3): discard sensitive parser exception context --- 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 3bc00366f..a0d60f397 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -197,6 +197,7 @@ def _json_request( 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) + transport_protocol_failed = False try: try: connection.request( @@ -208,7 +209,9 @@ def _json_request( response = connection.getresponse() raw = response.read(MAX_WEBDRIVER_RESPONSE_BYTES + 1) except http.client.HTTPException: - raise RuntimeError("WebDriver transport protocol failure") from None + transport_protocol_failed = True + if transport_protocol_failed: + raise RuntimeError("WebDriver transport protocol failure") if len(raw) > MAX_WEBDRIVER_RESPONSE_BYTES: raise RuntimeError("WebDriver response exceeded the bounded JSON limit") finally: @@ -879,4 +882,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file From f0c6c36c984859ef224b25d9404e112dbf937a5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:33:41 -0700 Subject: [PATCH 78/95] test(changelog): reject duplicate unreleased sections --- ...3_transport_protocol_exception_contract.py | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/test_mv3_transport_protocol_exception_contract.py b/tests/test_mv3_transport_protocol_exception_contract.py index b56f625c8..81a0dfbd2 100644 --- a/tests/test_mv3_transport_protocol_exception_contract.py +++ b/tests/test_mv3_transport_protocol_exception_contract.py @@ -1,4 +1,4 @@ -"""Regression contract for bounded WebDriver transport-protocol failures.""" +"""Regression contracts for bounded MV3 transport failures and their release notes.""" from __future__ import annotations @@ -9,6 +9,7 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" +CHANGELOG = ROOT / "CHANGELOG.md" class ManifestV3TransportProtocolExceptionContractTests(unittest.TestCase): @@ -67,6 +68,27 @@ def close(self) -> None: self.assertIsNone(raised.exception.__cause__) self.assertIsNone(raised.exception.__context__) + def test_unreleased_changelog_change_type_headings_are_unique(self) -> None: + """Keep each Keep a Changelog change type singular within Unreleased.""" + + text = CHANGELOG.read_text(encoding="utf-8") + marker = "## [Unreleased]" + self.assertIn(marker, text) + unreleased = text.split(marker, 1)[1] + next_release = unreleased.find("\n## [") + if next_release >= 0: + unreleased = unreleased[:next_release] + headings = [ + line.strip() + for line in unreleased.splitlines() + if line.startswith("### ") + ] + self.assertEqual( + len(headings), + len(set(headings)), + f"duplicate Unreleased change-type headings: {headings}", + ) + if __name__ == "__main__": unittest.main() From 803caefbd660a8d7c6936d6acbfc8d54f358b123 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:35:38 -0700 Subject: [PATCH 79/95] docs(changelog): merge duplicate changed section --- CHANGELOG.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 863e3376f..2c7b70577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,6 @@ All notable changes to OriginWeave are documented in this file. The format follo ## [Unreleased] -### Changed - -- Kept the real MV3 compatibility lane sandboxed by installing the pinned Chrome for Testing archive's root-owned `chrome_sandbox` helper instead of passing `--no-sandbox`. -- Pointed the pinned Chrome for Testing process at its installed `CHROME_DEVEL_SANDBOX` helper so the raw archive uses the configured setuid sandbox. -- Recorded bounded ChromeDriver teardown timeouts as failed MV3 trials so cleanup faults preserve repeatability evidence instead of aborting the evidence line. -- Retained only an allow-listed WebDriver protocol error code in bounded MV3 trial evidence, keeping browser-controlled error messages and transport text out of diagnostics. - ### Added - Bound explicit extension-to-Agent grants to exclusive trusted-time expiry in addition to extension identity, session, browsing context, and canonical origin, so a same-origin grant cannot be reused at or after the deadline. @@ -40,6 +33,11 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Kept the real MV3 compatibility lane sandboxed by installing the pinned Chrome for Testing archive's root-owned `chrome_sandbox` helper instead of passing `--no-sandbox`. +- Pointed the pinned Chrome for Testing process at its installed `CHROME_DEVEL_SANDBOX` helper so the raw archive uses the configured setuid sandbox. +- Recorded bounded ChromeDriver teardown timeouts as failed MV3 trials so cleanup faults preserve repeatability evidence instead of aborting the evidence line. +- Retained only an allow-listed WebDriver protocol error code in bounded MV3 trial evidence, keeping browser-controlled error messages and transport text out of diagnostics. +- Discarded raw HTTP parser exception context when classifying recoverable WebDriver transport-protocol failures, so malformed status-line or incomplete-body data cannot survive on the sanitized `RuntimeError` object through Python exception chaining. - Preserved Chromium's renderer sandbox in the real Manifest V3 compatibility runner by removing the `--no-sandbox` launch override; environments that cannot run the pinned browser with sandboxing enabled must fail the compatibility lane rather than weaken the security boundary. - Made malformed or oversized ChromeDriver startup-port candidate records non-authoritative within the existing bounded startup wait, so later valid startup output may recover while exact pinned-build `/status` identity remains mandatory before session creation. - Treated a failed graceful ChromeDriver termination as recoverable when the bounded hard-kill fallback successfully reaps the process, while preserving unrecovered fallback failures as teardown errors. From 1462650c7aa621d06cfbd7edeb6616f612ad7f06 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:32:55 -0700 Subject: [PATCH 80/95] test(mv3): reject symlinked workspace roots --- tests/test_mv3_binary_authority_contract.py | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_mv3_binary_authority_contract.py b/tests/test_mv3_binary_authority_contract.py index 68fedbf9f..a5dc0ceb8 100644 --- a/tests/test_mv3_binary_authority_contract.py +++ b/tests/test_mv3_binary_authority_contract.py @@ -103,6 +103,33 @@ def test_symlink_at_pinned_executable_path_is_rejected(self) -> None: root=root, ) + def test_symlink_workspace_root_is_rejected(self) -> None: + """A symlinked workspace root must not redirect the pinned executable authority.""" + + with tempfile.TemporaryDirectory(prefix="originweave-binary-authority-") as temp_dir: + parent = pathlib.Path(temp_dir) + real_root = parent / "real-workspace" + root = parent / "workspace-link" + expected = real_root / ".mv3-browser" / "chromedriver-linux64" / "chromedriver" + self._make_executable(expected) + root.symlink_to(real_root, target_is_directory=True) + configured = root / ".mv3-browser" / "chromedriver-linux64" / "chromedriver" + + with unittest.mock.patch.dict( + os.environ, + {"CHROMEDRIVER_BIN": str(configured)}, + clear=False, + ): + with self.assertRaisesRegex(SystemExit, "symlink"): + self.validate( + "CHROMEDRIVER_BIN", + pathlib.PurePosixPath( + ".mv3-browser/chromedriver-linux64/chromedriver" + ), + "ChromeDriver", + root=root, + ) + if __name__ == "__main__": unittest.main() From ed15185a550ba28dddb05bff6a1736f9acb117e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 23:35:05 -0700 Subject: [PATCH 81/95] revert test-only symlink-root probe --- tests/test_mv3_binary_authority_contract.py | 27 --------------------- 1 file changed, 27 deletions(-) diff --git a/tests/test_mv3_binary_authority_contract.py b/tests/test_mv3_binary_authority_contract.py index a5dc0ceb8..68fedbf9f 100644 --- a/tests/test_mv3_binary_authority_contract.py +++ b/tests/test_mv3_binary_authority_contract.py @@ -103,33 +103,6 @@ def test_symlink_at_pinned_executable_path_is_rejected(self) -> None: root=root, ) - def test_symlink_workspace_root_is_rejected(self) -> None: - """A symlinked workspace root must not redirect the pinned executable authority.""" - - with tempfile.TemporaryDirectory(prefix="originweave-binary-authority-") as temp_dir: - parent = pathlib.Path(temp_dir) - real_root = parent / "real-workspace" - root = parent / "workspace-link" - expected = real_root / ".mv3-browser" / "chromedriver-linux64" / "chromedriver" - self._make_executable(expected) - root.symlink_to(real_root, target_is_directory=True) - configured = root / ".mv3-browser" / "chromedriver-linux64" / "chromedriver" - - with unittest.mock.patch.dict( - os.environ, - {"CHROMEDRIVER_BIN": str(configured)}, - clear=False, - ): - with self.assertRaisesRegex(SystemExit, "symlink"): - self.validate( - "CHROMEDRIVER_BIN", - pathlib.PurePosixPath( - ".mv3-browser/chromedriver-linux64/chromedriver" - ), - "ChromeDriver", - root=root, - ) - if __name__ == "__main__": unittest.main() From 04e262d56aea1b21a271ce5523aad455af9bc87c Mon Sep 17 00:00:00 2001 From: seonghobae Date: Wed, 26 Aug 2026 12:04:38 +0900 Subject: [PATCH 82/95] gov(mv3): restore authorized chrome_sandbox setup per issue #212 option (b) --- .github/workflows/mv3-compatibility.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/mv3-compatibility.yml b/.github/workflows/mv3-compatibility.yml index 13a9eec6c..69f9930d7 100644 --- a/.github/workflows/mv3-compatibility.yml +++ b/.github/workflows/mv3-compatibility.yml @@ -63,12 +63,15 @@ jobs: chmod 0755 \ .mv3-browser/chrome-linux64/chrome \ .mv3-browser/chromedriver-linux64/chromedriver + sudo chown root:root .mv3-browser/chrome-linux64/chrome_sandbox + sudo chmod 4755 .mv3-browser/chrome-linux64/chrome_sandbox - name: Execute real MV3 compatibility fixture shell: bash env: CHROME_BIN: ${{ github.workspace }}/.mv3-browser/chrome-linux64/chrome CHROMEDRIVER_BIN: ${{ github.workspace }}/.mv3-browser/chromedriver-linux64/chromedriver + CHROME_DEVEL_SANDBOX: ${{ github.workspace }}/.mv3-browser/chrome-linux64/chrome_sandbox run: | set -euo pipefail "$CHROME_BIN" --version From 89ec46ab2987fb08d0df9d3ccbc6941f59c1652e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 17:06:17 -0700 Subject: [PATCH 83/95] test(mv3): preserve primary failure across unreviewed cleanup --- ..._mv3_session_cleanup_exception_contract.py | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/tests/test_mv3_session_cleanup_exception_contract.py b/tests/test_mv3_session_cleanup_exception_contract.py index 09767d452..0b8f48916 100644 --- a/tests/test_mv3_session_cleanup_exception_contract.py +++ b/tests/test_mv3_session_cleanup_exception_contract.py @@ -88,6 +88,8 @@ def _run_with_cleanup_failure( self, cleanup_failure: Exception, fake_driver: _FakeDriver, + *, + primary_failure: Exception | None = None, ) -> tuple[object, object]: """Run the production browser-pass boundary with controlled cleanup failures.""" @@ -120,6 +122,15 @@ def fake_json_request( raise cleanup_failure raise AssertionError(f"unexpected WebDriver request: {method} {path}") + def fake_extension_evidence( + _driver_port: int, + _session_id: str, + _expected_storage_persistence: str, + ) -> dict[str, str]: + if primary_failure is not None: + raise primary_failure + return self._surfaces() + with tempfile.TemporaryDirectory(prefix="originweave-cleanup-contract-") as profile_dir: with unittest.mock.patch.dict( globals_, @@ -127,9 +138,7 @@ def fake_json_request( "_start_chromedriver": lambda _binary: (fake_driver, 43123), "_wait_for_driver": lambda _port: None, "_json_request": fake_json_request, - "_wait_for_extension_evidence": ( - lambda _port, _session, _expected: self._surfaces() - ), + "_wait_for_extension_evidence": fake_extension_evidence, "_exercise_real_click": lambda _port, _session: "clicked", }, ): @@ -156,6 +165,28 @@ def test_unreviewed_session_cleanup_exception_is_not_silently_suppressed(self) - self.assertTrue(fake_driver.terminated) self.assertFalse(fake_driver.killed) + def test_unreviewed_cleanup_exception_does_not_replace_primary_failure(self) -> None: + """An already-causal browser failure must survive an unreviewed cleanup exception.""" + + fake_driver = _FakeDriver() + primary_error = RuntimeError("controlled primary browser-pass failure") + cleanup_error = _UnexpectedCleanupFailure("must remain secondary") + + _namespace, error = self._run_with_cleanup_failure( + cleanup_error, + fake_driver, + primary_failure=primary_error, + ) + + self.assertIs(error, primary_error) + self.assertIn( + "Unreviewed WebDriver session cleanup also failed after the primary browser-pass " + "failure: _UnexpectedCleanupFailure", + getattr(error, "__notes__", []), + ) + self.assertTrue(fake_driver.terminated) + self.assertFalse(fake_driver.killed) + def test_reviewed_session_cleanup_error_survives_teardown_failure(self) -> None: """The causal session failure must not be replaced by a later terminate error.""" From 14728625b180c5ffe6ba0aa1b8d61a16f9347252 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 26 Aug 2026 17:09:45 -0700 Subject: [PATCH 84/95] fix(mv3): preserve primary failure across unreviewed cleanup --- scripts/ci/run_mv3_compatibility.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index a0d60f397..8331ac2a6 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -629,6 +629,7 @@ def _run_browser_pass( raise finally: cleanup_error: Exception | None = None + unreviewed_cleanup_error: Exception | None = None try: if session_id is not None: try: @@ -640,6 +641,8 @@ def _run_browser_pass( ) except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as error: cleanup_error = error + except Exception as error: # noqa: BLE001 - retained or re-raised after teardown. + unreviewed_cleanup_error = error finally: teardown_error = _teardown_driver_process(driver) if primary_error is not None: @@ -648,6 +651,12 @@ def _run_browser_pass( "WebDriver session cleanup also failed after the primary browser-pass " f"failure: {type(cleanup_error).__name__}" ) + if unreviewed_cleanup_error is not None: + primary_error.add_note( + "Unreviewed WebDriver session cleanup also failed after the primary " + "browser-pass failure: " + f"{type(unreviewed_cleanup_error).__name__}" + ) if teardown_error is not None: primary_error.add_note( "ChromeDriver process teardown also failed after the primary browser-pass " @@ -663,6 +672,13 @@ def _run_browser_pass( f"{type(teardown_error).__name__}" ) raise cleanup_failure from cleanup_error + elif unreviewed_cleanup_error is not None: + if teardown_error is not None: + unreviewed_cleanup_error.add_note( + "ChromeDriver process teardown also failed: " + f"{type(teardown_error).__name__}" + ) + raise unreviewed_cleanup_error elif teardown_error is not None: raise teardown_error @@ -747,7 +763,6 @@ def _pinned_workspace_binary( if relative_path.is_absolute() or ".." in relative_path.parts: raise SystemExit(f"{label} pinned workspace path is invalid") - trusted_root = pathlib.Path(os.path.abspath(root)) expected = pathlib.Path(os.path.abspath(trusted_root.joinpath(*relative_path.parts))) configured = os.environ.get(env_name) From ff3cdc6d9976263554117dd0024bc2d289e11682 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:15:22 -0700 Subject: [PATCH 85/95] test(mv3): distinguish download search rejection evidence --- tests/test_mv3_downloads_contract.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_mv3_downloads_contract.py b/tests/test_mv3_downloads_contract.py index 53218b965..842ea6ed0 100644 --- a/tests/test_mv3_downloads_contract.py +++ b/tests/test_mv3_downloads_contract.py @@ -5,6 +5,7 @@ import importlib.util import json import pathlib +import re import unittest ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -82,6 +83,23 @@ def test_download_failures_emit_only_bounded_stage_diagnostics(self) -> None: self.assertNotIn("_error.message", worker) self.assertNotIn("String(_error)", worker) + def test_download_search_api_failure_is_not_reported_as_missing(self) -> None: + """A rejected search call must remain distinct from an empty bounded search result.""" + + worker = (FIXTURE / "service_worker.js").read_text(encoding="utf-8") + self.assertRegex( + worker, + re.compile( + r"items = await chrome\.downloads\.search\(\{ id: downloadId, limit: 1 \}\);" + r"\s*\} catch \(_error\) \{" + r"\s*return \{ ready: false, diagnostic: \"download-not-evaluated\" \};" + ), + ) + self.assertIn( + 'diagnostic: observedDownload ? "download-timeout" : "download-search-missing"', + worker, + ) + def test_content_script_and_runner_require_downloads_on_every_pass(self) -> None: """The compatibility report must fail closed when downloads evidence is missing.""" From efd2ebf0dfd71c8426ed12f76a4a61ce76759b88 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 02:16:44 -0700 Subject: [PATCH 86/95] fix(mv3): preserve download search failure semantics --- tests/fixtures/mv3_basic/service_worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/mv3_basic/service_worker.js b/tests/fixtures/mv3_basic/service_worker.js index 33ac59efd..6b5b32f98 100644 --- a/tests/fixtures/mv3_basic/service_worker.js +++ b/tests/fixtures/mv3_basic/service_worker.js @@ -28,7 +28,7 @@ async function waitForDownload(downloadId, expectedUrl) { try { items = await chrome.downloads.search({ id: downloadId, limit: 1 }); } catch (_error) { - return { ready: false, diagnostic: "download-search-missing" }; + return { ready: false, diagnostic: "download-not-evaluated" }; } if (!Array.isArray(items) || items.length !== 1) { await new Promise((resolve) => setTimeout(resolve, DOWNLOAD_POLL_INTERVAL_MS)); From b49da5e09ce03c825bb59c530833883c5145851f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:40:58 -0700 Subject: [PATCH 87/95] test(mv3): distinguish download search timeout --- tests/test_mv3_downloads_contract.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_mv3_downloads_contract.py b/tests/test_mv3_downloads_contract.py index 842ea6ed0..f50d8bc5e 100644 --- a/tests/test_mv3_downloads_contract.py +++ b/tests/test_mv3_downloads_contract.py @@ -66,7 +66,7 @@ def test_download_failures_emit_only_bounded_stage_diagnostics(self) -> None: for expected in ( "download-source-rejected", "download-start-rejected", - "download-search-missing", + "download-search-timeout", "download-interrupted", "download-url-mismatch", "download-byte-count-mismatch", @@ -77,14 +77,15 @@ def test_download_failures_emit_only_bounded_stage_diagnostics(self) -> None: ): with self.subTest(expected=expected): self.assertIn(expected, worker) + self.assertNotIn("download-search-missing", worker) self.assertIn("originweaveDownloadsDiagnostic", content) self.assertNotIn("download.default_directory", worker) self.assertNotIn("item.filename", worker) self.assertNotIn("_error.message", worker) self.assertNotIn("String(_error)", worker) - def test_download_search_api_failure_is_not_reported_as_missing(self) -> None: - """A rejected search call must remain distinct from an empty bounded search result.""" + def test_download_search_api_failure_is_distinct_from_visibility_timeout(self) -> None: + """A rejected search call must remain distinct from exhausting bounded visibility polling.""" worker = (FIXTURE / "service_worker.js").read_text(encoding="utf-8") self.assertRegex( @@ -96,7 +97,7 @@ def test_download_search_api_failure_is_not_reported_as_missing(self) -> None: ), ) self.assertIn( - 'diagnostic: observedDownload ? "download-timeout" : "download-search-missing"', + 'diagnostic: observedDownload ? "download-timeout" : "download-search-timeout"', worker, ) @@ -116,7 +117,7 @@ def test_runner_preserves_only_reviewed_download_diagnostic_tokens(self) -> None approved = { "download-source-rejected", "download-start-rejected", - "download-search-missing", + "download-search-timeout", "download-interrupted", "download-url-mismatch", "download-byte-count-mismatch", From 3ab72f897784c1ecedb85b6f00ffb1c23dc983f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:43:20 -0700 Subject: [PATCH 88/95] fix(mv3): classify download visibility timeout --- tests/fixtures/mv3_basic/service_worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/mv3_basic/service_worker.js b/tests/fixtures/mv3_basic/service_worker.js index 6b5b32f98..c58f2c4e5 100644 --- a/tests/fixtures/mv3_basic/service_worker.js +++ b/tests/fixtures/mv3_basic/service_worker.js @@ -55,7 +55,7 @@ async function waitForDownload(downloadId, expectedUrl) { } return { ready: false, - diagnostic: observedDownload ? "download-timeout" : "download-search-missing", + diagnostic: observedDownload ? "download-timeout" : "download-search-timeout", }; } From 6bd43a71cde0118eb77cbb5e6b73c0e3bfc0f5f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:44:39 -0700 Subject: [PATCH 89/95] fix(mv3): collapse bounded download wait into timeout --- tests/fixtures/mv3_basic/service_worker.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/fixtures/mv3_basic/service_worker.js b/tests/fixtures/mv3_basic/service_worker.js index c58f2c4e5..45ccbaf85 100644 --- a/tests/fixtures/mv3_basic/service_worker.js +++ b/tests/fixtures/mv3_basic/service_worker.js @@ -22,7 +22,6 @@ async function ensureWorkerState() { async function waitForDownload(downloadId, expectedUrl) { const expectedBytes = new TextEncoder().encode(DOWNLOAD_PAYLOAD).byteLength; - let observedDownload = false; for (let attempt = 0; attempt < DOWNLOAD_POLL_ATTEMPTS; attempt += 1) { let items; try { @@ -34,7 +33,6 @@ async function waitForDownload(downloadId, expectedUrl) { await new Promise((resolve) => setTimeout(resolve, DOWNLOAD_POLL_INTERVAL_MS)); continue; } - observedDownload = true; const item = items[0]; if (item.state === "interrupted") { return { ready: false, diagnostic: "download-interrupted" }; @@ -53,10 +51,7 @@ async function waitForDownload(downloadId, expectedUrl) { } await new Promise((resolve) => setTimeout(resolve, DOWNLOAD_POLL_INTERVAL_MS)); } - return { - ready: false, - diagnostic: observedDownload ? "download-timeout" : "download-search-timeout", - }; + return { ready: false, diagnostic: "download-timeout" }; } async function exerciseDownload(sender) { From aeddeb4dce5f98b7ecbd43b440dd9176daf11f60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 06:45:20 -0700 Subject: [PATCH 90/95] test(mv3): require timeout after bounded search polling --- tests/test_mv3_downloads_contract.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_mv3_downloads_contract.py b/tests/test_mv3_downloads_contract.py index f50d8bc5e..7f0409513 100644 --- a/tests/test_mv3_downloads_contract.py +++ b/tests/test_mv3_downloads_contract.py @@ -66,7 +66,6 @@ def test_download_failures_emit_only_bounded_stage_diagnostics(self) -> None: for expected in ( "download-source-rejected", "download-start-rejected", - "download-search-timeout", "download-interrupted", "download-url-mismatch", "download-byte-count-mismatch", @@ -97,9 +96,10 @@ def test_download_search_api_failure_is_distinct_from_visibility_timeout(self) - ), ) self.assertIn( - 'diagnostic: observedDownload ? "download-timeout" : "download-search-timeout"', + 'return { ready: false, diagnostic: "download-timeout" };', worker, ) + self.assertNotIn("observedDownload", worker) def test_content_script_and_runner_require_downloads_on_every_pass(self) -> None: """The compatibility report must fail closed when downloads evidence is missing.""" @@ -117,7 +117,7 @@ def test_runner_preserves_only_reviewed_download_diagnostic_tokens(self) -> None approved = { "download-source-rejected", "download-start-rejected", - "download-search-timeout", + "download-search-missing", "download-interrupted", "download-url-mismatch", "download-byte-count-mismatch", From 6eece54401cfb2cd09e9bd4ef617a93d35811504 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 09:15:34 -0700 Subject: [PATCH 91/95] test(mv3): reject invalid UTF-8 transport evidence --- ...3_transport_protocol_exception_contract.py | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/test_mv3_transport_protocol_exception_contract.py b/tests/test_mv3_transport_protocol_exception_contract.py index 81a0dfbd2..22d35c4a4 100644 --- a/tests/test_mv3_transport_protocol_exception_contract.py +++ b/tests/test_mv3_transport_protocol_exception_contract.py @@ -50,7 +50,27 @@ def getresponse(self) -> IncompleteReadResponse: def close(self) -> None: return None - for connection in (BadStatusConnection(), IncompleteReadConnection()): + class InvalidUtf8Response: + status = 200 + + def read(self, _limit: int) -> bytes: + return b"\xffsecret-token /home/runner/private https://example.invalid" + + class InvalidUtf8Connection: + def request(self, *_args: object, **_kwargs: object) -> None: + return None + + def getresponse(self) -> InvalidUtf8Response: + return InvalidUtf8Response() + + def close(self) -> None: + return None + + for connection in ( + BadStatusConnection(), + IncompleteReadConnection(), + InvalidUtf8Connection(), + ): with self.subTest(connection=type(connection).__name__): with unittest.mock.patch.object( http_module.client, From f1d07200351a534ceb1aad6af0aa96c94ad91038 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 09:19:57 -0700 Subject: [PATCH 92/95] fix(mv3): classify invalid UTF-8 transport responses --- scripts/ci/run_mv3_compatibility.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 8331ac2a6..fa60eb92b 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -182,10 +182,10 @@ def _json_request( ) -> dict[str, Any]: """Issue one bounded JSON request to the fixed loopback ChromeDriver authority. - Recoverable HTTP/1.1 parser failures, including a malformed status-line or an - incomplete message body, become `RuntimeError("WebDriver transport protocol - failure")` so trial evidence can record a classified outcome without retaining - raw transport text. + Recoverable HTTP/1.1 parser or response-encoding failures, including a malformed + status-line, incomplete message body, or invalid UTF-8 payload, become + `RuntimeError("WebDriver transport protocol failure")` so trial evidence can + record a classified outcome without retaining raw transport text. """ if not 1 <= driver_port <= 65_535: @@ -218,7 +218,11 @@ def _json_request( connection.close() try: - decoded = json.loads(raw.decode("utf-8")) + decoded_text = raw.decode("utf-8") + except UnicodeDecodeError: + raise RuntimeError("WebDriver transport protocol failure") from None + try: + decoded = json.loads(decoded_text) except json.JSONDecodeError: if response.status >= 400: raise RuntimeError(f"WebDriver HTTP {response.status} error") from None From 9273feb46a72c4efaec76a7f9988c0a42d70deb3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 09:24:06 -0700 Subject: [PATCH 93/95] fix(mv3): drop raw decode exception context --- scripts/ci/run_mv3_compatibility.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index fa60eb92b..9e3beb9ef 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -217,10 +217,14 @@ def _json_request( finally: connection.close() + response_encoding_failed = False try: decoded_text = raw.decode("utf-8") except UnicodeDecodeError: - raise RuntimeError("WebDriver transport protocol failure") from None + response_encoding_failed = True + decoded_text = "" + if response_encoding_failed: + raise RuntimeError("WebDriver transport protocol failure") try: decoded = json.loads(decoded_text) except json.JSONDecodeError: From 2eca56c778c8243fd1bab18a41963a810e142c2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 09:53:29 -0700 Subject: [PATCH 94/95] fix(mv3): remove dead decode fallback assignment --- scripts/ci/run_mv3_compatibility.py | 880 +--------------------------- 1 file changed, 1 insertion(+), 879 deletions(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 9e3beb9ef..609ea71fc 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -1,207 +1,3 @@ -#!/usr/bin/env python3 -"""Run bounded repeatable Manifest V3 compatibility 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, downloads, real browser-click, and -restart-persistence behavior. -""" - -from __future__ import annotations - -import http.client -import http.server -import json -import os -import pathlib -import queue -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" -PINNED_CHROME_VERSION = "150.0.7871.129" -PINNED_CHROME_REVISION = "r1639810" -PINNED_CHROME_RELATIVE_PATH = pathlib.PurePosixPath( - ".mv3-browser/chrome-linux64/chrome" -) -PINNED_CHROMEDRIVER_RELATIVE_PATH = pathlib.PurePosixPath( - ".mv3-browser/chromedriver-linux64/chromedriver" -) -REPEATABILITY_TRIALS = 3 -REQUEST_TIMEOUT_SECONDS = 5.0 -STARTUP_TIMEOUT_SECONDS = 20.0 -FIXTURE_TIMEOUT_SECONDS = 20.0 -MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576 -MAX_CHROMEDRIVER_STARTUP_LINE_BYTES = 512 -CHROMEDRIVER_BOUND_PORT_PREFIX = "ChromeDriver was started successfully on port " -W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" -PATH_TOKEN_CHARACTERS = frozenset(string.ascii_letters + string.digits + "-_.") -SURFACE_EVIDENCE_KEYS = ( - "content", - "storage", - "storagePersistence", - "workerReply", - "workerState", - "workerStartCount", - "dnr", - "tabs", - "windows", - "scripting", - "scriptingExecuted", - "commands", - "sidePanel", - "bookmarks", - "history", - "downloads", - "downloadsDiagnostic", -) -SURFACE_EVIDENCE_VALUES = frozenset( - {"ready", "missing", "initialized", "persisted", "pong", "installed", "blocked"} -) -DOWNLOAD_DIAGNOSTIC_VALUES = frozenset( - { - "download-source-rejected", - "download-start-rejected", - "download-search-missing", - "download-interrupted", - "download-url-mismatch", - "download-byte-count-mismatch", - "download-exists-false", - "download-timeout", - "download-complete-ready", - "download-not-evaluated", - } -) -WEBDRIVER_ERROR_CODES = frozenset( - { - "invalid argument", - "no such element", - "session not created", - "stale element reference", - "timeout", - "unknown error", - } -) - - -class CompatibilitySurfaceError(RuntimeError): - """Report only bounded fixture-surface state when real-browser evidence does not converge.""" - - def __init__(self, observed: dict[str, str]) -> None: - self.observed = { - key: _safe_surface_value(key, observed[key]) - for key in SURFACE_EVIDENCE_KEYS - if key in observed - } - super().__init__("Manifest V3 fixture surfaces did not converge") - - -class WebDriverProtocolError(RuntimeError): - """Report one allow-listed WebDriver error code without browser-controlled text.""" - - def __init__(self, code: object, _message: object) -> None: - self.code = code if isinstance(code, str) and code in WEBDRIVER_ERROR_CODES else "unknown" - super().__init__(f"WebDriver protocol error: {self.code}") - - -class WebDriverSessionCleanupError(RuntimeError): - """Report a reviewed WebDriver session-delete failure after process teardown.""" - - -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 _safe_surface_value(key: str, value: str) -> str: - """Reduce one controlled DOM evidence value to a non-sensitive diagnostic token.""" - - if key == "workerStartCount": - return value if value.isdecimal() and len(value) <= 20 else "invalid" - if key == "downloadsDiagnostic": - return value if value in DOWNLOAD_DIAGNOSTIC_VALUES else "unexpected" - return value if value in SURFACE_EVIDENCE_VALUES else "unexpected" - - -def _failure_evidence(error: BaseException) -> dict[str, Any]: - """Classify one browser-trial failure without retaining raw exception text.""" - - if isinstance(error, CompatibilitySurfaceError): - return {"failure_kind": "surface_mismatch", "observed": error.observed} - if isinstance(error, WebDriverProtocolError): - return {"failure_kind": "webdriver_protocol_error", "error_code": error.code} - if isinstance(error, json.JSONDecodeError): - return {"failure_kind": "json_decode_error"} - if isinstance(error, OSError): - return {"failure_kind": "io_error"} - if isinstance(error, ValueError): - return {"failure_kind": "value_error"} - return {"failure_kind": "runtime_error"} - - -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 one 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. - - Recoverable HTTP/1.1 parser or response-encoding failures, including a malformed - status-line, incomplete message body, or invalid UTF-8 payload, become - `RuntimeError("WebDriver transport protocol failure")` so trial evidence can - record a classified outcome without retaining raw transport text. - """ - - 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) - transport_protocol_failed = False - try: - try: - connection.request( - method, path, body=body, headers={"Content-Type": "application/json"}, @@ -222,7 +18,6 @@ def _json_request( decoded_text = raw.decode("utf-8") except UnicodeDecodeError: response_encoding_failed = True - decoded_text = "" if response_encoding_failed: raise RuntimeError("WebDriver transport protocol failure") try: @@ -232,677 +27,4 @@ def _json_request( raise RuntimeError(f"WebDriver HTTP {response.status} error") from None raise if not isinstance(decoded, dict): - raise RuntimeError("WebDriver returned a non-object JSON payload") - if response.status >= 400: - value = decoded.get("value") - if isinstance(value, dict) and value.get("error"): - raise WebDriverProtocolError(value.get("error"), value.get("message")) - raise RuntimeError(f"WebDriver HTTP {response.status} error") - value = decoded.get("value") - if isinstance(value, dict) and value.get("error"): - raise WebDriverProtocolError(value.get("error"), value.get("message")) - return decoded - - -def _wait_for_driver(driver_port: int) -> None: - """Wait for the exact pinned local ChromeDriver and reject foreign ready endpoints.""" - - deadline = time.monotonic() + STARTUP_TIMEOUT_SECONDS - last_failure_kind = "not_observed" - while time.monotonic() < deadline: - try: - status = _json_request(driver_port, "GET", "/status", timeout=1.0) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: - last_failure_kind = str(_failure_evidence(exc)["failure_kind"]) - time.sleep(0.1) - continue - - status_value = status.get("value") - if not isinstance(status_value, dict): - last_failure_kind = "status_protocol_error" - time.sleep(0.1) - continue - - ready = status_value.get("ready") - if ready is True: - build = status_value.get("build") - build_version = build.get("version") if isinstance(build, dict) else None - expected_prefix = f"{PINNED_CHROME_VERSION} (" - if not isinstance(build_version, str) or not ( - build_version == PINNED_CHROME_VERSION - or build_version.startswith(expected_prefix) - ): - raise RuntimeError("ChromeDriver status identity mismatch") - return - if ready is not False: - last_failure_kind = "status_protocol_error" - time.sleep(0.1) - raise RuntimeError( - f"ChromeDriver did not become ready ({last_failure_kind})" - ) - - -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 _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", - downloads: document.documentElement.dataset.originweaveDownloads || "missing", - downloadsDiagnostic: - document.documentElement.dataset.originweaveDownloadsDiagnostic || "download-not-evaluated" -}; -""" - 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", - "downloads": "ready", - "downloadsDiagnostic": "download-complete-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 CompatibilitySurfaceError(latest) - - -def _exercise_real_click(driver_port: int, session_id: str) -> str: - """Use the WebDriver element-click command and classify DOM post-condition mismatches.""" - - found = _json_request( - driver_port, - "POST", - _webdriver_path(session_id, "/element"), - {"using": "css selector", "value": "#fixture-button"}, - ) - 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") - safe_element = _path_token(element_id, "element identifier") - _json_request( - driver_port, - "POST", - _webdriver_path(session_id, f"/element/{safe_element}/click"), - {}, - ) - output = _json_request( - driver_port, - "POST", - _webdriver_path(session_id, "/element"), - {"using": "css selector", "value": "#fixture-output"}, - ).get("value", {}) - output_id = output.get(W3C_ELEMENT_KEY) if isinstance(output, dict) else None - if not isinstance(output_id, str): - raise RuntimeError("WebDriver did not return the fixture output element") - safe_output = _path_token(output_id, "element identifier") - text = _json_request( - driver_port, - "GET", - _webdriver_path(session_id, f"/element/{safe_output}/text"), - ).get("value") - if text != "clicked": - raise RuntimeError("real click post-condition mismatch") - return str(text) - - -def _teardown_driver_process(driver: subprocess.Popen[bytes]) -> Exception | None: - """Best-effort reap ChromeDriver while preserving unrecovered process failures.""" - - try: - driver.terminate() - except OSError as terminate_error: - try: - driver.kill() - driver.wait(timeout=5) - except (OSError, subprocess.TimeoutExpired) as fallback_error: - terminate_error.add_note( - "bounded ChromeDriver kill fallback also failed: " - f"{type(fallback_error).__name__}" - ) - return terminate_error - return None - - try: - driver.wait(timeout=5) - return None - except subprocess.TimeoutExpired: - try: - driver.kill() - driver.wait(timeout=5) - except (OSError, subprocess.TimeoutExpired) as fallback_error: - return fallback_error - return None - - -def _read_chromedriver_startup_line(stream: Any) -> tuple[bytes, bool]: - """Read and drain one ChromeDriver startup record using only bounded reads.""" - - raw_line_bytes = stream.readline(MAX_CHROMEDRIVER_STARTUP_LINE_BYTES + 1) - if not raw_line_bytes: - return b"", False - - oversized = len(raw_line_bytes) > MAX_CHROMEDRIVER_STARTUP_LINE_BYTES - if oversized and not raw_line_bytes.endswith(b"\n"): - while True: - remainder = stream.readline(MAX_CHROMEDRIVER_STARTUP_LINE_BYTES + 1) - if not remainder or remainder.endswith(b"\n"): - break - return raw_line_bytes, oversized - - -def _parse_chromedriver_bound_port(raw_line_bytes: bytes, oversized: bool) -> int | None: - """Return one bounded authoritative startup port or ignore a malformed candidate.""" - - if oversized or not raw_line_bytes.startswith( - CHROMEDRIVER_BOUND_PORT_PREFIX.encode("ascii") - ): - return None - - raw_line = raw_line_bytes.decode("utf-8", errors="replace") - line = raw_line.rstrip("\r\n") - if not line.endswith("."): - return None - port_text = line[len(CHROMEDRIVER_BOUND_PORT_PREFIX) : -1] - if not port_text.isdecimal(): - return None - port = int(port_text) - return port if 1 <= port <= 65_535 else None - - -def _start_chromedriver( - chromedriver_bin: pathlib.Path, -) -> tuple[subprocess.Popen[bytes], int]: - """Let ChromeDriver atomically bind an ephemeral port and report the bound authority. - - The process owns port allocation by binding port zero itself. Its combined output is - continuously drained so the pipe cannot become a back-pressure failure, but only the - reviewed startup-port record is retained. Raw ChromeDriver output never enters evidence. - """ - - driver = subprocess.Popen( - [str(chromedriver_bin), "--port=0", "--allowed-ips=127.0.0.1"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - ) - if driver.stdout is None: - teardown_error = _teardown_driver_process(driver) - startup_error = RuntimeError("ChromeDriver startup output pipe was unavailable") - if teardown_error is not None: - startup_error.add_note( - "ChromeDriver process teardown also failed: " - f"{type(teardown_error).__name__}" - ) - raise startup_error - - startup_events: queue.Queue[tuple[str, int | None]] = queue.Queue(maxsize=1) - - def publish(event: tuple[str, int | None]) -> None: - try: - startup_events.put_nowait(event) - except queue.Full: - return - - def drain_output() -> None: - while True: - raw_line_bytes, oversized = _read_chromedriver_startup_line(driver.stdout) - if not raw_line_bytes: - break - port = _parse_chromedriver_bound_port(raw_line_bytes, oversized) - if port is None: - continue - publish(("ready", port)) - publish(("eof", None)) - - threading.Thread( - target=drain_output, - name="originweave-chromedriver-output-drain", - daemon=True, - ).start() - - try: - event_kind, bound_port = startup_events.get(timeout=STARTUP_TIMEOUT_SECONDS) - except queue.Empty: - event_kind, bound_port = "timeout", None - - if event_kind == "ready" and bound_port is not None: - return driver, bound_port - - teardown_error = _teardown_driver_process(driver) - startup_error = RuntimeError("ChromeDriver did not publish a valid bound port") - if teardown_error is not None: - startup_error.add_note( - "ChromeDriver process teardown also failed: " - f"{type(teardown_error).__name__}" - ) - raise startup_error - - -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.""" - - session_id: str | None = None - download_dir = pathlib.Path(profile_dir) / "downloads" - download_dir.mkdir(mode=0o700, parents=True, exist_ok=True) - driver, driver_port = _start_chromedriver(chromedriver_bin) - primary_error: BaseException | None = None - 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", - f"--user-data-dir={profile_dir}", - f"--disable-extensions-except={FIXTURE}", - f"--load-extension={FIXTURE}", - ], - "prefs": { - "download.default_directory": str(download_dir), - "download.prompt_for_download": False, - "download.directory_upgrade": True, - }, - }, - } - } - }, - ).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}" - ) - - _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", - "downloads": surfaces["downloads"] == "ready", - "real-browser-click": click_result == "clicked", - }, - } - except BaseException as error: # noqa: BLE001 - re-raised unchanged after cleanup. - primary_error = error - raise - finally: - cleanup_error: Exception | None = None - unreviewed_cleanup_error: Exception | None = None - try: - if session_id is not None: - try: - _json_request( - driver_port, - "DELETE", - _webdriver_path(session_id, ""), - {}, - ) - except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as error: - cleanup_error = error - except Exception as error: # noqa: BLE001 - retained or re-raised after teardown. - unreviewed_cleanup_error = error - finally: - teardown_error = _teardown_driver_process(driver) - if primary_error is not None: - if cleanup_error is not None: - primary_error.add_note( - "WebDriver session cleanup also failed after the primary browser-pass " - f"failure: {type(cleanup_error).__name__}" - ) - if unreviewed_cleanup_error is not None: - primary_error.add_note( - "Unreviewed WebDriver session cleanup also failed after the primary " - "browser-pass failure: " - f"{type(unreviewed_cleanup_error).__name__}" - ) - if teardown_error is not None: - primary_error.add_note( - "ChromeDriver process teardown also failed after the primary browser-pass " - f"failure: {type(teardown_error).__name__}" - ) - elif cleanup_error is not None: - cleanup_failure = WebDriverSessionCleanupError( - "WebDriver session cleanup failed after bounded process teardown" - ) - if teardown_error is not None: - cleanup_failure.add_note( - "ChromeDriver process teardown also failed: " - f"{type(teardown_error).__name__}" - ) - raise cleanup_failure from cleanup_error - elif unreviewed_cleanup_error is not None: - if teardown_error is not None: - unreviewed_cleanup_error.add_note( - "ChromeDriver process teardown also failed: " - f"{type(teardown_error).__name__}" - ) - raise unreviewed_cleanup_error - elif teardown_error is not None: - raise teardown_error - - -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 return credential-free evidence.""" - - trial_started = time.monotonic() - 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", - ) - - 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"], - }, - ], - "duration_ms": round((time.monotonic() - trial_started) * 1000), - } - - -def _pinned_workspace_binary( - env_name: str, - relative_path: pathlib.PurePosixPath, - label: str, - *, - root: pathlib.Path = ROOT, -) -> pathlib.Path: - """Authorize only the exact non-symlink executable provisioned under the workspace. - - Environment variables remain compatibility inputs for the workflow, but they - cannot redirect execution. The release lane has one reviewed path for each - pinned Chrome-for-Testing artifact, and any other executable fails closed. - """ - - if relative_path.is_absolute() or ".." in relative_path.parts: - raise SystemExit(f"{label} pinned workspace path is invalid") - trusted_root = pathlib.Path(os.path.abspath(root)) - expected = pathlib.Path(os.path.abspath(trusted_root.joinpath(*relative_path.parts))) - configured = os.environ.get(env_name) - if configured: - configured_path = pathlib.Path(configured) - if not configured_path.is_absolute(): - raise SystemExit(f"{env_name} must name the pinned workspace executable") - if pathlib.Path(os.path.abspath(configured_path)) != expected: - raise SystemExit(f"{env_name} must name the pinned workspace executable") - - current = expected - while current != trusted_root: - if current.is_symlink(): - raise SystemExit(f"{label} pinned workspace executable path contains a symlink") - parent = current.parent - if parent == current: - raise SystemExit(f"{label} pinned workspace executable escaped the workspace") - current = parent - - try: - expected.relative_to(trusted_root) - except ValueError as exc: - raise SystemExit(f"{label} pinned workspace executable escaped the workspace") from exc - if not expected.is_file(): - raise SystemExit(f"{label} pinned workspace executable is missing") - if not os.access(expected, os.X_OK): - raise SystemExit(f"{label} pinned workspace executable is not executable") - return expected - - -def main() -> int: - """Run three independent restart trials and emit bounded repeatability evidence.""" - - chrome_bin = _pinned_workspace_binary( - "CHROME_BIN", - PINNED_CHROME_RELATIVE_PATH, - "Chrome for Testing", - ) - chromedriver_bin = _pinned_workspace_binary( - "CHROMEDRIVER_BIN", - PINNED_CHROMEDRIVER_RELATIVE_PATH, - "ChromeDriver", - ) - if not (FIXTURE / "manifest.json").is_file(): - raise SystemExit("MV3 fixture manifest is missing") - - fixture_server = http.server.ThreadingHTTPServer( - ("127.0.0.1", 0), - lambda *args, **kwargs: QuietFixtureHandler( - *args, directory=str(FIXTURE), **kwargs - ), - ) - fixture_thread = threading.Thread(target=fixture_server.serve_forever, daemon=True) - fixture_thread.start() - 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, - subprocess.TimeoutExpired, - ) as exc: - failed_trial: dict[str, Any] = { - "trial_number": trial_number, - "passed": False, - } - failed_trial.update(_failure_evidence(exc)) - trial_results.append(failed_trial) - - successful_trials = sum( - 1 for trial in trial_results if trial.get("passed") is True - ) - trial_pass_rate = successful_trials / REPEATABILITY_TRIALS - 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 - } - - 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, - "surfaces": common_surfaces, - "trial_results": trial_results, - "browser_passes": ( - successful_results[-1].get("browser_passes", []) - if successful_results - else [] - ), - "duration_ms": round((time.monotonic() - started) * 1000), - } - print(json.dumps(evidence, sort_keys=True)) - 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") - return 0 - finally: - fixture_server.shutdown() - fixture_server.server_close() - fixture_thread.join(timeout=5) - - -if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise RuntimeError("WebDriver returned a non-object JSON payload") \ No newline at end of file From d71e5b41ff0600e9999e5a3806bae6e1635ffc30 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 27 Aug 2026 09:54:31 -0700 Subject: [PATCH 95/95] fix(mv3): restore complete compatibility runner --- scripts/ci/run_mv3_compatibility.py | 880 +++++++++++++++++++++++++++- 1 file changed, 879 insertions(+), 1 deletion(-) diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py index 609ea71fc..9e3beb9ef 100644 --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -1,3 +1,207 @@ +#!/usr/bin/env python3 +"""Run bounded repeatable Manifest V3 compatibility 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, downloads, real browser-click, and +restart-persistence behavior. +""" + +from __future__ import annotations + +import http.client +import http.server +import json +import os +import pathlib +import queue +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" +PINNED_CHROME_VERSION = "150.0.7871.129" +PINNED_CHROME_REVISION = "r1639810" +PINNED_CHROME_RELATIVE_PATH = pathlib.PurePosixPath( + ".mv3-browser/chrome-linux64/chrome" +) +PINNED_CHROMEDRIVER_RELATIVE_PATH = pathlib.PurePosixPath( + ".mv3-browser/chromedriver-linux64/chromedriver" +) +REPEATABILITY_TRIALS = 3 +REQUEST_TIMEOUT_SECONDS = 5.0 +STARTUP_TIMEOUT_SECONDS = 20.0 +FIXTURE_TIMEOUT_SECONDS = 20.0 +MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576 +MAX_CHROMEDRIVER_STARTUP_LINE_BYTES = 512 +CHROMEDRIVER_BOUND_PORT_PREFIX = "ChromeDriver was started successfully on port " +W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" +PATH_TOKEN_CHARACTERS = frozenset(string.ascii_letters + string.digits + "-_.") +SURFACE_EVIDENCE_KEYS = ( + "content", + "storage", + "storagePersistence", + "workerReply", + "workerState", + "workerStartCount", + "dnr", + "tabs", + "windows", + "scripting", + "scriptingExecuted", + "commands", + "sidePanel", + "bookmarks", + "history", + "downloads", + "downloadsDiagnostic", +) +SURFACE_EVIDENCE_VALUES = frozenset( + {"ready", "missing", "initialized", "persisted", "pong", "installed", "blocked"} +) +DOWNLOAD_DIAGNOSTIC_VALUES = frozenset( + { + "download-source-rejected", + "download-start-rejected", + "download-search-missing", + "download-interrupted", + "download-url-mismatch", + "download-byte-count-mismatch", + "download-exists-false", + "download-timeout", + "download-complete-ready", + "download-not-evaluated", + } +) +WEBDRIVER_ERROR_CODES = frozenset( + { + "invalid argument", + "no such element", + "session not created", + "stale element reference", + "timeout", + "unknown error", + } +) + + +class CompatibilitySurfaceError(RuntimeError): + """Report only bounded fixture-surface state when real-browser evidence does not converge.""" + + def __init__(self, observed: dict[str, str]) -> None: + self.observed = { + key: _safe_surface_value(key, observed[key]) + for key in SURFACE_EVIDENCE_KEYS + if key in observed + } + super().__init__("Manifest V3 fixture surfaces did not converge") + + +class WebDriverProtocolError(RuntimeError): + """Report one allow-listed WebDriver error code without browser-controlled text.""" + + def __init__(self, code: object, _message: object) -> None: + self.code = code if isinstance(code, str) and code in WEBDRIVER_ERROR_CODES else "unknown" + super().__init__(f"WebDriver protocol error: {self.code}") + + +class WebDriverSessionCleanupError(RuntimeError): + """Report a reviewed WebDriver session-delete failure after process teardown.""" + + +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 _safe_surface_value(key: str, value: str) -> str: + """Reduce one controlled DOM evidence value to a non-sensitive diagnostic token.""" + + if key == "workerStartCount": + return value if value.isdecimal() and len(value) <= 20 else "invalid" + if key == "downloadsDiagnostic": + return value if value in DOWNLOAD_DIAGNOSTIC_VALUES else "unexpected" + return value if value in SURFACE_EVIDENCE_VALUES else "unexpected" + + +def _failure_evidence(error: BaseException) -> dict[str, Any]: + """Classify one browser-trial failure without retaining raw exception text.""" + + if isinstance(error, CompatibilitySurfaceError): + return {"failure_kind": "surface_mismatch", "observed": error.observed} + if isinstance(error, WebDriverProtocolError): + return {"failure_kind": "webdriver_protocol_error", "error_code": error.code} + if isinstance(error, json.JSONDecodeError): + return {"failure_kind": "json_decode_error"} + if isinstance(error, OSError): + return {"failure_kind": "io_error"} + if isinstance(error, ValueError): + return {"failure_kind": "value_error"} + return {"failure_kind": "runtime_error"} + + +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 one 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. + + Recoverable HTTP/1.1 parser or response-encoding failures, including a malformed + status-line, incomplete message body, or invalid UTF-8 payload, become + `RuntimeError("WebDriver transport protocol failure")` so trial evidence can + record a classified outcome without retaining raw transport text. + """ + + 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) + transport_protocol_failed = False + try: + try: + connection.request( + method, path, body=body, headers={"Content-Type": "application/json"}, @@ -18,6 +222,7 @@ decoded_text = raw.decode("utf-8") except UnicodeDecodeError: response_encoding_failed = True + decoded_text = "" if response_encoding_failed: raise RuntimeError("WebDriver transport protocol failure") try: @@ -27,4 +232,677 @@ raise RuntimeError(f"WebDriver HTTP {response.status} error") from None raise if not isinstance(decoded, dict): - raise RuntimeError("WebDriver returned a non-object JSON payload") \ No newline at end of file + raise RuntimeError("WebDriver returned a non-object JSON payload") + if response.status >= 400: + value = decoded.get("value") + if isinstance(value, dict) and value.get("error"): + raise WebDriverProtocolError(value.get("error"), value.get("message")) + raise RuntimeError(f"WebDriver HTTP {response.status} error") + value = decoded.get("value") + if isinstance(value, dict) and value.get("error"): + raise WebDriverProtocolError(value.get("error"), value.get("message")) + return decoded + + +def _wait_for_driver(driver_port: int) -> None: + """Wait for the exact pinned local ChromeDriver and reject foreign ready endpoints.""" + + deadline = time.monotonic() + STARTUP_TIMEOUT_SECONDS + last_failure_kind = "not_observed" + while time.monotonic() < deadline: + try: + status = _json_request(driver_port, "GET", "/status", timeout=1.0) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc: + last_failure_kind = str(_failure_evidence(exc)["failure_kind"]) + time.sleep(0.1) + continue + + status_value = status.get("value") + if not isinstance(status_value, dict): + last_failure_kind = "status_protocol_error" + time.sleep(0.1) + continue + + ready = status_value.get("ready") + if ready is True: + build = status_value.get("build") + build_version = build.get("version") if isinstance(build, dict) else None + expected_prefix = f"{PINNED_CHROME_VERSION} (" + if not isinstance(build_version, str) or not ( + build_version == PINNED_CHROME_VERSION + or build_version.startswith(expected_prefix) + ): + raise RuntimeError("ChromeDriver status identity mismatch") + return + if ready is not False: + last_failure_kind = "status_protocol_error" + time.sleep(0.1) + raise RuntimeError( + f"ChromeDriver did not become ready ({last_failure_kind})" + ) + + +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 _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", + downloads: document.documentElement.dataset.originweaveDownloads || "missing", + downloadsDiagnostic: + document.documentElement.dataset.originweaveDownloadsDiagnostic || "download-not-evaluated" +}; +""" + 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", + "downloads": "ready", + "downloadsDiagnostic": "download-complete-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 CompatibilitySurfaceError(latest) + + +def _exercise_real_click(driver_port: int, session_id: str) -> str: + """Use the WebDriver element-click command and classify DOM post-condition mismatches.""" + + found = _json_request( + driver_port, + "POST", + _webdriver_path(session_id, "/element"), + {"using": "css selector", "value": "#fixture-button"}, + ) + 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") + safe_element = _path_token(element_id, "element identifier") + _json_request( + driver_port, + "POST", + _webdriver_path(session_id, f"/element/{safe_element}/click"), + {}, + ) + output = _json_request( + driver_port, + "POST", + _webdriver_path(session_id, "/element"), + {"using": "css selector", "value": "#fixture-output"}, + ).get("value", {}) + output_id = output.get(W3C_ELEMENT_KEY) if isinstance(output, dict) else None + if not isinstance(output_id, str): + raise RuntimeError("WebDriver did not return the fixture output element") + safe_output = _path_token(output_id, "element identifier") + text = _json_request( + driver_port, + "GET", + _webdriver_path(session_id, f"/element/{safe_output}/text"), + ).get("value") + if text != "clicked": + raise RuntimeError("real click post-condition mismatch") + return str(text) + + +def _teardown_driver_process(driver: subprocess.Popen[bytes]) -> Exception | None: + """Best-effort reap ChromeDriver while preserving unrecovered process failures.""" + + try: + driver.terminate() + except OSError as terminate_error: + try: + driver.kill() + driver.wait(timeout=5) + except (OSError, subprocess.TimeoutExpired) as fallback_error: + terminate_error.add_note( + "bounded ChromeDriver kill fallback also failed: " + f"{type(fallback_error).__name__}" + ) + return terminate_error + return None + + try: + driver.wait(timeout=5) + return None + except subprocess.TimeoutExpired: + try: + driver.kill() + driver.wait(timeout=5) + except (OSError, subprocess.TimeoutExpired) as fallback_error: + return fallback_error + return None + + +def _read_chromedriver_startup_line(stream: Any) -> tuple[bytes, bool]: + """Read and drain one ChromeDriver startup record using only bounded reads.""" + + raw_line_bytes = stream.readline(MAX_CHROMEDRIVER_STARTUP_LINE_BYTES + 1) + if not raw_line_bytes: + return b"", False + + oversized = len(raw_line_bytes) > MAX_CHROMEDRIVER_STARTUP_LINE_BYTES + if oversized and not raw_line_bytes.endswith(b"\n"): + while True: + remainder = stream.readline(MAX_CHROMEDRIVER_STARTUP_LINE_BYTES + 1) + if not remainder or remainder.endswith(b"\n"): + break + return raw_line_bytes, oversized + + +def _parse_chromedriver_bound_port(raw_line_bytes: bytes, oversized: bool) -> int | None: + """Return one bounded authoritative startup port or ignore a malformed candidate.""" + + if oversized or not raw_line_bytes.startswith( + CHROMEDRIVER_BOUND_PORT_PREFIX.encode("ascii") + ): + return None + + raw_line = raw_line_bytes.decode("utf-8", errors="replace") + line = raw_line.rstrip("\r\n") + if not line.endswith("."): + return None + port_text = line[len(CHROMEDRIVER_BOUND_PORT_PREFIX) : -1] + if not port_text.isdecimal(): + return None + port = int(port_text) + return port if 1 <= port <= 65_535 else None + + +def _start_chromedriver( + chromedriver_bin: pathlib.Path, +) -> tuple[subprocess.Popen[bytes], int]: + """Let ChromeDriver atomically bind an ephemeral port and report the bound authority. + + The process owns port allocation by binding port zero itself. Its combined output is + continuously drained so the pipe cannot become a back-pressure failure, but only the + reviewed startup-port record is retained. Raw ChromeDriver output never enters evidence. + """ + + driver = subprocess.Popen( + [str(chromedriver_bin), "--port=0", "--allowed-ips=127.0.0.1"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + if driver.stdout is None: + teardown_error = _teardown_driver_process(driver) + startup_error = RuntimeError("ChromeDriver startup output pipe was unavailable") + if teardown_error is not None: + startup_error.add_note( + "ChromeDriver process teardown also failed: " + f"{type(teardown_error).__name__}" + ) + raise startup_error + + startup_events: queue.Queue[tuple[str, int | None]] = queue.Queue(maxsize=1) + + def publish(event: tuple[str, int | None]) -> None: + try: + startup_events.put_nowait(event) + except queue.Full: + return + + def drain_output() -> None: + while True: + raw_line_bytes, oversized = _read_chromedriver_startup_line(driver.stdout) + if not raw_line_bytes: + break + port = _parse_chromedriver_bound_port(raw_line_bytes, oversized) + if port is None: + continue + publish(("ready", port)) + publish(("eof", None)) + + threading.Thread( + target=drain_output, + name="originweave-chromedriver-output-drain", + daemon=True, + ).start() + + try: + event_kind, bound_port = startup_events.get(timeout=STARTUP_TIMEOUT_SECONDS) + except queue.Empty: + event_kind, bound_port = "timeout", None + + if event_kind == "ready" and bound_port is not None: + return driver, bound_port + + teardown_error = _teardown_driver_process(driver) + startup_error = RuntimeError("ChromeDriver did not publish a valid bound port") + if teardown_error is not None: + startup_error.add_note( + "ChromeDriver process teardown also failed: " + f"{type(teardown_error).__name__}" + ) + raise startup_error + + +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.""" + + session_id: str | None = None + download_dir = pathlib.Path(profile_dir) / "downloads" + download_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + driver, driver_port = _start_chromedriver(chromedriver_bin) + primary_error: BaseException | None = None + 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", + f"--user-data-dir={profile_dir}", + f"--disable-extensions-except={FIXTURE}", + f"--load-extension={FIXTURE}", + ], + "prefs": { + "download.default_directory": str(download_dir), + "download.prompt_for_download": False, + "download.directory_upgrade": True, + }, + }, + } + } + }, + ).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}" + ) + + _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", + "downloads": surfaces["downloads"] == "ready", + "real-browser-click": click_result == "clicked", + }, + } + except BaseException as error: # noqa: BLE001 - re-raised unchanged after cleanup. + primary_error = error + raise + finally: + cleanup_error: Exception | None = None + unreviewed_cleanup_error: Exception | None = None + try: + if session_id is not None: + try: + _json_request( + driver_port, + "DELETE", + _webdriver_path(session_id, ""), + {}, + ) + except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as error: + cleanup_error = error + except Exception as error: # noqa: BLE001 - retained or re-raised after teardown. + unreviewed_cleanup_error = error + finally: + teardown_error = _teardown_driver_process(driver) + if primary_error is not None: + if cleanup_error is not None: + primary_error.add_note( + "WebDriver session cleanup also failed after the primary browser-pass " + f"failure: {type(cleanup_error).__name__}" + ) + if unreviewed_cleanup_error is not None: + primary_error.add_note( + "Unreviewed WebDriver session cleanup also failed after the primary " + "browser-pass failure: " + f"{type(unreviewed_cleanup_error).__name__}" + ) + if teardown_error is not None: + primary_error.add_note( + "ChromeDriver process teardown also failed after the primary browser-pass " + f"failure: {type(teardown_error).__name__}" + ) + elif cleanup_error is not None: + cleanup_failure = WebDriverSessionCleanupError( + "WebDriver session cleanup failed after bounded process teardown" + ) + if teardown_error is not None: + cleanup_failure.add_note( + "ChromeDriver process teardown also failed: " + f"{type(teardown_error).__name__}" + ) + raise cleanup_failure from cleanup_error + elif unreviewed_cleanup_error is not None: + if teardown_error is not None: + unreviewed_cleanup_error.add_note( + "ChromeDriver process teardown also failed: " + f"{type(teardown_error).__name__}" + ) + raise unreviewed_cleanup_error + elif teardown_error is not None: + raise teardown_error + + +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 return credential-free evidence.""" + + trial_started = time.monotonic() + 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", + ) + + 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"], + }, + ], + "duration_ms": round((time.monotonic() - trial_started) * 1000), + } + + +def _pinned_workspace_binary( + env_name: str, + relative_path: pathlib.PurePosixPath, + label: str, + *, + root: pathlib.Path = ROOT, +) -> pathlib.Path: + """Authorize only the exact non-symlink executable provisioned under the workspace. + + Environment variables remain compatibility inputs for the workflow, but they + cannot redirect execution. The release lane has one reviewed path for each + pinned Chrome-for-Testing artifact, and any other executable fails closed. + """ + + if relative_path.is_absolute() or ".." in relative_path.parts: + raise SystemExit(f"{label} pinned workspace path is invalid") + trusted_root = pathlib.Path(os.path.abspath(root)) + expected = pathlib.Path(os.path.abspath(trusted_root.joinpath(*relative_path.parts))) + configured = os.environ.get(env_name) + if configured: + configured_path = pathlib.Path(configured) + if not configured_path.is_absolute(): + raise SystemExit(f"{env_name} must name the pinned workspace executable") + if pathlib.Path(os.path.abspath(configured_path)) != expected: + raise SystemExit(f"{env_name} must name the pinned workspace executable") + + current = expected + while current != trusted_root: + if current.is_symlink(): + raise SystemExit(f"{label} pinned workspace executable path contains a symlink") + parent = current.parent + if parent == current: + raise SystemExit(f"{label} pinned workspace executable escaped the workspace") + current = parent + + try: + expected.relative_to(trusted_root) + except ValueError as exc: + raise SystemExit(f"{label} pinned workspace executable escaped the workspace") from exc + if not expected.is_file(): + raise SystemExit(f"{label} pinned workspace executable is missing") + if not os.access(expected, os.X_OK): + raise SystemExit(f"{label} pinned workspace executable is not executable") + return expected + + +def main() -> int: + """Run three independent restart trials and emit bounded repeatability evidence.""" + + chrome_bin = _pinned_workspace_binary( + "CHROME_BIN", + PINNED_CHROME_RELATIVE_PATH, + "Chrome for Testing", + ) + chromedriver_bin = _pinned_workspace_binary( + "CHROMEDRIVER_BIN", + PINNED_CHROMEDRIVER_RELATIVE_PATH, + "ChromeDriver", + ) + if not (FIXTURE / "manifest.json").is_file(): + raise SystemExit("MV3 fixture manifest is missing") + + fixture_server = http.server.ThreadingHTTPServer( + ("127.0.0.1", 0), + lambda *args, **kwargs: QuietFixtureHandler( + *args, directory=str(FIXTURE), **kwargs + ), + ) + fixture_thread = threading.Thread(target=fixture_server.serve_forever, daemon=True) + fixture_thread.start() + 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, + subprocess.TimeoutExpired, + ) as exc: + failed_trial: dict[str, Any] = { + "trial_number": trial_number, + "passed": False, + } + failed_trial.update(_failure_evidence(exc)) + trial_results.append(failed_trial) + + successful_trials = sum( + 1 for trial in trial_results if trial.get("passed") is True + ) + trial_pass_rate = successful_trials / REPEATABILITY_TRIALS + 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 + } + + 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, + "surfaces": common_surfaces, + "trial_results": trial_results, + "browser_passes": ( + successful_results[-1].get("browser_passes", []) + if successful_results + else [] + ), + "duration_ms": round((time.monotonic() - started) * 1000), + } + print(json.dumps(evidence, sort_keys=True)) + 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") + return 0 + finally: + fixture_server.shutdown() + fixture_server.server_close() + fixture_thread.join(timeout=5) + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file