From 39c4544eec5ce9a0ad6ab4d5ec1bfdfd318149a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 06:17:44 +0900 Subject: [PATCH 01/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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 5f5d0e60a78582ba0bd7e24853b25396d04cec66 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:21:52 +0000 Subject: [PATCH 39/39] fix(mv3): classify click postcondition mismatches Keep page-controlled WebDriver element text out of runner exceptions. Co-authored-by: Seongho Bae --- CHANGELOG.md | 1 + docs/doctoring.md | 6 ++++++ docs/doctoring/mv3-compatibility.md | 6 ++++++ scripts/ci/run_mv3_compatibility.py | 4 ++-- 4 files changed, 15 insertions(+), 2 deletions(-) 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 31a41c2a4..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, @@ -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)