Skip to content
Draft
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
107 commits
Select commit Hold shift + click to select a range
39c4544
test(mv3): define real downloads compatibility contract
seonghobae Aug 9, 2026
50138f1
feat(mv3): declare downloads compatibility permission
seonghobae Aug 9, 2026
b98c2ca
test(mv3): add deterministic local download fixture
seonghobae Aug 9, 2026
bedc430
feat(mv3): exercise bounded local downloads API
seonghobae Aug 9, 2026
7d25a50
test(mv3): propagate downloads compatibility evidence
seonghobae Aug 9, 2026
054b32a
test(mv3): require downloads evidence every browser pass
seonghobae Aug 9, 2026
5c5f9d0
test(mv3): require bounded surface failure diagnostics
seonghobae Aug 9, 2026
9259e28
fix(mv3): retain bounded surface failure evidence
seonghobae Aug 9, 2026
b10e3cc
test(mv3): require bounded downloads failure stages
seonghobae Aug 9, 2026
20f1d83
fix(mv3): classify bounded download failure stages
seonghobae Aug 9, 2026
dd94266
fix(mv3): propagate bounded download diagnostics
seonghobae Aug 9, 2026
a38d56d
test(mv3): name bounded downloads readiness evidence
seonghobae Aug 10, 2026
643298d
test(mv3): require loopback downloads evidence
seonghobae Aug 10, 2026
61a5a64
fix(mv3): download from controlled fixture origin
seonghobae Aug 10, 2026
c58bde7
test(mv3): keep download diagnostics fixture-bounded
seonghobae Aug 10, 2026
669e308
test(mv3): require bounded download diagnostics in runner evidence
seonghobae Aug 10, 2026
27ce890
fix(mv3): preserve bounded download stage diagnostics
seonghobae Aug 10, 2026
e1042bf
merge: align MV3 downloads proof with protected main
seonghobae Aug 15, 2026
7bd2d43
test(mv3): exercise raw diagnostic sanitization
seonghobae Aug 15, 2026
7d21945
test(mv3): reproduce restart download overwrite race
seonghobae Aug 15, 2026
806f571
fix(mv3): avoid restart download overwrite race
seonghobae Aug 15, 2026
6ffb02e
test(mv3): expose swallowed session cleanup failures
seonghobae Aug 16, 2026
1d391df
fix(mv3): fail closed on unexpected session cleanup
seonghobae Aug 16, 2026
8759518
test(mv3): normalize unittest mock imports
seonghobae Aug 16, 2026
02e4550
test(mv3): reject untrusted browser binary overrides
seonghobae Aug 16, 2026
7cbc2fa
fix(mv3): bind browser executables to pinned workspace paths
seonghobae Aug 16, 2026
f410460
test(mv3): preserve session cleanup failure over teardown errors
seonghobae Aug 16, 2026
319f5b5
fix(mv3): preserve cleanup cause across process teardown
seonghobae Aug 16, 2026
6a1ace6
test(mv3): keep timeout kill fallback non-failing
seonghobae Aug 16, 2026
d60f305
fix(mv3): keep bounded wait timeout fallback successful
seonghobae Aug 16, 2026
ac7f1f5
test(mv3): retain bounded fallback failure evidence
seonghobae Aug 16, 2026
7fc08e5
fix(mv3): retain fallback teardown diagnostics
seonghobae Aug 16, 2026
ab8a6e9
test(mv3): reject raw webdriver error retention
seonghobae Aug 16, 2026
f6f307f
fix(mv3): sanitize webdriver protocol errors
seonghobae Aug 16, 2026
4e24f41
test(mv3): require chrome.downloads primary citation
cursoragent Aug 16, 2026
1141103
docs(mv3): record chrome.downloads APA evidence
cursoragent Aug 16, 2026
d9914c6
test(mv3): reject raw ChromeDriver startup errors
seonghobae Aug 16, 2026
3a35b78
fix(mv3): classify ChromeDriver startup failures
seonghobae Aug 16, 2026
9c29a08
test(mv3): reject raw click postcondition text
seonghobae Aug 16, 2026
e129c28
fix(mv3): bound click mismatch diagnostics
seonghobae Aug 16, 2026
cbd5d8c
docs(mv3): consolidate click diagnostic boundary
seonghobae Aug 16, 2026
c5e13a1
test(mv3): expose unclassified transport protocol exceptions
seonghobae Aug 16, 2026
5b123d9
fix(mv3): classify WebDriver transport protocol failures
cursoragent Aug 16, 2026
1c63849
test(mv3): reject raw browser version diagnostics
seonghobae Aug 16, 2026
6a6e5f7
fix(mv3): classify unexpected browser version
seonghobae Aug 16, 2026
c457f1d
docs(changelog): record classified browser version diagnostics
seonghobae Aug 16, 2026
58b3d08
test(mv3): expose recovered terminate cleanup failure
seonghobae Aug 16, 2026
1db35c9
fix(mv3): accept successful bounded kill fallback
seonghobae Aug 16, 2026
484c74d
docs(changelog): record bounded teardown fallback recovery
seonghobae Aug 16, 2026
0a43e09
test(mv3): make cleanup helper failure explicit
seonghobae Aug 16, 2026
8ba95b9
test(mv3): expose teardown exit race
seonghobae Aug 16, 2026
208447f
test(mv3): leave exit-race repair to canonical owner
seonghobae Aug 16, 2026
a4b1da1
Merge branch 'main' into test/mv3-downloads
seonghobae Aug 16, 2026
b299f76
test(mv3): preserve primary failure through cleanup
seonghobae Aug 16, 2026
c5e33b4
fix(mv3): preserve primary browser-pass failures
seonghobae Aug 16, 2026
b01e973
test(mv3): require bounded ChromeDriver status authority
seonghobae Aug 18, 2026
4be3b77
fix(mv3): verify pinned ChromeDriver status authority
seonghobae Aug 18, 2026
93e4be4
test(mv3): reject ChromeDriver port release-bind race
seonghobae Aug 19, 2026
41462e4
fix(mv3): let ChromeDriver own ephemeral port binding
seonghobae Aug 19, 2026
3ad1978
test(mv3): mock the ChromeDriver startup boundary
seonghobae Aug 19, 2026
4f36c70
test(mv3): adapt primary cleanup contract to startup owner
seonghobae Aug 19, 2026
656d1bd
test(mv3): preserve cleanup contracts after startup hardening
seonghobae Aug 19, 2026
61929f0
test(mv3): reproduce Popen compatibility gate failure
seonghobae Aug 19, 2026
93d36ef
fix(mv3): decode ChromeDriver startup output explicitly
seonghobae Aug 19, 2026
6963e4d
test(mv3): bound chromedriver startup line reads
seonghobae Aug 20, 2026
ecaac90
fix(mv3): bound chromedriver startup output reads
seonghobae Aug 20, 2026
089116b
test(mv3): require recoverable startup candidate parsing
seonghobae Aug 20, 2026
3148876
fix(mv3): ignore malformed startup port candidates
seonghobae Aug 20, 2026
fab11f6
docs(mv3): record startup recovery authority boundary
seonghobae Aug 20, 2026
e434ac9
docs(changelog): record bounded ChromeDriver startup recovery
seonghobae Aug 20, 2026
e501797
test(mv3): prove malformed startup candidate recovery
seonghobae Aug 20, 2026
a45c83e
fix(mv3): preserve chromium sandbox
seonghobae Aug 20, 2026
bf3a2b0
fix(mv3): install chromium sandbox helper
seonghobae Aug 20, 2026
1953a8f
fix(mv3): classify bounded webdriver errors
seonghobae Aug 20, 2026
df8565c
fix(mv3): classify http webdriver errors
seonghobae Aug 20, 2026
84dfe07
fix(mv3): configure chromium sandbox path
seonghobae Aug 20, 2026
dff276c
fix(mv3): retain timeout trial evidence
seonghobae Aug 20, 2026
7b54fdf
test(mv3): reject retained parser exception context
seonghobae Aug 21, 2026
e363cec
fix(mv3): discard sensitive parser exception context
seonghobae Aug 21, 2026
f0c6c36
test(changelog): reject duplicate unreleased sections
seonghobae Aug 21, 2026
803caef
docs(changelog): merge duplicate changed section
seonghobae Aug 21, 2026
1462650
test(mv3): reject symlinked workspace roots
seonghobae Aug 21, 2026
ed15185
revert test-only symlink-root probe
seonghobae Aug 21, 2026
a7a55eb
Merge remote-tracking branch 'origin/main' into pr-43
seonghobae Aug 26, 2026
04e262d
gov(mv3): restore authorized chrome_sandbox setup per issue #212 opti…
seonghobae Aug 26, 2026
89ec46a
test(mv3): preserve primary failure across unreviewed cleanup
seonghobae Aug 27, 2026
1472862
fix(mv3): preserve primary failure across unreviewed cleanup
seonghobae Aug 27, 2026
fe39cef
Merge branch 'main' into test/mv3-downloads
seonghobae Aug 27, 2026
ff3cdc6
test(mv3): distinguish download search rejection evidence
seonghobae Aug 27, 2026
efd2ebf
fix(mv3): preserve download search failure semantics
seonghobae Aug 27, 2026
b49da5e
test(mv3): distinguish download search timeout
seonghobae Aug 27, 2026
3ab72f8
fix(mv3): classify download visibility timeout
seonghobae Aug 27, 2026
6bd43a7
fix(mv3): collapse bounded download wait into timeout
seonghobae Aug 27, 2026
aeddeb4
test(mv3): require timeout after bounded search polling
seonghobae Aug 27, 2026
6eece54
test(mv3): reject invalid UTF-8 transport evidence
seonghobae Aug 27, 2026
f1d0720
fix(mv3): classify invalid UTF-8 transport responses
seonghobae Aug 27, 2026
9273feb
fix(mv3): drop raw decode exception context
seonghobae Aug 27, 2026
2eca56c
fix(mv3): remove dead decode fallback assignment
seonghobae Aug 27, 2026
d71e5b4
fix(mv3): restore complete compatibility runner
seonghobae Aug 27, 2026
4fca1c6
test(mv3): require direct UTF-8 transport failure translation
seonghobae Aug 27, 2026
b3e9ba1
fix(mv3): translate UTF-8 transport failures directly
seonghobae Aug 27, 2026
e48981d
test(mv3): reject retained UTF-8 decode exception context
seonghobae Aug 27, 2026
3549f10
fix(mv3): avoid retaining invalid UTF-8 response context
seonghobae Aug 27, 2026
c5b7e61
test(mv3): reproduce Python 3.10 cleanup-note masking
seonghobae Aug 28, 2026
28e79fe
fix(mv3): preserve cleanup cause on Python 3.10
seonghobae Aug 28, 2026
e5db34c
chore(mv3): non-force restack downloads evidence on protected main
seonghobae Sep 3, 2026
6f3134d
fix(mv3): restore sandbox after restack
seonghobae Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 95 additions & 12 deletions scripts/ci/run_mv3_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
W3C WebDriver HTTP protocol only to prove that a real Chrome for Testing build
can load the controlled MV3 fixture and repeatedly exercise service-worker,
content-script, storage, declarative-net-request, tabs, windows, scripting,
commands, side-panel, bookmarks, history, real browser-click, and
commands, side-panel, bookmarks, history, downloads, real browser-click, and
restart-persistence behavior.
"""

Expand Down Expand Up @@ -36,6 +36,54 @@
MAX_WEBDRIVER_RESPONSE_BYTES = 1_048_576
W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf"
PATH_TOKEN_CHARACTERS = frozenset(string.ascii_letters + string.digits + "-_.")
SURFACE_EVIDENCE_KEYS = (
"content",
"storage",
"storagePersistence",
"workerReply",
"workerState",
"workerStartCount",
"dnr",
"tabs",
"windows",
"scripting",
"scriptingExecuted",
"commands",
"sidePanel",
"bookmarks",
"history",
"downloads",
"downloadsDiagnostic",
)
SURFACE_EVIDENCE_VALUES = frozenset(
{"ready", "missing", "initialized", "persisted", "pong", "installed", "blocked"}
)
DOWNLOAD_DIAGNOSTIC_VALUES = frozenset(
{
"download-source-rejected",
"download-start-rejected",
"download-search-missing",
"download-interrupted",
"download-url-mismatch",
"download-byte-count-mismatch",
"download-exists-false",
"download-timeout",
"download-complete-ready",
"download-not-evaluated",
}
)


class CompatibilitySurfaceError(RuntimeError):
"""Report only bounded fixture-surface state when real-browser evidence does not converge."""

def __init__(self, observed: dict[str, str]) -> None:
self.observed = {
key: _safe_surface_value(key, observed[key])
for key in SURFACE_EVIDENCE_KEYS
if key in observed
}
super().__init__("Manifest V3 fixture surfaces did not converge")


class QuietFixtureHandler(http.server.SimpleHTTPRequestHandler):
Expand All @@ -45,6 +93,30 @@ def log_message(self, _format: str, *args: object) -> None:
"""Suppress request logs because the fixture contains no diagnostic value."""


def _safe_surface_value(key: str, value: str) -> str:
"""Reduce one controlled DOM evidence value to a non-sensitive diagnostic token."""

if key == "workerStartCount":
return value if value.isdecimal() and len(value) <= 20 else "invalid"
if key == "downloadsDiagnostic":
return value if value in DOWNLOAD_DIAGNOSTIC_VALUES else "unexpected"
return value if value in SURFACE_EVIDENCE_VALUES else "unexpected"


def _failure_evidence(error: BaseException) -> dict[str, Any]:
"""Classify one browser-trial failure without retaining raw exception text."""

if isinstance(error, CompatibilitySurfaceError):
return {"failure_kind": "surface_mismatch", "observed": error.observed}
if isinstance(error, json.JSONDecodeError):
return {"failure_kind": "json_decode_error"}
if isinstance(error, OSError):
return {"failure_kind": "io_error"}
if isinstance(error, ValueError):
return {"failure_kind": "value_error"}
return {"failure_kind": "runtime_error"}


def _free_loopback_port() -> int:
"""Reserve and release one loopback TCP port for a short-lived local service."""

Expand Down Expand Up @@ -178,7 +250,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 = {
Expand All @@ -196,6 +271,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] = {}
Expand All @@ -212,9 +289,7 @@ def _wait_for_extension_evidence(
):
return latest
time.sleep(0.1)
raise RuntimeError(
f"MV3 fixture did not converge: expected={expected!r}, observed={latest!r}"
)
raise CompatibilitySurfaceError(latest)


def _exercise_real_click(driver_port: int, session_id: str) -> str:
Expand Down Expand Up @@ -268,6 +343,8 @@ def _run_browser_pass(

driver_port = _free_loopback_port()
session_id: str | None = None
download_dir = pathlib.Path(profile_dir) / "downloads"
download_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
driver = subprocess.Popen(
[str(chromedriver_bin), f"--port={driver_port}", "--allowed-ips=127.0.0.1"],
stdout=subprocess.DEVNULL,
Expand Down Expand Up @@ -298,6 +375,11 @@ def _run_browser_pass(
f"--disable-extensions-except={FIXTURE}",
f"--load-extension={FIXTURE}",
],
"prefs": {
"download.default_directory": str(download_dir),
Comment thread
seonghobae marked this conversation as resolved.
"download.prompt_for_download": False,
"download.directory_upgrade": True,
},
Comment thread
seonghobae marked this conversation as resolved.
},
}
}
Expand Down Expand Up @@ -349,6 +431,7 @@ def _run_browser_pass(
"side-panel": surfaces["sidePanel"] == "ready",
"bookmarks": surfaces["bookmarks"] == "ready",
"history": surfaces["history"] == "ready",
"downloads": surfaces["downloads"] == "ready",
"real-browser-click": click_result == "clicked",
},
}
Expand Down Expand Up @@ -468,13 +551,13 @@ def main() -> int:
trial_number,
)
)
except (OSError, ValueError, RuntimeError, json.JSONDecodeError):
trial_results.append(
{
"trial_number": trial_number,
"passed": False,
}
)
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
Comment thread
seonghobae marked this conversation as resolved.
Outdated
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
Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/mv3_basic/content_script.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
})();
1 change: 1 addition & 0 deletions tests/fixtures/mv3_basic/download.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
OriginWeave deterministic MV3 download fixture.
3 changes: 2 additions & 1 deletion tests/fixtures/mv3_basic/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"scripting",
"sidePanel",
"bookmarks",
"history"
"history",
"downloads"
],
"host_permissions": [
"http://127.0.0.1/*"
Expand Down
90 changes: 90 additions & 0 deletions tests/fixtures/mv3_basic/service_worker.js
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -16,6 +20,85 @@ async function ensureWorkerState() {
return "installed";
}

async function waitForDownload(downloadId, expectedUrl) {
const expectedBytes = new TextEncoder().encode(DOWNLOAD_PAYLOAD).byteLength;
let observedDownload = false;
for (let attempt = 0; attempt < DOWNLOAD_POLL_ATTEMPTS; attempt += 1) {
let items;
try {
items = await chrome.downloads.search({ id: downloadId, limit: 1 });
} catch (_error) {
return { ready: false, diagnostic: "download-search-missing" };
}
if (!Array.isArray(items) || items.length !== 1) {
await new Promise((resolve) => setTimeout(resolve, DOWNLOAD_POLL_INTERVAL_MS));
continue;
}
observedDownload = true;
const item = items[0];
if (item.state === "interrupted") {
return { ready: false, diagnostic: "download-interrupted" };
}
if (item.state === "complete") {
if (item.url !== expectedUrl) {
return { ready: false, diagnostic: "download-url-mismatch" };
}
if (item.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" };
Comment thread
seonghobae marked this conversation as resolved.
}
await new Promise((resolve) => setTimeout(resolve, DOWNLOAD_POLL_INTERVAL_MS));
}
return {
ready: false,
diagnostic: observedDownload ? "download-timeout" : "download-search-missing",
};
Comment thread
seonghobae marked this conversation as resolved.
Outdated
}

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: "overwrite",
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);
Comment thread
seonghobae marked this conversation as resolved.
}

async function exerciseCoreApis(sender) {
const tabId = sender?.tab?.id;
if (!Number.isInteger(tabId)) {
Expand Down Expand Up @@ -56,6 +139,9 @@ async function exerciseCoreApis(sender) {
});
const historyReady = Array.isArray(historyItems);

const downloadResult = await exerciseDownload(sender);
const downloadsReady = downloadResult.ready;
Comment thread
seonghobae marked this conversation as resolved.

return {
tabs: tabReady ? "ready" : "missing",
windows: windowReady ? "ready" : "missing",
Expand All @@ -64,6 +150,8 @@ async function exerciseCoreApis(sender) {
sidePanel: sidePanelReady ? "ready" : "missing",
bookmarks: bookmarksReady ? "ready" : "missing",
history: historyReady ? "ready" : "missing",
downloads: downloadsReady ? "ready" : "missing",
downloadsDiagnostic: downloadResult.diagnostic,
};
}

Expand Down Expand Up @@ -91,6 +179,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
sidePanel: "missing",
bookmarks: "missing",
history: "missing",
downloads: "missing",
downloadsDiagnostic: "download-not-evaluated",
});
}
);
Expand Down
17 changes: 17 additions & 0 deletions tests/test_mv3_compatibility_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,23 @@ def test_runner_reports_repeated_trial_pass_rate(self) -> None:
with self.subTest(expected=expected):
self.assertIn(expected, runner)

def test_runner_preserves_safe_surface_failure_evidence(self) -> None:
"""A failed trial must identify the bounded fixture surface without leaking raw errors."""

namespace = runpy.run_path(str(RUNNER), run_name="mv3_contract")
surface_error = namespace["CompatibilitySurfaceError"]
failure_evidence = namespace["_failure_evidence"]

observed = {"downloads": "missing", "storage": "ready"}
diagnostic = failure_evidence(surface_error(observed))
self.assertEqual(diagnostic["failure_kind"], "surface_mismatch")
self.assertEqual(diagnostic["observed"], observed)

generic = failure_evidence(
RuntimeError("secret-token https://example.invalid /home/runner/private")
)
self.assertEqual(generic, {"failure_kind": "runtime_error"})

def test_workflow_runs_the_real_browser_lane_without_model_credentials(self) -> None:
"""Compatibility evidence must execute Chromium and never require LLM secrets."""

Expand Down
Loading
Loading