diff --git a/.github/workflows/mv3-compatibility.yml b/.github/workflows/mv3-compatibility.yml index 13a9eec6c..69f9930d7 100644 --- a/.github/workflows/mv3-compatibility.yml +++ b/.github/workflows/mv3-compatibility.yml @@ -63,12 +63,15 @@ jobs: chmod 0755 \ .mv3-browser/chrome-linux64/chrome \ .mv3-browser/chromedriver-linux64/chromedriver + sudo chown root:root .mv3-browser/chrome-linux64/chrome_sandbox + sudo chmod 4755 .mv3-browser/chrome-linux64/chrome_sandbox - name: Execute real MV3 compatibility fixture shell: bash env: CHROME_BIN: ${{ github.workspace }}/.mv3-browser/chrome-linux64/chrome CHROMEDRIVER_BIN: ${{ github.workspace }}/.mv3-browser/chromedriver-linux64/chromedriver + CHROME_DEVEL_SANDBOX: ${{ github.workspace }}/.mv3-browser/chrome-linux64/chrome_sandbox run: | set -euo pipefail "$CHROME_BIN" --version diff --git a/CHANGELOG.md b/CHANGELOG.md index 94d45c486..6ff286e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,18 @@ All notable changes to OriginWeave are documented in this file. The format follo ### Changed +- Kept the real MV3 compatibility lane sandboxed by installing the pinned Chrome for Testing archive's root-owned `chrome_sandbox` helper instead of passing `--no-sandbox`. +- Pointed the pinned Chrome for Testing process at its installed `CHROME_DEVEL_SANDBOX` helper so the raw archive uses the configured setuid sandbox. +- Recorded bounded ChromeDriver teardown timeouts as failed MV3 trials so cleanup faults preserve repeatability evidence instead of aborting the evidence line. +- Retained only an allow-listed WebDriver protocol error code in bounded MV3 trial evidence, keeping browser-controlled error messages and transport text out of diagnostics. +- Discarded raw HTTP parser exception context when classifying recoverable WebDriver transport-protocol failures, so malformed status-line or incomplete-body data cannot survive on the sanitized `RuntimeError` object through Python exception chaining. +- Preserved Chromium's renderer sandbox in the real Manifest V3 compatibility runner by removing the `--no-sandbox` launch override; environments that cannot run the pinned browser with sandboxing enabled must fail the compatibility lane rather than weaken the security boundary. +- Made malformed or oversized ChromeDriver startup-port candidate records non-authoritative within the existing bounded startup wait, so later valid startup output may recover while exact pinned-build `/status` identity remains mandatory before session creation. +- Treated a failed graceful ChromeDriver termination as recoverable when the bounded hard-kill fallback successfully reaps the process, while preserving unrecovered fallback failures as teardown errors. +- Classified a mismatched Chrome `browserVersion` capability as an expected-only diagnostic so browser-reported capability text cannot enter Manifest V3 runner exception output. +- Classified Manifest V3 WebDriver HTTP/1.1 parser failures as a fixed transport-protocol token so a malformed status-line or incomplete message body cannot enter runner exception text. +- Classified Manifest V3 real-click post-condition failures as a fixed mismatch token so page-controlled WebDriver text cannot enter runner exception text. +- Recorded the current Chrome Extensions `chrome.downloads` primary reference in APA 7th form and stated that the active downloads lane proves one controlled loopback payload in pinned Chromium, not Agent filesystem authority. - Aligned the hourly product-development branch-coverage toolchain and its one-shot materializer with the reviewed `nightly-2026-08-18` pin, and corrected the official Dependabot Rust-toolchain reference. - 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 ec51daaf3..ae59b8cc2 100644 --- a/docs/doctoring.md +++ b/docs/doctoring.md @@ -8,6 +8,17 @@ 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. + +### Manifest V3 WebDriver transport-protocol diagnostics + +RFC 9112 defines the HTTP/1.1 status-line and the requirement that a message body match the announced framing. A malformed status-line or an incomplete body is a recoverable parser failure, not a trusted diagnostic payload. W3C WebDriver carries commands over that HTTP transport. The Manifest V3 compatibility runner therefore converts `http.client.HTTPException` subclasses such as `BadStatusLine` and `IncompleteRead` into the classified message `WebDriver transport protocol failure`. Raw status-line text, partial body bytes, paths, URLs, or tokens must not enter exception text, trial evidence, or logs. + +### Manifest V3 click post-condition diagnostics + +W3C WebDriver Get Element Text returns the rendered text content of a located element. That value is page-controlled data, not a trusted diagnostic token. The Manifest V3 compatibility runner therefore compares the fixture output against the exact expected `clicked` token and, on mismatch, raises only the classified message `real click post-condition mismatch`. Raw element text must not enter exception text, trial evidence, or logs. The final Model Context Protocol `2026-07-28` specification defines the currently reviewed MCP generation. Its stateless request model carries protocol metadata per request and standard Streamable HTTP routing metadata for MCP operations; its Tools surface defines bounded, case-sensitive tool names and requires clients to treat tool annotations as untrusted unless supplied by a trusted server. OriginWeave therefore keeps MCP outside the product authority model. Active PR #168 implements only a bounded Rust `tools/call` routing/action-policy foundation for that exact generation; the complete transport, request-metadata, discovery, OAuth, browser, secret, and persistence adapter remains planned and cannot be inferred from the core routing primitive. ### Browser origin equivalence @@ -122,6 +133,8 @@ Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform resource identifi 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 @@ -138,6 +151,8 @@ Evtimov, I., Zharmagambetov, A., Grattafiori, A., Guo, C., & Chaudhuri, K. (2025 Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). Internet Engineering Task Force. https://doi.org/10.17487/RFC9110 +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP/1.1* (RFC 9112). Internet Engineering Task Force. https://doi.org/10.17487/RFC9112 + Fugu Team, Sakana AI. (2026). *Sakana Fugu technical report* [Technical report]. arXiv. https://doi.org/10.48550/arXiv.2606.21228 Huston, G., & Buraglio, N. (2024). *Expanding the IPv6 documentation space* (RFC 9637). Internet Engineering Task Force. https://doi.org/10.17487/RFC9637 @@ -192,6 +207,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 571c49329..07731c19a 100644 --- a/docs/doctoring/mv3-compatibility.md +++ b/docs/doctoring/mv3-compatibility.md @@ -1,13 +1,15 @@ # Manifest V3 compatibility evidence baseline - **Status:** Active implementation evidence for issue #27 -- **Reviewed:** 2026-08-11 +- **Reviewed:** 2026-08-21 - **Pinned browser:** Chrome for Testing `150.0.7871.129`, Chromium revision `r1639810` OriginWeave uses Chromium as its compatibility kernel, so browser-extension compatibility must be demonstrated with executable Chromium evidence rather than inferred from architecture alone. The protected-main lane exercises a controlled unpacked Manifest V3 extension against one exact Chrome for Testing build and proves service-worker, content-script, storage, declarative-network-request, tabs, windows, scripting, commands, side-panel, bookmarks/history read compatibility, restart persistence, repeatability, and one real WebDriver click/post-condition. Active stacked compatibility work adds downloads, bounded bookmark/history mutation, profile isolation, explicit extension update/version-migration evidence, and an exact content-script isolated-world check. OriginWeave does **not claim 100% Chrome extension compatibility**. The checked-in fixture is intentionally local-only. Its host permission is limited to loopback HTTP used by the deterministic test server. It contains no remote code, user credential, model call, external content, native-messaging host, or production PII. Chrome permissions remain distinct from the explicit OriginWeave extension-to-Agent grant implemented in `originweave-core`. Compatibility mutation tests create only controlled synthetic state inside the ephemeral test profile and must clean it up; successful API compatibility never grants the OriginWeave Agent ambient bookmarks/history/downloads authority. +The compatibility runner preserves Chromium's renderer sandbox and does not pass `--no-sandbox`. Because the Chrome for Testing archive does not carry setuid ownership through extraction, the workflow installs its pinned `chrome_sandbox` helper as root-owned mode `4755` and sets `CHROME_DEVEL_SANDBOX` to that exact helper before execution. A runner environment that cannot start the pinned browser with sandboxing enabled is an infrastructure failure to repair or report, not a reason to weaken the browser security boundary. + ## Supported-capability evidence matrix This matrix separates protected-main executable evidence from active, non-shipped evidence and from genuinely unproven surfaces. A row marked **ACTIVE_PR** is never a release claim; exact head/run provenance belongs in `docs/evidence/2026-08-10-active-pr-maturity.md` and must be refreshed when the branch changes. @@ -38,6 +40,24 @@ 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. + +## WebDriver transport-protocol diagnostic boundary + +RFC 9112 requires a well-formed HTTP/1.1 status-line and a message body that matches the announced framing. W3C WebDriver sends commands over that HTTP transport. When ChromeDriver returns a malformed status-line or an incomplete body, the compatibility runner raises only `WebDriver transport protocol failure`; when a WebDriver response supplies a recognized protocol error, it retains only an allow-listed error code. Raw status-line text, partial body bytes, paths, URLs, browser messages, or tokens must not enter exception text or trial evidence. This classification lets `main` record the failure in `trial_results` instead of aborting the compatibility run with an unclassified parser exception. + +## ChromeDriver startup-record robustness boundary + +ChromeDriver startup stdout is diagnostic input, not authority. The compatibility runner retains at most `MAX_CHROMEDRIVER_STARTUP_LINE_BYTES + 1` bytes from one record and drains the remainder through bounded reads. A prefixed record that is oversized, lacks the required terminal period, carries a non-decimal port, or names a port outside `1..65535` is treated as non-authoritative and ignored while the existing bounded startup wait continues. A later well-formed candidate can therefore recover from malformed-but-expected startup diagnostics without turning the malformed record into success. + +A syntactically valid reported port is still insufficient authority. Before a WebDriver session is created, the loopback `/status` endpoint must identify the exact pinned ChromeDriver build. If no valid candidate appears before EOF or the startup deadline, or if the status endpoint identifies a foreign build, startup still fails closed and the process is reaped through the reviewed bounded teardown path. + +## Click post-condition diagnostic boundary + +W3C WebDriver Get Element Text returns rendered element text. That value is page-controlled data. The compatibility runner compares the fixture output against the exact expected `clicked` token and, on mismatch, retains only the classified message `real click post-condition mismatch`. Raw element text must not enter exception text or trial evidence. + ## 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. @@ -50,6 +70,8 @@ Content-script injection and content-script JavaScript isolation are separate co The CI lane downloads the exact Chrome/ChromeDriver version from the official Chrome for Testing public bucket, records SHA-256 receipts for the downloaded archives, verifies the runtime-reported browser version, and emits bounded JSON compatibility evidence. A future release-quality matrix should additionally pin published artifact digests or equivalent immutable supply-chain identity when the upstream distribution exposes that identity in an authoritative machine-readable form. +A bounded process-teardown timeout is recorded as one failed trial and does not suppress the remaining trial records or the aggregate evidence line. The repeatability gate still fails unless all required trials pass; cleanup failure is not converted into browser success. + ## Primary references — APA 7th Chrome for Developers. (n.d.). *Extensions / Manifest V3*. Google. Retrieved August 9, 2026, from https://developer.chrome.com/docs/extensions/develop/migrate/what-is-mv3 @@ -60,6 +82,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 @@ -67,3 +91,7 @@ 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/ + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP/1.1* (RFC 9112). Internet Engineering Task Force. https://doi.org/10.17487/RFC9112 + +World Wide Web Consortium. (2018, June 5). *WebDriver* (W3C Recommendation). https://www.w3.org/TR/2018/REC-webdriver1-20180605/ diff --git a/scripts/ci/run_mv3_compatibility.py b/scripts/ci/run_mv3_compatibility.py old mode 100755 new mode 100644 index 28a3fb1e2..9e3beb9ef --- a/scripts/ci/run_mv3_compatibility.py +++ b/scripts/ci/run_mv3_compatibility.py @@ -5,19 +5,18 @@ 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. """ from __future__ import annotations -import contextlib import http.client import http.server import json import os import pathlib -import socket +import queue import string import subprocess import tempfile @@ -29,13 +28,91 @@ FIXTURE = ROOT / "tests" / "fixtures" / "mv3_basic" PINNED_CHROME_VERSION = "150.0.7871.129" PINNED_CHROME_REVISION = "r1639810" +PINNED_CHROME_RELATIVE_PATH = pathlib.PurePosixPath( + ".mv3-browser/chrome-linux64/chrome" +) +PINNED_CHROMEDRIVER_RELATIVE_PATH = pathlib.PurePosixPath( + ".mv3-browser/chromedriver-linux64/chromedriver" +) REPEATABILITY_TRIALS = 3 REQUEST_TIMEOUT_SECONDS = 5.0 STARTUP_TIMEOUT_SECONDS = 20.0 FIXTURE_TIMEOUT_SECONDS = 20.0 MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576 +MAX_CHROMEDRIVER_STARTUP_LINE_BYTES = 512 +CHROMEDRIVER_BOUND_PORT_PREFIX = "ChromeDriver was started successfully on port " W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" PATH_TOKEN_CHARACTERS = frozenset(string.ascii_letters + string.digits + "-_.") +SURFACE_EVIDENCE_KEYS = ( + "content", + "storage", + "storagePersistence", + "workerReply", + "workerState", + "workerStartCount", + "dnr", + "tabs", + "windows", + "scripting", + "scriptingExecuted", + "commands", + "sidePanel", + "bookmarks", + "history", + "downloads", + "downloadsDiagnostic", +) +SURFACE_EVIDENCE_VALUES = frozenset( + {"ready", "missing", "initialized", "persisted", "pong", "installed", "blocked"} +) +DOWNLOAD_DIAGNOSTIC_VALUES = frozenset( + { + "download-source-rejected", + "download-start-rejected", + "download-search-missing", + "download-interrupted", + "download-url-mismatch", + "download-byte-count-mismatch", + "download-exists-false", + "download-timeout", + "download-complete-ready", + "download-not-evaluated", + } +) +WEBDRIVER_ERROR_CODES = frozenset( + { + "invalid argument", + "no such element", + "session not created", + "stale element reference", + "timeout", + "unknown error", + } +) + + +class CompatibilitySurfaceError(RuntimeError): + """Report only bounded fixture-surface state when real-browser evidence does not converge.""" + + def __init__(self, observed: dict[str, str]) -> None: + self.observed = { + key: _safe_surface_value(key, observed[key]) + for key in SURFACE_EVIDENCE_KEYS + if key in observed + } + super().__init__("Manifest V3 fixture surfaces did not converge") + + +class WebDriverProtocolError(RuntimeError): + """Report one allow-listed WebDriver error code without browser-controlled text.""" + + def __init__(self, code: object, _message: object) -> None: + self.code = code if isinstance(code, str) and code in WEBDRIVER_ERROR_CODES else "unknown" + super().__init__(f"WebDriver protocol error: {self.code}") + + +class WebDriverSessionCleanupError(RuntimeError): + """Report a reviewed WebDriver session-delete failure after process teardown.""" class QuietFixtureHandler(http.server.SimpleHTTPRequestHandler): @@ -45,12 +122,30 @@ def log_message(self, _format: str, *args: object) -> None: """Suppress request logs because the fixture contains no diagnostic value.""" -def _free_loopback_port() -> int: - """Reserve and release one loopback TCP port for a short-lived local service.""" +def _safe_surface_value(key: str, value: str) -> str: + """Reduce one controlled DOM evidence value to a non-sensitive diagnostic token.""" + + if key == "workerStartCount": + return value if value.isdecimal() and len(value) <= 20 else "invalid" + if key == "downloadsDiagnostic": + return value if value in DOWNLOAD_DIAGNOSTIC_VALUES else "unexpected" + return value if value in SURFACE_EVIDENCE_VALUES else "unexpected" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - return int(sock.getsockname()[1]) + +def _failure_evidence(error: BaseException) -> dict[str, Any]: + """Classify one browser-trial failure without retaining raw exception text.""" + + if isinstance(error, CompatibilitySurfaceError): + return {"failure_kind": "surface_mismatch", "observed": error.observed} + if isinstance(error, WebDriverProtocolError): + return {"failure_kind": "webdriver_protocol_error", "error_code": error.code} + if isinstance(error, json.JSONDecodeError): + return {"failure_kind": "json_decode_error"} + if isinstance(error, OSError): + return {"failure_kind": "io_error"} + if isinstance(error, ValueError): + return {"failure_kind": "value_error"} + return {"failure_kind": "runtime_error"} def _path_token(value: str, label: str) -> str: @@ -85,7 +180,13 @@ def _json_request( *, timeout: float = REQUEST_TIMEOUT_SECONDS, ) -> dict[str, Any]: - """Issue one bounded JSON request to the fixed loopback ChromeDriver authority.""" + """Issue one bounded JSON request to the fixed loopback ChromeDriver authority. + + Recoverable HTTP/1.1 parser or response-encoding failures, including a malformed + status-line, incomplete message body, or invalid UTF-8 payload, become + `RuntimeError("WebDriver transport protocol failure")` so trial evidence can + record a classified outcome without retaining raw transport text. + """ if not 1 <= driver_port <= 65_535: raise ValueError("invalid ChromeDriver port") @@ -96,46 +197,89 @@ def _json_request( body = None if payload is None else json.dumps(payload).encode("utf-8") connection = http.client.HTTPConnection("127.0.0.1", driver_port, timeout=timeout) + transport_protocol_failed = False try: - connection.request( - method, - path, - body=body, - headers={"Content-Type": "application/json"}, - ) - response = connection.getresponse() - raw = response.read(MAX_WEBDRIVER_RESPONSE_BYTES + 1) + try: + connection.request( + method, + path, + body=body, + headers={"Content-Type": "application/json"}, + ) + response = connection.getresponse() + raw = response.read(MAX_WEBDRIVER_RESPONSE_BYTES + 1) + except http.client.HTTPException: + transport_protocol_failed = True + if transport_protocol_failed: + raise RuntimeError("WebDriver transport protocol failure") if len(raw) > MAX_WEBDRIVER_RESPONSE_BYTES: raise RuntimeError("WebDriver response exceeded the bounded JSON limit") - if response.status >= 400: - detail = raw.decode("utf-8", errors="replace") - raise RuntimeError(f"WebDriver HTTP {response.status}: {detail}") finally: connection.close() - decoded = json.loads(raw.decode("utf-8")) + response_encoding_failed = False + try: + decoded_text = raw.decode("utf-8") + except UnicodeDecodeError: + response_encoding_failed = True + decoded_text = "" + if response_encoding_failed: + raise RuntimeError("WebDriver transport protocol failure") + try: + decoded = json.loads(decoded_text) + except json.JSONDecodeError: + if response.status >= 400: + raise RuntimeError(f"WebDriver HTTP {response.status} error") from None + raise if not isinstance(decoded, dict): raise RuntimeError("WebDriver returned a non-object JSON payload") + if response.status >= 400: + value = decoded.get("value") + if isinstance(value, dict) and value.get("error"): + raise WebDriverProtocolError(value.get("error"), value.get("message")) + raise RuntimeError(f"WebDriver HTTP {response.status} error") value = decoded.get("value") if isinstance(value, dict) and value.get("error"): - raise RuntimeError(f"WebDriver error: {value.get('error')}: {value.get('message')}") + raise WebDriverProtocolError(value.get("error"), value.get("message")) return decoded def _wait_for_driver(driver_port: int) -> None: - """Wait for the exact local ChromeDriver process to become ready.""" + """Wait for the exact pinned local ChromeDriver and reject foreign ready endpoints.""" 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) + continue + + status_value = status.get("value") + if not isinstance(status_value, dict): + last_failure_kind = "status_protocol_error" + time.sleep(0.1) + continue + + ready = status_value.get("ready") + if ready is True: + build = status_value.get("build") + build_version = build.get("version") if isinstance(build, dict) else None + expected_prefix = f"{PINNED_CHROME_VERSION} (" + if not isinstance(build_version, str) or not ( + build_version == PINNED_CHROME_VERSION + or build_version.startswith(expected_prefix) + ): + raise RuntimeError("ChromeDriver status identity mismatch") + return + if ready is not False: + last_failure_kind = "status_protocol_error" time.sleep(0.1) - raise RuntimeError(f"ChromeDriver did not become ready: {last_error}") + raise RuntimeError( + f"ChromeDriver did not become ready ({last_failure_kind})" + ) def _execute(driver_port: int, session_id: str, script: str) -> Any: @@ -178,7 +322,10 @@ 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", + downloadsDiagnostic: + document.documentElement.dataset.originweaveDownloadsDiagnostic || "download-not-evaluated" }; """ expected = { @@ -196,6 +343,8 @@ def _wait_for_extension_evidence( "sidePanel": "ready", "bookmarks": "ready", "history": "ready", + "downloads": "ready", + "downloadsDiagnostic": "download-complete-ready", } deadline = time.monotonic() + FIXTURE_TIMEOUT_SECONDS latest: dict[str, str] = {} @@ -212,13 +361,11 @@ 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: - """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, @@ -253,10 +400,142 @@ 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) +def _teardown_driver_process(driver: subprocess.Popen[bytes]) -> Exception | None: + """Best-effort reap ChromeDriver while preserving unrecovered process failures.""" + + try: + driver.terminate() + except OSError as terminate_error: + try: + driver.kill() + driver.wait(timeout=5) + except (OSError, subprocess.TimeoutExpired) as fallback_error: + terminate_error.add_note( + "bounded ChromeDriver kill fallback also failed: " + f"{type(fallback_error).__name__}" + ) + return terminate_error + return None + + try: + driver.wait(timeout=5) + return None + except subprocess.TimeoutExpired: + try: + driver.kill() + driver.wait(timeout=5) + except (OSError, subprocess.TimeoutExpired) as fallback_error: + return fallback_error + return None + + +def _read_chromedriver_startup_line(stream: Any) -> tuple[bytes, bool]: + """Read and drain one ChromeDriver startup record using only bounded reads.""" + + raw_line_bytes = stream.readline(MAX_CHROMEDRIVER_STARTUP_LINE_BYTES + 1) + if not raw_line_bytes: + return b"", False + + oversized = len(raw_line_bytes) > MAX_CHROMEDRIVER_STARTUP_LINE_BYTES + if oversized and not raw_line_bytes.endswith(b"\n"): + while True: + remainder = stream.readline(MAX_CHROMEDRIVER_STARTUP_LINE_BYTES + 1) + if not remainder or remainder.endswith(b"\n"): + break + return raw_line_bytes, oversized + + +def _parse_chromedriver_bound_port(raw_line_bytes: bytes, oversized: bool) -> int | None: + """Return one bounded authoritative startup port or ignore a malformed candidate.""" + + if oversized or not raw_line_bytes.startswith( + CHROMEDRIVER_BOUND_PORT_PREFIX.encode("ascii") + ): + return None + + raw_line = raw_line_bytes.decode("utf-8", errors="replace") + line = raw_line.rstrip("\r\n") + if not line.endswith("."): + return None + port_text = line[len(CHROMEDRIVER_BOUND_PORT_PREFIX) : -1] + if not port_text.isdecimal(): + return None + port = int(port_text) + return port if 1 <= port <= 65_535 else None + + +def _start_chromedriver( + chromedriver_bin: pathlib.Path, +) -> tuple[subprocess.Popen[bytes], int]: + """Let ChromeDriver atomically bind an ephemeral port and report the bound authority. + + The process owns port allocation by binding port zero itself. Its combined output is + continuously drained so the pipe cannot become a back-pressure failure, but only the + reviewed startup-port record is retained. Raw ChromeDriver output never enters evidence. + """ + + driver = subprocess.Popen( + [str(chromedriver_bin), "--port=0", "--allowed-ips=127.0.0.1"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + if driver.stdout is None: + teardown_error = _teardown_driver_process(driver) + startup_error = RuntimeError("ChromeDriver startup output pipe was unavailable") + if teardown_error is not None: + startup_error.add_note( + "ChromeDriver process teardown also failed: " + f"{type(teardown_error).__name__}" + ) + raise startup_error + + startup_events: queue.Queue[tuple[str, int | None]] = queue.Queue(maxsize=1) + + def publish(event: tuple[str, int | None]) -> None: + try: + startup_events.put_nowait(event) + except queue.Full: + return + + def drain_output() -> None: + while True: + raw_line_bytes, oversized = _read_chromedriver_startup_line(driver.stdout) + if not raw_line_bytes: + break + port = _parse_chromedriver_bound_port(raw_line_bytes, oversized) + if port is None: + continue + publish(("ready", port)) + publish(("eof", None)) + + threading.Thread( + target=drain_output, + name="originweave-chromedriver-output-drain", + daemon=True, + ).start() + + try: + event_kind, bound_port = startup_events.get(timeout=STARTUP_TIMEOUT_SECONDS) + except queue.Empty: + event_kind, bound_port = "timeout", None + + if event_kind == "ready" and bound_port is not None: + return driver, bound_port + + teardown_error = _teardown_driver_process(driver) + startup_error = RuntimeError("ChromeDriver did not publish a valid bound port") + if teardown_error is not None: + startup_error.add_note( + "ChromeDriver process teardown also failed: " + f"{type(teardown_error).__name__}" + ) + raise startup_error + + def _run_browser_pass( chrome_bin: pathlib.Path, chromedriver_bin: pathlib.Path, @@ -266,14 +545,11 @@ def _run_browser_pass( ) -> dict[str, Any]: """Run one fresh browser process against a shared bounded compatibility profile.""" - driver_port = _free_loopback_port() session_id: str | None = None - driver = subprocess.Popen( - [str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"], - stdout=subprocess.DEVNULL, - stderr=subprocess.STDOUT, - text=True, - ) + download_dir = pathlib.Path(profile_dir) / "downloads" + download_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + driver, driver_port = _start_chromedriver(chromedriver_bin) + primary_error: BaseException | None = None try: _wait_for_driver(driver_port) session = _json_request( @@ -293,11 +569,15 @@ def _run_browser_pass( "--disable-component-update", "--disable-sync", "--disable-dev-shm-usage", - "--no-sandbox", f"--user-data-dir={profile_dir}", f"--disable-extensions-except={FIXTURE}", f"--load-extension={FIXTURE}", ], + "prefs": { + "download.default_directory": str(download_dir), + "download.prompt_for_download": False, + "download.directory_upgrade": True, + }, }, } } @@ -315,8 +595,7 @@ def _run_browser_pass( ) if browser_version != PINNED_CHROME_VERSION: raise RuntimeError( - f"unexpected Chrome version: expected {PINNED_CHROME_VERSION}, " - f"got {browser_version!r}" + f"unexpected Chrome version; expected {PINNED_CHROME_VERSION}" ) _json_request( @@ -349,24 +628,67 @@ 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", }, } + except BaseException as error: # noqa: BLE001 - re-raised unchanged after cleanup. + primary_error = error + raise 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 + unreviewed_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 + except Exception as error: # noqa: BLE001 - retained or re-raised after teardown. + unreviewed_cleanup_error = error + finally: + teardown_error = _teardown_driver_process(driver) + if primary_error is not None: + if cleanup_error is not None: + primary_error.add_note( + "WebDriver session cleanup also failed after the primary browser-pass " + f"failure: {type(cleanup_error).__name__}" + ) + if unreviewed_cleanup_error is not None: + primary_error.add_note( + "Unreviewed WebDriver session cleanup also failed after the primary " + "browser-pass failure: " + f"{type(unreviewed_cleanup_error).__name__}" + ) + if teardown_error is not None: + primary_error.add_note( + "ChromeDriver process teardown also failed after the primary browser-pass " + f"failure: {type(teardown_error).__name__}" + ) + elif cleanup_error is not None: + cleanup_failure = WebDriverSessionCleanupError( + "WebDriver session cleanup failed after bounded process teardown" + ) + if teardown_error is not None: + cleanup_failure.add_note( + "ChromeDriver process teardown also failed: " + f"{type(teardown_error).__name__}" + ) + raise cleanup_failure from cleanup_error + elif unreviewed_cleanup_error is not None: + if teardown_error is not None: + unreviewed_cleanup_error.add_note( + "ChromeDriver process teardown also failed: " + f"{type(teardown_error).__name__}" + ) + raise unreviewed_cleanup_error + elif teardown_error is not None: + raise teardown_error def _run_restart_trial( @@ -433,15 +755,65 @@ 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") @@ -468,13 +840,19 @@ 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, + subprocess.TimeoutExpired, + ) as exc: + failed_trial: dict[str, Any] = { + "trial_number": trial_number, + "passed": False, + } + failed_trial.update(_failure_evidence(exc)) + trial_results.append(failed_trial) successful_trials = sum( 1 for trial in trial_results if trial.get("passed") is True @@ -527,4 +905,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/fixtures/mv3_basic/content_script.js b/tests/fixtures/mv3_basic/content_script.js index 8b6af5314..b70d1a27f 100644 --- a/tests/fixtures/mv3_basic/content_script.js +++ b/tests/fixtures/mv3_basic/content_script.js @@ -27,4 +27,7 @@ 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"; + document.documentElement.dataset.originweaveDownloadsDiagnostic = + response?.downloadsDiagnostic ?? "download-not-evaluated"; })(); 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. 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/*" diff --git a/tests/fixtures/mv3_basic/service_worker.js b/tests/fixtures/mv3_basic/service_worker.js index 70687838a..45ccbaf85 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,80 @@ 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) { + let items; + try { + items = await chrome.downloads.search({ id: downloadId, limit: 1 }); + } catch (_error) { + return { ready: false, diagnostic: "download-not-evaluated" }; + } + if (!Array.isArray(items) || items.length !== 1) { + await new Promise((resolve) => setTimeout(resolve, DOWNLOAD_POLL_INTERVAL_MS)); + continue; + } + 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.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 { ready: false, diagnostic: "download-timeout" }; +} + +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({ + url, + filename: "originweave-mv3/download.txt", + conflictAction: "uniquify", + saveAs: false, + }); + } catch (_error) { + return { ready: false, diagnostic: "download-start-rejected" }; + } + if (!Number.isInteger(downloadId)) { + return { ready: false, diagnostic: "download-start-rejected" }; + } + return waitForDownload(downloadId, url); +} + async function exerciseCoreApis(sender) { const tabId = sender?.tab?.id; if (!Number.isInteger(tabId)) { @@ -56,6 +134,9 @@ async function exerciseCoreApis(sender) { }); const historyReady = Array.isArray(historyItems); + const downloadResult = await exerciseDownload(sender); + const downloadsReady = downloadResult.ready; + return { tabs: tabReady ? "ready" : "missing", windows: windowReady ? "ready" : "missing", @@ -64,6 +145,8 @@ async function exerciseCoreApis(sender) { sidePanel: sidePanelReady ? "ready" : "missing", bookmarks: bookmarksReady ? "ready" : "missing", history: historyReady ? "ready" : "missing", + downloads: downloadsReady ? "ready" : "missing", + downloadsDiagnostic: downloadResult.diagnostic, }; } @@ -91,6 +174,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { sidePanel: "missing", bookmarks: "missing", history: "missing", + downloads: "missing", + downloadsDiagnostic: "download-not-evaluated", }); } ); 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() diff --git a/tests/test_mv3_browser_version_diagnostic_contract.py b/tests/test_mv3_browser_version_diagnostic_contract.py new file mode 100644 index 000000000..c2f78060b --- /dev/null +++ b/tests/test_mv3_browser_version_diagnostic_contract.py @@ -0,0 +1,107 @@ +"""Regression contract for classified Chrome capability version diagnostics.""" + +from __future__ import annotations + +import pathlib +import runpy +import tempfile +import unittest +import unittest.mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class _FakeDriver: + """Model bounded ChromeDriver process cleanup without launching a process.""" + + def __init__(self) -> None: + self.terminated = False + + def terminate(self) -> None: + """Record graceful teardown.""" + + self.terminated = True + + def kill(self) -> None: + """Fail if the normal teardown unexpectedly needs hard-kill fallback.""" + + raise AssertionError("unexpected ChromeDriver hard-kill fallback") + + def wait(self, timeout: float) -> int: + """Model an immediately reaped ChromeDriver process.""" + + if timeout <= 0: + raise AssertionError("timeout must remain positive") + return 0 + + +class ManifestV3BrowserVersionDiagnosticTests(unittest.TestCase): + """Keep browser-reported capability text out of runner diagnostics.""" + + def test_browser_version_mismatch_does_not_retain_raw_capability_text(self) -> None: + """An unexpected browser version must fail closed with a classified safe message.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_browser_version_contract") + run_browser_pass = namespace["_run_browser_pass"] + globals_ = run_browser_pass.__globals__ + fake_driver = _FakeDriver() + raw_version = "151.0 secret-token /home/runner/private https://example.invalid" + + def fake_json_request( + _driver_port: int, + method: str, + path: str, + _payload=None, + *, + timeout: float = 5.0, + ): + if timeout <= 0: + raise AssertionError("timeout must remain positive") + if method == "POST" and path == "/session": + return { + "value": { + "sessionId": "session-1", + "capabilities": {"browserVersion": raw_version}, + } + } + if method == "DELETE" and path.endswith("/session/session-1"): + return {"value": None} + raise AssertionError(f"unexpected WebDriver request: {method} {path}") + + with tempfile.TemporaryDirectory( + prefix="originweave-browser-version-contract-" + ) as profile_dir: + with ( + unittest.mock.patch.dict( + globals_, + { + "_start_chromedriver": lambda _binary: (fake_driver, 43123), + "_wait_for_driver": lambda _port: None, + "_json_request": fake_json_request, + }, + ), + ): + with self.assertRaises(RuntimeError) as raised: + run_browser_pass( + pathlib.Path("/controlled/chrome"), + pathlib.Path("/controlled/chromedriver"), + "http://127.0.0.1:8080/page.html", + profile_dir, + "initialized", + ) + + rendered = str(raised.exception) + self.assertEqual( + rendered, + f"unexpected Chrome version; expected {namespace['PINNED_CHROME_VERSION']}", + ) + self.assertNotIn("secret-token", rendered) + self.assertNotIn("/home/runner/private", rendered) + self.assertNotIn("example.invalid", rendered) + self.assertIsNone(raised.exception.__cause__) + self.assertTrue(fake_driver.terminated) + + +if __name__ == "__main__": + unittest.main() 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() diff --git a/tests/test_mv3_compatibility_contract.py b/tests/test_mv3_compatibility_contract.py index 10872ddac..eef1d849f 100644 --- a/tests/test_mv3_compatibility_contract.py +++ b/tests/test_mv3_compatibility_contract.py @@ -5,7 +5,10 @@ import json import pathlib import runpy +import subprocess +import tempfile import unittest +import unittest.mock ROOT = pathlib.Path(__file__).resolve().parents[1] FIXTURE = ROOT / "tests" / "fixtures" / "mv3_basic" @@ -120,6 +123,30 @@ def test_runner_transport_cannot_follow_dynamic_url_schemes(self) -> None: self.assertNotIn("urllib.request", runner) self.assertNotIn("urllib.error", runner) + def test_runner_preserves_chromium_sandbox(self) -> None: + """The real-browser compatibility lane must not disable Chromium sandboxing.""" + + runner = RUNNER.read_text(encoding="utf-8") + self.assertNotIn('"--no-sandbox"', runner) + + def test_workflow_installs_chromium_sandbox_helper(self) -> None: + """The downloaded Chrome for Testing build must have its setuid sandbox installed.""" + + workflow = WORKFLOW.read_text(encoding="utf-8") + self.assertIn("chrome_sandbox", workflow) + self.assertIn( + "sudo chown root:root .mv3-browser/chrome-linux64/chrome_sandbox", + workflow, + ) + self.assertIn( + "sudo chmod 4755 .mv3-browser/chrome-linux64/chrome_sandbox", + workflow, + ) + self.assertIn( + "CHROME_DEVEL_SANDBOX: ${{ github.workspace }}/.mv3-browser/chrome-linux64/chrome_sandbox", + workflow, + ) + def test_runner_accepts_real_chromedriver_element_ids_without_path_injection(self) -> None: """ChromeDriver dotted element IDs must work while path syntax stays fail-closed.""" @@ -162,6 +189,190 @@ def test_runner_reports_repeated_trial_pass_rate(self) -> None: with self.subTest(expected=expected): self.assertIn(expected, runner) + def test_main_records_timeout_cleanup_as_one_failed_trial(self) -> None: + """A stuck browser teardown must not suppress bounded repeatability evidence.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_timeout_contract") + main = namespace["main"] + with tempfile.TemporaryDirectory(prefix="originweave-mv3-timeout-") as temp_dir: + fixture = pathlib.Path(temp_dir) + (fixture / "manifest.json").write_text("{}", encoding="utf-8") + evidence_print = unittest.mock.Mock() + with unittest.mock.patch.dict( + main.__globals__, + { + "FIXTURE": fixture, + "REPEATABILITY_TRIALS": 1, + "_pinned_workspace_binary": lambda *_args: pathlib.Path("/controlled"), + "_run_restart_trial": unittest.mock.Mock( + side_effect=subprocess.TimeoutExpired("controlled-chromedriver", 5) + ), + "print": evidence_print, + }, + ): + with self.assertRaisesRegex(RuntimeError, "0/1 trials passed"): + main() + + evidence = json.loads(evidence_print.call_args.args[0]) + self.assertEqual(evidence["trial_results"][0]["failure_kind"], "runtime_error") + + def test_runner_preserves_safe_surface_failure_evidence(self) -> None: + """A failed trial must identify the bounded fixture surface without leaking raw errors.""" + + 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_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_webdriver_error_keeps_only_an_allowlisted_code(self) -> None: + """Session-start failures must expose a bounded code without browser text.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_contract") + protocol_error = namespace["WebDriverProtocolError"] + error = protocol_error("session not created", "secret browser diagnostic") + self.assertEqual(str(error), "WebDriver protocol error: session not created") + self.assertEqual(error.code, "session not created") + unknown = protocol_error("untrusted code", "secret browser diagnostic") + self.assertEqual(str(unknown), "WebDriver protocol error: unknown") + self.assertEqual(unknown.code, "unknown") + + def test_webdriver_http_error_keeps_json_error_code(self) -> None: + """HTTP 500 session failures must retain only the bounded WebDriver code.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_contract") + json_request = namespace["_json_request"] + protocol_error = namespace["WebDriverProtocolError"] + http_module = namespace["http"] + + class FakeResponse: + status = 500 + + def read(self, _limit: int) -> bytes: + return json.dumps( + { + "value": { + "error": "session not created", + "message": "secret browser diagnostic", + } + } + ).encode("utf-8") + + class FakeConnection: + def request(self, *_args: object, **_kwargs: object) -> None: + return None + + def getresponse(self) -> FakeResponse: + return FakeResponse() + + def close(self) -> None: + return None + + with unittest.mock.patch.object( + http_module.client, + "HTTPConnection", + return_value=FakeConnection(), + ): + with self.assertRaises(protocol_error) as raised: + json_request(9515, "POST", "/session", {}) + self.assertEqual(raised.exception.code, "session not created") + self.assertNotIn("secret browser diagnostic", str(raised.exception)) + + def test_chromedriver_startup_timeout_does_not_retain_raw_last_error(self) -> None: + """Startup timeout diagnostics must classify transient errors without copying raw text.""" + + 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.""" @@ -193,6 +404,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) diff --git a/tests/test_mv3_downloads_contract.py b/tests/test_mv3_downloads_contract.py new file mode 100644 index 000000000..7f0409513 --- /dev/null +++ b/tests/test_mv3_downloads_contract.py @@ -0,0 +1,174 @@ +"""Fail-first contract for real Manifest V3 downloads compatibility.""" + +from __future__ import annotations + +import importlib.util +import json +import pathlib +import re +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests" / "fixtures" / "mv3_basic" +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.""" + + def test_fixture_declares_downloads_permission_and_local_resource(self) -> None: + """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_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", + '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_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.""" + + worker = (FIXTURE / "service_worker.js").read_text(encoding="utf-8") + content = (FIXTURE / "content_script.js").read_text(encoding="utf-8") + for expected in ( + "download-source-rejected", + "download-start-rejected", + "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.assertNotIn("download-search-missing", worker) + self.assertIn("originweaveDownloadsDiagnostic", content) + self.assertNotIn("download.default_directory", worker) + self.assertNotIn("item.filename", worker) + self.assertNotIn("_error.message", worker) + self.assertNotIn("String(_error)", worker) + + def test_download_search_api_failure_is_distinct_from_visibility_timeout(self) -> None: + """A rejected search call must remain distinct from exhausting bounded visibility polling.""" + + worker = (FIXTURE / "service_worker.js").read_text(encoding="utf-8") + self.assertRegex( + worker, + re.compile( + r"items = await chrome\.downloads\.search\(\{ id: downloadId, limit: 1 \}\);" + r"\s*\} catch \(_error\) \{" + r"\s*return \{ ready: false, diagnostic: \"download-not-evaluated\" \};" + ), + ) + self.assertIn( + 'return { ready: false, diagnostic: "download-timeout" };', + worker, + ) + self.assertNotIn("observedDownload", worker) + + def test_content_script_and_runner_require_downloads_on_every_pass(self) -> None: + """The compatibility report must fail closed when downloads evidence is missing.""" + + 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) + + 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 + ) + + approved_error = runner.CompatibilitySurfaceError( + { + "downloads": "missing", + "downloadsDiagnostic": "download-source-rejected", + } + ) + approved_evidence = runner._failure_evidence(approved_error) + self.assertEqual( + approved_evidence["observed"]["downloadsDiagnostic"], + "download-source-rejected", + ) + + 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.""" + + runner = RUNNER.read_text(encoding="utf-8") + self.assertIn("originweaveDownloadsDiagnostic", runner) + self.assertIn('"downloadsDiagnostic": "download-complete-ready"', runner) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mv3_driver_status_authority_contract.py b/tests/test_mv3_driver_status_authority_contract.py new file mode 100644 index 000000000..f7bc97bf4 --- /dev/null +++ b/tests/test_mv3_driver_status_authority_contract.py @@ -0,0 +1,95 @@ +"""Regression contracts for bounded ChromeDriver startup/status authority.""" + +from __future__ import annotations + +import pathlib +import runpy +import unittest +import unittest.mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class ManifestV3DriverStatusAuthorityTests(unittest.TestCase): + """Startup evidence must come from the pinned ChromeDriver status shape.""" + + def test_driver_owns_ephemeral_port_selection_without_a_release_bind_race(self) -> None: + """ChromeDriver itself must bind port zero instead of racing on a released probe port.""" + + source = RUNNER.read_text(encoding="utf-8") + self.assertNotIn("def _free_loopback_port", source) + self.assertNotIn("driver_port = _free_loopback_port()", source) + self.assertIn('"--port=0"', source) + + def test_ready_status_rejects_a_different_chromedriver_build(self) -> None: + """A ready loopback endpoint cannot impersonate the pinned driver build.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_driver_status_authority") + wait_for_driver = namespace["_wait_for_driver"] + globals_ = wait_for_driver.__globals__ + + def mismatched_status( + _driver_port: int, + method: str, + path: str, + _payload=None, + *, + timeout: float = 5.0, + ) -> dict[str, object]: + self.assertEqual(method, "GET") + self.assertEqual(path, "/status") + self.assertGreater(timeout, 0) + return { + "value": { + "ready": True, + "build": {"version": "149.0.0.0 (controlled-mismatch)"}, + } + } + + with unittest.mock.patch.dict(globals_, {"_json_request": mismatched_status}): + with self.assertRaisesRegex(RuntimeError, "ChromeDriver status identity mismatch"): + wait_for_driver(43123) + + def test_malformed_status_value_is_bounded_and_retried(self) -> None: + """Malformed external status JSON must not escape as AttributeError.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_driver_status_protocol") + wait_for_driver = namespace["_wait_for_driver"] + globals_ = wait_for_driver.__globals__ + monotonic_values = iter((0.0, 0.0, 21.0)) + status_calls = 0 + + def malformed_status( + _driver_port: int, + method: str, + path: str, + _payload=None, + *, + timeout: float = 5.0, + ) -> dict[str, object]: + nonlocal status_calls + status_calls += 1 + self.assertEqual(method, "GET") + self.assertEqual(path, "/status") + self.assertGreater(timeout, 0) + return {"value": []} + + with ( + unittest.mock.patch.dict(globals_, {"_json_request": malformed_status}), + unittest.mock.patch.object( + globals_["time"], "monotonic", side_effect=lambda: next(monotonic_values) + ), + unittest.mock.patch.object(globals_["time"], "sleep", return_value=None), + ): + with self.assertRaisesRegex( + RuntimeError, + r"ChromeDriver did not become ready \(status_protocol_error\)", + ): + wait_for_driver(43123) + + self.assertEqual(status_calls, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mv3_primary_failure_cleanup_contract.py b/tests/test_mv3_primary_failure_cleanup_contract.py new file mode 100644 index 000000000..8b1644fa3 --- /dev/null +++ b/tests/test_mv3_primary_failure_cleanup_contract.py @@ -0,0 +1,103 @@ +"""Regression contract for preserving a primary browser-pass failure through cleanup.""" + +from __future__ import annotations + +import pathlib +import runpy +import tempfile +import unittest +import unittest.mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class _FakeDriver: + """Model a ChromeDriver process that tears down successfully.""" + + def __init__(self) -> None: + self.terminated = False + self.wait_calls = 0 + + def terminate(self) -> None: + """Record graceful termination.""" + + self.terminated = True + + def kill(self) -> None: + """Fail if the hard-kill fallback is unexpectedly required.""" + + raise AssertionError("hard-kill fallback was not expected") + + def wait(self, timeout: float) -> int: + """Model an immediately reaped process.""" + + if timeout <= 0: + raise AssertionError("timeout must remain positive") + self.wait_calls += 1 + return 0 + + +class ManifestV3PrimaryFailureCleanupTests(unittest.TestCase): + """Cleanup failures must not replace the causal browser-pass failure.""" + + def test_primary_browser_failure_survives_reviewed_session_cleanup_failure(self) -> None: + """A later reviewed cleanup error must remain secondary to the primary failure.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_primary_cleanup_contract") + run_browser_pass = namespace["_run_browser_pass"] + globals_ = run_browser_pass.__globals__ + fake_driver = _FakeDriver() + primary_error = RuntimeError("controlled primary browser-pass failure") + cleanup_error = OSError("controlled session cleanup failure") + + def fake_json_request( + _driver_port: int, + method: str, + path: str, + _payload=None, + *, + timeout: float = 5.0, + ): + if timeout <= 0: + raise AssertionError("timeout must remain positive") + if method == "POST" and path == "/session": + return { + "value": { + "sessionId": "session-1", + "capabilities": { + "browserVersion": namespace["PINNED_CHROME_VERSION"] + }, + } + } + if method == "POST" and path == "/session/session-1/url": + raise primary_error + if method == "DELETE" and path == "/session/session-1": + raise cleanup_error + raise AssertionError(f"unexpected WebDriver request: {method} {path}") + + with tempfile.TemporaryDirectory(prefix="originweave-primary-cleanup-") as profile_dir: + with unittest.mock.patch.dict( + globals_, + { + "_start_chromedriver": lambda _binary: (fake_driver, 43123), + "_wait_for_driver": lambda _port: None, + "_json_request": fake_json_request, + }, + ): + with self.assertRaises(RuntimeError) as raised: + run_browser_pass( + pathlib.Path("/controlled/chrome"), + pathlib.Path("/controlled/chromedriver"), + "http://127.0.0.1:8080/page.html", + profile_dir, + "initialized", + ) + + self.assertIs(raised.exception, primary_error) + self.assertTrue(fake_driver.terminated) + self.assertEqual(fake_driver.wait_calls, 1) + + +if __name__ == "__main__": + unittest.main() 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..0b8f48916 --- /dev/null +++ b/tests/test_mv3_session_cleanup_exception_contract.py @@ -0,0 +1,249 @@ +"""Regression contract for fail-closed WebDriver session cleanup.""" + +from __future__ import annotations + +import pathlib +import runpy +import subprocess +import tempfile +import unittest +import unittest.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, + *, + 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 + + 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.""" + + 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.""" + + 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 + + +class ManifestV3SessionCleanupExceptionTests(unittest.TestCase): + """Unexpected cleanup failures must remain visible after process teardown.""" + + @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, + *, + primary_failure: Exception | None = None, + ) -> 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__ + + 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 cleanup_failure + raise AssertionError(f"unexpected WebDriver request: {method} {path}") + + def fake_extension_evidence( + _driver_port: int, + _session_id: str, + _expected_storage_persistence: str, + ) -> dict[str, str]: + if primary_failure is not None: + raise primary_failure + return self._surfaces() + + with tempfile.TemporaryDirectory(prefix="originweave-cleanup-contract-") as profile_dir: + with unittest.mock.patch.dict( + globals_, + { + "_start_chromedriver": lambda _binary: (fake_driver, 43123), + "_wait_for_driver": lambda _port: None, + "_json_request": fake_json_request, + "_wait_for_extension_evidence": fake_extension_evidence, + "_exercise_real_click": lambda _port, _session: "clicked", + }, + ): + try: + run_browser_pass( + pathlib.Path("/controlled/chrome"), + pathlib.Path("/controlled/chromedriver"), + "http://127.0.0.1:8080/page.html", + profile_dir, + "initialized", + ) + except Exception as error: # noqa: BLE001 - return exact boundary error. + return namespace, error + raise AssertionError("cleanup failure unexpectedly became success") + + def test_unreviewed_session_cleanup_exception_is_not_silently_suppressed(self) -> None: + """A new exception class must propagate while ChromeDriver is still terminated.""" + + 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_unreviewed_cleanup_exception_does_not_replace_primary_failure(self) -> None: + """An already-causal browser failure must survive an unreviewed cleanup exception.""" + + fake_driver = _FakeDriver() + primary_error = RuntimeError("controlled primary browser-pass failure") + cleanup_error = _UnexpectedCleanupFailure("must remain secondary") + + _namespace, error = self._run_with_cleanup_failure( + cleanup_error, + fake_driver, + primary_failure=primary_error, + ) + + self.assertIs(error, primary_error) + self.assertIn( + "Unreviewed WebDriver session cleanup also failed after the primary browser-pass " + "failure: _UnexpectedCleanupFailure", + getattr(error, "__notes__", []), + ) + self.assertTrue(fake_driver.terminated) + self.assertFalse(fake_driver.killed) + + def test_reviewed_session_cleanup_error_survives_teardown_failure(self) -> None: + """The causal session failure must not be replaced by a later terminate error.""" + + 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) + + 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) + + def test_successful_kill_after_terminate_error_is_normal_cleanup(self) -> None: + """A recoverable terminate error must not fail cleanup after bounded kill succeeds.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_teardown_contract") + fake_driver = _FakeDriver(terminate_error=OSError("terminate failed")) + + error = namespace["_teardown_driver_process"](fake_driver) + + self.assertIsNone(error) + self.assertTrue(fake_driver.terminated) + self.assertTrue(fake_driver.killed) + self.assertEqual(fake_driver.wait_calls, 1) + + def test_failed_kill_fallback_is_recorded_on_the_primary_teardown_error(self) -> None: + """A secondary fallback failure must not disappear while the first error stays causal.""" + + 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() diff --git a/tests/test_mv3_subprocess_compatibility_contract.py b/tests/test_mv3_subprocess_compatibility_contract.py new file mode 100644 index 000000000..74737401d --- /dev/null +++ b/tests/test_mv3_subprocess_compatibility_contract.py @@ -0,0 +1,133 @@ +"""Regression contract for portable, bounded ChromeDriver output decoding.""" + +from __future__ import annotations + +import ast +import io +import pathlib +import runpy +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +RUNNER = ROOT / "scripts" / "ci" / "run_mv3_compatibility.py" + + +class ChromeDriverSubprocessCompatibilityContractTests(unittest.TestCase): + """Keep subprocess decoding explicit and bound retained startup-line memory.""" + + def test_chromedriver_popen_uses_binary_pipe_with_explicit_utf8_decode(self) -> None: + """Popen must avoid text-decoding kwargs and decode bounded output explicitly.""" + + source = RUNNER.read_text(encoding="utf-8") + tree = ast.parse(source) + start = next( + node + for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "_start_chromedriver" + ) + popen_calls = [ + node + for node in ast.walk(start) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "subprocess" + and node.func.attr == "Popen" + ] + self.assertEqual(len(popen_calls), 1) + keyword_names = {keyword.arg for keyword in popen_calls[0].keywords} + self.assertTrue({"text", "encoding", "errors"}.isdisjoint(keyword_names)) + + self.assertIn('raw_line_bytes.decode("utf-8", errors="replace")', source) + self.assertIn("len(raw_line_bytes) > MAX_CHROMEDRIVER_STARTUP_LINE_BYTES", source) + + def test_startup_output_reader_never_requests_an_unbounded_line(self) -> None: + """A newline-free subprocess record must be drained in bounded reads.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_startup_output_bound") + read_line = namespace["_read_chromedriver_startup_line"] + maximum = namespace["MAX_CHROMEDRIVER_STARTUP_LINE_BYTES"] + + class RecordingStream(io.BytesIO): + def __init__(self, initial_bytes: bytes) -> None: + super().__init__(initial_bytes) + self.requested_sizes: list[int] = [] + + def readline(self, size: int = -1) -> bytes: + self.requested_sizes.append(size) + return super().readline(size) + + stream = RecordingStream(b"x" * (maximum * 4) + b"\nnext\n") + raw_line, oversized = read_line(stream) + + self.assertTrue(oversized) + self.assertLessEqual(len(raw_line), maximum + 1) + self.assertTrue(stream.requested_sizes) + self.assertNotIn(-1, stream.requested_sizes) + self.assertLessEqual(max(stream.requested_sizes), maximum + 1) + self.assertEqual(read_line(stream), (b"next\n", False)) + + def test_startup_port_parser_treats_malformed_candidates_as_non_authoritative(self) -> None: + """Malformed candidate records must be ignorable while a later valid record can win.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_startup_port_parser") + parse_bound_port = namespace["_parse_chromedriver_bound_port"] + prefix = namespace["CHROMEDRIVER_BOUND_PORT_PREFIX"].encode("ascii") + maximum = namespace["MAX_CHROMEDRIVER_STARTUP_LINE_BYTES"] + + self.assertIsNone(parse_bound_port(prefix + b"not-a-port.\n", False)) + self.assertIsNone(parse_bound_port(prefix + b"9515\n", False)) + self.assertIsNone(parse_bound_port(prefix + b"9515.\n", True)) + self.assertIsNone(parse_bound_port(b"ordinary ChromeDriver diagnostic\n", False)) + self.assertIsNone(parse_bound_port(prefix + b"0.\n", False)) + self.assertIsNone(parse_bound_port(prefix + b"65536.\n", False)) + self.assertEqual(parse_bound_port(prefix + b"9515.\n", False), 9515) + self.assertLess(len(prefix) + len(b"9515.\n"), maximum) + + def test_chromedriver_startup_recovers_after_malformed_candidate(self) -> None: + """One malformed candidate must not prevent a later valid bound-port record.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_startup_recovery") + start_chromedriver = namespace["_start_chromedriver"] + prefix = namespace["CHROMEDRIVER_BOUND_PORT_PREFIX"].encode("ascii") + subprocess_module = namespace["subprocess"] + + class FakeDriver: + def __init__(self) -> None: + self.stdout = io.BytesIO( + prefix + b"not-a-port.\n" + prefix + b"9515.\n" + ) + self.terminate_calls = 0 + self.kill_calls = 0 + self.wait_calls = 0 + + def terminate(self) -> None: + self.terminate_calls += 1 + + def kill(self) -> None: + self.kill_calls += 1 + + def wait(self, timeout: float | None = None) -> int: + del timeout + self.wait_calls += 1 + return 0 + + fake_driver = FakeDriver() + original_popen = subprocess_module.Popen + subprocess_module.Popen = lambda *args, **kwargs: fake_driver + try: + returned_driver, bound_port = start_chromedriver( + pathlib.Path("/reviewed/chromedriver") + ) + finally: + subprocess_module.Popen = original_popen + + self.assertIs(returned_driver, fake_driver) + self.assertEqual(bound_port, 9515) + self.assertEqual(fake_driver.terminate_calls, 0) + self.assertEqual(fake_driver.kill_calls, 0) + self.assertEqual(fake_driver.wait_calls, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mv3_transport_protocol_exception_contract.py b/tests/test_mv3_transport_protocol_exception_contract.py new file mode 100644 index 000000000..22d35c4a4 --- /dev/null +++ b/tests/test_mv3_transport_protocol_exception_contract.py @@ -0,0 +1,114 @@ +"""Regression contracts for bounded MV3 transport failures and their release notes.""" + +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" +CHANGELOG = ROOT / "CHANGELOG.md" + + +class ManifestV3TransportProtocolExceptionContractTests(unittest.TestCase): + """Keep recoverable HTTP parser failures inside the typed runner boundary.""" + + def test_http_protocol_exceptions_are_classified_without_raw_transport_text(self) -> None: + """Parser failures must become bounded errors with no retained raw exception chain.""" + + namespace = runpy.run_path(str(RUNNER), run_name="mv3_transport_contract") + json_request = namespace["_json_request"] + http_module = namespace["http"] + raw_secret = "secret-token /home/runner/private https://example.invalid" + + class BadStatusConnection: + def request(self, *_args: object, **_kwargs: object) -> None: + return None + + def getresponse(self) -> object: + raise http_module.client.BadStatusLine(raw_secret) + + def close(self) -> None: + return None + + class IncompleteReadResponse: + status = 200 + + def read(self, _limit: int) -> bytes: + partial = raw_secret.encode("utf-8") + raise http_module.client.IncompleteRead(partial, len(partial) + 10) + + class IncompleteReadConnection: + def request(self, *_args: object, **_kwargs: object) -> None: + return None + + def getresponse(self) -> IncompleteReadResponse: + return IncompleteReadResponse() + + def close(self) -> None: + return None + + class InvalidUtf8Response: + status = 200 + + def read(self, _limit: int) -> bytes: + return b"\xffsecret-token /home/runner/private https://example.invalid" + + class InvalidUtf8Connection: + def request(self, *_args: object, **_kwargs: object) -> None: + return None + + def getresponse(self) -> InvalidUtf8Response: + return InvalidUtf8Response() + + def close(self) -> None: + return None + + for connection in ( + BadStatusConnection(), + IncompleteReadConnection(), + InvalidUtf8Connection(), + ): + with self.subTest(connection=type(connection).__name__): + with unittest.mock.patch.object( + http_module.client, + "HTTPConnection", + return_value=connection, + ): + with self.assertRaises(RuntimeError) as raised: + json_request(9515, "GET", "/status") + + rendered = str(raised.exception) + self.assertEqual(rendered, "WebDriver transport protocol failure") + self.assertNotIn("secret-token", rendered) + self.assertNotIn("/home/runner/private", rendered) + self.assertNotIn("example.invalid", rendered) + self.assertIsNone(raised.exception.__cause__) + self.assertIsNone(raised.exception.__context__) + + def test_unreleased_changelog_change_type_headings_are_unique(self) -> None: + """Keep each Keep a Changelog change type singular within Unreleased.""" + + text = CHANGELOG.read_text(encoding="utf-8") + marker = "## [Unreleased]" + self.assertIn(marker, text) + unreleased = text.split(marker, 1)[1] + next_release = unreleased.find("\n## [") + if next_release >= 0: + unreleased = unreleased[:next_release] + headings = [ + line.strip() + for line in unreleased.splitlines() + if line.startswith("### ") + ] + self.assertEqual( + len(headings), + len(set(headings)), + f"duplicate Unreleased change-type headings: {headings}", + ) + + +if __name__ == "__main__": + unittest.main()