From f6905719c3c80d829037e509d6e46129b131d893 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:31:29 +0800 Subject: [PATCH] fix(browser): bound capture work and preserve observation gaps Own service-worker CDP registrations and retry failed teardown. Admit redaction and network metadata against remaining capture capacity, retain explicit omission reasons, and preserve source URL, redirect ancestry, caller-selected content, and historical capture values. Forward CLI cancellation through provider cleanup. Reviewed fixes: #2263, #2637, #2767, #2845, #2859, #2863, #2872, #2874, #2876, #2879, #2955, #2957, #2961, #3033. Validation: 25 current event/network source tests passed; earlier focused browser lanes passed 201 source tests and 176 boundary tests with two skips, plus 35 Python tests. Real browser/Windows lanes were not rerun. Playwright allocation before admission remains unbounded by these retention limits. --- bridge/mitmproxy/capture.py | 118 ++- bridge/mitmproxy/test_capture.py | 174 +++++ docs/browser-observation.md | 32 +- docs/browser-scenario-contract.md | 41 +- .../browser-capture-metadata-budget-e2e.mjs | 4 +- ...wser-scenario-service-worker-setup-e2e.mjs | 260 +++++++ .../browser-scenario-service-workers-e2e.mjs | 605 ++++++++++++++++ scripts/verify-real-browser.mjs | 7 + src/application/BrowserObservationService.ts | 17 +- src/browser/BrowserScenarioSecrets.ts | 22 + src/browser/CdpBrowserProvider.ts | 32 +- .../CdpCaptureDocuments.baseUrl.test.ts | 36 + src/browser/CdpCaptureDocuments.ts | 3 +- src/browser/CdpWebMcpDiscovery.ts | 28 +- .../PlaywrightElectronActiveProvider.test.ts | 21 + .../PlaywrightElectronActiveProvider.ts | 20 +- src/browser/PlaywrightScenarioEvents.test.ts | 156 +++- src/browser/PlaywrightScenarioEvents.ts | 104 ++- .../PlaywrightScenarioServiceWorkers.ts | 307 ++++++++ src/browser/PlaywrightScenarioSession.test.ts | 311 +++++++- src/browser/PlaywrightScenarioSession.ts | 107 ++- src/browser/WebSourceMapFetcher.ts | 16 +- src/browser/assets/LocalWebScriptArtifacts.ts | 5 + src/browser/history/CaptureRedaction.ts | 17 + src/browser/history/HarCapture.test.ts | 33 + src/browser/history/HarCapture.ts | 9 +- src/browser/history/QlogCapture.test.ts | 25 + src/browser/history/QlogCapture.ts | 20 +- src/browser/history/SazMetadata.test.ts | 24 + src/browser/history/SazMetadata.ts | 10 +- .../network/PlaywrightNetworkContent.ts | 43 +- .../network/PlaywrightScenarioNetwork.test.ts | 117 ++- .../network/PlaywrightScenarioNetwork.ts | 239 ++++-- src/captures/TsharkCaptureProvider.ts | 1 + src/cli/browserAdvancedCommands.ts | 78 +- src/cli/browserCommands.ts | 191 ++--- src/cliJavaScriptRuntimeCommands.ts | 103 +-- .../analysisView/pcapCaptureView.test.ts | 99 +++ src/domain/analysisView/pcapCaptureView.ts | 13 +- .../webNetworkCaptureView.test.ts | 683 ++++++++++++++++++ .../analysisView/webNetworkCaptureView.ts | 306 +++++++- src/domain/browserScenarioCapture.test.ts | 8 + src/domain/browserScenarioCaptureValues.ts | 196 ++--- src/domain/browserScenarioDiff.test.ts | 304 ++++++++ src/domain/browserScenarioDiff.ts | 197 ++++- src/domain/browserScenarioDiffEvidence.ts | 81 ++- src/domain/browserScenarioDiffValues.ts | 15 +- .../browserScenarioNetworkComparison.ts | 222 ++++++ src/domain/browserScenarioNormalization.ts | 38 +- src/domain/webCaptureDiff.ts | 58 +- src/domain/webMcpDiscovery.test.ts | 128 ++++ src/domain/webMcpDiscovery.ts | 3 + ...cdpBrowserProvider.document_script.test.ts | 126 ++-- .../browser/historicalWebCapture.test.ts | 62 +- tests/boundary/browser/webCaptureDiff.test.ts | 59 ++ .../browser/webSourceMapFetcher.test.ts | 28 + tests/boundary/cli/browserCli.test.ts | 9 +- tests/fixtures/fakeCdpBrowserEvents.ts | 283 ++++---- tests/fixtures/fakeCdpBrowserTypes.ts | 1 + tests/fixtures/webScriptCapture.ts | 1 + 60 files changed, 5539 insertions(+), 717 deletions(-) create mode 100644 bridge/mitmproxy/test_capture.py create mode 100644 scripts/lib/browser-scenario-service-worker-setup-e2e.mjs create mode 100644 scripts/lib/browser-scenario-service-workers-e2e.mjs create mode 100644 src/browser/PlaywrightScenarioServiceWorkers.ts create mode 100644 src/browser/history/SazMetadata.test.ts create mode 100644 src/domain/analysisView/pcapCaptureView.test.ts create mode 100644 src/domain/browserScenarioNetworkComparison.ts create mode 100644 src/domain/webMcpDiscovery.test.ts diff --git a/bridge/mitmproxy/capture.py b/bridge/mitmproxy/capture.py index dcd90e745e..4123060120 100644 --- a/bridge/mitmproxy/capture.py +++ b/bridge/mitmproxy/capture.py @@ -41,6 +41,87 @@ class BoundedReader: return self.handle.read(count) +SPECIAL_SCHEMES = {"ftp", "file", "http", "https", "ws", "wss"} +SCHEME_PREFIX = r"^[\x00-\x20]*([a-z][a-z0-9+.-]*):" +NETWORK_PREFIX = r"^[\x00-\x20]*[/\\]{2}" +URL_IGNORABLE_CONTROLS = "\t\n\r" +URL_CONTROL_TRANSLATION = {ord(char): None for char in URL_IGNORABLE_CONTROLS} + + +def authority_start(text, base_scheme=None): + """Return where an authority begins and whether it is special, or None. + + Follows WHATWG URL parsing as src/domain/recordedTransportUrl.ts does: + special schemes (and scheme-relative references) skip every leading "/" + or "\\" before the authority, so `https://\\user:pass@host` still has + userinfo. File URLs carry no userinfo and keep their "//" boundary. + """ + scheme = re.match(SCHEME_PREFIX, text, flags=re.I) + name = scheme.group(1).lower() if scheme is not None else None + if name is not None and name in SPECIAL_SCHEMES and name != "file": + # A same-scheme Location without two slash-equivalent separators is + # relative to the recorded request, so its @ belongs to the path. + following = text[scheme.end():scheme.end() + 2] + if name == base_scheme and (len(following) < 2 or any(char not in "/\\" for char in following)): + return None + start = scheme.end() + else: + if scheme is not None: + if text[scheme.end():scheme.end() + 2] != "//": + return None + return scheme.end() + 2, name in SPECIAL_SCHEMES + # A non-special base treats backslashes as authority data and only + # recognizes two forward slashes as a network reference. + if base_scheme is not None and base_scheme not in SPECIAL_SCHEMES: + prefix = re.match(r"^[\x00-\x20]*//", text) + return None if prefix is None else (prefix.end(), False) + prefix = re.match(NETWORK_PREFIX, text) + if prefix is None: + return None + start = prefix.end() + if base_scheme == "file": + return start, True + while start < len(text) and text[start] in "/\\": + start += 1 + return start, True + + +def without_userinfo(value, base_scheme=None): + """Remove an authority's userinfo using the WHATWG authority boundary. + + Special schemes and scheme-relative references also end the authority + at a backslash, so later path text containing "@" is not userinfo. + """ + binary = isinstance(value, bytes) + text = value.decode("latin-1") if binary else value + # WHATWG removes these controls before interpreting scheme/separators. + # Keep that parse view separate from the exact source spelling and bytes. + parsed_text = text.translate(URL_CONTROL_TRANSLATION) + located = authority_start(parsed_text, base_scheme) + if located is None: + return value + start, special = located + terminators = "/?#\\" if special else "/?#" + end = start + while end < len(parsed_text) and parsed_text[end] not in terminators: + end += 1 + at = parsed_text.rfind("@", start, end) + if at < start: + return value + parsed_offset = 0 + raw_start = 0 + for raw_offset, char in enumerate(text): + if char in URL_IGNORABLE_CONTROLS: + continue + if parsed_offset == start: + raw_start = raw_offset + if parsed_offset == at: + safe = text[:raw_start] + text[raw_offset + 1 :] + return safe.encode("latin-1") if binary else safe + parsed_offset += 1 + raise CaptureFailure("decoder", "Normalized URL authority could not be bound to its source.") + + def project_record(state, secrets): binaries, numbers, redactions = [], [], [] byte_secrets = [] @@ -58,16 +139,23 @@ def project_record(state, secrets): return None return text - def transport_value(value, path, transport_url): + def transport_value(value, path, transport_url, base_scheme): if not (transport_url or re.fullmatch(r"/(?:backup/)*request/(?:path|authority)", path)): return value, False binary = isinstance(value, bytes) - pattern = rb"^([\x00-\x20]*(?:[a-z][a-z0-9+.-]*:)?//)[^/?#]*@" if binary else r"^([\x00-\x20]*(?:[a-z][a-z0-9+.-]*:)?//)[^/?#]*@" - replacement = rb"\1" if binary else r"\1" separator = b"@" if binary else "@" - safe_url = re.sub(pattern, replacement, value, flags=re.I) - if path.endswith("/authority") and separator in safe_url: - safe_url = safe_url.rsplit(separator, 1)[1] + if transport_url: + safe_url = without_userinfo(value, base_scheme) + elif path.endswith("/authority"): + safe_url = value.rsplit(separator, 1)[-1] + else: + # Native request.path is origin-form unless it explicitly carries + # a scheme (absolute-form). Leading separators are path evidence, + # not a header URL reference's network authority. + text = value.decode("latin-1") if binary else value + safe_url = without_userinfo(value) if re.match( + SCHEME_PREFIX, text.translate(URL_CONTROL_TRANSLATION), flags=re.I + ) else value changed = safe_url != value if changed: redactions.append({"pointer": path, "reason": "transport-credential"}) @@ -80,7 +168,7 @@ def project_record(state, secrets): del numbers[before[1]:] del redactions[before[2]:] - def visit(value, path, depth=0, credential=False, transport_url=False): + def visit(value, path, depth=0, credential=False, transport_url=False, base_scheme=None): if depth > 64: raise CaptureFailure("input-limit", "Native capture exceeds the 64-level evidence nesting budget.", path) if credential: @@ -90,7 +178,7 @@ def project_record(state, secrets): binaries.append({"pointer": path, "representation": "producer-bytes", "state": "redacted", "content_base64": None, "bytes": None, "sha256": None}) return None if isinstance(value, bytes): - safe_bytes, credential_url = transport_value(value, path, transport_url) + safe_bytes, credential_url = transport_value(value, path, transport_url, base_scheme) content_base64 = base64.b64encode(value).decode("ascii") digest = hashlib.sha256(value).hexdigest() hidden = any(secret in value or secret in safe_bytes for secret in byte_secrets) or any(secret in content_base64 or secret in digest or secret in str(len(value)) for secret in secrets) @@ -107,7 +195,7 @@ def project_record(state, secrets): return None return safe_text if isinstance(value, str): - safe_text, _ = transport_value(value, path, transport_url) + safe_text, _ = transport_value(value, path, transport_url, base_scheme) return redact(safe_text, path, value) if isinstance(value, bool) or value is None: return value @@ -125,10 +213,16 @@ def project_record(state, secrets): for index, field in enumerate(value): if not isinstance(field, (list, tuple)) or len(field) != 2 or not isinstance(field[0], bytes): raise CaptureFailure("format", "Native header/trailer is not an ordered byte pair.", pointer(path, index)) - result.append([visit(field[0], pointer(pointer(path, index), 0), depth + 2), visit(field[1], pointer(pointer(path, index), 1), depth + 2, field[0].lower() in CREDENTIALS, field[0].lower() in {b"location", b"referer", b"origin"})]) + result.append([visit(field[0], pointer(pointer(path, index), 0), depth + 2), visit(field[1], pointer(pointer(path, index), 1), depth + 2, field[0].lower() in CREDENTIALS, field[0].lower() in {b"location", b"referer", b"origin"}, base_scheme if field[0].lower() == b"location" else None)]) return result - return [visit(item, pointer(path, index), depth + 1) for index, item in enumerate(value)] + return [visit(item, pointer(path, index), depth + 1, base_scheme=base_scheme) for index, item in enumerate(value)] if isinstance(value, dict): + if path == "" or re.fullmatch(r"/backup(?:/backup)*", path): + request = value.get("request") + scheme = request.get("scheme") if isinstance(request, dict) else None + if isinstance(scheme, bytes): + scheme = scheme.decode("latin-1") + base_scheme = scheme.lower() if isinstance(scheme, str) and re.fullmatch(r"[a-z][a-z0-9+.-]*", scheme, flags=re.I) else None result = {} for key, item in value.items(): if not isinstance(key, str): @@ -139,7 +233,7 @@ def project_record(state, secrets): validate_omitted(item, pointer(path, key), depth + 1) redactions.append({"pointer": path, "reason": "explicit-sensitive-value", "scope": "property-name"}) continue - result[key] = visit(item, pointer(path, key), depth + 1) + result[key] = visit(item, pointer(path, key), depth + 1, base_scheme=base_scheme) return result raise CaptureFailure("decoder", "Native parser returned an unexpected value type: " + type(value).__name__ + ".", path) diff --git a/bridge/mitmproxy/test_capture.py b/bridge/mitmproxy/test_capture.py new file mode 100644 index 0000000000..f0a1106b1b --- /dev/null +++ b/bridge/mitmproxy/test_capture.py @@ -0,0 +1,174 @@ +"""Source-owned tests for the mitmproxy adapter's record projection.""" + +import importlib.util +import os +import sys +import types +import unittest +from unittest import mock + + +def load_capture(): + """Load the adapter with fresh mitmproxy stand-ins scoped to its import.""" + fake_mitmproxy = types.ModuleType("mitmproxy") + fake_mitmproxy.ctx = types.SimpleNamespace() + fake_mitmproxy.version = types.SimpleNamespace(VERSION="12.2.3") + fake_io = types.ModuleType("mitmproxy.io") + fake_io.tnetstring = types.ModuleType("mitmproxy.io.tnetstring") + stand_ins = { + "mitmproxy": fake_mitmproxy, + "mitmproxy.io": fake_io, + "mitmproxy.io.tnetstring": fake_io.tnetstring, + } + spec = importlib.util.spec_from_file_location( + "rea_mitmproxy_capture", os.path.join(os.path.dirname(__file__), "capture.py") + ) + module = importlib.util.module_from_spec(spec) + with mock.patch.dict(sys.modules, stand_ins): + spec.loader.exec_module(module) + return module + + +capture = load_capture() + + +def location(value, scheme=None): + state = {"response": {"headers": [[b"Location", value]]}} + if scheme is not None: + state["request"] = {"scheme": scheme} + record = capture.project_record(state, []) + sidecar = next( + field for field in record["binary_fields"] if field["pointer"] == "/response/headers/0/1" + ) + return record["reported"]["response"]["headers"][0][1], sidecar, record["redactions"] + + +class TransportUserinfoTests(unittest.TestCase): + def test_ignorable_controls_cannot_hide_authority_credentials(self): + for control in [b"\t", b"\n", b"\r"]: + for value in [b"https://" + control + b"/user:pass@host/path", b"ht" + control + b"tps://user:" + control + b"pass@host/path"]: + with self.subTest(value=value): + text, sidecar, redactions = location(value, b"https") + self.assertNotIn("user", text) + self.assertNotIn("pass", text) + self.assertEqual(sidecar["state"], "redacted") + self.assertEqual(len(redactions), 1) + + def test_mixed_network_separators_remove_credentials(self): + for prefix in [b"\\\\", b"/\\", b"\\/"]: + with self.subTest(prefix=prefix): + text, sidecar, redactions = location(prefix + b"user:pass@host/path", b"https") + self.assertEqual(text, prefix.decode("ascii") + "host/path") + self.assertEqual(sidecar["state"], "redacted") + self.assertEqual(len(redactions), 1) + + def test_non_special_base_keeps_backslashes_inside_network_authorities(self): + text, sidecar, redactions = location(b"//user\\name:pass@host/x", b"foo") + self.assertEqual(text, "//host/x") + self.assertEqual(sidecar["state"], "redacted") + self.assertEqual(len(redactions), 1) + text, sidecar, redactions = location(b"/\\invoice@customer", b"foo") + self.assertEqual(text, "/\\invoice@customer") + self.assertEqual(sidecar["state"], "retained") + self.assertEqual(redactions, []) + + def test_native_request_paths_preserve_origin_form_and_redact_absolute_form(self): + for value in [b"/\\invoice@customer", b"//invoice@customer", b"\\\\invoice@customer"]: + with self.subTest(value=value): + record = capture.project_record({"request": {"path": value}}, []) + self.assertEqual(record["reported"]["request"]["path"], value.decode("ascii")) + self.assertEqual(record["binary_fields"][0]["state"], "retained") + self.assertEqual(record["redactions"], []) + record = capture.project_record({"request": {"path": b"https://user:pass@host/x"}}, []) + self.assertEqual(record["reported"]["request"]["path"], "https://host/x") + self.assertEqual(record["binary_fields"][0]["state"], "redacted") + + def test_same_scheme_locations_preserve_relative_path_text_and_bytes(self): + for value in [b"https:invoice@customer", b"https:/invoice@customer", b"https:\\invoice@customer"]: + with self.subTest(value=value): + text, sidecar, redactions = location(value, b"https") + self.assertEqual(text, value.decode("ascii")) + self.assertEqual(sidecar["state"], "retained") + self.assertEqual(redactions, []) + + def test_location_authorities_still_remove_credentials_with_a_base_scheme(self): + for value, scheme, expected in [ + (b"https://user:pass@host/path", b"https", "https://host/path"), + (b"https:\\\\user:pass@host/path", b"https", "https:\\\\host/path"), + (b"https:user:pass@host/path", b"http", "https:host/path"), + ]: + with self.subTest(value=value, scheme=scheme): + text, sidecar, redactions = location(value, scheme) + self.assertEqual(text, expected) + self.assertEqual(sidecar["state"], "redacted") + self.assertEqual(len(redactions), 1) + + def test_backup_location_uses_its_own_request_scheme(self): + record = capture.project_record({ + "request": {"scheme": b"http"}, + "backup": { + "request": {"scheme": b"https"}, + "response": {"headers": [[b"Location", b"https:invoice@customer"]]}, + }, + }, []) + self.assertEqual(record["reported"]["backup"]["response"]["headers"][0][1], "https:invoice@customer") + self.assertEqual(record["redactions"], []) + + def test_backslash_ends_a_special_scheme_authority(self): + text, sidecar, redactions = location(b"https://example.test\\part@tail") + self.assertEqual(text, "https://example.test\\part@tail") + self.assertEqual(sidecar["state"], "retained") + self.assertIsNotNone(sidecar["content_base64"]) + self.assertEqual(redactions, []) + + def test_userinfo_before_a_backslash_is_still_removed(self): + text, sidecar, redactions = location(b"https://user:pw@example.test\\path") + self.assertEqual(text, "https://example.test\\path") + self.assertEqual(sidecar["state"], "redacted") + self.assertEqual( + redactions, + [{"pointer": "/response/headers/0/1", "reason": "transport-credential"}], + ) + + def test_ordinary_userinfo_is_removed(self): + text, _sidecar, _redactions = location(b"https://user:pw@example.test/a@b") + self.assertEqual(text, "https://example.test/a@b") + + def test_empty_userinfo_marker_is_removed(self): + self.assertEqual( + capture.without_userinfo("https://@host"), "https://host" + ) + + def test_special_schemes_skip_leading_slashes_before_userinfo(self): + for value, expected in [ + (b"https://\\user:pass@host/path", b"https://\\host/path"), + (b"https:\\\\user:pass@host/path", b"https:\\\\host/path"), + (b"https:user:pass@host", b"https:host"), + (b"//\\user:pass@host", b"//\\host"), + ]: + with self.subTest(value=value): + self.assertEqual(capture.without_userinfo(value), expected) + text, sidecar, redactions = location(b"https://\\user:pass@host/path") + self.assertEqual(text, "https://\\host/path") + self.assertEqual(sidecar["state"], "redacted") + self.assertEqual(len(redactions), 1) + + def test_file_base_keeps_paths_after_the_fixed_network_boundary(self): + for value in [b"///u@h", b"////u@h"]: + with self.subTest(value=value): + text, sidecar, redactions = location(value, b"file") + self.assertEqual(text, value.decode("ascii")) + self.assertEqual(sidecar["state"], "retained") + self.assertIsNotNone(sidecar["content_base64"]) + self.assertEqual(redactions, []) + + def test_file_urls_keep_path_text(self): + self.assertEqual(capture.without_userinfo("file:///a@b"), "file:///a@b") + + def test_non_special_schemes_keep_backslashes_in_the_authority(self): + self.assertEqual(capture.without_userinfo("foo://a\\b@host/x"), "foo://host/x") + self.assertEqual(capture.without_userinfo(b"//a\\b@host"), b"//a\\b@host") + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 879d01520a..9479f4b3c6 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -18,7 +18,7 @@ Inspect native SAZ or HAR 1.2 offline with REA, or connect Everywhere's separate - `inspect_web_page` / `rea inspect-web-page` captures DOM structure, accessibility nodes, scripts, resources, safe response/DOM metadata, attach-window network and console metadata, WebSocket frame sizes, workers, quota, and optionally selected storage key names, redacted storage-content fingerprints, or script sources. - `analyze_web_bundle` / `rea analyze-web-bundle` parses selected script artifacts without execution and derives chunk edges, route and endpoint candidates, vendor fingerprints, static WebMCP declarations, and optionally source-map/original-source evidence. - `observe_web_session` / `rea observe-web-session` arms an observation window of the requested duration for an external user action and records ordered reload, SPA navigation, redirect, failure, lifecycle, and target-termination metadata. -- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return every in-scope registration and its complete observed `input_schema` declaration and canonical `input_schema_sha256` digest inline. Declarations are untrusted JSON data, including declared types, required fields, enums, examples and reference URLs. REA does not resolve schema references. These fields replace `input_schema_shape`. Registrations with the same name and URL in distinct observed frames retain separate identities. `tool_key` includes the observed frame ID; it is not a stable identity across navigation or browser sessions. Same-frame registration updates replace that owner only, and removal leaves other owners intact. REA never exposes `WebMCP.invokeTool`; malformed protocol payloads fail explicitly instead of returning incomplete tool metadata. +- `discover_webmcp_tools` / `rea discover-webmcp-tools` uses the experimental CDP WebMCP domain to return every in-scope registration and its complete observed `input_schema` declaration and canonical `input_schema_sha256` digest inline. Declarations are untrusted JSON data, including declared types, required fields, enums, examples and reference URLs. REA does not resolve schema references. These fields replace `input_schema_shape`. Registrations with the same name and URL in distinct observed frames retain separate identities. `tool_key` includes the observed frame ID; it is not a stable identity across navigation or browser sessions. Same-frame registration updates replace that owner only, and removal leaves other owners intact. Declared `readOnly`, `untrustedContent`, `consequential`, and `autosubmit` annotation hints are reported per registration: `true`/`false` are declared values, `null` means the declaration did not provide the hint in that capture, and a missing `consequential` key means the hint was not recorded, either in a historical capture or because the observed annotation was invalid. Consult discovery completeness and exclusions to distinguish these cases. Malformed annotation values retain the registration with incomplete coverage and an `invalid_protocol_value` exclusion. Other malformed protocol payloads can fail discovery explicitly. REA never exposes `WebMCP.invokeTool`. - `compare_web_captures` / `rea compare-web-captures` compares passive captures or exact-step-aligned browser scenarios. Scenario results commit reproducible literal normalization and expose alignment failures plus artifact-level action, screenshot, DOM, accessibility, URL, history, storage, and event differences. Missing or truncated evidence is never treated as equivalence. - `capture_web_screenshot` / `rea capture-web-screenshot` returns a content-addressed visible-viewport PNG. - `compare_web_screenshots` / `rea compare-web-screenshots` performs bounded local PNG pixel comparison without OCR or external services. @@ -190,6 +190,36 @@ rea discover-webmcp-tools http://127.0.0.1:9222 TARGET_ID \ --json ``` +### WebMCP discovery prerequisites + +`discover_webmcp_tools` reports only what the attached browser's experimental +`WebMCP` CDP domain exposes. When a browser rejects the unknown domain, the +result reports `status: "unavailable"`. Whether a page can register tools at +all depends on several conditions that are not interchangeable: + +- A site's own origin-trial token can enable the API for that origin + **provided the trial is still active, the browser version and platform + support it, and user settings permit it** — token presence alone is not a + guarantee. The trial is announced for Chrome 149 and later. +- The `WebMCPTesting` feature — `--enable-features=WebMCPTesting` at launch, or + `chrome://flags/#enable-webmcp-testing` in a normal profile — enables the + API page-side for that browser instance, independent of per-site tokens. + In one pilot run, a site whose registration code was present but whose + served token was scoped for third-party use registered 0 tools under a + default launch and 4 under the flag; that the token did not enroll the + first-party document is consistent with this but is an inference, not a + measured Chrome behavior. + +In Chrome 154 specifically (one tested version), `WebMCP.enable` answered +under a default launch — the debug domain was not flag-gated in that run. +Other versions may differ, so an unattributed zero-tool result should be +read as registration state only after domain availability is confirmed. + +Discovery is attach-only: the command needs a reachable loopback CDP endpoint +and a target ID from `list_browser_targets`, and it never navigates, reloads, +or evaluates the page. Observe the page in the state you want to measure; +registered tools reflect the document as loaded. + Accessibility names/descriptions, console primitive text, JSON body shapes, WebSocket shapes, script content, and storage key names are selected independently: ```bash diff --git a/docs/browser-scenario-contract.md b/docs/browser-scenario-contract.md index 403865be8c..a581129b70 100644 --- a/docs/browser-scenario-contract.md +++ b/docs/browser-scenario-contract.md @@ -15,6 +15,12 @@ there is no separate origin allowlist or request-interception layer. Environment settings (including service workers blocked by default), empty storage, and a final sanitized URL capture are supplied by default. Set `environment.service_workers` to `allow` when the application requires them. +For attached browsers, `block` rejects captures when the selected page or its +frames are controlled by a service worker or already have a registration, +including after navigation. Existing caller registrations are never removed. +Navigation can run destination code before a newly discovered worker is rejected; +that rejection does not roll back browser effects. REA removes its owned +future-document scripts, restores live frame methods, then disconnects. Add secret declarations, storage seeds, and additional artifact or event capture only when needed. Action, secret, and storage counts are not capped. Duration, action, and navigation @@ -249,8 +255,12 @@ Each observed request object receives a capture-scoped `transaction_id`. Requests sharing a URL remain distinct. Redirects link to the preceding transaction only when it was observed; a null predecessor does not establish that no earlier hop existed. Request events require both transaction fields; -an absent ID is malformed capture data, while a null redirect predecessor is -an explicit unknown. +an absent ID is malformed capture data. `redirected_from_transaction_state` +records `root` when Playwright observed no predecessor and `unknown` when a +predecessor exists but was outside the capture. A linked predecessor is carried +by `redirected_from_transaction_id` with no state field. Older captures without +the state field retain the conservative meaning that a null predecessor is +unknown. `scenario.network_content` records the resolved content selection. Comparing captures with different selections reports network evidence as unknown with a @@ -284,8 +294,10 @@ browser cleanup, with a provider-owned five-second timeout per read and cancellation support. Failed or unexposed reads retain per-content states and make selected event coverage incomplete. Body events can arrive during later steps or final drain; event bounds describe receipt during the step, while the source event -reference establishes the transaction phase. Comparison attributes content to -its source event's step and excludes the sequence reference from normalization. +reference establishes the transaction phase. Comparison attributes content, like +every other event of its transaction, to the step that sent the request (the +receipt step when that request was not observed) and excludes the sequence +reference from normalization. Playwright scenario capture does not supply initiator stacks; passive CDP observation retains its own initiator evidence. @@ -300,7 +312,22 @@ an unchanged claim; observed differences may still prove a changed result. The comparison records its full normalization commitment and SHA-256 digest. Built-in rules exclude step elapsed time and event sequence/index fields, compare screenshots by content digest, and compare DOM/accessibility captures -by normalized text. Optional caller rules are bounded exact-literal +by normalized text. Network events are grouped by transaction without their +capture-local transaction IDs, attributed to the step that sent the request, +and compared as an unordered collection after caller rules apply, because +concurrent responses arrive in any order and can land in a later step; a +redirect retains the complete predecessor ancestry, including each hop's own +request, response outcome, and selected network content. Each transaction's +capture-local IDs, sequence/index fields, and content source-sequence anchor +are removed. Caller rules normalize retained strings before hashing. The +`preserve_redirect_transaction_ancestry` built-in rule fingerprints each hop +as canonical SHA-256 of `{ events, redirected_from }`: `events` is the sorted +list of its normalized event SHA-256 digests, and `redirected_from` is its +predecessor fingerprint or null for a root. Selected transaction fingerprints +are sorted for the step's network comparison. Temporary numeric graph indices +are lookup references only and do not enter the final digest. Missing or cyclic +predecessor evidence yields unknown comparability instead of an unchanged claim. +Other events keep receipt order. Optional caller rules are bounded exact-literal replacements over named artifact kinds. Rules and artifact-kind lists are sorted canonically before application, making the same policy reproducible regardless of input order. Rules operate only on already-redacted durable @@ -318,3 +345,7 @@ selection. Captures with different selections report event evidence as unknown with a recovery instruction, even when no selected family emitted an event. Captures written before this field existed omit it, so their event evidence is also unknown. + +Network normalization also commits the removal of capture-local transaction IDs, +attribution to the requesting step, and unordered transaction comparison. These +built-in rule identities participate in the normalization digest. diff --git a/scripts/lib/browser-capture-metadata-budget-e2e.mjs b/scripts/lib/browser-capture-metadata-budget-e2e.mjs index 26b695371d..26e339b250 100644 --- a/scripts/lib/browser-capture-metadata-budget-e2e.mjs +++ b/scripts/lib/browser-capture-metadata-budget-e2e.mjs @@ -54,10 +54,12 @@ export async function verifyBrowserCaptureMetadataBudget(endpoint) { if (address === null || typeof address === "string") throw new Error("Browser metadata fixture did not bind a TCP port"); fixtureOrigin = `http://127.0.0.1:${String(address.port)}`; + // Each independent capture opens a new Debugger session. Keep each script + // reachable so V8 cannot collect it before the later sessions attach. const annotations = Array.from( { length: SCRIPT_COUNT }, (_, index) => - ``, + ``, ).join(""); page = `${annotations}`; const fixtureUrl = `${fixtureOrigin}/capture-metadata-budget`; diff --git a/scripts/lib/browser-scenario-service-worker-setup-e2e.mjs b/scripts/lib/browser-scenario-service-worker-setup-e2e.mjs new file mode 100644 index 0000000000..ace6fc5675 --- /dev/null +++ b/scripts/lib/browser-scenario-service-worker-setup-e2e.mjs @@ -0,0 +1,260 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer, request } from "node:http"; +import { connect } from "node:net"; + +import { createBrowserScenarioProvider } from "../../dist/composition/browserScenario.js"; +import { browserScenarioSchema } from "../../dist/domain/browserScenario.js"; +import { PlaywrightScenarioServiceWorkers } from "../../dist/browser/PlaywrightScenarioServiceWorkers.js"; + +const beforeDeadline = async (promise, label, timeoutMs = 2_000) => { + let timer; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${label} did not settle`)), + timeoutMs, + ); + }), + ]); + } finally { + clearTimeout(timer); + } +}; + +// Real loopback proxy: Playwright's first transport is forwarded to Chrome, +// then only the policy owner's discovery/handshake is deliberately withheld. +const startProxy = async (endpoint, mode, ownerOnly = false) => { + const upstream = new URL(endpoint); + const sockets = new Set(); + let versions = 0; + let upgrades = 0; + let enabled = true; + let entered; + const stalled = new Promise((resolve) => { + entered = resolve; + }); + let closed; + let stalledClose; + const stall = (socket) => { + socket.resume(); + socket.once("end", () => socket.end()); + stalledClose = new Promise((resolve) => { + closed = resolve; + }); + socket.once("close", () => closed()); + entered(); + }; + const server = createServer((incoming, response) => { + const versionRequest = incoming.url.replace(/\/$/, "") === "/json/version"; + if (versionRequest) versions += 1; + if ( + enabled && + mode === "http-discovery" && + versions === 2 && + versionRequest + ) { + stall(incoming.socket); + return; + } + const outgoing = request(new URL(incoming.url, upstream), (received) => { + const chunks = []; + received.on("data", (chunk) => chunks.push(chunk)); + received.on("end", () => { + const content = Buffer.concat(chunks).toString("utf8"); + response.writeHead(received.statusCode, { + "content-type": "application/json", + }); + response.end( + content.replaceAll( + `127.0.0.1:${upstream.port}`, + `127.0.0.1:${server.address().port}`, + ), + ); + }); + }); + outgoing.on("error", () => response.destroy()); + response.on("close", () => outgoing.destroy()); + outgoing.end(); + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + server.on("upgrade", (incoming, socket, head) => { + upgrades += 1; + if ( + enabled && + mode !== "http-discovery" && + upgrades === (ownerOnly ? 1 : 2) + ) { + stall(socket); + return; + } + const outgoing = connect(Number(upstream.port), "127.0.0.1", () => { + const headers = incoming.rawHeaders.flatMap((value, index, all) => + index % 2 === 0 ? [`${value}: ${all[index + 1]}`] : [], + ); + outgoing.write( + `${incoming.method} ${incoming.url} HTTP/${incoming.httpVersion}\r\n${headers.join("\r\n")}\r\n\r\n`, + ); + if (head.length > 0) outgoing.write(head); + outgoing.pipe(socket); + socket.pipe(outgoing); + }); + outgoing.on("error", () => socket.destroy()); + socket.on("error", () => outgoing.destroy()); + socket.on("close", () => outgoing.destroy()); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const origin = `http://127.0.0.1:${server.address().port}`; + let proxyEndpoint = origin; + if (mode === "browser-websocket") { + const version = await (await fetch(`${endpoint}/json/version`)).json(); + proxyEndpoint = version.webSocketDebuggerUrl.replace( + `127.0.0.1:${upstream.port}`, + `127.0.0.1:${server.address().port}`, + ); + } + return { + endpoint: proxyEndpoint, + stalled, + waitClosed: () => stalledClose, + resume: () => { + enabled = false; + }, + close: async () => { + const done = once(server, "close"); + server.close(); + for (const socket of sockets) socket.destroy(); + await done; + }, + }; +}; + +export const verifyScenarioPolicySetup = async ( + endpoint, + targetId, + origin, + page, +) => { + const result = {}; + for (const mode of ["http-websocket", "http-discovery", "deadline"]) { + const proxy = await startProxy(endpoint, mode); + const provider = createBrowserScenarioProvider(process.env); + const scenario = browserScenarioSchema.parse({ + browser: { + mode: "connect", + cdp_endpoint: proxy.endpoint, + target_id: targetId, + }, + start_url: { url: origin }, + environment: { service_workers: "block" }, + actions: [ + { step_id: "settle", action: "wait_for_timeout", duration_ms: 1 }, + ], + capture: { events: [], after_each_step: ["dom"], at_end: ["dom"] }, + }); + const cancellation = new AbortController(); + let failure; + try { + const pending = provider.captureScenario(scenario, { + signal: cancellation.signal, + }); + await beforeDeadline(proxy.stalled, `${mode} setup`); + if (mode !== "deadline") cancellation.abort(); + const cancelled = await beforeDeadline( + pending, + `${mode} termination`, + mode === "deadline" ? 12_000 : 2_000, + ); + assert.equal(cancelled.ok, false); + assert.equal(cancelled.error.cleanupIncomplete, false); + if (mode === "deadline") assert.equal(cancelled.error.reason, "timeout"); + await beforeDeadline(proxy.waitClosed(), `${mode} socket release`); + assert.equal( + await page.evaluate(() => + navigator.serviceWorker.register.toString().includes("[native code]"), + ), + true, + ); + proxy.resume(); + const next = await beforeDeadline( + provider.captureScenario(scenario), + `${mode} target reacquisition`, + ); + if (!next.ok) throw next.error; + assert.equal(next.value.browser.cleanup, "disconnected-external"); + result[mode] = { + ...(mode === "deadline" ? { timed_out: true } : { cancelled: true }), + socket_closed: true, + target_reused: true, + }; + } catch (cause) { + failure = cause; + throw cause; + } finally { + cancellation.abort(); + try { + await proxy.close(); + } finally { + try { + await provider.close(); + } catch (cleanupFailure) { + if (failure !== undefined) + throw new AggregateError([failure, cleanupFailure]); + throw cleanupFailure; + } + } + } + } + const proxy = await startProxy(endpoint, "browser-websocket", true); + const policy = new PlaywrightScenarioServiceWorkers( + page, + proxy.endpoint, + targetId, + ); + try { + const rejected = assert.rejects( + policy.initialize(), + (error) => error._tag === "AnalysisCancelledError", + ); + await beforeDeadline(proxy.stalled, "browser websocket approval handshake"); + await beforeDeadline(policy.close(), "browser websocket cleanup"); + await rejected; + await beforeDeadline( + proxy.waitClosed(), + "browser websocket socket release", + ); + proxy.resume(); + const next = new PlaywrightScenarioServiceWorkers( + page, + proxy.endpoint, + targetId, + ); + try { + await beforeDeadline( + next.initialize(), + "browser websocket reacquisition", + ); + } finally { + await next.close(); + } + result["browser-websocket"] = { + cancelled: true, + socket_closed: true, + target_reused: true, + direct_owner: true, + }; + } finally { + try { + await policy.close(); + } finally { + await proxy.close(); + } + } + return result; +}; diff --git a/scripts/lib/browser-scenario-service-workers-e2e.mjs b/scripts/lib/browser-scenario-service-workers-e2e.mjs new file mode 100644 index 0000000000..3af2f4f8d6 --- /dev/null +++ b/scripts/lib/browser-scenario-service-workers-e2e.mjs @@ -0,0 +1,605 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import { setTimeout as delay } from "node:timers/promises"; + +import { chromium } from "playwright-core"; + +import { PlaywrightScenarioSession } from "../../dist/browser/PlaywrightScenarioSession.js"; +import { PlaywrightScenarioServiceWorkers } from "../../dist/browser/PlaywrightScenarioServiceWorkers.js"; +import { createBrowserScenarioProvider } from "../../dist/composition/browserScenario.js"; +import { browserScenarioSchema } from "../../dist/domain/browserScenario.js"; +import { runScenarioCli } from "./browser-scenario-verifier.mjs"; +import { verifyScenarioPolicySetup } from "./browser-scenario-service-worker-setup-e2e.mjs"; + +const startSite = async () => { + let otherOrigin; + const server = createServer((request, response) => { + const path = new URL(request.url, "http://localhost").pathname; + if (path === "/sw.js") { + response.setHeader("content-type", "application/javascript"); + response.end(`self.addEventListener("install", () => self.skipWaiting()); + self.addEventListener("activate", event => event.waitUntil(clients.claim())); + self.addEventListener("fetch", event => { + if (new URL(event.request.url).pathname === "/probe") + event.respondWith(new Response("worker")); + });`); + } else if (path === "/probe") { + response.end("network"); + } else if (path === "/failed-navigation") { + response.destroy(); + } else if (path === "/stalled-image.png") { + response.writeHead(200, { "content-type": "image/png" }); + response.flushHeaders(); + } else if (path === "/pending-image" || path === "/pending-child") { + response.setHeader("content-type", "text/html"); + response.end( + '', + ); + } else if (path === "/pending-frame") { + response.setHeader("content-type", "text/html"); + response.end( + '', + ); + } else { + response.setHeader("content-type", "text/html"); + response.end(`

ready

+ Navigate + ${path === "/frame" ? `` : ""} + ${ + path === "/register" + ? `` + : "" + }`); + } + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return { + origin: `http://127.0.0.1:${server.address().port}`, + setOtherOrigin: (origin) => { + otherOrigin = origin; + }, + close: async () => { + const closed = once(server, "close"); + server.close(); + server.closeAllConnections(); + await closed; + }, + }; +}; + +/** Real attached-page policy checks, with caller-owned workers on a second origin. */ +export async function verifyScenarioServiceWorkers(endpoint, targetId) { + const first = await startSite(); + const second = await startSite(); + first.setOtherOrigin(second.origin); + second.setOtherOrigin(first.origin); + const observer = await chromium.connectOverCDP(endpoint); + let provider = createBrowserScenarioProvider(process.env); + let session; + let failure; + try { + let page; + for (const context of observer.contexts()) { + for (const candidate of context.pages()) { + const cdp = await context.newCDPSession(candidate); + const { targetInfo } = await cdp.send("Target.getTargetInfo"); + await cdp.detach(); + if (targetInfo.targetId === targetId) page = candidate; + } + } + assert.ok(page, "External fixture page disappeared"); + const scenarioFor = (url, actions = [], policy = "block") => + browserScenarioSchema.parse({ + browser: { + mode: "connect", + cdp_endpoint: endpoint, + target_id: targetId, + }, + start_url: { url }, + environment: { service_workers: policy }, + actions: + actions.length === 0 + ? [ + { + step_id: "settle", + action: "wait_for_timeout", + duration_ms: 1, + }, + ] + : actions, + capture: { events: [], after_each_step: ["dom"], at_end: ["dom"] }, + }); + const assertRestored = async () => { + assert.equal(page.isClosed(), false); + assert.equal( + await page.evaluate(() => + ServiceWorkerContainer.prototype.register + .toString() + .includes("[native code]"), + ), + true, + ); + }; + const assertRejected = async (scenario) => { + const result = await provider.captureScenario(scenario); + assert.equal( + result.ok, + false, + "Worker-influenced capture unexpectedly succeeded", + ); + assert.equal(result.error.reason, "target_not_allowed"); + assert.equal( + result.error.partialObservation.capture.browser.cleanup, + "disconnected-external", + ); + await assertRestored(); + }; + + // A fresh attached origin blocks registration, then restores both the + // current document and future documents when its transport disconnects. + const clean = scenarioFor(`${first.origin}/register`, [ + { + step_id: "registration-rejected", + action: "wait_for", + locator: { kind: "css", selector: "#state[data-blocked='true']" }, + state: "visible", + }, + ]); + const cleanCapture = (await runScenarioCli(clean)).normalized_result; + assert.ok(cleanCapture.steps.every(({ status }) => status === "completed")); + assert.equal(cleanCapture.browser.cleanup, "disconnected-external"); + await assertRestored(); + await page.reload(); + await page.getByText("registered", { exact: true }).waitFor(); + await page.evaluate(async () => { + for (const registration of await navigator.serviceWorker.getRegistrations()) + await registration.unregister(); + }); + await page.goto("about:blank"); + + // The caller installs a real worker at a different origin, then moves the + // selected target away before REA attaches. Its registration must survive. + await page.goto(second.origin); + await page.evaluate(async () => { + await navigator.serviceWorker.register("/sw.js"); + await navigator.serviceWorker.ready; + }); + await page.reload(); + assert.equal( + await page.evaluate(async () => (await fetch("/probe")).text()), + "worker", + ); + await page.goto("about:blank"); + await assertRejected(scenarioFor(second.origin)); + await page.goto("about:blank"); + await assertRejected( + scenarioFor(first.origin, [ + { + step_id: "cross-origin", + action: "goto", + destination: { url: second.origin }, + wait_until: "load", + }, + ]), + ); + await page.goto("about:blank"); + await assertRejected( + scenarioFor(first.origin, [ + { + step_id: "link-navigation", + action: "click", + locator: { kind: "css", selector: "#navigate" }, + }, + { step_id: "loaded", action: "wait_for_timeout", duration_ms: 100 }, + ]), + ); + await page.goto("about:blank"); + await assertRejected(scenarioFor(`${first.origin}/frame`)); + + const allowed = await provider.captureScenario( + scenarioFor(second.origin, [], "allow"), + ); + if (!allowed.ok) throw allowed.error; + assert.ok( + allowed.value.steps.every(({ status }) => status === "completed"), + ); + assert.equal( + await page.evaluate(async () => (await fetch("/probe")).text()), + "worker", + ); + assert.equal( + await page.evaluate( + async () => (await navigator.serviceWorker.getRegistrations()).length, + ), + 1, + ); + + // Restoration also runs after navigation failure and active cancellation. + await page.goto("about:blank"); + const failed = await provider.captureScenario( + scenarioFor(`${first.origin}/failed-navigation`), + ); + assert.equal(failed.ok, false); + // Chromium completes its error-document transition after goto rejects. + await page.waitForSelector("#main-frame-error", { state: "attached" }); + // A context replacement can defer restoration. Retained cleanup must + // restore the replacement document before releasing the external page. + await provider.close(); + provider = createBrowserScenarioProvider(process.env); + await page.goto(first.origin); + await assertRestored(); + const cancellation = new AbortController(); + const pending = provider.captureScenario( + scenarioFor(`${first.origin}/register`, [ + { + step_id: "wait", + action: "wait_for", + locator: { kind: "css", selector: "#absent" }, + state: "visible", + timeout_ms: 30_000, + }, + ]), + { signal: cancellation.signal }, + ); + try { + const deadline = Date.now() + 10_000; + while ((await page.locator("#state").textContent()) !== "blocked") { + assert.ok( + Date.now() < deadline, + "Scenario never applied registration policy", + ); + await delay(25); + } + } finally { + cancellation.abort(); + } + const cancelled = await pending; + assert.ok( + !cancelled.ok || + cancelled.value.steps.some(({ status }) => status === "cancelled"), + ); + await assertRestored(); + + // Caller changes made after installation belong to the caller, too. + session = await PlaywrightScenarioSession.open( + scenarioFor(first.origin), + process.env, + {}, + ); + await page.evaluate(() => { + ServiceWorkerContainer.prototype.register = + function callerReplacement() {}; + }); + await session.close(); + session = undefined; + assert.equal( + await page.evaluate(() => ServiceWorkerContainer.prototype.register.name), + "callerReplacement", + ); + await page.reload(); + await assertRestored(); + await page.evaluate(() => { + delete ServiceWorkerContainer.prototype.register; + }); + const policyBrowser = await chromium.connectOverCDP(endpoint); + try { + let policyPage; + for (const context of policyBrowser.contexts()) { + for (const candidate of context.pages()) { + const cdp = await context.newCDPSession(candidate); + const { targetInfo } = await cdp.send("Target.getTargetInfo"); + await cdp.detach(); + if (targetInfo.targetId === targetId) policyPage = candidate; + } + } + assert.ok(policyPage); + const policy = new PlaywrightScenarioServiceWorkers( + policyPage, + endpoint, + targetId, + ); + await policy.initialize(); + await policy.close(); + } finally { + await policyBrowser.close(); + } + assert.equal( + await page.evaluate(() => + Object.hasOwn(ServiceWorkerContainer.prototype, "register"), + ), + false, + ); + await page.reload(); + await assertRestored(); + const policyCleanup = await verifyPolicyCleanup({ + page, + endpoint, + targetId, + provider, + scenarioFor, + origin: first.origin, + }); + return { + mocked: false, + cli: true, + production_provider: true, + initial_navigation: true, + action_navigation: true, + cross_origin_frame: true, + allow_preserves_existing_worker: true, + registration_blocked: true, + live_document_and_reload_restored: true, + failure_and_cancellation_cleanup: true, + caller_replacement_preserved: true, + caller_original_absence_preserved: true, + policyCleanup, + external_target_preserved: true, + }; + } catch (cause) { + failure = cause; + throw cause; + } finally { + await session?.close(); + try { + await provider.close(); + } catch (cleanupFailure) { + if (failure !== undefined) + throw new AggregateError([failure, cleanupFailure]); + throw cleanupFailure; + } finally { + await observer.close(); + await first.close(); + await second.close(); + } + } +} + +const verifyPolicyCleanup = async ({ + page, + endpoint, + targetId, + provider, + scenarioFor, + origin, +}) => { + const nativeRegister = async () => + page.evaluate(() => + navigator.serviceWorker.register.toString().includes("[native code]"), + ); + for (const path of ["/pending-image", "/pending-frame"]) { + const cancellation = new AbortController(); + const pending = provider.captureScenario(scenarioFor(`${origin}${path}`), { + signal: cancellation.signal, + }); + try { + await page + .locator( + path === "/pending-image" ? "#pending-image" : "#pending-frame", + ) + .waitFor({ state: "attached" }); + if (path === "/pending-frame") { + const child = page + .frames() + .find((frame) => frame.parentFrame() !== null); + assert.ok(child); + await child.waitForURL(`${origin}/pending-child`, { + waitUntil: "domcontentloaded", + }); + } + } finally { + cancellation.abort(); + } + const result = await pending; + assert.equal(result.ok, false); + assert.equal( + result.error.partialObservation.capture.browser.cleanup, + "disconnected-external", + ); + assert.equal(await nativeRegister(), true); + const next = await provider.captureScenario(scenarioFor(origin)); + if (!next.ok) throw next.error; + assert.equal(next.value.browser.cleanup, "disconnected-external"); + } + + await page.addInitScript("globalThis.reaCallerInitScript = 'preserved'"); + const policy = new PlaywrightScenarioServiceWorkers(page, endpoint, targetId); + await policy.initialize(); + const exposure = await page.evaluate(async () => { + const register = navigator.serviceWorker.register; + const originals = Object.getOwnPropertySymbols(register).map( + (symbol) => register[symbol], + ); + const restore = Object.getOwnPropertyNames(globalThis).find((name) => + name.startsWith("rea-restore-"), + ); + const denied = globalThis[restore]("not-the-session-secret"); + let blocked = false; + try { + await navigator.serviceWorker.register("/sw.js"); + } catch { + blocked = true; + } + return { + exposesOriginal: originals.some( + (value) => typeof value?.value === "function", + ), + denied, + blocked, + registrations: (await navigator.serviceWorker.getRegistrations()).length, + }; + }); + assert.deepEqual(exposure, { + exposesOriginal: false, + denied: false, + blocked: true, + registrations: 0, + }); + + // Crossing the renderer boundary must not layer a wrapper over an earlier + // wrapper in a surviving realm, or leave an OOPIF unprotected. + const crossSite = `${origin.replace("127.0.0.1", "localhost")}/register`; + await page.evaluate((url) => { + const child = document.createElement("iframe"); + child.id = "cross-site"; + child.src = url; + document.body.append(child); + }, crossSite); + const child = page.frames().find((frame) => frame.parentFrame() !== null); + assert.ok(child); + await child.waitForURL(crossSite, { waitUntil: "load" }); + assert.equal(await child.locator("#state").textContent(), "blocked"); + await page.locator("#cross-site").evaluate((frame, url) => { + frame.src = url; + }, `${origin}/register`); + await child.waitForURL(`${origin}/register`, { waitUntil: "load" }); + assert.equal(await child.locator("#state").textContent(), "blocked"); + + // Gate one real frame evaluation to force a new main document between live + // frame restorations. Browser replies and navigation remain real. + const originalEvaluate = child.evaluate.bind(child); + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + let entered; + const started = new Promise((resolve) => { + entered = resolve; + }); + child.evaluate = async (...args) => { + entered(); + await gate; + return originalEvaluate(...args); + }; + const closing = policy.close(); + void closing.catch(() => undefined); + try { + await started; + await page.goto(origin); + } finally { + release(); + child.evaluate = originalEvaluate; + } + await closing; + assert.equal(await nativeRegister(), true); + await page.reload(); + assert.equal(await nativeRegister(), true); + assert.equal( + await page.evaluate(() => globalThis.reaCallerInitScript), + "preserved", + ); + assert.equal( + await page.evaluate(() => + Object.getOwnPropertyNames(globalThis).some((name) => + name.startsWith("rea-restore-"), + ), + ), + false, + ); + await verifyRejectedPolicyInstallation(page.context(), endpoint, origin); + const setup = await verifyScenarioPolicySetup( + endpoint, + targetId, + origin, + page, + ); + return { + stalled_image_cancelled: true, + stalled_frame_cancelled: true, + target_reused_after_cancel: true, + private_original_descriptor: true, + wrong_restore_token_rejected: true, + renderer_transition: true, + navigation_during_restoration: true, + caller_init_script_preserved: true, + failed_main_installation_rejected: true, + failed_accessor_installation_rejected: true, + failed_iframe_installation_rejected: true, + replaced_iframe_policy_rejected: true, + setup, + }; +}; + +const verifyRejectedPolicyInstallation = async (context, endpoint, origin) => { + for (const realm of ["main", "accessor", "existing-iframe", "new-iframe"]) { + const page = await context.newPage(); + let policy; + try { + await page.goto(origin); + const cdp = await context.newCDPSession(page); + const { targetInfo } = await cdp.send("Target.getTargetInfo"); + await cdp.detach(); + policy = new PlaywrightScenarioServiceWorkers( + page, + endpoint, + targetInfo.targetId, + ); + if (realm === "new-iframe") await policy.initialize(); + let frame = page.mainFrame(); + if (realm === "existing-iframe" || realm === "new-iframe") { + const crossSite = origin.replace("127.0.0.1", "localhost"); + await page.evaluate((url) => { + const child = document.createElement("iframe"); + child.src = url; + document.body.append(child); + }, crossSite); + frame = page + .frames() + .find((candidate) => candidate.parentFrame() !== null); + assert.ok(frame); + await frame.waitForURL(crossSite, { waitUntil: "load" }); + } + await frame.evaluate((kind) => { + const prototype = ServiceWorkerContainer.prototype; + const descriptor = Object.getOwnPropertyDescriptor( + prototype, + "register", + ); + if (kind === "accessor") { + const original = descriptor.value; + Object.defineProperty(prototype, "register", { + configurable: true, + enumerable: descriptor.enumerable, + get: () => original, + }); + return; + } + Object.defineProperty(prototype, "register", { + ...descriptor, + configurable: false, + ...(kind === "new-iframe" + ? { value: function callerReplacement() {} } + : {}), + }); + }, realm); + await assert.rejects( + realm === "new-iframe" ? policy.assertBlocked() : policy.initialize(), + (error) => error.reason === "target_not_allowed", + ); + await policy.close(); + assert.equal( + await frame.evaluate((replaced) => { + const register = navigator.serviceWorker.register; + return replaced + ? register.name === "callerReplacement" + : register.toString().includes("[native code]"); + }, realm === "new-iframe"), + true, + ); + } finally { + try { + await policy?.close(); + } finally { + await page.close(); + } + } + } +}; diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs index 158877bca7..3f793d82c4 100644 --- a/scripts/verify-real-browser.mjs +++ b/scripts/verify-real-browser.mjs @@ -50,6 +50,7 @@ import { verifyBrowserCaptureMetadataBudget } from "./lib/browser-capture-metada import { artifactCliEvidence, artifactMcpResult } from "./lib/artifact-e2e.mjs"; import { verifyScenarioEnvironment } from "./lib/browser-scenario-environment-e2e.mjs"; import { verifyScenarioStorage } from "./lib/browser-scenario-storage-e2e.mjs"; +import { verifyScenarioServiceWorkers } from "./lib/browser-scenario-service-workers-e2e.mjs"; const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret"; const SCENARIO_URL_SECRET_VALUE = "rea-browser-url-verifier-secret"; @@ -299,6 +300,11 @@ try { endpoint, targetId: target, }); + process.stderr.write("Browser verifier: scenario service workers\n"); + const scenarioServiceWorkers = await verifyScenarioServiceWorkers( + endpoint, + target, + ); const launchedScenario = await createBrowserScenarioProvider( process.env, ).captureScenario( @@ -400,6 +406,7 @@ try { scenarioFailure, scenarioEnvironment, scenarioStorage, + scenarioServiceWorkers, verified: true, }; } finally { diff --git a/src/application/BrowserObservationService.ts b/src/application/BrowserObservationService.ts index a080f1ef89..0637774c4c 100644 --- a/src/application/BrowserObservationService.ts +++ b/src/application/BrowserObservationService.ts @@ -11,7 +11,10 @@ import type { CaptureWebScreenshotInput, CompareWebScreenshotsInput, } from "../domain/webScreenshot.js"; -import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, +} from "../domain/analysisErrorCore.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../domain/result.js"; import type { ExecutionOptions } from "./AnalysisProvider.js"; @@ -27,6 +30,8 @@ export const listBrowserTargets = async ( const ready = requireBrowser(browser, "list_browser_targets"); if (!ready.ok) return ready; const result = await ready.value.listTargets(input, options); + if (result.ok && options.signal?.aborted === true) + return err(new AnalysisCancelledError("list_browser_targets")); return result.ok ? ok( createBrowserEvidence( @@ -48,6 +53,8 @@ export const inspectWebPage = async ( const ready = requireBrowser(browser, "inspect_web_page"); if (!ready.ok) return ready; const result = await ready.value.inspectPage(input, options); + if (result.ok && options.signal?.aborted === true) + return err(new AnalysisCancelledError("inspect_web_page")); return result.ok ? ok( createBrowserEvidence( @@ -69,6 +76,8 @@ export const analyzeWebBundle = async ( const ready = requireBrowser(browser, "analyze_web_bundle"); if (!ready.ok) return ready; const analyzed = await ready.value.analyzeBundle(input, options); + if (analyzed.ok && options.signal?.aborted === true) + return err(new AnalysisCancelledError("analyze_web_bundle")); if (!analyzed.ok) return analyzed; return ok( createBrowserEvidence( @@ -89,6 +98,8 @@ export const observeWebSession = async ( const ready = requireBrowser(browser, "observe_web_session"); if (!ready.ok) return ready; const observed = await ready.value.observeSession(input, options); + if (observed.ok && options.signal?.aborted === true) + return err(new AnalysisCancelledError("observe_web_session")); return observed.ok ? ok( createBrowserEvidence( @@ -110,6 +121,8 @@ export const discoverWebMcpTools = async ( const ready = requireBrowser(browser, "discover_webmcp_tools"); if (!ready.ok) return ready; const discovered = await ready.value.discoverWebMcpTools(input, options); + if (discovered.ok && options.signal?.aborted === true) + return err(new AnalysisCancelledError("discover_webmcp_tools")); return discovered.ok ? ok( createBrowserEvidence( @@ -151,6 +164,8 @@ export const captureWebScreenshot = async ( const ready = requireBrowser(browser, "capture_web_screenshot"); if (!ready.ok) return ready; const captured = await ready.value.captureScreenshot(input, options); + if (captured.ok && options.signal?.aborted === true) + return err(new AnalysisCancelledError("capture_web_screenshot")); return captured.ok ? ok( createBrowserEvidence( diff --git a/src/browser/BrowserScenarioSecrets.ts b/src/browser/BrowserScenarioSecrets.ts index aa583c016d..5ed7eed164 100644 --- a/src/browser/BrowserScenarioSecrets.ts +++ b/src/browser/BrowserScenarioSecrets.ts @@ -230,6 +230,7 @@ export class BrowserScenarioSecrets { private readonly urlReplacements: readonly SecretReplacement[]; private readonly textReplacements: readonly SecretReplacement[]; private readonly textNeedles: readonly SecretNeedle[]; + private readonly redactionCostPerCharacter: number; private constructor(private readonly values: ReadonlyMap) { this.urlPatterns = declaredPatterns(values, "url"); @@ -237,6 +238,19 @@ export class BrowserScenarioSecrets { this.urlReplacements = secretReplacements(values, "url"); this.textReplacements = secretReplacements(values, "text"); this.textNeedles = secretNeedles(this.textReplacements, this.textPatterns); + this.redactionCostPerCharacter = 8; + for (const [replacements, patterns] of [ + [this.textReplacements, this.textPatterns], + [this.urlReplacements, this.urlPatterns], + ] as const) + for (const replacement of replacements) + this.redactionCostPerCharacter = Math.max( + this.redactionCostPerCharacter, + 8 + + (Buffer.byteLength(redactionMarker(replacement.id, patterns)) * 8 + + 128) / + replacement.text.length, + ); } static resolve( @@ -271,6 +285,14 @@ export class BrowserScenarioSecrets { return url.href; } + /** Conservative preflight for output expansion and per-match part arrays. + * Callers measuring untrusted observations must reserve this cost before + * invoking redaction. A one-byte secret can otherwise expand every byte. + */ + redactionAllocationCost(length: number): number { + return Math.ceil(length * this.redactionCostPerCharacter); + } + redact(value: string): string { const patterns = this.textPatterns; if (patterns.length === 0) return value; diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index 744a829839..14757a9a68 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -43,7 +43,10 @@ import { type WebScreenshotDiff, } from "../domain/webScreenshot.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; -import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisInputError, +} from "../domain/analysisErrorCore.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js"; @@ -119,7 +122,7 @@ export class CdpBrowserProvider implements BrowserObservationPort { }), ); } catch (cause: unknown) { - return err(providerError(cause, "list_browser_targets")); + return err(providerError(cause, "list_browser_targets", options.signal)); } } @@ -131,7 +134,7 @@ export class CdpBrowserProvider implements BrowserObservationPort { const captured = await this.#capture(input, options, "inspect_web_page"); return ok(webPageInspectionSchema.parse(captured.inspection)); } catch (cause: unknown) { - return err(providerError(cause, "inspect_web_page")); + return err(providerError(cause, "inspect_web_page", options.signal)); } } @@ -164,7 +167,7 @@ export class CdpBrowserProvider implements BrowserObservationPort { ), ); } catch (cause: unknown) { - return err(providerError(cause, "analyze_web_bundle")); + return err(providerError(cause, "analyze_web_bundle", options.signal)); } } @@ -184,7 +187,7 @@ export class CdpBrowserProvider implements BrowserObservationPort { ), ); } catch (cause: unknown) { - return err(providerError(cause, "observe_web_session")); + return err(providerError(cause, "observe_web_session", options.signal)); } } @@ -204,7 +207,7 @@ export class CdpBrowserProvider implements BrowserObservationPort { ), ); } catch (cause: unknown) { - return err(providerError(cause, "discover_webmcp_tools")); + return err(providerError(cause, "discover_webmcp_tools", options.signal)); } } @@ -236,7 +239,9 @@ export class CdpBrowserProvider implements BrowserObservationPort { ), ); } catch (cause: unknown) { - return err(providerError(cause, "capture_web_screenshot")); + return err( + providerError(cause, "capture_web_screenshot", options.signal), + ); } } @@ -413,11 +418,18 @@ const scopeToTargetOrigin = ( const providerError = ( cause: unknown, operation: BrowserObservationOperation, + signal?: AbortSignal, ): AnalysisError => cause instanceof BrowserObservationError && cause.operation !== operation ? new BrowserObservationError(operation, cause.reason, { cause }) : cause instanceof AnalysisError ? cause - : new ProviderAdapterError(CDP_BROWSER_PROVIDER_IDENTITY.id, operation, { - cause, - }); + : signal?.aborted === true + ? new AnalysisCancelledError(operation, { cause }) + : new ProviderAdapterError( + CDP_BROWSER_PROVIDER_IDENTITY.id, + operation, + { + cause, + }, + ); diff --git a/src/browser/CdpCaptureDocuments.baseUrl.test.ts b/src/browser/CdpCaptureDocuments.baseUrl.test.ts index e3201eade4..26dbbeb76f 100644 --- a/src/browser/CdpCaptureDocuments.baseUrl.test.ts +++ b/src/browser/CdpCaptureDocuments.baseUrl.test.ts @@ -59,6 +59,42 @@ const capture = (value: ReturnType) => { }; describe("DOM metadata document base URLs", () => { + it("splits link relations only on HTML ASCII whitespace", () => { + const strings = [ + documentUrl, + "#document", + "LINK", + "", + "href", + "/valid", + "rel", + "alternate\u00a0mcp alternate\u000bmodel-context alternate\u2003ai-plugin alternate\ufeffservice-desc mcp\tservice-desc", + ]; + const result = captureDom( + { + strings, + documents: [ + { + documentURL: 0, + nodes: { + nodeType: [9, 1], + nodeName: [1, 2], + nodeValue: [3, 3], + parentIndex: [-1, 0], + attributes: [[], [4, 5, 6, 7]], + }, + }, + ], + }, + new Set([origin]), + input, + ).next(); + if (!result.done) throw new Error("Unexpected SVG identity query"); + expect( + result.value.agentHints.map(({ declaration }) => declaration), + ).toEqual(["mcp", "service-desc"]); + }); + it("resolves links, actions and agent hints against the captured document base", () => { const result = capture(snapshot(`${origin}/screens/assets/`)); expect(result.urls.map(({ url }) => url)).toEqual([ diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index f7eb4fe31a..f95904fc0e 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -556,7 +556,8 @@ const domMetadata = ( } const rel = (pairs.get("rel") ?? "") .toLowerCase() - .split(/\s+/u) + // HTML link type tokens are separated by ASCII whitespace only. + .split(/[\t\n\f\r ]+/u) .filter((value) => agentRelValues.includes(value)); const href = urls.find(({ attribute }) => attribute === "href")?.url ?? null; const agentHints = diff --git a/src/browser/CdpWebMcpDiscovery.ts b/src/browser/CdpWebMcpDiscovery.ts index b414bdd89c..51bfafc71a 100644 --- a/src/browser/CdpWebMcpDiscovery.ts +++ b/src/browser/CdpWebMcpDiscovery.ts @@ -446,6 +446,24 @@ const normalizeTool = ( } const description = cdpStringValue(value.description) ?? ""; const annotations = recordValue(value.annotations); + const malformedAnnotations = + Object.hasOwn(value, "annotations") && annotations === undefined; + if (malformedAnnotations) + completeness.exclude("webmcp_tools", "invalid_protocol_value"); + const annotation = (key: string): boolean | null => { + if (annotations === undefined || !Object.hasOwn(annotations, key)) + return null; + const hint = annotations[key]; + if (typeof hint === "boolean") return hint; + completeness.exclude("webmcp_tools", "invalid_protocol_value"); + return null; + }; + const consequential = annotation("consequential"); + const unknownConsequential = + malformedAnnotations || + (annotations !== undefined && + Object.hasOwn(annotations, "consequential") && + typeof annotations.consequential !== "boolean"); const declaration = jsonValueSchema.safeParse(value.inputSchema); if (!declaration.success) completeness.exclude("webmcp_tools", "invalid_protocol_value"); @@ -466,9 +484,10 @@ const normalizeTool = ( ? digestCanonicalValue(schema) : null, annotations: { - read_only: booleanOrNull(annotations?.readOnly), - untrusted_content: booleanOrNull(annotations?.untrustedContent), - autosubmit: booleanOrNull(annotations?.autosubmit), + read_only: annotation("readOnly"), + untrusted_content: annotation("untrustedContent"), + ...(unknownConsequential ? {} : { consequential }), + autosubmit: annotation("autosubmit"), }, registration_source: registrationSource( recordValue(value.stackTrace), @@ -540,9 +559,6 @@ const toolKey = (frameUrl: string, frameId: string, name: string): string => .update(compositeKey([frameUrl, frameId, name])) .digest("hex")}`; -const booleanOrNull = (value: unknown): boolean | null => - typeof value === "boolean" ? value : null; - const integerOrNull = (value: unknown): number | null => { const number = numberValue(value); return number === undefined ? null : Math.max(0, Math.trunc(number)); diff --git a/src/browser/PlaywrightElectronActiveProvider.test.ts b/src/browser/PlaywrightElectronActiveProvider.test.ts index cdb2698eeb..50f9905d51 100644 --- a/src/browser/PlaywrightElectronActiveProvider.test.ts +++ b/src/browser/PlaywrightElectronActiveProvider.test.ts @@ -8,6 +8,7 @@ import type { OwnedProcessGroup } from "../process/ProcessOwnership.js"; import { cleanupElectronProcesses, PlaywrightElectronActiveProvider, + safeElectronEnvironment, } from "./PlaywrightElectronActiveProvider.js"; afterEach(() => { @@ -45,6 +46,26 @@ it("passes the selected environment and ownership token to Electron launch", asy }); }); +it("passes required Windows runtime variables without inheriting unrelated values", () => { + expect( + safeElectronEnvironment( + { + SYSTEMROOT: "C:\\Windows", + LOCALAPPDATA: "C:\\Users\\analyst\\AppData\\Local", + USERPROFILE: "C:\\Users\\analyst", + APP_SECRET: "must-not-pass", + }, + "run-id", + "win32", + ), + ).toEqual({ + SYSTEMROOT: "C:\\Windows", + LOCALAPPDATA: "C:\\Users\\analyst\\AppData\\Local", + USERPROFILE: "C:\\Users\\analyst", + REA_PROCESS_RUN_ID: "run-id", + }); +}); + const ownership = { runId: "run", leaderPid: 4242, diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index 14a98aab4a..816f864890 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -358,9 +358,10 @@ export const cleanupElectronProcesses = async ( }; }; -const safeElectronEnvironment = ( +export const safeElectronEnvironment = ( environment: Readonly>, runId: string, + platform = process.platform, ): Record => Object.fromEntries([ ...[ @@ -372,6 +373,23 @@ const safeElectronEnvironment = ( "USER", "DISPLAY", "WAYLAND_DISPLAY", + ...(platform === "win32" + ? [ + "SYSTEMROOT", + "TEMP", + "APPDATA", + "LOCALAPPDATA", + "USERPROFILE", + "COMSPEC", + "PATHEXT", + "PROGRAMDATA", + "ALLUSERSPROFILE", + "USERNAME", + "SYSTEMDRIVE", + "HOMEDRIVE", + "HOMEPATH", + ] + : []), ] .map((name) => [name, environment[name]] as const) .filter( diff --git a/src/browser/PlaywrightScenarioEvents.test.ts b/src/browser/PlaywrightScenarioEvents.test.ts index 4e7841365f..4c0e21d30b 100644 --- a/src/browser/PlaywrightScenarioEvents.test.ts +++ b/src/browser/PlaywrightScenarioEvents.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { Page } from "playwright-core"; import { browserScenarioSchema } from "../domain/browserScenario.js"; @@ -166,43 +166,127 @@ describe("PlaywrightScenarioEvents", () => { stack: "s".repeat(300_000), }); }); +}); - it("retains every observed event without a collection count ceiling", () => { - const listeners = new Map void)[]>(); - const page = { - url: () => "https://app.example.test/", - on: (name: string, listener: (value: Error) => void) => { - const registered = listeners.get(name) ?? []; - registered.push(listener); - listeners.set(name, registered); - }, - } as unknown as Page; - const scenario = browserScenarioSchema.parse({ - browser: { - mode: "launch", - executable_path: "/opt/chromium", - }, - start_url: { url: "https://app.example.test/" }, - actions: [ - { step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }, - ], - capture: { events: ["page-errors"] }, +it("retains ordinary event volumes and reports event-count exhaustion", () => { + const listeners = new Map void)[]>(); + const page = { + url: () => "https://app.example.test/", + on: (name: string, listener: (value: Error) => void) => { + const registered = listeners.get(name) ?? []; + registered.push(listener); + listeners.set(name, registered); + }, + } as unknown as Page; + const scenario = browserScenarioSchema.parse({ + browser: { + mode: "launch", + executable_path: "/opt/chromium", + }, + start_url: { url: "https://app.example.test/" }, + actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }], + capture: { events: ["page-errors"] }, + }); + const secrets = BrowserScenarioSecrets.resolve(scenario, {}); + if (secrets === undefined) throw new Error("Expected resolved secrets"); + const events = new PlaywrightScenarioEvents({ + page, + enabled: new Set(["page-errors"]), + secrets, + }); + for (let index = 0; index < 2_001; index += 1) + listeners.get("pageerror")?.forEach((listener) => { + const error = new Error(`failure-${index}`); + error.stack = ""; + listener(error); }); - const secrets = BrowserScenarioSecrets.resolve(scenario, {}); - if (secrets === undefined) throw new Error("Expected resolved secrets"); - const events = new PlaywrightScenarioEvents({ - page, - enabled: new Set(["page-errors"]), - secrets, - }); - for (let index = 0; index < 2_001; index += 1) - listeners - .get("pageerror") - ?.forEach((listener) => listener(new Error(`failure-${index}`))); - expect(events.result()).toMatchObject({ retained: 2_001, dropped: 0 }); - expect(events.result().items.at(-1)).toMatchObject({ - kind: "page-error", - message: "failure-2000", + expect(events.result()).toMatchObject({ retained: 2_001, dropped: 0 }); + expect(events.result().items.at(-1)).toMatchObject({ + kind: "page-error", + message: "failure-2000", + }); + // Omit whole oversized events, preserving ordinary messages rather than + // inventing truncated observations. This also tests admission before redact. + const redact = vi.spyOn(secrets, "redact"); + listeners.get("pageerror")?.forEach((listener) => { + const error = new Error("x".repeat(1_000_000)); + error.stack = ""; + listener(error); + }); + expect(redact).not.toHaveBeenCalled(); + for (let index = 0; index < 10_000; index += 1) + listeners.get("pageerror")?.forEach((listener) => { + const error = new Error(`short-${index}`); + error.stack = ""; + listener(error); }); + expect(events.result()).toMatchObject({ retained: 10_000, dropped: 2_002 }); + expect(events.limitations().join(" ")).toContain("resource budget"); + const aggregate = new PlaywrightScenarioEvents({ + page, + enabled: new Set(["page-errors"]), + secrets, + }); + const aggregateListener = listeners.get("pageerror")?.at(-1); + const aggregateRedact = vi.spyOn(secrets, "redact"); + let refused = false; + for (let index = 0; index < 64 && !refused; index += 1) { + aggregateRedact.mockClear(); + const droppedBefore = aggregate.result().dropped; + const error = new Error("x".repeat(400_000)); + error.stack = ""; + aggregateListener?.(error); + refused = aggregate.result().dropped > droppedBefore; + if (refused) expect(aggregateRedact.mock.calls.length).toBe(0); + } + expect(refused).toBe(true); + + const small = new Error("small event remains eligible"); + small.stack = ""; + aggregateRedact.mockClear(); + aggregateListener?.(small); + expect(aggregateRedact.mock.calls.length).toBeGreaterThan(0); + expect(aggregate.result().retained).toBeGreaterThan(0); + expect(aggregate.result().items.at(-1)).toMatchObject({ + kind: "page-error", + message: "small event remains eligible", }); }); + +it("caps live descendant listeners and reuses capacity after closure", () => { + const listeners = new Map void>(); + const page = { + on: (name: string, listener: (value: unknown) => void) => + listeners.set(name, listener), + off: vi.fn(), + } as unknown as Page; + const scenario = browserScenarioSchema.parse({ + browser: { mode: "launch", executable_path: "/fixture/chrome" }, + start_url: { url: "https://example.test" }, + actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }], + }); + const secrets = BrowserScenarioSecrets.resolve(scenario, {}); + if (secrets === undefined) throw new Error("Expected resolved secrets"); + const events = new PlaywrightScenarioEvents({ + page, + enabled: new Set(["websockets"]), + secrets, + }); + const sockets = Array.from({ length: 2049 }, () => ({ + url: () => "wss://example.test/", + on: vi.fn<(name: string, listener: () => void) => void>(), + off: vi.fn(), + })); + for (const socket of sockets.slice(0, 2048)) + listeners.get("websocket")?.(socket); + expect(sockets[2046]?.on).toHaveBeenCalledTimes(3); + expect(sockets[2047]?.on).not.toHaveBeenCalled(); + expect(events.result().dropped).toBe(1); + const close = sockets[0]?.on.mock.calls.find( + ([name]) => name === "close", + )?.[1]; + close?.(); + expect(sockets[0]?.off).toHaveBeenCalledTimes(3); + listeners.get("websocket")?.(sockets[2048]); + expect(sockets[2048]?.on).toHaveBeenCalledTimes(3); +}); diff --git a/src/browser/PlaywrightScenarioEvents.ts b/src/browser/PlaywrightScenarioEvents.ts index aee9db6307..807d1ae70d 100644 --- a/src/browser/PlaywrightScenarioEvents.ts +++ b/src/browser/PlaywrightScenarioEvents.ts @@ -46,9 +46,30 @@ interface EventCaptureOptions { readonly signal?: AbortSignal; } +// Conservative allocation accounting includes UTF-16 storage and worst-case +// JSON escaping. Reserve before schema cloning; never serialize to measure. +const MAX_EVENT_COST = 4 * 1024 * 1024; +const MAX_CAPTURE_COST = 16 * 1024 * 1024; +const MAX_EVENTS = 10_000; +const MAX_LISTENER_OWNERS = 2048; +const allocationCost = (value: unknown, remaining: number): number => { + if (typeof value === "string") return value.length * 8 + 2; + if (value === null || typeof value !== "object") return 32; + let cost = 2; + for (const key in value) { + if (!Object.hasOwn(value, key)) continue; + const item = (value as Record)[key]; + cost += key.length * 8 + 4 + allocationCost(item, remaining - cost); + if (cost > remaining) break; + } + return cost; +}; + /** Arrival-ordered Playwright event capture with current-step attribution. */ export class PlaywrightScenarioEvents { private readonly items: BrowserScenarioEvent[] = []; + private retainedCost = 0; + private dropped = 0; private stepIndex = 0; private sequence = 0; private readonly enabled: ReadonlySet; @@ -85,6 +106,7 @@ export class PlaywrightScenarioEvents { selection: network, secrets: options.secrets, emit: (event) => this.push(event), + admitText: (values) => this.admitProducerText(values), ...(options.signal === undefined ? {} : { signal: options.signal }), }); this.network = collector; @@ -157,6 +179,10 @@ export class PlaywrightScenarioEvents { `Popup ${family} events before Page discovery are unavailable; later events are retained.`, ); const gaps = [...popupGaps, ...(this.network?.limitations() ?? [])]; + if (this.dropped > 0) + gaps.push( + "Browser event collection reached its resource budget; omitted events and descendant observations are unknown.", + ); return this.enabled.has("network") && !this.ownsContext ? [ "Shared-context network capture excludes requests without a known root or descendant Page frame.", @@ -172,14 +198,24 @@ export class PlaywrightScenarioEvents { } { return { retained: this.items.length, - dropped: 0, + dropped: this.dropped, items: this.items, }; } private push(event: UnindexedEvent): number { - if (this.sealed) return this.sequence; + if (this.sealed) return 0; this.sequence += 1; + if (this.items.length >= MAX_EVENTS) { + this.dropped += 1; + return 0; + } + const cost = allocationCost(event, MAX_EVENT_COST) + 128; + if (cost > MAX_EVENT_COST || this.retainedCost + cost > MAX_CAPTURE_COST) { + this.dropped += 1; + return 0; + } + this.retainedCost += cost; const parsed = browserScenarioEventSchema.parse({ ...event, sequence: this.sequence, @@ -189,6 +225,44 @@ export class PlaywrightScenarioEvents { return this.sequence; } + private admitText(...values: readonly string[]): boolean { + return this.admitProducerText(values); + } + + /** Admit conservative output cost before running redaction or cloning. */ + private admitProducerText(values: Iterable): boolean { + if (this.sealed) return false; + let cost = 4096; + for (const value of values) { + cost += this.secrets.redactionAllocationCost(value.length); + if ( + cost > MAX_EVENT_COST || + cost > MAX_CAPTURE_COST - this.retainedCost + ) { + this.sequence += 1; + this.dropped += 1; + return false; + } + } + if ( + this.items.length >= MAX_EVENTS || + cost > MAX_EVENT_COST || + cost > MAX_CAPTURE_COST - this.retainedCost + ) { + this.sequence += 1; + this.dropped += 1; + return false; + } + return true; + } + + private admitOwner(): boolean { + if (this.sealed) return false; + if (this.stopListeners.size < MAX_LISTENER_OWNERS) return true; + this.dropped += 1; + return false; + } + private retainListener(owner: object, stop: () => void): void { let stops = this.stopListeners.get(owner); if (stops === undefined) { @@ -220,6 +294,7 @@ export class PlaywrightScenarioEvents { } if (this.enabled.has("page-errors")) { const onError = (error: Error) => { + if (!this.admitText(error.message, error.stack ?? "")) return; this.push({ kind: "page-error", message: this.secrets.redact(error.message), @@ -271,16 +346,20 @@ export class PlaywrightScenarioEvents { } private console(message: ConsoleMessage): void { + const rawText = message.text(); const location = message.location(); + if (!this.admitText(rawText, location.url, message.type())) return; this.push({ kind: "console", level: this.secrets.redact(message.type()), - text: this.secrets.redact(message.text()), + text: this.secrets.redact(rawText), url: location.url === "" ? null : this.safeUrl(location.url), }); } private webSocket(socket: WebSocket): void { + if (!this.admitOwner()) return; + if (!this.admitText(socket.url())) return; const url = this.safeUrl(socket.url()); this.push({ kind: "websocket-opened", url }); const onSent = ({ payload }: { payload: string | Buffer }) => @@ -317,6 +396,7 @@ export class PlaywrightScenarioEvents { }); return; } + if (!this.admitText(payload)) return; const text = this.secrets.redact(payload); this.push({ kind, @@ -332,6 +412,7 @@ export class PlaywrightScenarioEvents { kind: "frame-attached" | "frame-detached" | "frame-navigated", frame: Frame, ): void { + if (!this.admitText(frame.url(), frame.name())) return; this.push({ kind, url: frame.url() === "" ? null : this.safeUrl(frame.url()), @@ -340,6 +421,7 @@ export class PlaywrightScenarioEvents { } private worker(worker: Worker): void { + if (!this.admitOwner() || !this.admitText(worker.url())) return; const details = { url: this.safeUrl(worker.url()), name: null, @@ -355,6 +437,7 @@ export class PlaywrightScenarioEvents { private popup(page: Page): void { if (this.pages.has(page)) return; + if (!this.admitOwner() || !this.admitText(page.url())) return; this.pages.add(page); for (const family of [ "frames", @@ -369,12 +452,14 @@ export class PlaywrightScenarioEvents { }; if (this.enabled.has("popups")) { this.push({ kind: "popup-opened", ...opened }); - const onClose = () => + const onClose = () => { + if (!this.admitText(page.url())) return; this.push({ kind: "popup-closed", url: page.url() === "" ? null : this.safeUrl(page.url()), name: null, }); + }; page.on("close", onClose); this.retainListener(page, () => page.off("close", onClose)); } @@ -382,11 +467,12 @@ export class PlaywrightScenarioEvents { } private download(download: Download): void { - this.push({ - kind: "download-cancelled", - suggested_filename: this.secrets.redact(download.suggestedFilename()), - url: this.safeUrl(download.url()), - }); + if (this.admitText(download.suggestedFilename(), download.url())) + this.push({ + kind: "download-cancelled", + suggested_filename: this.secrets.redact(download.suggestedFilename()), + url: this.safeUrl(download.url()), + }); void download.cancel().catch((cause: unknown) => { // best-effort cleanup: download cancellation must not reject unhandled. void cause; diff --git a/src/browser/PlaywrightScenarioServiceWorkers.ts b/src/browser/PlaywrightScenarioServiceWorkers.ts new file mode 100644 index 0000000000..4963fbf5df --- /dev/null +++ b/src/browser/PlaywrightScenarioServiceWorkers.ts @@ -0,0 +1,307 @@ +import { randomUUID } from "node:crypto"; + +import { z } from "zod"; +import type { Page } from "playwright-core"; + +import { browserObservationEndpointSchema } from "../domain/browserObservation.js"; +import { BrowserObservationError } from "../domain/browserObservationError.js"; +import { CdpConnection } from "./CdpConnection.js"; +import { withCdpDiscoveryDeadline } from "./CdpEndpoint.js"; +import { + attachCdpTargetSession, + openCdpEndpoint, + type CdpTargetSession, +} from "./CdpTargetSession.js"; +import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.js"; + +const OPERATION = "capture_browser_scenario" as const; +const scriptResponse = z.object({ identifier: z.string() }); +const attachedEvent = z.object({ + sessionId: z.string(), + targetInfo: z.object({ type: z.string() }), +}); +const detachedEvent = z.object({ sessionId: z.string() }); +type PolicyTarget = { + readonly id: string; + ready: Promise; + identifier?: string; +}; + +/** Owns only its frame scripts and registration wrappers, never caller-owned workers. */ +export class PlaywrightScenarioServiceWorkers { + private readonly marker = JSON.stringify( + `rea-service-workers-${randomUUID()}`, + ); + private readonly restoreKey = JSON.stringify(`rea-restore-${randomUUID()}`); + private readonly secret = JSON.stringify(randomUUID()); + #initialization: Promise | undefined; + #restoration: Promise | undefined; + #session: CdpTargetSession | undefined; + #targets = new Map(); + #closed = false; + #released = false; + #detached = false; + #removeListener: (() => void) | undefined; + #setup = new AbortController(); + + constructor( + private readonly page: Page, + private readonly endpoint: string, + private readonly targetId: string, + ) {} + + initialize(): Promise { + return (this.#initialization ??= this.install()); + } + + private async install(): Promise { + this.#session = await withCdpDiscoveryDeadline( + OPERATION, + this.#setup.signal, + async (signal) => { + await withPlaywrightExecutionBoundary( + () => this.assertBlocked(), + undefined, + signal, + ); + const opened = await openCdpEndpoint( + browserObservationEndpointSchema.parse(this.endpoint), + OPERATION, + signal, + ); + const target = opened.discovery.targets.find( + ({ id }) => id === this.targetId, + ); + if (target === undefined) { + await opened.connection?.close(); + throw new BrowserObservationError(OPERATION, "target_not_found"); + } + const connection = + opened.connection ?? + (await CdpConnection.connect( + opened.discovery.webSocket.url, + OPERATION, + signal, + )); + return await attachCdpTargetSession( + connection, + target, + OPERATION, + signal, + ); + }, + ); + const connection = this.#session.connection; + this.#removeListener = connection.onEvent((event) => { + if ( + event.method === "Target.attachedToTarget" && + this.#targets.has(event.sessionId ?? "") + ) { + const { sessionId, targetInfo } = attachedEvent.parse(event.params); + if (targetInfo.type === "iframe") this.addTarget(sessionId); + } else if (event.method === "Target.detachedFromTarget") { + const id = detachedEvent.parse(event.params).sessionId; + this.#targets.delete(id); + if (id === this.#session?.sessionId) this.#detached = true; + } + }); + const id = this.#session.sessionId; + if (id === undefined) + throw new BrowserObservationError(OPERATION, "protocol_error"); + this.addTarget(id); + await this.settle(); + // CDP returns an identifier even when an init script throws in a realm. + await withPlaywrightExecutionBoundary( + () => this.assertBlocked(), + undefined, + this.#setup.signal, + ); + } + + private addTarget(id: string): void { + const target: PolicyTarget = { id, ready: Promise.resolve() }; + this.#targets.set(id, target); + const ready = this.initializeTarget(target); + target.ready = ready; + void ready.catch(() => undefined); + } + + private async initializeTarget(target: PolicyTarget): Promise { + try { + if (this.#closed) return; + await this.send(target.id, "Page.enable"); + if (this.#closed) return; + const added = scriptResponse.parse( + await this.send(target.id, "Page.addScriptToEvaluateOnNewDocument", { + runImmediately: true, + source: `(() => { + if (!("serviceWorker" in navigator)) return; + const prototype = ServiceWorkerContainer.prototype; + const descriptor = Object.getOwnPropertyDescriptor(prototype, "register"); + if (typeof descriptor?.value === "function" && Object.hasOwn(descriptor.value, Symbol.for(${this.marker}))) return; + const register = () => Promise.reject(new Error("service workers are blocked by scenario policy")); + Object.defineProperty(register, Symbol.for(${this.marker}), { value: true }); + const secret = ${this.secret}; + Object.defineProperty(globalThis, ${this.restoreKey}, { configurable: true, value: (provided) => { + if (provided !== secret) return false; + if (Object.getOwnPropertyDescriptor(prototype, "register")?.value === register) { + if (descriptor === undefined) delete prototype.register; + else Object.defineProperty(prototype, "register", descriptor); + } + delete globalThis[${this.restoreKey}]; + return true; + }}); + Object.defineProperty(prototype, "register", { ...descriptor, configurable: true, value: register }); + })()`, + }), + ); + target.identifier = added.identifier; + if (!this.#closed) + await this.send(target.id, "Target.setAutoAttach", { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + filter: [{ type: "iframe", exclude: false }, { exclude: true }], + }); + } finally { + // Only this page's iframe targets are attached; unrelated workers/popups + // are not acquired. Resume a child even if installation or cleanup fails. + if ( + this.#targets.has(target.id) && + target.id !== this.#session?.sessionId + ) + await this.send(target.id, "Runtime.runIfWaitingForDebugger"); + } + } + + private send( + id: string, + method: string, + params: Readonly> = {}, + ): Promise { + if (this.#session === undefined) + throw new BrowserObservationError(OPERATION, "disconnected"); + return this.#session.connection.send(method, params, id); + } + + private async settle(): Promise { + for (;;) { + const targets = [...this.#targets.values()]; + await Promise.all( + targets.map(async (target) => { + try { + await target.ready; + } catch (cause: unknown) { + if (this.#targets.get(target.id) === target) throw cause; + } + }), + ); + if ( + targets.length === this.#targets.size && + targets.every((target) => this.#targets.get(target.id) === target) + ) + return; + } + } + + async assertBlocked(): Promise { + if (this.#session !== undefined) await this.settle(); + for (const frame of this.page.frames()) { + let violatesPolicy: unknown; + try { + violatesPolicy = await frame.evaluate(`(async () => { + if (!("serviceWorker" in navigator)) return false; + const serviceWorker = navigator.serviceWorker; + if (${this.#session !== undefined} && + (!Object.hasOwn(serviceWorker.register, Symbol.for(${this.marker})) || + typeof globalThis[${this.restoreKey}] !== "function")) return true; + if (serviceWorker.controller !== null) return true; + return (await serviceWorker.getRegistrations()).length > 0; + })()`); + } catch (cause: unknown) { + if (frame.isDetached()) continue; + throw cause; + } + if (violatesPolicy) + throw new BrowserObservationError(OPERATION, "target_not_allowed"); + } + } + + close(stopLoading = false): Promise { + this.#closed = true; + // Abort discovery/handshakes and reads, never an already-sent page mutation. + this.#setup.abort(); + const restoration = (this.#restoration ??= this.restore(stopLoading)); + void restoration.then( + () => { + if (this.#restoration === restoration) this.#restoration = undefined; + }, + () => { + if (this.#restoration === restoration) this.#restoration = undefined; + }, + ); + return withPlaywrightExecutionBoundary(() => restoration, 1_000); + } + + private async restore(stopLoading: boolean): Promise { + await this.#initialization?.catch(() => undefined); + if (this.#released) return; + const session = this.#session; + if (session === undefined) return; + if (!this.page.isClosed()) { + // This owner exists even with empty storage seeds. Stopped navigation is + // independent of storage and restoration never waits for a load event. + if (stopLoading && !this.#detached && session.sessionId !== undefined) + await this.send(session.sessionId, "Page.stopLoading"); + await Promise.all( + [...this.#targets.values()].map(({ ready }) => + ready.catch(() => undefined), + ), + ); + for (const target of this.#targets.values()) { + if (!this.#detached && target.identifier !== undefined) { + try { + await this.send( + target.id, + "Page.removeScriptToEvaluateOnNewDocument", + { identifier: target.identifier }, + ); + } catch (cause: unknown) { + if (this.#targets.get(target.id) === target) throw cause; + } + delete target.identifier; + } + } + // Future documents cannot install our wrapper after the removals above. + // A realm lost to navigation is not assumed restored: retain and retry. + for (const frame of this.page.frames()) { + if (frame.isDetached()) continue; + try { + await frame.evaluate(`(() => { + const restore = globalThis[${this.restoreKey}]; + if (typeof restore === "function" && restore(${this.secret}) === true) return; + const register = globalThis.ServiceWorkerContainer?.prototype.register; + if (typeof register === "function" && Object.hasOwn(register, Symbol.for(${this.marker}))) + throw new Error("service-worker policy restoration could not be confirmed"); + })()`); + } catch (cause: unknown) { + if (!frame.isDetached()) throw cause; + } + } + } + // Detaching the exact owned session also releases its iframe auto-attach. + if ( + !this.page.isClosed() && + !this.#detached && + session.sessionId !== undefined + ) { + await session.connection.send("Target.detachFromTarget", { + sessionId: session.sessionId, + }); + this.#detached = true; + } + this.#removeListener?.(); + await session.connection.close(); + this.#released = true; + } +} diff --git a/src/browser/PlaywrightScenarioSession.test.ts b/src/browser/PlaywrightScenarioSession.test.ts index dbc170880b..d279207731 100644 --- a/src/browser/PlaywrightScenarioSession.test.ts +++ b/src/browser/PlaywrightScenarioSession.test.ts @@ -12,6 +12,29 @@ const mocks = vi.hoisted(() => ({ } | undefined, finishEvents: vi.fn(), + policySend: vi.fn(), + policyTransportClose: vi.fn(), + policyEvent: undefined as + | ((event: { method: string; params: unknown }) => void) + | undefined, +})); + +vi.mock("./CdpTargetSession.js", () => ({ + openCdpEndpoint: async () => ({ + discovery: { targets: [{ id: "target" }] }, + connection: { + send: mocks.policySend, + onEvent: (listener: typeof mocks.policyEvent) => { + mocks.policyEvent = listener; + return () => undefined; + }, + close: mocks.policyTransportClose, + }, + }), + attachCdpTargetSession: async (connection: unknown) => ({ + connection, + sessionId: "policy-session", + }), })); vi.mock("./PlaywrightScenarioBrowser.js", () => ({ @@ -85,6 +108,10 @@ const fixture = () => { evaluate: vi.fn(async (_script: unknown) => false), addInitScript: vi.fn(async (_script: unknown) => undefined), context: () => context, + frames: () => [page], + isDetached: () => false, + isClosed: (): boolean => false, + waitForLoadState: vi.fn(async () => undefined), }); const context = Object.assign(new EventEmitter(), { setDefaultTimeout: vi.fn(), @@ -123,8 +150,154 @@ const fixture = () => { beforeEach(() => { vi.clearAllMocks(); + mocks.policySend.mockReset(); + mocks.policyTransportClose.mockReset(); + mocks.policyEvent = undefined; + mocks.policySend.mockImplementation(async (method: string) => + method === "Page.addScriptToEvaluateOnNewDocument" + ? { identifier: "owned-policy-script" } + : {}, + ); + mocks.policyTransportClose.mockResolvedValue(undefined); }); +it("stops attached navigation without storage and retries rejected policy cleanup", async () => { + const { cleanup } = fixture(); + mocks.storage = undefined; + const attached = browserScenarioSchema.parse({ + ...scenario, + browser: { + mode: "connect", + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "target", + }, + environment: { service_workers: "block" }, + storage: {}, + }); + const session = await PlaywrightScenarioSession.open(attached, {}, {}); + const failure = new Error("stopLoading rejected"); + mocks.policySend.mockRejectedValueOnce(failure); + await expect(session.close(true)).rejects.toBe(failure); + expect(cleanup.close).not.toHaveBeenCalled(); + await expect(session.close(true)).resolves.toBe("disconnected-external"); + expect( + mocks.policySend.mock.calls.filter( + ([method]) => method === "Page.stopLoading", + ), + ).toHaveLength(2); + expect(cleanup.close).toHaveBeenCalledTimes(1); +}); + +it("rejects an init script accepted by CDP when the live realm is unprotected", async () => { + const { page, cleanup } = fixture(); + page.evaluate.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const attached = browserScenarioSchema.parse({ + ...scenario, + browser: { + mode: "connect", + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "target", + }, + environment: { service_workers: "block" }, + }); + await expect( + PlaywrightScenarioSession.open(attached, {}, {}), + ).rejects.toMatchObject({ + reason: "target_not_allowed", + }); + expect( + mocks.policySend.mock.calls.some( + ([method]) => method === "Page.addScriptToEvaluateOnNewDocument", + ), + ).toBe(true); + expect( + mocks.policySend.mock.calls.some( + ([method]) => method === "Page.removeScriptToEvaluateOnNewDocument", + ), + ).toBe(true); + expect(cleanup.close).toHaveBeenCalledTimes(1); +}); + +it("retains an owned script identifier after rejected removal", async () => { + const { cleanup } = fixture(); + const attached = browserScenarioSchema.parse({ + ...scenario, + browser: { + mode: "connect", + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "target", + }, + environment: { service_workers: "block" }, + }); + const session = await PlaywrightScenarioSession.open(attached, {}, {}); + const failure = new Error("removeScript rejected"); + mocks.policySend.mockRejectedValueOnce(failure); + await expect(session.close()).rejects.toBe(failure); + expect(cleanup.close).not.toHaveBeenCalled(); + await expect(session.close()).resolves.toBe("disconnected-external"); + const removals = mocks.policySend.mock.calls.filter( + ([method]) => method === "Page.removeScriptToEvaluateOnNewDocument", + ); + expect(removals).toHaveLength(2); + expect( + removals.every(([, params]) => params.identifier === "owned-policy-script"), + ).toBe(true); +}); + +it("retries transport close without detaching an already released policy session", async () => { + fixture(); + const attached = browserScenarioSchema.parse({ + ...scenario, + browser: { + mode: "connect", + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "target", + }, + environment: { service_workers: "block" }, + }); + const session = await PlaywrightScenarioSession.open(attached, {}, {}); + const failure = new Error("transport close rejected"); + mocks.policyTransportClose.mockRejectedValueOnce(failure); + await expect(session.close()).rejects.toBe(failure); + await expect(session.close()).resolves.toBe("disconnected-external"); + expect( + mocks.policySend.mock.calls.filter( + ([method]) => method === "Target.detachFromTarget", + ), + ).toHaveLength(1); + expect(mocks.policyTransportClose).toHaveBeenCalledTimes(2); +}); + +it.each(["closed page", "detached target"])( + "releases its policy transport after a %s", + async (terminal) => { + const { page } = fixture(); + const attached = browserScenarioSchema.parse({ + ...scenario, + browser: { + mode: "connect", + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "target", + }, + environment: { service_workers: "block" }, + }); + const session = await PlaywrightScenarioSession.open(attached, {}, {}); + if (terminal === "closed page") page.isClosed = () => true; + else + mocks.policyEvent?.({ + method: "Target.detachedFromTarget", + params: { sessionId: "policy-session" }, + }); + await expect(session.close()).resolves.toBe("disconnected-external"); + expect( + mocks.policySend.mock.calls.some( + ([method]) => method === "Target.detachFromTarget", + ), + ).toBe(false); + expect(mocks.policyTransportClose).toHaveBeenCalledTimes(1); + }, +); + it("uses browser disconnect as terminal storage release after detach failure", async () => { const { cleanup, storage } = fixture(); const detachFailure = new Error("CDP detach rejected"); @@ -176,18 +349,136 @@ it("blocks new attached service-worker registration without removing existing re }); const session = await PlaywrightScenarioSession.open(attached, {}, {}); - expect(page.evaluate).toHaveBeenCalledTimes(2); - expect(page.addInitScript).toHaveBeenCalledTimes(1); - expect(page.addInitScript).toHaveBeenCalledWith( - expect.stringContaining("service workers are blocked by scenario policy"), - ); - expect(page.addInitScript).toHaveBeenCalledWith( - expect.not.stringContaining("getRegistrations"), - ); - expect(page.addInitScript).toHaveBeenCalledWith( - expect.not.stringContaining("unregister"), + expect(page.evaluate).toHaveBeenCalledTimes(3); + const injection = mocks.policySend.mock.calls.find( + ([method]) => method === "Page.addScriptToEvaluateOnNewDocument", + )?.[1]; + expect(injection.source).toContain( + "service workers are blocked by scenario policy", ); + expect(injection.source).not.toContain("getRegistrations"); + expect(injection.source).not.toContain("unregister"); await expect(session.close()).resolves.toBe("disconnected-external"); + expect(mocks.policySend).toHaveBeenCalledWith( + "Page.removeScriptToEvaluateOnNewDocument", + { identifier: "owned-policy-script" }, + "policy-session", + ); + expect(cleanup.close).toHaveBeenCalledTimes(1); +}); + +it("retains the attached connection when registration restoration fails", async () => { + const { cleanup, page } = fixture(); + const attached = browserScenarioSchema.parse({ + ...scenario, + browser: { + mode: "connect", + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "target", + }, + environment: { service_workers: "block" }, + }); + const session = await PlaywrightScenarioSession.open(attached, {}, {}); + const failure = new Error("registration restoration failed"); + page.evaluate.mockRejectedValueOnce(failure); + await expect(session.close()).rejects.toBe(failure); + expect(cleanup.close).not.toHaveBeenCalled(); + await expect(session.close()).resolves.toBe("disconnected-external"); + expect(cleanup.close).toHaveBeenCalledTimes(1); + await expect(session.close()).resolves.toBe("disconnected-external"); +}); + +it("cancels a stalled policy preflight without allowing late setup", async () => { + const { cleanup, page } = fixture(); + let release: () => void = () => undefined; + let entered: () => void = () => undefined; + const started = new Promise((resolve) => { + entered = resolve; + }); + page.evaluate.mockImplementationOnce(async () => { + entered(); + await new Promise((resolve) => { + release = resolve; + }); + return false; + }); + const cancellation = new AbortController(); + const opening = PlaywrightScenarioSession.open( + browserScenarioSchema.parse({ + ...scenario, + browser: { + mode: "connect", + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "target", + }, + environment: { service_workers: "block" }, + }), + {}, + { signal: cancellation.signal }, + ); + const outcome = expect(opening).rejects.toMatchObject({ + _tag: "AnalysisCancelledError", + }); + await started; + cancellation.abort(); + await outcome; + expect(cleanup.close).toHaveBeenCalledTimes(1); + expect(cleanup.close.mock.calls[0]).toHaveLength(1); + release(); + await new Promise((resolve) => setImmediate(resolve)); + expect(mocks.policySend).not.toHaveBeenCalled(); +}); + +it("waits for a pending registration installation before cancelled cleanup", async () => { + const { cleanup, page } = fixture(); + let releaseInstallation: () => void = () => undefined; + let installationStarted: () => void = () => undefined; + const installed = new Promise((resolve) => { + releaseInstallation = resolve; + }); + const started = new Promise((resolve) => { + installationStarted = resolve; + }); + mocks.policySend.mockImplementation(async (method: string) => { + if (method === "Page.addScriptToEvaluateOnNewDocument") { + installationStarted(); + await installed; + return { identifier: "owned-policy-script" }; + } + return {}; + }); + const cancellation = new AbortController(); + const opening = PlaywrightScenarioSession.open( + browserScenarioSchema.parse({ + ...scenario, + browser: { + mode: "connect", + cdp_endpoint: "http://127.0.0.1:9222", + target_id: "target", + }, + environment: { service_workers: "block" }, + }), + {}, + { signal: cancellation.signal }, + ); + const outcome = expect(opening).rejects.toMatchObject({ + _tag: "AnalysisCancelledError", + }); + await started; + cancellation.abort(); + await new Promise((resolve) => setImmediate(resolve)); + expect(cleanup.close).not.toHaveBeenCalled(); + expect(page.evaluate).toHaveBeenCalledTimes(1); + releaseInstallation(); + await outcome; + expect(page.evaluate.mock.calls.at(-1)?.[0]).toContain( + "policy restoration could not be confirmed", + ); + expect(mocks.policySend).toHaveBeenCalledWith( + "Page.stopLoading", + {}, + "policy-session", + ); expect(cleanup.close).toHaveBeenCalledTimes(1); }); diff --git a/src/browser/PlaywrightScenarioSession.ts b/src/browser/PlaywrightScenarioSession.ts index 949193b7c4..56a043732f 100644 --- a/src/browser/PlaywrightScenarioSession.ts +++ b/src/browser/PlaywrightScenarioSession.ts @@ -1,4 +1,4 @@ -import type { BrowserContext, Page } from "playwright-core"; +import type { BrowserContext } from "playwright-core"; import type { BrowserScenario } from "../domain/browserScenario.js"; import type { BrowserScenarioAction } from "../domain/browserScenarioValues.js"; @@ -14,6 +14,7 @@ import { performPlaywrightScenarioAction } from "./PlaywrightScenarioActions.js" import { capturePlaywrightStepArtifacts } from "./PlaywrightScenarioArtifacts.js"; import { PlaywrightScenarioEvents } from "./PlaywrightScenarioEvents.js"; import { PlaywrightScenarioStorage } from "./PlaywrightScenarioStorage.js"; +import { PlaywrightScenarioServiceWorkers } from "./PlaywrightScenarioServiceWorkers.js"; import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.js"; import { browserScenarioCaptureData, @@ -50,41 +51,13 @@ const installCookies = async ( ); }; -const blockAttachedServiceWorkers = async (page: Page): Promise => { - const hasServiceWorker = await page.evaluate(`(async () => { - if (!("serviceWorker" in navigator)) return false; - const serviceWorker = navigator.serviceWorker; - if (serviceWorker.controller !== null) return true; - return (await serviceWorker.getRegistrations()).length > 0; - })()`); - // An attached context belongs to the caller. Reject a block request when it - // already has a worker instead of unregistering that caller-owned resource. - if (hasServiceWorker) - throw new BrowserObservationError(OPERATION, "target_not_allowed"); - const blockRegistration = `(() => { - if (!("serviceWorker" in navigator)) return; - Object.defineProperty(ServiceWorkerContainer.prototype, "register", { - configurable: false, - value: () => Promise.reject(new Error("service workers are blocked by scenario policy")) - }); - })()`; - await page.addInitScript(blockRegistration); - await page.evaluate(blockRegistration); -}; - const initializePage = async ( context: BrowserContext, - page: Page, scenario: BrowserScenario, secrets: BrowserScenarioSecrets, ): Promise => { context.setDefaultTimeout(0); context.setDefaultNavigationTimeout(0); - if ( - scenario.browser.mode === "connect" && - scenario.environment.service_workers === "block" - ) - await blockAttachedServiceWorkers(page); await installCookies(context, scenario, secrets); }; @@ -97,6 +70,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { private readonly secrets: BrowserScenarioSecrets; private readonly eventCapture: PlaywrightScenarioEvents; private readonly storage: PlaywrightScenarioStorage | undefined; + private readonly serviceWorkers: PlaywrightScenarioServiceWorkers | undefined; private readonly signal: AbortSignal | undefined; #storageReleased = false; @@ -107,6 +81,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { readonly secrets: BrowserScenarioSecrets; readonly eventCapture: PlaywrightScenarioEvents; readonly storage: PlaywrightScenarioStorage | undefined; + readonly serviceWorkers: PlaywrightScenarioServiceWorkers | undefined; readonly signal: AbortSignal | undefined; }, ) { @@ -116,6 +91,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { this.secrets = options.secrets; this.eventCapture = options.eventCapture; this.storage = options.storage; + this.serviceWorkers = options.serviceWorkers; this.signal = options.signal; this.version = opened.browser.version(); this.initialUrl = opened.page.url(); @@ -178,6 +154,11 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { undefined, options.signal, ); + await withPlaywrightExecutionBoundary( + () => session.serviceWorkers?.assertBlocked() ?? Promise.resolve(), + undefined, + options.signal, + ); await withPlaywrightExecutionBoundary( () => session.storage?.settle() ?? Promise.resolve(), undefined, @@ -228,9 +209,21 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { ): Promise { let events: PlaywrightScenarioEvents | undefined; let storage: PlaywrightScenarioStorage | undefined; + const serviceWorkers = + scenario.browser.mode === "connect" && + scenario.environment.service_workers === "block" + ? new PlaywrightScenarioServiceWorkers( + opened.page, + scenario.browser.cdp_endpoint, + scenario.browser.target_id, + ) + : undefined; try { await withPlaywrightExecutionBoundary( - () => initializePage(opened.context, opened.page, scenario, secrets), + async () => { + await serviceWorkers?.initialize(); + await initializePage(opened.context, scenario, secrets); + }, undefined, options.signal, ); @@ -261,12 +254,14 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { secrets, eventCapture: events, storage, + serviceWorkers, signal: options.signal, }); } catch (cause: unknown) { let storageReleased = false; const cleanup = async () => { let storageFailure: { readonly cause: unknown } | undefined; + let policyFailure: { readonly cause: unknown } | undefined; let browserFailure: { readonly cause: unknown } | undefined; if (!storageReleased) { try { @@ -276,17 +271,30 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { } } try { - await opened.cleanup.close( - events === undefined - ? undefined - : () => events?.finish() ?? Promise.resolve(), - options.signal, - ); + if (!opened.cleanup.browserClosed) await serviceWorkers?.close(true); } catch (failure: unknown) { - browserFailure = { cause: failure }; + policyFailure = { cause: failure }; + } + if (policyFailure === undefined) { + try { + await opened.cleanup.close( + events === undefined + ? undefined + : () => events?.finish() ?? Promise.resolve(), + ); + } catch (failure: unknown) { + browserFailure = { cause: failure }; + } + } else { + try { + await events?.finish(); + } catch (failure: unknown) { + browserFailure = { cause: failure }; + } } if (opened.cleanup.browserClosed) storageReleased = true; throwCleanupFailures([ + ...(policyFailure === undefined ? [] : [policyFailure.cause]), ...(storageReleased || storageFailure === undefined ? [] : [storageFailure.cause]), @@ -358,6 +366,11 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { >, signal?: AbortSignal, ) { + await withPlaywrightExecutionBoundary( + () => this.serviceWorkers?.assertBlocked() ?? Promise.resolve(), + undefined, + signal, + ); await withPlaywrightExecutionBoundary( () => this.storage?.settle() ?? Promise.resolve(), undefined, @@ -378,6 +391,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { async close(stopLoading = this.signal?.aborted === true) { let storageFailure: { readonly cause: unknown } | undefined; + let policyFailure: { readonly cause: unknown } | undefined; let browserFailure: { readonly cause: unknown } | undefined; if (!this.#storageReleased) { try { @@ -387,12 +401,27 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort { } } try { - await this.opened.cleanup.close(() => this.eventCapture.finish()); + if (!this.opened.cleanup.browserClosed) + await this.serviceWorkers?.close(stopLoading); } catch (failure: unknown) { - browserFailure = { cause: failure }; + policyFailure = { cause: failure }; + } + if (policyFailure === undefined) { + try { + await this.opened.cleanup.close(() => this.eventCapture.finish()); + } catch (failure: unknown) { + browserFailure = { cause: failure }; + } + } else { + try { + await this.eventCapture.finish(); + } catch (failure: unknown) { + browserFailure = { cause: failure }; + } } if (this.opened.cleanup.browserClosed) this.#storageReleased = true; throwCleanupFailures([ + ...(policyFailure === undefined ? [] : [policyFailure.cause]), ...(this.#storageReleased || storageFailure === undefined ? [] : [storageFailure.cause]), diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index 5dbbe2ad10..4ab805f43a 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -729,7 +729,10 @@ const originalModuleEdges = ( ); continue; } - const parsed = parseJavaScriptSource(source.artifact.text); + const parsed = parseJavaScriptSource( + source.artifact.text, + sourceParserPath(sourceName), + ); check(); if (parsed === null) { incomplete.push(sourceName); @@ -783,6 +786,17 @@ const originalModuleEdges = ( return { edges, incomplete }; }; +/** Select parser semantics from the source URL path, excluding query/fragment. */ +const sourceParserPath = (source: string): string => { + try { + return new URL(source).pathname; + } catch (cause: unknown) { + // Unresolved source identities can still carry a useful relative path. + void cause; + return source; + } +}; + const checkOperation = ( callerSignal: AbortSignal | undefined, operationSignal: AbortSignal | undefined, diff --git a/src/browser/assets/LocalWebScriptArtifacts.ts b/src/browser/assets/LocalWebScriptArtifacts.ts index 51fc14c45b..13391bff87 100644 --- a/src/browser/assets/LocalWebScriptArtifacts.ts +++ b/src/browser/assets/LocalWebScriptArtifacts.ts @@ -84,6 +84,11 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort { "path", `Selected source path changes during normalization: ${selected.content.relative_path}`, ); + if (process.platform === "win32" && portable.includes("\\")) + throw new ArtifactReaderFailure( + "path", + `Windows source path cannot contain backslashes as literal name characters: ${JSON.stringify(portable)}`, + ); const root = resolve(dirname(input.manifest_path)); const sourcePath = join(root, "files", portable); targetPath = sourcePath; diff --git a/src/browser/history/CaptureRedaction.ts b/src/browser/history/CaptureRedaction.ts index 450b1bb05d..63feb4899e 100644 --- a/src/browser/history/CaptureRedaction.ts +++ b/src/browser/history/CaptureRedaction.ts @@ -1,3 +1,4 @@ +import { isSafeNumber, type LosslessNumber } from "lossless-json"; import type { WebNetworkCaptureRecord } from "../../domain/webNetworkCapture.js"; import { parseRecordedTransportUrl } from "../../domain/recordedTransportUrl.js"; @@ -50,6 +51,22 @@ export class CaptureRedaction { return null; } + /** Check both retained spellings before projecting a producer JSON number. */ + number( + value: LosslessNumber, + pointer: string, + redact = true, + ): { reported: number | null; literal: string } | null { + const reported = isSafeNumber(value.value) ? Number(value.value) : null; + if ( + redact && + (this.text(value.value, pointer) === null || + (reported !== null && this.text(String(reported), pointer) === null)) + ) + return null; + return { reported, literal: value.value }; + } + /** Known HAR credential surfaces; unknown extensions are ordinary caller-selected evidence. */ harCredential( pointer: string, diff --git a/src/browser/history/HarCapture.test.ts b/src/browser/history/HarCapture.test.ts index b931fda063..60cf99e1b3 100644 --- a/src/browser/history/HarCapture.test.ts +++ b/src/browser/history/HarCapture.test.ts @@ -257,6 +257,39 @@ it.each(["9007199254740993", "4740993", "99"])( }, ); +it.each([ + { encoded: "1e3", sensitive: "1000" }, + { encoded: "123.4500", sensitive: "123.45" }, + { encoded: "0.0000001", sensitive: "1e-7" }, +])( + "excludes sensitive numbers introduced by JSON normalization: $encoded", + ({ encoded, sensitive }) => { + const text = JSON.stringify(historicalHar()) + .replace('"log":{', `"_number":${encoded},"log":{`) + .replace('"cache":{}', `"cache":{},"_number":${encoded}`); + const result = decodeHarCapture(text, [sensitive]); + for (const projection of [result.container, ...result.records]) { + expect(projection.reported).toMatchObject({ _number: null }); + expect(projection.numeric_literals).not.toContainEqual( + expect.objectContaining({ pointer: "/_number" }), + ); + expect(projection.redactions).toContainEqual({ + pointer: "/_number", + reason: "explicit-sensitive-value", + }); + } + const unmarked = decodeHarCapture(text, []); + for (const projection of [unmarked.container, ...unmarked.records]) { + expect(projection.reported).toMatchObject({ _number: Number(encoded) }); + expect(projection.numeric_literals).toContainEqual({ + pointer: "/_number", + producer_type: "json-number", + literal: encoded, + }); + } + }, +); + it("keeps unsafe extension numeric lexemes and rejects unsafe mandatory schema numbers", () => { const text = JSON.stringify(historicalHar()); const result = decodeHarCapture( diff --git a/src/browser/history/HarCapture.ts b/src/browser/history/HarCapture.ts index 81c5ffb15f..d23083b6bf 100644 --- a/src/browser/history/HarCapture.ts +++ b/src/browser/history/HarCapture.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { isSafeNumber, LosslessNumber } from "lossless-json"; +import { LosslessNumber } from "lossless-json"; import { parseCaptureJson } from "./CaptureJson.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { @@ -121,13 +121,14 @@ const projectHar = ( ) return redactor.credential(pointer); if (item instanceof LosslessNumber) { - if (redact && redactor.text(item.value, pointer) === null) return null; + const number = redactor.number(item, pointer, redact); + if (number === null) return null; numeric_literals.push({ pointer, producer_type: "json-number", - literal: item.value, + literal: number.literal, }); - return isSafeNumber(item.value) ? Number(item.value) : null; + return number.reported; } if (typeof item === "string") { if ( diff --git a/src/browser/history/QlogCapture.test.ts b/src/browser/history/QlogCapture.test.ts index fb4e100729..23c776565f 100644 --- a/src/browser/history/QlogCapture.test.ts +++ b/src/browser/history/QlogCapture.test.ts @@ -186,6 +186,31 @@ it("excludes structural auth material and explicit normalized values without gue expect(traces[0]?.incomplete_fact_records).toContain(2); }); +it.each([ + { hidden: "sent", incomplete: [0], streams: 0, losses: 1 }, + { hidden: "lost", incomplete: [1], streams: 1, losses: 0 }, + { hidden: "name", incomplete: [0, 1], streams: 0, losses: 0 }, +])( + "lists records whose event name a declaration of $hidden hides as incomplete", + ({ hidden, incomplete, streams, losses }) => { + const { traces } = facts( + capture([ + event([frame(0, 10, true)]), + { + name: "recovery:packet_lost", + time: 1, + data: { packet_number: 5, type: "1RTT" }, + }, + { name: "extension:custom", time: 2, data: { body: "ordinary" } }, + ]), + [hidden], + ); + expect(traces[0]?.incomplete_fact_records).toEqual(incomplete); + expect(traces[0]?.streams).toHaveLength(streams); + expect(traces[0]?.loss_declarations).toHaveLength(losses); + }, +); + it("omits a hidden derived total for a stream with more contributions than call arguments allow", () => { const events = Array.from({ length: 130 }, (_, index) => event( diff --git a/src/browser/history/QlogCapture.ts b/src/browser/history/QlogCapture.ts index 8d19fc64fb..245ec1fd85 100644 --- a/src/browser/history/QlogCapture.ts +++ b/src/browser/history/QlogCapture.ts @@ -84,7 +84,12 @@ export const decodeQlogCapture = ( "Record ordinal is derived from trace/event source traversal, not global time order. Qlog lengths and FIN do not expose original HTTP body bytes or establish network completeness.", ], }); - collectTransportFacts(projection, ordinal, { fact, streams, excluded }); + collectTransportFacts(projection, ordinal, { + fact, + streams, + excluded, + name: object(event) && typeof event.name === "string" ? event.name : "", + }); } for (const stream of streams.values()) { summarizeStream(stream); @@ -223,14 +228,25 @@ const collectTransportFacts = ( fact: Trace; streams: Map; excluded: (value: string) => boolean; + /** Validated source event name, before declarations are applied. */ + name: string; }, ): void => { - const { fact, streams, excluded } = state; + const { fact, streams, excluded, name } = state; if (!object(projection.reported) || !object(projection.reported.data)) { fact.incomplete_fact_records.push(ordinal); return; } const visible = projection.reported; + // Facts are selected by event name, so a declaration that hides the name + // of a loss or packet event omits its facts like any other hidden field. + if ( + (name === "recovery:packet_lost" || packetEvent(name)) && + visible.name !== name + ) { + fact.incomplete_fact_records.push(ordinal); + return; + } const data = visible.data; if (!object(data)) return; const literals = new Map( diff --git a/src/browser/history/SazMetadata.test.ts b/src/browser/history/SazMetadata.test.ts new file mode 100644 index 0000000000..a5dbbf7838 --- /dev/null +++ b/src/browser/history/SazMetadata.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from "vitest"; +import { decodeSazMetadata } from "./SazMetadata.js"; + +it("retains U+FFFD in well-formed metadata as an ordinary XML character", () => { + const decoded = JSON.stringify( + decodeSazMetadata( + Buffer.from( + '�', + ), + ), + ); + expect(decoded).toContain('{"name":"V","value":"caf�"}'); + expect(decoded).toContain('"value":"�"'); +}); + +it.each([ + "�", + '', + "�", +])("still rejects malformed metadata that also contains U+FFFD: %j", (xml) => { + expect(() => decodeSazMetadata(Buffer.from(xml))).toThrow( + "SAZ metadata is not well-formed XML.", + ); +}); diff --git a/src/browser/history/SazMetadata.ts b/src/browser/history/SazMetadata.ts index 6e311f8e65..f9d1b6733f 100644 --- a/src/browser/history/SazMetadata.ts +++ b/src/browser/history/SazMetadata.ts @@ -25,7 +25,15 @@ export const decodeSazMetadata = (bytes: Buffer): JsonValue => { try { document = new DOMParser({ normalizeLineEndings: (source) => source, - onError: () => { + onError: (level, message) => { + // The parser warns whenever the source contains U+FFFD. It is a legal + // XML Char, and the fatal decoder above already rejected invalid + // source bytes, so only that warning is not a well-formedness failure. + if ( + level === "warning" && + message.startsWith("Unicode replacement character detected") + ) + return; parseFailed = true; }, }).parseFromString(xml, "application/xml"); diff --git a/src/browser/network/PlaywrightNetworkContent.ts b/src/browser/network/PlaywrightNetworkContent.ts index 9a35d179f6..390831030f 100644 --- a/src/browser/network/PlaywrightNetworkContent.ts +++ b/src/browser/network/PlaywrightNetworkContent.ts @@ -33,12 +33,15 @@ const credentialHeaders = new Set([ const errorMessage = ( cause: unknown, secrets: BrowserScenarioSecrets, -): string => - secrets.redact( +): string => { + const message = cause instanceof Error && cause.message !== "" ? cause.message - : "Network read failed", - ); + : "Network read failed"; + return secrets.redactionAllocationCost(message.length) <= 1024 * 1024 + ? secrets.redact(message) + : "Network read failed; diagnostic exceeds the allocation budget."; +}; /** Read actual producer headers without flattening duplicates or exposing credentials. */ export const captureNetworkHeaders = async ( @@ -52,6 +55,15 @@ export const captureNetworkHeaders = async ( options.timeoutMs, options.signal, ); + let cost = 0; + for (const { name, value } of headers) { + cost += secrets.redactionAllocationCost(name.length + value.length) + 128; + if (cost > 1024 * 1024) + return { + state: "unavailable", + message: "Headers exceed the 1 MiB allocation budget.", + }; + } return { state: "captured", items: headers.map(({ name, value }) => { @@ -87,8 +99,31 @@ export const captureNetworkBody = async ( options.signal, ); if (observed === null) return { state: "not_exposed" }; + // This bounds subsequent redaction, base64 and validation copies, but the + // Playwright API itself has already materialized the body (see PR limits). + if (observed.length > 64 * 1024) + return { + state: "unavailable", + reason: "body-read-failed", + message: "Body exceeds the 64 KiB retained-content budget.", + }; + if (secrets.redactionAllocationCost(observed.length) > 1024 * 1024) + return { + state: "unavailable", + reason: "body-read-failed", + message: "Body redaction exceeds the allocation budget.", + }; const retained = secrets.redactBytes(observed); const contentType = source.headers()["content-type"]; + if ( + contentType !== undefined && + secrets.redactionAllocationCost(contentType.length) > 64 * 1024 + ) + return { + state: "unavailable", + reason: "body-read-failed", + message: "Content type exceeds the retained metadata budget.", + }; return { state: "captured", representation: diff --git a/src/browser/network/PlaywrightScenarioNetwork.test.ts b/src/browser/network/PlaywrightScenarioNetwork.test.ts index 0c67c7c3a6..97e95a2968 100644 --- a/src/browser/network/PlaywrightScenarioNetwork.test.ts +++ b/src/browser/network/PlaywrightScenarioNetwork.test.ts @@ -27,7 +27,7 @@ class RequestFixture implements ScenarioNetworkRequest { resourceType() { return "fetch"; } - headers() { + headers(): Record { return { "content-type": "text/plain", authorization: "Bearer private-transport", @@ -127,7 +127,7 @@ const harness = ( return sequence; }, }); - return { collector, events }; + return { collector, events, secrets }; }; const bodyText = (event: BrowserScenarioEvent) => { @@ -444,10 +444,13 @@ it("links observed redirect hops and leaves unseen predecessors unknown", async collector.request(unseen); await collector.finish(); const requests = events.filter((event) => event.kind === "request"); + expect(requests[0]?.redirected_from_transaction_state).toBe("root"); expect(requests[1]?.redirected_from_transaction_id).toBe( requests[0]?.transaction_id, ); + expect(requests[1]?.redirected_from_transaction_state).toBeUndefined(); expect(requests[2]?.redirected_from_transaction_id).toBeNull(); + expect(requests[2]?.redirected_from_transaction_state).toBe("unknown"); }); it("preserves read errors and does not turn unexposed or empty bytes into the same state", async () => { @@ -533,3 +536,113 @@ it("redacts declared encodings in body media metadata", async () => { }), ); }); + +it("bounds admitted transactions before invoking content producers", async () => { + const { collector, events } = harness(); + const admitted: RequestFixture[] = []; + for (let index = 0; index < 1024; index += 1) { + const request = new RequestFixture(`body-${index}`); + admitted.push(request); + collector.request(request); + collector.request(request); // repeated producer delivery cannot add jobs + } + const rejected = new RequestFixture("rejected"); + const response = new ResponseFixture(rejected, "rejected"); + collector.request(rejected); + collector.response(response); + collector.finished(rejected); + await collector.finish(); + expect(admitted[0]?.bodyReads).toBe(1); + expect( + admitted + .slice(16) + .every((request) => request.bodyReads === 0 && request.headerReads === 0), + ).toBe(true); + expect(rejected.bodyReads).toBe(0); + expect(rejected.headerReads).toBe(0); + expect(response.bodyReads).toBe(0); + expect(response.headerReads).toBe(0); + expect( + events.filter((event) => event.kind === "network-content"), + ).toHaveLength(1024); + expect(collector.limitations().join(" ")).toContain("resource budget"); +}); + +it("keeps header names and selected body when unselected values are oversized", async () => { + const { collector, events } = harness({ + request_body: true, + response_body: false, + header_values: false, + }); + const request = new RequestFixture("retained body"); + request.headers = () => ({ + "x-large-unselected": "x".repeat(1024 * 1024 + 1), + }); + collector.request(request); + await collector.finish(); + + expect(events).toContainEqual( + expect.objectContaining({ + kind: "request", + header_names: ["x-large-unselected"], + }), + ); + expect( + events.find((event) => event.kind === "network-content"), + ).toMatchObject({ + phase: "request", + headers: { state: "not_requested" }, + body: { state: "captured" }, + }); + expect(request.bodyReads).toBe(1); +}); + +it("omits oversized body copies and retains ordinary content", async () => { + const { collector, events } = harness(); + collector.request(new RequestFixture("x".repeat(64 * 1024 + 1))); + collector.request(new RequestFixture("ordinary")); + await collector.finish(); + const content = events.filter((event) => event.kind === "network-content"); + expect(content[0]?.body).toMatchObject({ + state: "unavailable", + message: expect.stringContaining("budget"), + }); + expect(bodyText(content[1]!)).toBe("ordinary"); +}); + +it("reserves redaction expansion before creating replacement copies", async () => { + const { collector, events, secrets } = harness(selection, undefined, "a"); + const redact = vi.spyOn(secrets, "redactBytes"); + collector.request(new RequestFixture("a".repeat(64 * 1024))); + await collector.finish(); + expect( + events.find((event) => event.kind === "network-content")?.body, + ).toMatchObject({ + state: "unavailable", + message: expect.stringContaining("redaction"), + }); + // Ordinary header values can still pass through the byte redactor. + expect(redact.mock.calls.every(([value]) => value.length < 64 * 1024)).toBe( + true, + ); +}); + +it("does not read content when phase metadata was omitted", async () => { + const { secrets } = harness(); + const redact = vi.spyOn(secrets, "redact"); + const collector = new PlaywrightScenarioNetwork({ + selection, + secrets, + admitText: () => false, + emit: () => 0, + }); + const request = new RequestFixture("ordinary"); + collector.request(request); + await collector.finish(); + expect(redact).not.toHaveBeenCalled(); + expect(request.bodyReads + request.headerReads).toBe(0); + expect(collector.limitations().join(" ")).toContain( + "aggregate browser event budget", + ); + expect(collector.limitations().join(" ")).toContain("incomplete"); +}); diff --git a/src/browser/network/PlaywrightScenarioNetwork.ts b/src/browser/network/PlaywrightScenarioNetwork.ts index 45129cb95f..24479ef378 100644 --- a/src/browser/network/PlaywrightScenarioNetwork.ts +++ b/src/browser/network/PlaywrightScenarioNetwork.ts @@ -46,18 +46,22 @@ type UnindexedNetworkEvent = BrowserScenarioEvent extends infer Event interface Transaction { readonly id: string; readonly redirectedFrom: string | null; + readonly redirectedFromState: "root" | "unknown" | undefined; response?: { readonly source: ScenarioNetworkResponse; readonly sequence: number; }; terminal?: "finished" | "failed"; responseScheduled: boolean; + requestObserved: boolean; } interface NetworkCaptureOptions { readonly selection: BrowserNetworkContentSelection; readonly secrets: BrowserScenarioSecrets; readonly emit: (event: UnindexedNetworkEvent) => number; + /** Check aggregate event capacity before redacting producer-owned metadata. */ + readonly admitText?: (values: Iterable) => boolean; readonly signal?: AbortSignal; /** Production uses 5s; a fixture can supply its own bounded-read clock. */ readonly readTimeoutMs?: number; @@ -72,6 +76,7 @@ export class PlaywrightScenarioNetwork { private readonly pending: Promise[] = []; private readonly gaps = new Set(); private accepting = true; + private activeReads = 0; private readonly readOptions: NetworkReadOptions; constructor(private readonly options: NetworkCaptureOptions) { @@ -85,21 +90,21 @@ export class PlaywrightScenarioNetwork { request(request: ScenarioNetworkRequest): void { if (!this.accepting) return; const transaction = this.transaction(request); + if (transaction === undefined || transaction.requestObserved) return; + if (!this.admitMetadata(request)) return; + transaction.requestObserved = true; const sequence = this.options.emit({ ...this.metadata(request, transaction), kind: "request", status: null, failure: null, }); + if (sequence === 0) return; if ( this.options.selection.request_body || this.options.selection.header_values ) - this.collect( - transaction, - sequence, - "request", - request, + this.collect(transaction, sequence, "request", request, () => this.options.selection.request_body ? captureNetworkBody( async () => request.postDataBuffer(), @@ -117,6 +122,16 @@ export class PlaywrightScenarioNetwork { if (!this.accepting) return; const request = response.request(); const transaction = this.transaction(request); + if ( + transaction === undefined || + !this.admitMetadata( + request, + undefined, + [], + [{ source: response, url: response.url() }], + ) + ) + return; const sequence = this.options.emit({ ...this.metadata(request, transaction), kind: "response", @@ -125,6 +140,7 @@ export class PlaywrightScenarioNetwork { header_names: this.headerNames(response), failure: null, }); + if (sequence === 0) return; transaction.response = { source: response, sequence }; } @@ -132,13 +148,18 @@ export class PlaywrightScenarioNetwork { finished(request: ScenarioNetworkRequest): void { if (!this.accepting) return; const transaction = this.transaction(request); + if (transaction === undefined || !this.admitMetadata(request)) return; transaction.terminal = "finished"; - this.options.emit({ + const sequence = this.options.emit({ ...this.metadata(request, transaction), kind: "request-finished", status: null, failure: null, }); + if (sequence === 0) { + transaction.responseScheduled = true; + return; + } this.responseContent(transaction); } @@ -146,16 +167,21 @@ export class PlaywrightScenarioNetwork { failed(request: ScenarioNetworkRequest): void { if (!this.accepting) return; const transaction = this.transaction(request); + if (transaction === undefined) return; transaction.terminal = "failed"; - const message = this.options.secrets.redact( - request.failure()?.errorText || "Unknown request failure", - ); - this.options.emit({ + const failure = request.failure()?.errorText || "Unknown request failure"; + if (!this.admitMetadata(request, undefined, [failure])) return; + const message = this.options.secrets.redact(failure); + const sequence = this.options.emit({ ...this.metadata(request, transaction), kind: "request-failed", status: null, failure: message, }); + if (sequence === 0) { + transaction.responseScheduled = true; + return; + } this.responseContent(transaction, { state: "unavailable", reason: "request-failed", @@ -172,10 +198,11 @@ export class PlaywrightScenarioNetwork { this.accepting = false; for (const [request, transaction] of this.transactions) { if (transaction.terminal === undefined) { + if (!this.admitMetadata(request)) continue; this.gaps.add( "Some network requests had not completed when capture ended; later activity is unknown.", ); - this.options.emit({ + const sequence = this.options.emit({ ...this.metadata(request, transaction), kind: "request-unfinished", status: null, @@ -185,6 +212,10 @@ export class PlaywrightScenarioNetwork { ? "cancelled" : "capture-ended", }); + if (sequence === 0) { + transaction.responseScheduled = true; + continue; + } this.responseContent(transaction, { state: "unavailable", reason: @@ -204,22 +235,110 @@ export class PlaywrightScenarioNetwork { return [...this.gaps]; } - private transaction(request: ScenarioNetworkRequest): Transaction { + private transaction( + request: ScenarioNetworkRequest, + ): Transaction | undefined { const known = this.transactions.get(request); if (known !== undefined) return known; + if (this.transactions.size >= 1024) { + this.gaps.add( + "Network collection reached its 1024-transaction resource budget; further transactions were omitted.", + ); + return undefined; + } const predecessor = request.redirectedFrom(); + const predecessorTransaction = + predecessor === null ? undefined : this.transactions.get(predecessor); const transaction: Transaction = { id: `request-${this.transactions.size + 1}`, - redirectedFrom: + redirectedFrom: predecessorTransaction?.id ?? null, + redirectedFromState: predecessor === null - ? null - : (this.transactions.get(predecessor)?.id ?? null), + ? "root" + : predecessorTransaction === undefined + ? "unknown" + : undefined, responseScheduled: false, + requestObserved: false, }; this.transactions.set(request, transaction); return transaction; } + private admitProducerStrings(values: Iterable): boolean { + const collected: string[] = []; + let cost = 0; + for (const value of values) { + cost += this.options.secrets.redactionAllocationCost(value.length) + 128; + if (cost > 1024 * 1024) { + this.gaps.add( + "Some network metadata exceeded its allocation budget and was omitted.", + ); + return false; + } + collected.push(value); + } + if (this.options.admitText?.(collected) ?? true) return true; + this.gaps.add( + "Some network metadata was omitted because the aggregate browser event budget was reached; affected transactions are incomplete.", + ); + return false; + } + + private admitMetadata( + source: NetworkHeadersSource, + url?: string, + additionalStrings: readonly string[] = [], + additionalSources: readonly { + source: NetworkHeadersSource; + url?: string; + }[] = [], + ): boolean { + return this.admitProducerStrings( + this.metadataStrings(source, url, additionalStrings, additionalSources), + ); + } + + private *metadataStrings( + source: NetworkHeadersSource, + url: string | undefined, + additionalStrings: readonly string[], + additionalSources: readonly { + source: NetworkHeadersSource; + url?: string; + }[], + ): Generator { + yield* this.retainedMetadataStrings(source, source.headers(), url); + yield* additionalStrings; + for (const extra of additionalSources) + yield* this.retainedMetadataStrings( + extra.source, + extra.source.headers(), + extra.url, + ); + } + + private *retainedMetadataStrings( + source: NetworkHeadersSource, + headers: Readonly>, + url?: string, + ): Generator { + if (url !== undefined) yield url; + for (const name in headers) { + if (!Object.hasOwn(headers, name)) continue; + yield name; + // Names are always retained in metadata. Values are retained only in + // selected header-value content; do not charge discarded values. + if (this.options.selection.header_values) yield headers[name]!; + } + if ("method" in source) { + const request = source as ScenarioNetworkRequest; + yield request.method(); + yield request.url(); + yield request.resourceType(); + } + } + private headerNames(source: NetworkHeadersSource): string[] { return Object.keys(source.headers()) .map((name) => this.options.secrets.redact(name)) @@ -230,6 +349,11 @@ export class PlaywrightScenarioNetwork { return { transaction_id: transaction.id, redirected_from_transaction_id: transaction.redirectedFrom, + ...(transaction.redirectedFromState === undefined + ? {} + : { + redirected_from_transaction_state: transaction.redirectedFromState, + }), method: this.options.secrets.redact(request.method()), url: this.options.secrets.sanitizeUrl(request.url()), resource_type: this.options.secrets.redact(request.resourceType()), @@ -255,17 +379,18 @@ export class PlaywrightScenarioNetwork { response.sequence, "response", response.source, - this.options.selection.response_body - ? unavailable === undefined - ? captureNetworkBody( - () => response.source.body(), - "response", - response.source, - this.options.secrets, - this.readOptions, - ) - : Promise.resolve(unavailable) - : Promise.resolve({ state: "not_requested" }), + () => + this.options.selection.response_body + ? unavailable === undefined + ? captureNetworkBody( + () => response.source.body(), + "response", + response.source, + this.options.secrets, + this.readOptions, + ) + : Promise.resolve(unavailable) + : Promise.resolve({ state: "not_requested" }), ); } @@ -274,30 +399,54 @@ export class PlaywrightScenarioNetwork { sequence: number, phase: "request" | "response", source: NetworkHeadersSource, - body: Promise, + body: () => Promise, ): void { + if (this.activeReads >= 16) { + const message = "Network content exceeds the 16 concurrent-read budget."; + this.gaps.add(message); + this.options.emit({ + kind: "network-content", + transaction_id: transaction.id, + source_event_sequence: sequence, + phase, + headers: this.options.selection.header_values + ? { state: "unavailable", message } + : { state: "not_requested" }, + body: this.options.selection[ + phase === "request" ? "request_body" : "response_body" + ] + ? { state: "unavailable", reason: "body-read-failed", message } + : { state: "not_requested" }, + }); + return; + } + this.activeReads += 1; const headers = this.options.selection.header_values ? captureNetworkHeaders(source, this.options.secrets, this.readOptions) : Promise.resolve({ state: "not_requested" } as const); this.pending.push( - Promise.all([headers, body]).then(([retainedHeaders, retainedBody]) => { - if ( - retainedHeaders.state === "unavailable" || - retainedBody.state === "unavailable" || - retainedBody.state === "not_exposed" - ) - this.gaps.add( - "Some selected network content was unavailable; per-transaction content states retain the reason.", - ); - this.options.emit({ - kind: "network-content", - transaction_id: transaction.id, - source_event_sequence: sequence, - phase, - headers: retainedHeaders, - body: retainedBody, - }); - }), + Promise.all([headers, body()]) + .then(([retainedHeaders, retainedBody]) => { + if ( + retainedHeaders.state === "unavailable" || + retainedBody.state === "unavailable" || + retainedBody.state === "not_exposed" + ) + this.gaps.add( + "Some selected network content was unavailable; per-transaction content states retain the reason.", + ); + this.options.emit({ + kind: "network-content", + transaction_id: transaction.id, + source_event_sequence: sequence, + phase, + headers: retainedHeaders, + body: retainedBody, + }); + }) + .finally(() => { + this.activeReads -= 1; + }), ); } } diff --git a/src/captures/TsharkCaptureProvider.ts b/src/captures/TsharkCaptureProvider.ts index c4c89b88e0..5103ac40b7 100644 --- a/src/captures/TsharkCaptureProvider.ts +++ b/src/captures/TsharkCaptureProvider.ts @@ -166,6 +166,7 @@ export class TsharkCaptureProvider implements PacketCapturePort { const configurationPath = join(root.path, "configuration"); await mkdir(configurationPath, { mode: 0o700 }); const execute = async (args: readonly string[]) => { + capturedOutput = undefined; const output = await runOwnedCommand( { command: limiter, diff --git a/src/cli/browserAdvancedCommands.ts b/src/cli/browserAdvancedCommands.ts index dc0c268d19..90f7b35c9f 100644 --- a/src/cli/browserAdvancedCommands.ts +++ b/src/cli/browserAdvancedCommands.ts @@ -27,6 +27,7 @@ import type { JsonValue } from "../domain/jsonValue.js"; import { parseCliJsonInput } from "../cliJsonInput.js"; import type { Logger } from "pino"; import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { withCommandCancellation } from "./commandCancellation.js"; /** Register WebMCP, capture-diff, and screenshot CLI equivalents. */ export const registerAdvancedBrowserCommands = ( @@ -61,23 +62,29 @@ const registerWebMcp = ( .describe("Observation duration in milliseconds"), }), run: ({ args, options }) => - logCliCommand(logger, "discover-webmcp-tools", async () => { - const context = browserContext(); - const input = { - cdp_endpoint: args.endpoint, - allowed_origins: options.allowedOrigins, - target_id: args.targetId, - observation_ms: options.observationMs, - }; - const parsed = discoverWebMcpToolsInputSchema.safeParse(input); - if (!parsed.success) - return inputError( - "discover_webmcp_tools", - projectInputIssues(parsed.error.issues, input), + withCommandCancellation((signal) => + logCliCommand(logger, "discover-webmcp-tools", async () => { + const context = browserContext(); + const input = { + cdp_endpoint: args.endpoint, + allowed_origins: options.allowedOrigins, + target_id: args.targetId, + observation_ms: options.observationMs, + }; + const parsed = discoverWebMcpToolsInputSchema.safeParse(input); + if (!parsed.success) + return inputError( + "discover_webmcp_tools", + projectInputIssues(parsed.error.issues, input), + ); + const result = await discoverWebMcpTools( + context.provider, + parsed.data, + { signal }, ); - const result = await discoverWebMcpTools(context.provider, parsed.data); - return result.ok ? result.value : browserCliError(result.error); - }), + return result.ok ? result.value : browserCliError(result.error); + }), + ), }); }; @@ -161,25 +168,28 @@ const registerScreenshot = ( ...browserScopeOptions, }), run: ({ args, options }) => - logCliCommand(logger, "capture-web-screenshot", async () => { - const context = browserContext(); - const input = { - cdp_endpoint: args.endpoint, - allowed_origins: options.allowedOrigins, - target_id: args.targetId, - }; - const parsed = captureWebScreenshotInputSchema.safeParse(input); - if (!parsed.success) - return inputError( - "capture_web_screenshot", - projectInputIssues(parsed.error.issues, input), + withCommandCancellation((signal) => + logCliCommand(logger, "capture-web-screenshot", async () => { + const context = browserContext(); + const input = { + cdp_endpoint: args.endpoint, + allowed_origins: options.allowedOrigins, + target_id: args.targetId, + }; + const parsed = captureWebScreenshotInputSchema.safeParse(input); + if (!parsed.success) + return inputError( + "capture_web_screenshot", + projectInputIssues(parsed.error.issues, input), + ); + const result = await captureWebScreenshot( + context.provider, + parsed.data, + { signal }, ); - const result = await captureWebScreenshot( - context.provider, - parsed.data, - ); - return result.ok ? result.value : browserCliError(result.error); - }), + return result.ok ? result.value : browserCliError(result.error); + }), + ), }); }; diff --git a/src/cli/browserCommands.ts b/src/cli/browserCommands.ts index e9043ec80e..d04d3fb550 100644 --- a/src/cli/browserCommands.ts +++ b/src/cli/browserCommands.ts @@ -22,6 +22,7 @@ import { browserScopeOptions, observationDuration, } from "../cliObservationOptions.js"; +import { withCommandCancellation } from "./commandCancellation.js"; /** Register CLI equivalents of the passive browser MCP tools. */ export const registerBrowserCommands = ( @@ -48,24 +49,30 @@ const registerTargetList = ( }), options: z.object({ ...browserScopeOptions }), run: ({ args, options }) => - logCliCommand(logger, "list-browser-targets", async () => { - const context = browserContext(); - const input = { - cdp_endpoint: args.endpoint, - allowed_origins: options.allowedOrigins, - }; - const parsed = listBrowserTargetsInputSchema.safeParse(input); - if (!parsed.success) - return browserCliError( - analysisInputErrorFromIssues( - "list_browser_targets", - parsed.error.issues, - input, - ), + withCommandCancellation((signal) => + logCliCommand(logger, "list-browser-targets", async () => { + const context = browserContext(); + const input = { + cdp_endpoint: args.endpoint, + allowed_origins: options.allowedOrigins, + }; + const parsed = listBrowserTargetsInputSchema.safeParse(input); + if (!parsed.success) + return browserCliError( + analysisInputErrorFromIssues( + "list_browser_targets", + parsed.error.issues, + input, + ), + ); + const result = await listBrowserTargets( + context.provider, + parsed.data, + { signal }, ); - const result = await listBrowserTargets(context.provider, parsed.data); - return result.ok ? result.value : browserCliError(result.error); - }), + return result.ok ? result.value : browserCliError(result.error); + }), + ), }); }; @@ -84,33 +91,37 @@ const registerPageInspection = ( }), options: browserPageInspectionOptions, run: ({ args, options }) => - logCliCommand(logger, "inspect-web-page", async () => { - const context = browserContext(); - const input = { - cdp_endpoint: args.endpoint, - allowed_origins: options.allowedOrigins, - target_id: args.targetId, - observation_ms: options.observationMs, - include_accessibility_text: options.includeAccessibilityText, - include_console_text: options.includeConsoleText, - include_json_body_shapes: options.includeJsonBodyShapes, - include_websocket_shapes: options.includeWebsocketShapes, - include_script_sources: options.includeScriptSources, - include_storage_keys: options.includeStorageKeys, - include_storage_fingerprints: options.includeStorageFingerprints, - }; - const parsed = inspectWebPageInputSchema.safeParse(input); - if (!parsed.success) - return browserCliError( - analysisInputErrorFromIssues( - "inspect_web_page", - parsed.error.issues, - input, - ), - ); - const result = await inspectWebPage(context.provider, parsed.data); - return result.ok ? result.value : browserCliError(result.error); - }), + withCommandCancellation((signal) => + logCliCommand(logger, "inspect-web-page", async () => { + const context = browserContext(); + const input = { + cdp_endpoint: args.endpoint, + allowed_origins: options.allowedOrigins, + target_id: args.targetId, + observation_ms: options.observationMs, + include_accessibility_text: options.includeAccessibilityText, + include_console_text: options.includeConsoleText, + include_json_body_shapes: options.includeJsonBodyShapes, + include_websocket_shapes: options.includeWebsocketShapes, + include_script_sources: options.includeScriptSources, + include_storage_keys: options.includeStorageKeys, + include_storage_fingerprints: options.includeStorageFingerprints, + }; + const parsed = inspectWebPageInputSchema.safeParse(input); + if (!parsed.success) + return browserCliError( + analysisInputErrorFromIssues( + "inspect_web_page", + parsed.error.issues, + input, + ), + ); + const result = await inspectWebPage(context.provider, parsed.data, { + signal, + }); + return result.ok ? result.value : browserCliError(result.error); + }), + ), }); }; @@ -136,28 +147,32 @@ const registerBundleAnalysis = ( .describe("Fetch source maps referenced by captured scripts"), }), run: ({ args, options }) => - logCliCommand(logger, "analyze-web-bundle", async () => { - const context = browserContext(); - const input = { - cdp_endpoint: args.endpoint, - allowed_origins: options.allowedOrigins, - target_id: args.targetId, - observation_ms: options.observationMs, - include_script_sources: true, - fetch_source_maps: options.fetchSourceMaps, - }; - const parsed = analyzeWebBundleInputSchema.safeParse(input); - if (!parsed.success) - return browserCliError( - analysisInputErrorFromIssues( - "analyze_web_bundle", - parsed.error.issues, - input, - ), - ); - const result = await analyzeWebBundle(context.provider, parsed.data); - return result.ok ? result.value : browserCliError(result.error); - }), + withCommandCancellation((signal) => + logCliCommand(logger, "analyze-web-bundle", async () => { + const context = browserContext(); + const input = { + cdp_endpoint: args.endpoint, + allowed_origins: options.allowedOrigins, + target_id: args.targetId, + observation_ms: options.observationMs, + include_script_sources: true, + fetch_source_maps: options.fetchSourceMaps, + }; + const parsed = analyzeWebBundleInputSchema.safeParse(input); + if (!parsed.success) + return browserCliError( + analysisInputErrorFromIssues( + "analyze_web_bundle", + parsed.error.issues, + input, + ), + ); + const result = await analyzeWebBundle(context.provider, parsed.data, { + signal, + }); + return result.ok ? result.value : browserCliError(result.error); + }), + ), }); }; @@ -178,25 +193,31 @@ const registerObservationSession = ( observationMs: observationDuration(10_000, 1), }), run: ({ args, options }) => - logCliCommand(logger, "observe-web-session", async () => { - const context = browserContext(); - const input = { - cdp_endpoint: args.endpoint, - allowed_origins: options.allowedOrigins, - target_id: args.targetId, - observation_ms: options.observationMs, - }; - const parsed = observeWebSessionInputSchema.safeParse(input); - if (!parsed.success) - return browserCliError( - analysisInputErrorFromIssues( - "observe_web_session", - parsed.error.issues, - input, - ), + withCommandCancellation((signal) => + logCliCommand(logger, "observe-web-session", async () => { + const context = browserContext(); + const input = { + cdp_endpoint: args.endpoint, + allowed_origins: options.allowedOrigins, + target_id: args.targetId, + observation_ms: options.observationMs, + }; + const parsed = observeWebSessionInputSchema.safeParse(input); + if (!parsed.success) + return browserCliError( + analysisInputErrorFromIssues( + "observe_web_session", + parsed.error.issues, + input, + ), + ); + const result = await observeWebSession( + context.provider, + parsed.data, + { signal }, ); - const result = await observeWebSession(context.provider, parsed.data); - return result.ok ? result.value : browserCliError(result.error); - }), + return result.ok ? result.value : browserCliError(result.error); + }), + ), }); }; diff --git a/src/cliJavaScriptRuntimeCommands.ts b/src/cliJavaScriptRuntimeCommands.ts index 23ff1e40ea..ee8d504428 100644 --- a/src/cliJavaScriptRuntimeCommands.ts +++ b/src/cliJavaScriptRuntimeCommands.ts @@ -16,6 +16,7 @@ import { analysisInputErrorFromIssues } from "./domain/inputIssueProjection.js"; import { projectAnalysisError } from "./domain/analysisErrorProjection.js"; import type { JsonValue } from "./domain/jsonValue.js"; import type { Logger } from "pino"; +import { withCommandCancellation } from "./cli/commandCancellation.js"; const observeOptionsSchema = z.object({ runtimeKind: javascriptRuntimeKindSchema @@ -42,30 +43,33 @@ export const registerJavaScriptRuntimeObservationCommands = ( endpoint: z.string().describe("Literal-loopback Inspector endpoint"), }), run: ({ args }) => - logCliCommand( - logger, - CLI_COMMANDS.listJavaScriptRuntimeTargets, - async () => { - const context = runtimeContext(); - const input = { - inspector_endpoint: args.endpoint, - }; - const parsed = - listJavaScriptRuntimeTargetsInputSchema.safeParse(input); - if (!parsed.success) - return cliError( - analysisInputErrorFromIssues( - "list_javascript_runtime_targets", - parsed.error.issues, - input, - ), + withCommandCancellation((signal) => + logCliCommand( + logger, + CLI_COMMANDS.listJavaScriptRuntimeTargets, + async () => { + const context = runtimeContext(); + const input = { + inspector_endpoint: args.endpoint, + }; + const parsed = + listJavaScriptRuntimeTargetsInputSchema.safeParse(input); + if (!parsed.success) + return cliError( + analysisInputErrorFromIssues( + "list_javascript_runtime_targets", + parsed.error.issues, + input, + ), + ); + const result = await listJavaScriptRuntimeTargets( + context.provider, + parsed.data, + { signal }, ); - const result = await listJavaScriptRuntimeTargets( - context.provider, - parsed.data, - ); - return result.ok ? result.value : cliError(result.error); - }, + return result.ok ? result.value : cliError(result.error); + }, + ), ), }); @@ -79,29 +83,36 @@ export const registerJavaScriptRuntimeObservationCommands = ( }), options: observeOptionsSchema, run: ({ args, options }) => - logCliCommand(logger, CLI_COMMANDS.observeJavaScriptRuntime, async () => { - const context = runtimeContext(); - const input = { - inspector_endpoint: args.endpoint, - target_id: args.targetId, - runtime_kind: options.runtimeKind, - observation_ms: options.observationMs, - }; - const parsed = observeJavaScriptRuntimeInputSchema.safeParse(input); - if (!parsed.success) - return cliError( - analysisInputErrorFromIssues( - "observe_javascript_runtime", - parsed.error.issues, - input, - ), - ); - const result = await observeJavaScriptRuntime( - context.provider, - parsed.data, - ); - return result.ok ? result.value : cliError(result.error); - }), + withCommandCancellation((signal) => + logCliCommand( + logger, + CLI_COMMANDS.observeJavaScriptRuntime, + async () => { + const context = runtimeContext(); + const input = { + inspector_endpoint: args.endpoint, + target_id: args.targetId, + runtime_kind: options.runtimeKind, + observation_ms: options.observationMs, + }; + const parsed = observeJavaScriptRuntimeInputSchema.safeParse(input); + if (!parsed.success) + return cliError( + analysisInputErrorFromIssues( + "observe_javascript_runtime", + parsed.error.issues, + input, + ), + ); + const result = await observeJavaScriptRuntime( + context.provider, + parsed.data, + { signal }, + ); + return result.ok ? result.value : cliError(result.error); + }, + ), + ), }); }; diff --git a/src/domain/analysisView/pcapCaptureView.test.ts b/src/domain/analysisView/pcapCaptureView.test.ts new file mode 100644 index 0000000000..6e56ea3e60 --- /dev/null +++ b/src/domain/analysisView/pcapCaptureView.test.ts @@ -0,0 +1,99 @@ +import { expect, it } from "vitest"; +import { createEvidence } from "../evidence.js"; +import { + projectAnalysisView, + type AnalysisViewParent, +} from "./analysisView.js"; + +const capture = (tsharkStderr: string): AnalysisViewParent => { + const target = { + path: "/recordings/input.pcap", + sha256: "c".repeat(64), + bytes: 24, + }; + const report = { + client: { + tshark_command: "/usr/bin/tshark", + tshark_source: "path", + tshark_version: "4.6.6", + capinfos_command: "/usr/bin/capinfos", + capinfos_source: "path", + capinfos_version: "4.6.6", + }, + target, + file_type: "pcap", + encapsulation: "Ethernet", + packet_count: 0, + capture_start: null, + capture_end: null, + capture_duration_seconds: null, + snapshot_length: null, + endpoints: [], + endpoint_count: 0, + dns_queries: [], + dns_query_count: 0, + tls_sni_values: [], + tls_sni_count: 0, + protocol_distribution: [], + protocol_count: 0, + http_request_uris: [], + http_request_uri_count: 0, + http_uri_redaction_count: 0, + unattributed_dns_addresses: [], + unattributed_dns_address_count: 0, + unattributed_dns_packet_count: 0, + coverage: "complete", + diagnostics: { capinfos_stderr: "", tshark_stderr: tsharkStderr }, + }; + const evidence = createEvidence( + { ...target, format: "file" }, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "inspect_pcap_capture", + parameters: { path: target.path }, + result: report, + confidence: "derived", + authority: "historical-reference", + }, + ); + return { + evidenceId: evidence.evidence_id, + operation: evidence.operation, + normalizedResult: report, + subject: evidence.subject, + limitations: evidence.limitations, + }; +}; + +const diagnostics = (offset: number, limit: number) => + ({ + kind: "pcap", + facet: "diagnostics", + stream: "tshark", + offset, + limit, + }) as const; + +it("pages diagnostics without splitting a surrogate pair", () => { + const parent = capture("ab😀cd"); + expect(projectAnalysisView(parent, diagnostics(3, 2))).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + const first = projectAnalysisView(parent, diagnostics(0, 3)); + if (!first.ok) throw first.error; + expect(first.value).toMatchObject({ + page: { facet: "diagnostics", text: "ab", status: "retained" }, + coverage: { examined: 2, total: 6, next_offset: 2 }, + }); + let text = ""; + for (let offset: number | null = 0; offset !== null;) { + const page = projectAnalysisView(parent, diagnostics(offset, 3)); + if (!page.ok) throw page.error; + if (page.value.kind !== "pcap" || page.value.page.facet !== "diagnostics") + throw new Error("Expected a diagnostics page."); + text += page.value.page.text; + offset = page.value.coverage.next_offset; + } + expect(text).toBe("ab😀cd"); +}); diff --git a/src/domain/analysisView/pcapCaptureView.ts b/src/domain/analysisView/pcapCaptureView.ts index a28398d443..c388f79896 100644 --- a/src/domain/analysisView/pcapCaptureView.ts +++ b/src/domain/analysisView/pcapCaptureView.ts @@ -13,6 +13,7 @@ import { completeWithinViewCoverage, pageViewCoverage, } from "./analysisViewCoverage.js"; +import { analysisTextPage } from "./analysisViewText.js"; const captureSchema = pcapResultSchemas.inspect_pcap_capture; const fields = captureSchema.shape; @@ -288,7 +289,13 @@ export const projectPcapCaptureView = ( view.stream === "tshark" ? capture.diagnostics?.tshark_stderr : capture.diagnostics?.capinfos_stderr; - const text = value?.slice(view.offset, view.offset + view.limit) ?? ""; + const text = analysisTextPage( + value ?? "", + view.offset, + view.limit, + "Diagnostics", + ); + if (!text.ok) return text; return ok({ kind: "pcap", view, @@ -296,10 +303,10 @@ export const projectPcapCaptureView = ( source_coverage: capture.coverage, page: { facet: "diagnostics", - text, + text: text.value.item.text, status: value === undefined ? "unknown" : "retained", }, - coverage: pageViewCoverage(view.offset, text.length, value?.length ?? 0), + coverage: text.value.coverage, unknowns: value === undefined ? [ diff --git a/src/domain/analysisView/webNetworkCaptureView.test.ts b/src/domain/analysisView/webNetworkCaptureView.test.ts index ff73b29a60..7035c2b95f 100644 --- a/src/domain/analysisView/webNetworkCaptureView.test.ts +++ b/src/domain/analysisView/webNetworkCaptureView.test.ts @@ -174,3 +174,686 @@ it("rejects invalid ordinal coverage in a borrowed parent", () => { ); expect(result.ok).toBe(false); }); + +const nativePreview = ( + format: "mitmproxy" | "har", + request: Readonly>, + { + response = { + http_version: "HTTP/1.1", + status_code: 404, + reason: "Not Found", + content: "omitted body", + }, + producerType = "integer", + sidecars, + flowType = "http", + redactions = [], + binaryFields = [], + }: { + readonly response?: Readonly>; + readonly producerType?: "integer" | "float"; + readonly flowType?: string | null; + readonly binaryFields?: readonly Record[]; + readonly redactions?: readonly { + pointer: string; + reason: string; + scope?: string; + }[]; + readonly sidecars?: readonly { + pointer: string; + producer_type: string; + literal: string; + }[]; + } = {}, +) => { + // The bridge records a numeric sidecar for every native number. + const literals = + sidecars ?? + Object.entries({ request, response }).flatMap(([side, fields]) => + Object.entries(fields) + .filter(([, value]) => typeof value === "number") + .map(([field, value]) => ({ + pointer: `/${side}/${field}`, + producer_type: producerType, + literal: String(value), + })), + ); + const artifact = { + path: "/recordings/flows.mitm", + sha256: "c".repeat(64), + bytes: 100, + }; + const decoder = { id: "fixture", name: "Fixture", version: "1" }; + const report = webNetworkCaptureSchema.parse({ + artifact, + decoder, + format, + runtime_attribution: "unknown", + limitations: [], + container: { + reported: null, + numeric_literals: [], + redactions: [], + records_pointer: null, + }, + total_records: 1, + records: [ + { + ordinal: 0, + location: { kind: "byte-range", offset: 0, bytes: 10 }, + reported: { + ...(flowType === null ? {} : { type: flowType }), + request: { ...request, headers: [["Accept", "*/*"]] }, + response, + }, + binary_fields: binaryFields, + numeric_literals: literals, + redactions, + limitations: [], + }, + ], + }); + const evidence = createEvidence({ ...artifact, format: "file" }, decoder, { + operation: "inspect_web_network_capture", + parameters: {}, + result: report, + confidence: "observed", + authority: "historical-reference", + }); + const preview = projectAnalysisView( + { + evidenceId: evidence.evidence_id, + operation: evidence.operation, + normalizedResult: report, + subject: evidence.subject, + limitations: evidence.limitations, + }, + { kind: "capture", detail: "preview", offset: 0, limit: 1 }, + ); + if (!preview.ok || preview.value.kind !== "capture") + throw new Error("Expected a capture preview page."); + return preview.value; +}; + +it("previews the mitmproxy request target and status code as headline fields", () => { + const preview = nativePreview("mitmproxy", { + host: "example.test", + port: 443, + method: "GET", + scheme: "https", + authority: "", + path: "/users/42", + http_version: "HTTP/1.1", + }); + expect(preview).toMatchObject({ + items: [ + { + reported: { + type: "http", + request: { + method: "GET", + scheme: "https", + host: "example.test", + port: 443, + authority: "", + path: "/users/42", + http_version: "HTTP/1.1", + }, + response: { + http_version: "HTTP/1.1", + status_code: 404, + reason: "Not Found", + }, + }, + numeric_literals: [ + { + pointer: "/request/port", + producer_type: "integer", + literal: "443", + }, + { + pointer: "/response/status_code", + producer_type: "integer", + literal: "404", + }, + ], + }, + ], + }); + expect(JSON.stringify(preview)).not.toContain("Accept"); + expect(JSON.stringify(preview)).not.toContain("omitted body"); +}); + +it("previews the asterisk-form OPTIONS target", () => { + expect( + nativePreview("mitmproxy", { method: "OPTIONS", path: "*" }).items[0] + ?.reported, + ).toMatchObject({ request: { method: "OPTIONS", path: "*" } }); +}); + +it.each([ + [ + "an ambiguous absolute path", + "mitmproxy", + "https:user:password@example.test/a", + ], + ["a network-path reference", "mitmproxy", "//user:password@example.test/a"], + [ + "a slash-backslash reference", + "mitmproxy", + "/\\user:password@example.test/a", + ], + [ + "a slash-backslash reference with an unparseable port", + "mitmproxy", + "/\\user:password@example.test:65536/a", + ], + ["a path with an at-sign", "mitmproxy", "/users/password@example"], + ["a slash-tab reference", "mitmproxy", "/\t/user:password@example.test/a"], + ["an oversized path", "mitmproxy", `/${"password".repeat(1024)}`], + ["a nested path value", "mitmproxy", { content: "password ".repeat(64) }], + ["a path in a non-mitmproxy record", "har", "/users/password"], +] as const)("keeps %s out of the preview", (_label, format, path) => { + const preview = nativePreview(format, { method: "GET", path }); + const [item] = preview.items; + expect(item?.reported).toMatchObject({ request: { method: "GET" } }); + expect(JSON.stringify(item?.reported)).not.toContain("password"); + if (format === "har") + expect(JSON.stringify(item?.reported)).not.toContain("status_code"); +}); + +it("bounds the serialized mitmproxy headline of each preview record", () => { + const control = "\u0001".repeat(8 * 1024); + const malformed = nativePreview("mitmproxy", { + method: "GET", + scheme: control, + host: control, + port: control, + authority: control, + path: `/${control}`, + }); + expect(malformed.items[0]?.reported).toMatchObject({ + request: { method: "GET" }, + }); + expect( + Buffer.byteLength(JSON.stringify(malformed.items[0]?.reported)), + ).toBeLessThan(1024); + const escaped = nativePreview("mitmproxy", { + method: "GET", + path: `/${'"'.repeat(2 * 1024 - 1)}`, + }); + expect(escaped.items[0]?.reported).toMatchObject({ + request: { path: `/${'"'.repeat(2 * 1024 - 1)}` }, + }); + expect( + Buffer.byteLength(JSON.stringify(escaped.items[0]?.reported)), + ).toBeLessThan(5 * 1024); +}); + +it("keeps producer floats out of integer mitmproxy headline slots", () => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", port: 443, path: "/a" }, + { + response: { + http_version: "HTTP/1.1", + status_code: 404, + reason: "Not Found", + }, + producerType: "float", + }, + ).items[0]?.reported; + expect(reported).toMatchObject({ + request: { method: "GET", path: "/a" }, + response: { reason: "Not Found" }, + }); + expect(reported).not.toMatchObject({ request: { port: 443 } }); + expect(reported).not.toMatchObject({ response: { status_code: 404 } }); +}); + +it.each([ + [ + "an oversized literal", + [ + { + pointer: "/request/port", + producer_type: "integer", + literal: "4".repeat(100_000), + }, + ], + ], + [ + "a literal that does not represent the value", + [{ pointer: "/request/port", producer_type: "integer", literal: "444" }], + ], + [ + "duplicate sidecars", + [ + { pointer: "/request/port", producer_type: "integer", literal: "443" }, + { pointer: "/request/port", producer_type: "integer", literal: "443" }, + ], + ], +])( + "keeps a port whose sidecar has %s out of the preview", + (_label, sidecars) => { + const [item] = nativePreview( + "mitmproxy", + { method: "GET", port: 443, path: "/a" }, + { response: { http_version: "HTTP/1.1", reason: "OK" }, sidecars }, + ).items; + expect(item?.reported).toEqual({ + type: "http", + request: { method: "GET", path: "/a" }, + response: { http_version: "HTTP/1.1", reason: "OK" }, + }); + expect(item?.numeric_literals).toEqual([]); + }, +); + +it.each(["tcp", null])( + "adds no mitmproxy headline fields to a %s flow", + (flowType) => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", path: "/a", port: 443 }, + { flowType }, + ).items[0]?.reported; + expect(reported).toEqual({ + ...(flowType === null ? {} : { type: flowType }), + request: { method: "GET" }, + response: { http_version: "HTTP/1.1", reason: "Not Found" }, + }); + }, +); + +it("keeps a string target that carries a numeric sidecar out of the preview", () => { + const [item] = nativePreview( + "mitmproxy", + { method: "GET", path: "/a" }, + { + response: { http_version: "HTTP/1.1", reason: "OK" }, + sidecars: [ + { + pointer: "/request/path", + producer_type: "integer", + literal: "9".repeat(100_000), + }, + ], + }, + ).items; + expect(item?.reported).toEqual({ + type: "http", + request: { method: "GET" }, + response: { http_version: "HTTP/1.1", reason: "OK" }, + }); + expect(item?.numeric_literals).toEqual([]); +}); + +it("keeps a mitmproxy headline the record marks as redacted out of the preview", () => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", host: "secret", path: "/a" }, + { + response: { http_version: "HTTP/1.1", reason: "OK" }, + redactions: [ + { pointer: "/request/host", reason: "explicit-sensitive-value" }, + ], + }, + ).items[0]?.reported; + expect(reported).toEqual({ + type: "http", + request: { method: "GET", path: "/a" }, + response: { http_version: "HTTP/1.1", reason: "OK" }, + }); +}); + +it("rejects a non-number integer headline without throwing", () => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", path: "/a", port: { toString: null } }, + { + response: { http_version: "HTTP/1.1", reason: "OK" }, + sidecars: [ + { pointer: "/request/port", producer_type: "integer", literal: "443" }, + ], + }, + ).items[0]?.reported; + expect(reported).toEqual({ + type: "http", + request: { method: "GET", path: "/a" }, + response: { http_version: "HTTP/1.1", reason: "OK" }, + }); +}); + +it.each([ + ["the request", "/request", { method: "GET" }], + ["the record root", "", {}], +] as const)( + "keeps mitmproxy headlines under a redacted %s out of the preview", + (_label, pointer, request) => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", host: "secret", path: "/secret" }, + { + response: { http_version: "HTTP/1.1", reason: "OK" }, + redactions: [{ pointer, reason: "explicit-sensitive-value" }], + }, + ).items[0]?.reported; + expect(JSON.stringify(reported)).not.toContain("secret"); + expect(reported).toMatchObject({ request }); + }, +); + +it("keeps a mitmproxy headline with a redacted byte descriptor out of the preview", () => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", host: "example.test", path: "/secret" }, + { + response: { http_version: "HTTP/1.1", reason: "OK" }, + binaryFields: [ + { + pointer: "/request/path", + representation: "producer-bytes", + state: "redacted", + content_base64: null, + bytes: null, + sha256: null, + }, + ], + }, + ).items[0]?.reported; + expect(reported).toMatchObject({ + request: { method: "GET", host: "example.test" }, + }); + expect(JSON.stringify(reported)).not.toContain("secret"); +}); + +it("adds no mitmproxy headline fields when the flow type is redacted", () => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", path: "/a" }, + { + response: { http_version: "HTTP/1.1", reason: "OK" }, + redactions: [{ pointer: "/type", reason: "explicit-sensitive-value" }], + }, + ).items[0]?.reported; + expect(reported).toMatchObject({ request: { method: "GET" } }); + expect(reported).not.toMatchObject({ request: { path: "/a" } }); +}); + +it.each([ + ["the flow type", "/type"], + ["the record root", ""], +])( + "adds no mitmproxy headline fields when %s carries a numeric sidecar", + (_label, pointer) => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", path: "/a" }, + { + response: { http_version: "HTTP/1.1", reason: "OK" }, + sidecars: [{ pointer, producer_type: "integer", literal: "1" }], + }, + ).items[0]?.reported; + expect(reported).toMatchObject({ request: { method: "GET" } }); + expect(reported).not.toMatchObject({ request: { path: "/a" } }); + }, +); + +const retainedBytes = (pointer: string, text: string) => { + const bytes = Buffer.from(text); + return { + pointer, + representation: "producer-bytes", + state: "retained", + content_base64: bytes.toString("base64"), + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }; +}; + +it("adds no mitmproxy headline fields when the flow type was producer bytes", () => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", path: "/a" }, + { + response: { http_version: "HTTP/1.1", reason: "OK" }, + binaryFields: [retainedBytes("/type", "http")], + }, + ).items[0]?.reported; + expect(reported).toMatchObject({ type: "http", request: { method: "GET" } }); + expect(reported).not.toMatchObject({ request: { path: "/a" } }); +}); + +it("keeps mitmproxy headlines under a byte-valued side out of the preview", () => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", path: "/a", port: 443 }, + { + response: { http_version: "HTTP/1.1", reason: "OK", status_code: 200 }, + binaryFields: [ + retainedBytes("/request", "GET /a"), + retainedBytes("/response", "200"), + ], + }, + ).items[0]?.reported; + expect(reported).toEqual({ + type: "http", + request: { method: "GET" }, + response: { http_version: "HTTP/1.1", reason: "OK" }, + }); +}); + +it("previews a byte-backed path but not a byte-backed port", () => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", path: "/a", port: 443 }, + { + response: { http_version: "HTTP/1.1", reason: "OK" }, + binaryFields: [ + retainedBytes("/request/path", "/a"), + retainedBytes("/request/port", "443"), + ], + }, + ).items[0]?.reported; + expect(reported).toEqual({ + type: "http", + request: { method: "GET", path: "/a" }, + response: { http_version: "HTTP/1.1", reason: "OK" }, + }); +}); + +it("adds no mitmproxy headline fields when the flow type has a child facet", () => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", path: "/a" }, + { + response: { http_version: "HTTP/1.1", reason: "OK" }, + sidecars: [ + { pointer: "/type/child", producer_type: "integer", literal: "1" }, + ], + }, + ).items[0]?.reported; + expect(reported).toMatchObject({ request: { method: "GET" } }); + expect(reported).not.toMatchObject({ request: { path: "/a" } }); +}); + +it.each([ + [ + "a child sidecar", + { + sidecars: [ + { pointer: "/request/path/0", producer_type: "integer", literal: "1" }, + ], + }, + ], + [ + "a child redaction", + { + redactions: [ + { pointer: "/request/path/0", reason: "explicit-sensitive-value" }, + ], + }, + ], + [ + "HAR content bytes", + { + binaryFields: [ + { + ...retainedBytes("/request/path", "/a"), + representation: "har-base64-content", + }, + ], + }, + ], + [ + "bytes that are not its text", + { binaryFields: [retainedBytes("/request/path", "/b")] }, + ], + [ + "a byte count that is not its text", + { binaryFields: [{ ...retainedBytes("/request/path", "/a"), bytes: 3 }] }, + ], + [ + "a digest that is not its text", + { + binaryFields: [ + { ...retainedBytes("/request/path", "/a"), sha256: "0".repeat(64) }, + ], + }, + ], +] as const)( + "keeps a mitmproxy path with %s out of the preview", + (_label, facets) => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", host: "example.test", path: "/a" }, + { response: { http_version: "HTTP/1.1", reason: "OK" }, ...facets }, + ).items[0]?.reported; + expect(reported).toEqual({ + type: "http", + request: { method: "GET", host: "example.test" }, + response: { http_version: "HTTP/1.1", reason: "OK" }, + }); + }, +); + +it.each([ + ["path", { method: "GET", path: "/\ud800" }, { method: "GET" }], + [ + "host", + { method: "GET", host: "a\udc00.test", path: "/a" }, + { method: "GET", path: "/a" }, + ], +])( + "keeps a mitmproxy %s with an unpaired surrogate out of the preview", + (_field, request, expected) => { + const reported = nativePreview("mitmproxy", request, { + response: { http_version: "HTTP/1.1", reason: "OK" }, + }).items[0]?.reported; + expect(reported).toEqual({ + type: "http", + request: expected, + response: { http_version: "HTTP/1.1", reason: "OK" }, + }); + }, +); + +it("keeps an unpaired-surrogate path out despite replacement-character bytes", () => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", path: "/\ud800" }, + { + response: { http_version: "HTTP/1.1", reason: "OK" }, + binaryFields: [retainedBytes("/request/path", "/�")], + }, + ).items[0]?.reported; + expect(reported).toEqual({ + type: "http", + request: { method: "GET" }, + response: { http_version: "HTTP/1.1", reason: "OK" }, + }); +}); + +it("keeps mitmproxy headlines under a numeric sidecar out of the preview", () => { + const [item] = nativePreview( + "mitmproxy", + { method: "GET", path: "/a", port: 443 }, + { + response: { http_version: "HTTP/1.1", reason: "OK", status_code: 200 }, + sidecars: [ + { pointer: "/request", producer_type: "integer", literal: "7" }, + { pointer: "/request/port", producer_type: "integer", literal: "443" }, + { + pointer: "/response/status_code", + producer_type: "integer", + literal: "200", + }, + ], + }, + ).items; + expect(item?.reported).toEqual({ + type: "http", + request: { method: "GET" }, + response: { http_version: "HTTP/1.1", reason: "OK", status_code: 200 }, + }); + expect(item?.numeric_literals).toEqual([ + { + pointer: "/response/status_code", + producer_type: "integer", + literal: "200", + }, + ]); +}); + +it("keeps sibling mitmproxy headlines next to an omitted property name", () => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", host: "example.test", path: "/a" }, + { + response: { http_version: "HTTP/1.1", reason: "OK" }, + redactions: [ + { + pointer: "/request", + reason: "explicit-sensitive-value", + scope: "property-name", + }, + ], + }, + ).items[0]?.reported; + expect(reported).toMatchObject({ + request: { method: "GET", host: "example.test", path: "/a" }, + }); +}); + +it("selects mitmproxy target fields from the request and the status from the response only", () => { + const reported = nativePreview( + "mitmproxy", + { method: "GET", path: "/a", status_code: 200 }, + { + response: { http_version: "HTTP/1.1", reason: "OK", path: "/extension" }, + }, + ).items[0]?.reported; + expect(reported).toEqual({ + type: "http", + request: { method: "GET", path: "/a" }, + response: { http_version: "HTTP/1.1", reason: "OK" }, + }); +}); + +it.each(["scheme", "host", "port", "authority"])( + "keeps a userinfo-bearing mitmproxy %s out of the preview", + (field) => { + const preview = nativePreview("mitmproxy", { + method: "GET", + path: "/users/42", + [field]: "user:password@example.test", + }); + const reported = preview.items[0]?.reported; + expect(reported).toMatchObject({ + request: { method: "GET", path: "/users/42" }, + }); + expect(JSON.stringify(reported)).not.toContain("password"); + }, +); diff --git a/src/domain/analysisView/webNetworkCaptureView.ts b/src/domain/analysisView/webNetworkCaptureView.ts index b335e9241e..f33bdb0df1 100644 --- a/src/domain/analysisView/webNetworkCaptureView.ts +++ b/src/domain/analysisView/webNetworkCaptureView.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; import { analysisInputErrorFromIssues } from "../inputIssueProjection.js"; @@ -62,12 +63,290 @@ const object = ( ? value : null; +const HEADLINE_FIELDS = [ + "method", + "url", + "httpVersion", + "http_version", + "status", + "statusText", + "reason", +]; +// Native mitmproxy flows report the request target in parts and the status +// as status_code. They are previewed only for mitmproxy records and only when +// each value has its field's well-formed shape, which also bounds the +// serialized size of every record (at most ~4 KiB for an escape-heavy path). +// The path must be origin or asterisk form without control characters and +// without any "@": native redaction misses absolute and ambiguous spellings, +// and URL parsing can fail before it recognises their userinfo, so a preview +// fails closed on the marker every userinfo needs. Host and authority never +// carry a userinfo marker either. +// Target fields come only from the request and the status only from the +// response, and integer slots require the producer's own integer type. +// Excluded values stay in complete record pages. +const MITMPROXY_HEADLINE_FIELDS: Readonly> = { + request: ["scheme", "host", "port", "authority", "path"], + response: ["status_code"], +}; +const MITMPROXY_PATH_MAX_LENGTH = 2 * 1024; +const integerIn = (value: JsonValue, min: number, max: number): boolean => + typeof value === "number" && + Number.isInteger(value) && + value >= min && + value <= max; +/** True when a code unit is C0, DEL or (optionally) a space. */ +const hasControl = (value: string, includeSpace: boolean): boolean => { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code < 0x20 || code === 0x7f || (includeSpace && code === 0x20)) + return true; + } + return false; +}; +// An unpaired surrogate has no UTF-8 form, so no producer reported it as text. +const LONE_SURROGATE = /\p{Cs}/u; +const hostLike = (value: JsonValue, maxLength: number): boolean => + typeof value === "string" && + value.length <= maxLength && + !hasControl(value, true) && + !/[@/\\]/u.test(value) && + !LONE_SURROGATE.test(value); +type NumericSidecar = WebNetworkCaptureRecord["numeric_literals"][number]; +type BinaryField = WebNetworkCaptureRecord["binary_fields"][number]; +const MITMPROXY_INTEGER_FIELDS = new Set(["port", "status_code"]); +/** Retained native producer bytes whose content, length and digest are all the text's UTF-8. */ +const describesText = (descriptor: BinaryField, text: string): boolean => { + if ( + descriptor.representation !== "producer-bytes" || + descriptor.state !== "retained" + ) + return false; + // An unpaired surrogate has no UTF-8 form; Buffer.from would invent + // replacement-character bytes that no producer value could have had. + const utf8 = Buffer.from(text, "utf8"); + return ( + utf8.toString("utf8") === text && + descriptor.bytes === utf8.length && + descriptor.content_base64 === utf8.toString("base64") && + descriptor.sha256 === createHash("sha256").update(utf8).digest("hex") + ); +}; +/** + * Integer slots need exactly one integer sidecar whose literal is the value's + * canonical decimal, and no byte descriptor. String slots need no sidecar and + * at most one byte descriptor, which must describe the value's own UTF-8. + * Selecting a field also selects its sidecars, so this keeps every copied + * literal small and consistent. + */ +const representationMatches = ( + field: string, + value: JsonValue, + sidecars: readonly NumericSidecar[], + bytes: readonly BinaryField[], +): boolean => { + if (MITMPROXY_INTEGER_FIELDS.has(field)) + return ( + bytes.length === 0 && + typeof value === "number" && + sidecars.length === 1 && + sidecars[0]?.producer_type === "integer" && + sidecars[0].literal === String(value) + ); + if (sidecars.length !== 0 || bytes.length > 1) return false; + const [descriptor] = bytes; + return ( + descriptor === undefined || + (typeof value === "string" && describesText(descriptor, value)) + ); +}; +const headlineShape = (field: string, value: JsonValue): boolean => { + switch (field) { + case "scheme": + return ( + typeof value === "string" && + /^[A-Za-z][A-Za-z0-9+.-]{0,31}$/u.test(value) + ); + case "host": + return hostLike(value, 255); + case "authority": + return hostLike(value, 262); + case "port": + return integerIn(value, 0, 65_535); + case "status_code": + return integerIn(value, 100, 999); + case "path": + return ( + typeof value === "string" && + value.length <= MITMPROXY_PATH_MAX_LENGTH && + !hasControl(value, false) && + !value.includes("@") && + !LONE_SURROGATE.test(value) && + (value === "*" || + (value.startsWith("/") && + !value.startsWith("//") && + !value.startsWith("/\\"))) + ); + default: + return false; + } +}; +/** Strict ancestors of a JSON pointer, from the root down. */ +const ancestorsOf = (pointer: string): string[] => { + const ancestors: string[] = []; + for ( + let at = pointer.indexOf("/"); + at !== -1; + at = pointer.indexOf("/", at + 1) + ) + ancestors.push(pointer.slice(0, at)); + return ancestors; +}; +/** Add a pointer's strict ancestors, stopping at one already present (its own ancestors are too). */ +const addAncestors = (pointer: string, into: Set): void => { + for ( + let at = pointer.lastIndexOf("/"); + at !== -1; + at = at === 0 ? -1 : pointer.lastIndexOf("/", at - 1) + ) { + const ancestor = pointer.slice(0, at); + if (into.has(ancestor)) return; + into.add(ancestor); + } +}; +const groupByPointer = ( + facets: readonly T[], +): Map => { + const groups = new Map(); + for (const facet of facets) { + const group = groups.get(facet.pointer); + if (group === undefined) groups.set(facet.pointer, [facet]); + else group.push(facet); + } + return groups; +}; + +/** A record's facet coordinates, indexed once for every headline check. */ +type FacetIndex = { + readonly sidecars: ReadonlyMap; + readonly bytes: ReadonlyMap; + /** The producer held a number or bytes here. */ + readonly scalars: ReadonlySet; + /** A facet lies beneath: the producer held a container here. */ + readonly containers: ReadonlySet; + /** + * Withheld by a value redaction or a redacted byte descriptor; either facet + * withholds the value even when the other one is missing. + */ + readonly withheld: ReadonlySet; + /** The parent of an omitted property name; it hides no sibling values. */ + readonly omittedNames: ReadonlySet; +}; +const indexFacets = (record: WebNetworkCaptureRecord): FacetIndex => { + const containers = new Set(); + const withheld = new Set(); + const omittedNames = new Set(); + for (const { pointer } of record.numeric_literals) + addAncestors(pointer, containers); + for (const { pointer, state } of record.binary_fields) { + addAncestors(pointer, containers); + if (state === "redacted") withheld.add(pointer); + } + for (const redaction of record.redactions) { + addAncestors(redaction.pointer, containers); + ("scope" in redaction ? omittedNames : withheld).add(redaction.pointer); + } + const sidecars = groupByPointer(record.numeric_literals); + const bytes = groupByPointer(record.binary_fields); + return { + sidecars, + bytes, + scalars: new Set([...sidecars.keys(), ...bytes.keys()]), + containers, + withheld, + omittedNames, + }; +}; +/** + * A value the record marks as redacted, or that lies under a redacted value, + * is never promoted even if it is still present (imported or caller-supplied + * Evidence). A scalar is also inconsistent when a numeric sidecar or byte + * descriptor sits on an ancestor (the producer held a scalar there) or any + * facet sits beneath it (the producer held a container at it). + */ +const usableScalar = (index: FacetIndex, pointer: string): boolean => { + const ancestors = ancestorsOf(pointer); + return ( + !index.omittedNames.has(pointer) && + !index.containers.has(pointer) && + !index.withheld.has(pointer) && + !ancestors.some( + (ancestor) => index.withheld.has(ancestor) || index.scalars.has(ancestor), + ) + ); +}; +/** + * The container format alone does not make a flow HTTP; its own type does, + * when the producer supplied that type as text rather than a number or bytes. + */ +const textualFlowType = (index: FacetIndex): boolean => + usableScalar(index, "/type") && !index.scalars.has("/type"); +/** Shape checks run first, so the representation check only sees bounded values. */ +const mitmproxyHeadline = ( + index: FacetIndex, + pointer: string, + field: string, + value: JsonValue, +): boolean => + usableScalar(index, pointer) && + headlineShape(field, value) && + representationMatches( + field, + value, + index.sidecars.get(pointer) ?? [], + index.bytes.get(pointer) ?? [], + ); + +/** Copy one message side's headlines; mitmproxy fields need the record's facet index. */ +const selectHeadlines = ( + fields: Readonly>, + key: string, + facets: FacetIndex | null, + selectedPointers: Set, +): Record => { + const selected: Record = {}; + const candidates = [ + ...HEADLINE_FIELDS.map((field) => [field, null] as const), + ...(facets === null ? [] : (MITMPROXY_HEADLINE_FIELDS[key] ?? [])).map( + (field) => [field, facets] as const, + ), + ]; + for (const [field, index] of candidates) { + const value = fields[field]; + if ( + !Object.hasOwn(fields, field) || + value === undefined || + (index !== null && + !mitmproxyHeadline(index, `/${key}/${field}`, field, value)) + ) + continue; + selected[field] = value; + selectedPointers.add(`/${key}/${field}`); + } + return selected; +}; + /** Select already parsed producer facts without interpreting HTTP or payload bytes again. */ -const preview = (record: WebNetworkCaptureRecord) => { +const preview = (record: WebNetworkCaptureRecord, format: string) => { const source = object(record.reported); const reported: Record = {}; const selectedPointers = new Set(); if (source !== null) { + const index = + format === "mitmproxy" && source.type === "http" + ? indexFacets(record) + : null; + const mitmproxyFacets = + index !== null && textualFlowType(index) ? index : null; for (const key of ["session_id", "name", "time", "startedDateTime", "type"]) if (Object.hasOwn(source, key) && source[key] !== undefined) { reported[key] = source[key]; @@ -82,21 +361,12 @@ const preview = (record: WebNetworkCaptureRecord) => { reported[key] = value; continue; } - const selected: Record = {}; - for (const field of [ - "method", - "url", - "httpVersion", - "http_version", - "status", - "statusText", - "reason", - ]) - if (Object.hasOwn(fields, field) && fields[field] !== undefined) { - selected[field] = fields[field]; - selectedPointers.add(`/${key}/${field}`); - } - reported[key] = selected; + reported[key] = selectHeadlines( + fields, + key, + mitmproxyFacets, + selectedPointers, + ); } } return { @@ -245,7 +515,9 @@ export const projectWebNetworkCaptureView = ( view, ...shared, items: captureViewItemsSchema.parse( - view.detail === "complete" ? records : records.map(preview), + view.detail === "complete" + ? records + : records.map((record) => preview(record, report.format)), ), coverage: pageViewCoverage( view.offset, diff --git a/src/domain/browserScenarioCapture.test.ts b/src/domain/browserScenarioCapture.test.ts index 8a7b392830..bc075967d7 100644 --- a/src/domain/browserScenarioCapture.test.ts +++ b/src/domain/browserScenarioCapture.test.ts @@ -15,6 +15,7 @@ const networkEvent = { step_index: 0, transaction_id: "request-1", redirected_from_transaction_id: null, + redirected_from_transaction_state: "root", method: "GET", url: sanitizeBrowserUrl("https://example.test/resource"), resource_type: "document", @@ -40,6 +41,13 @@ it("requires transaction IDs and explicit unknown redirect predecessors", () => redirected_from_transaction_id: undefined, }).success, ).toBe(false); + expect( + browserScenarioEventSchema.safeParse({ + ...request, + redirected_from_transaction_id: "request-0", + redirected_from_transaction_state: "unknown", + }).success, + ).toBe(false); }); it("parses each network outcome without contradictory status or failure data", () => { diff --git a/src/domain/browserScenarioCaptureValues.ts b/src/domain/browserScenarioCaptureValues.ts index 26c1559bb3..aa00f222ae 100644 --- a/src/domain/browserScenarioCaptureValues.ts +++ b/src/domain/browserScenarioCaptureValues.ts @@ -144,6 +144,8 @@ const networkEventShape = { ...eventBase, transaction_id: z.string().min(1), redirected_from_transaction_id: z.string().min(1).nullable(), + /** Distinguishes a captured root from an unobserved predecessor for null links. */ + redirected_from_transaction_state: z.enum(["root", "unknown"]).optional(), method: z.string().min(1), url: sanitizedBrowserUrlSchema, resource_type: z.string().min(1), @@ -157,96 +159,110 @@ const webSocketFrameShape = { truncated: z.boolean(), }; -export const browserScenarioEventSchema = z.union([ - z.strictObject({ - ...eventBase, - kind: z.literal("console"), - level: z.string().min(1), - text: z.string(), - url: eventUrl, - }), - z.strictObject({ - ...eventBase, - kind: z.literal("page-error"), - message: z.string(), - stack: z.string().nullable(), - }), - z.strictObject({ - ...networkEventShape, - kind: z.literal("request"), - status: z.null(), - failure: z.null(), - }), - z.strictObject({ - ...networkEventShape, - kind: z.literal("response"), - status: z.number().int().min(100).max(599), - failure: z.null(), - }), - z.strictObject({ - ...networkEventShape, - kind: z.literal("request-failed"), - status: z.null(), - failure: z.string().min(1), - }), - z.strictObject({ - ...networkEventShape, - kind: z.literal("request-finished"), - status: z.null(), - failure: z.null(), - }), - z.strictObject({ - ...networkEventShape, - kind: z.literal("request-unfinished"), - status: z.null(), - failure: z.null(), - reason: z.enum(["capture-ended", "cancelled"]), - }), - z.strictObject({ - ...eventBase, - kind: z.literal("network-content"), - transaction_id: z.string().min(1), - source_event_sequence: z.number().int().min(1), - phase: z.enum(["request", "response"]), - headers: browserNetworkHeadersSchema, - body: browserNetworkBodySchema, - }), - z.strictObject({ - ...eventBase, - kind: z.enum(["websocket-opened", "websocket-closed"]), - url: sanitizedBrowserUrlSchema, - }), - z.strictObject({ - ...webSocketFrameShape, - payload_type: z.literal("text"), - payload_text: z.string(), - }), - z.strictObject({ - ...webSocketFrameShape, - payload_type: z.literal("binary"), - payload_text: z.null(), - }), - z.strictObject({ - ...eventBase, - kind: z.enum([ - "frame-attached", - "frame-detached", - "frame-navigated", - "worker-created", - "worker-closed", - "popup-opened", - "popup-closed", - ]), - url: eventUrl, - name: z.string().nullable(), - }), - z.strictObject({ - ...eventBase, - kind: z.literal("download-cancelled"), - suggested_filename: z.string(), - url: sanitizedBrowserUrlSchema, - }), -]); +export const browserScenarioEventSchema = z + .union([ + z.strictObject({ + ...eventBase, + kind: z.literal("console"), + level: z.string().min(1), + text: z.string(), + url: eventUrl, + }), + z.strictObject({ + ...eventBase, + kind: z.literal("page-error"), + message: z.string(), + stack: z.string().nullable(), + }), + z.strictObject({ + ...networkEventShape, + kind: z.literal("request"), + status: z.null(), + failure: z.null(), + }), + z.strictObject({ + ...networkEventShape, + kind: z.literal("response"), + status: z.number().int().min(100).max(599), + failure: z.null(), + }), + z.strictObject({ + ...networkEventShape, + kind: z.literal("request-failed"), + status: z.null(), + failure: z.string().min(1), + }), + z.strictObject({ + ...networkEventShape, + kind: z.literal("request-finished"), + status: z.null(), + failure: z.null(), + }), + z.strictObject({ + ...networkEventShape, + kind: z.literal("request-unfinished"), + status: z.null(), + failure: z.null(), + reason: z.enum(["capture-ended", "cancelled"]), + }), + z.strictObject({ + ...eventBase, + kind: z.literal("network-content"), + transaction_id: z.string().min(1), + source_event_sequence: z.number().int().min(1), + phase: z.enum(["request", "response"]), + headers: browserNetworkHeadersSchema, + body: browserNetworkBodySchema, + }), + z.strictObject({ + ...eventBase, + kind: z.enum(["websocket-opened", "websocket-closed"]), + url: sanitizedBrowserUrlSchema, + }), + z.strictObject({ + ...webSocketFrameShape, + payload_type: z.literal("text"), + payload_text: z.string(), + }), + z.strictObject({ + ...webSocketFrameShape, + payload_type: z.literal("binary"), + payload_text: z.null(), + }), + z.strictObject({ + ...eventBase, + kind: z.enum([ + "frame-attached", + "frame-detached", + "frame-navigated", + "worker-created", + "worker-closed", + "popup-opened", + "popup-closed", + ]), + url: eventUrl, + name: z.string().nullable(), + }), + z.strictObject({ + ...eventBase, + kind: z.literal("download-cancelled"), + suggested_filename: z.string(), + url: sanitizedBrowserUrlSchema, + }), + ]) + .superRefine((event, context) => { + if ( + "redirected_from_transaction_id" in event && + event.redirected_from_transaction_id !== null && + event.redirected_from_transaction_state !== undefined + ) + context.addIssue({ + code: "custom", + message: + "Redirect state is valid only when the predecessor transaction ID is null", + path: ["redirected_from_transaction_state"], + }); + }); export type BrowserScenarioEvent = z.infer; const completenessSectionSchema = z.enum([ diff --git a/src/domain/browserScenarioDiff.test.ts b/src/domain/browserScenarioDiff.test.ts index f15bd9da0c..aaf3ab9d8b 100644 --- a/src/domain/browserScenarioDiff.test.ts +++ b/src/domain/browserScenarioDiff.test.ts @@ -29,6 +29,7 @@ const networkCapture = (sourceSequence: number, receiptStep: number) => { resource_type: "fetch", transaction_id: "request-1", redirected_from_transaction_id: null, + redirected_from_transaction_state: "root", header_names: [], status: null, failure: null, @@ -235,6 +236,309 @@ it("reports event-family mismatch and legacy selection as unknown", () => { ); }); +const concurrentCapture = ( + requestOrder: readonly ("a" | "b")[], + statuses: Readonly>, + responseStep = 0, + responseOrder: readonly ("a" | "b")[] = requestOrder, + redirect = false, +) => { + const base = scenarioCapture({}); + const network = (path: "a" | "b") => ({ + step_index: 0, + transaction_id: `request-${String(requestOrder.indexOf(path) + 1)}`, + redirected_from_transaction_id: + redirect && path === "b" + ? `request-${String(requestOrder.indexOf("a") + 1)}` + : null, + ...(!redirect || path !== "b" + ? { redirected_from_transaction_state: "root" as const } + : {}), + method: "GET", + url: { + url: `https://app.example.test/${path}.js`, + origin: "https://app.example.test", + query_parameter_names: [], + redacted: false, + }, + resource_type: "script", + header_names: [], + failure: null, + }); + const request = (path: "a" | "b") => ({ + ...network(path), + kind: "request", + status: null, + }); + const response = (path: "a" | "b") => ({ + ...network(path), + step_index: responseStep, + kind: "response", + status: statuses[path], + }); + const events = ( + redirect + ? requestOrder.flatMap((path) => [request(path), response(path)]) + : [...requestOrder.map(request), ...responseOrder.map(response)] + ).map((event, index) => ({ ...event, sequence: index + 1 })); + return browserScenarioCaptureSchema.parse({ + ...base, + events: { items: events, retained: events.length, dropped: 0 }, + }); +}; + +const redirectChainCapture = (swapLeaves = false, reverseReceipt = false) => { + const base = scenarioCapture({}); + const nodes = [ + { id: "root-a", parent: null, path: "first-a", status: 301 }, + { id: "root-b", parent: null, path: "first-b", status: 302 }, + { id: "middle-a", parent: "root-a", path: "same-middle", status: 307 }, + { id: "middle-b", parent: "root-b", path: "same-middle", status: 307 }, + { + id: "leaf-a", + parent: swapLeaves ? "middle-b" : "middle-a", + path: "last-a", + status: 200, + }, + { + id: "leaf-b", + parent: swapLeaves ? "middle-a" : "middle-b", + path: "last-b", + status: 200, + }, + ]; + const events = (reverseReceipt ? [...nodes].reverse() : nodes) + .flatMap((node) => { + const network = { + step_index: 0, + transaction_id: (reverseReceipt ? "renamed-" : "") + node.id, + redirected_from_transaction_id: + node.parent === null + ? null + : (reverseReceipt ? "renamed-" : "") + node.parent, + ...(node.parent === null + ? { redirected_from_transaction_state: "root" as const } + : {}), + method: "GET", + url: { + url: `https://app.example.test/${node.path}`, + origin: "https://app.example.test", + query_parameter_names: [], + redacted: false, + }, + resource_type: "document", + header_names: [], + failure: null, + }; + return [ + { ...network, kind: "request", status: null }, + { ...network, kind: "response", status: node.status }, + ]; + }) + .map((event, index) => ({ ...event, sequence: index + 1 })); + return browserScenarioCaptureSchema.parse({ + ...base, + events: { items: events, retained: events.length, dropped: 0 }, + }); +}; + +describe("browser scenario network event order", () => { + it("retains multi-hop redirect ancestry while ignoring IDs and concurrent receipt order", () => { + const compare = (after: BrowserScenarioCapture) => + compareBrowserScenarios( + compareBrowserScenariosInputSchema.parse({ + before_scenario: redirectChainCapture(), + after_scenario: after, + }), + ); + expect(compare(redirectChainCapture(false, true)).overall_status).toBe( + "unchanged", + ); + expect(compare(redirectChainCapture(true)).overall_status).toBe("changed"); + }); + + it("keeps missing and cyclic redirect ancestry incomparable", () => { + for (const predecessor of ["missing", "middle-a", null]) { + const capture = redirectChainCapture(); + const events = capture.events.items.map((event) => + "transaction_id" in event && event.transaction_id === "root-a" + ? { + ...event, + redirected_from_transaction_id: predecessor, + redirected_from_transaction_state: undefined, + } + : event, + ); + const broken = browserScenarioCaptureSchema.parse({ + ...capture, + events: { ...capture.events, items: events }, + }); + const compared = compareBrowserScenarios( + compareBrowserScenariosInputSchema.parse({ + before_scenario: broken, + after_scenario: broken, + }), + ); + expect(compared.overall_status).toBe("unknown"); + expect( + compared.steps.flatMap((step) => step.artifact_diffs), + ).toContainEqual( + expect.objectContaining({ + artifact: "events", + status: "unknown", + reason: expect.stringContaining("Redirect"), + }), + ); + } + }); + + it("keeps contradictory phase ancestry unknown", () => { + const capture = concurrentCapture(["a", "b"], { a: 200, b: 404 }); + const events = capture.events.items.map((event) => + event.kind === "response" && event.transaction_id === "request-2" + ? { + ...event, + redirected_from_transaction_id: "request-1", + redirected_from_transaction_state: undefined, + } + : event, + ); + const malformed = browserScenarioCaptureSchema.parse({ + ...capture, + events: { ...capture.events, items: events }, + }); + const compared = compareBrowserScenarios( + compareBrowserScenariosInputSchema.parse({ + before_scenario: malformed, + after_scenario: malformed, + }), + ); + expect(compared.overall_status).toBe("unknown"); + expect( + compared.steps.flatMap((step) => step.artifact_diffs), + ).toContainEqual( + expect.objectContaining({ + artifact: "events", + status: "unknown", + reason: expect.stringContaining("Redirect predecessor"), + }), + ); + }); +}); + +describe("browser scenario network comparison stability", () => { + it("keeps normalization away from generated network fingerprints", () => { + const rules = [..."0123456789abcdef"].map((match, index) => ({ + rule_id: `remove-fingerprint-hex-${String(index)}`, + artifacts: ["events"] as const, + match, + replacement: "", + })); + const compared = compareBrowserScenarios( + compareBrowserScenariosInputSchema.parse({ + before_scenario: concurrentCapture(["a", "b"], { + a: 200, + b: 404, + }), + after_scenario: concurrentCapture(["a", "b"], { + a: 201, + b: 404, + }), + normalization: { rules }, + }), + ); + expect(compared.overall_status).toBe("changed"); + }); + + it("reports changed ordinary events despite incomplete redirect ancestry", () => { + const withGap = (text: string) => { + const base = scenarioCapture({}); + const consoleEvent = base.events.items[0]; + if (consoleEvent?.kind !== "console") + throw new Error("Expected console fixture"); + return browserScenarioCaptureSchema.parse({ + ...base, + scenario: { + ...base.scenario, + event_families: ["console", "network"], + }, + events: { + retained: 2, + dropped: 0, + items: [ + { ...consoleEvent, text }, + { + sequence: 2, + step_index: 0, + kind: "request", + transaction_id: "orphan-request", + redirected_from_transaction_id: null, + method: "GET", + url: { + url: "https://app.example.test/orphan", + origin: "https://app.example.test", + query_parameter_names: [], + redacted: false, + }, + resource_type: "fetch", + header_names: [], + status: null, + failure: null, + }, + ], + }, + }); + }; + const compared = compareBrowserScenarios( + compareBrowserScenariosInputSchema.parse({ + before_scenario: withGap("settings-ready"), + after_scenario: withGap("settings-failed"), + }), + ); + expect(compared.overall_status).toBe("changed"); + }); + + it("compares concurrent network transactions without receipt order, receipt step or capture-local IDs", () => { + const compare = ( + before: BrowserScenarioCapture, + after: BrowserScenarioCapture, + ) => + compareBrowserScenarios( + compareBrowserScenariosInputSchema.parse({ + before_scenario: before, + after_scenario: after, + }), + ).overall_status; + const statuses = { a: 200, b: 404 }; + + expect( + compare( + concurrentCapture(["a", "b"], statuses), + concurrentCapture(["b", "a"], statuses, 1, ["a", "b"]), + ), + ).toBe("unchanged"); + expect( + compare( + concurrentCapture(["a", "b"], statuses), + concurrentCapture(["b", "a"], { a: 404, b: 200 }), + ), + ).toBe("changed"); + expect( + compare( + concurrentCapture(["a", "b"], statuses, 0, ["a", "b"]), + concurrentCapture(["a", "b"], statuses, 0, ["b", "a"]), + ), + ).toBe("unchanged"); + // Redirect linkage is distinct from independent concurrent transactions. + expect( + compare( + concurrentCapture(["a", "b"], statuses), + concurrentCapture(["a", "b"], statuses, 0, ["a", "b"], true), + ), + ).toBe("changed"); + }); +}); + describe("browser scenario comparison", () => { it("records canonical literal normalization and ignores declared volatile fields", () => { const before = scenarioCapture({ diff --git a/src/domain/browserScenarioDiff.ts b/src/domain/browserScenarioDiff.ts index 2cb761a76b..55c8901bce 100644 --- a/src/domain/browserScenarioDiff.ts +++ b/src/domain/browserScenarioDiff.ts @@ -1,12 +1,14 @@ import type { BrowserScenarioCapture } from "./browserScenarioCapture.js"; import type { BrowserScenarioStep } from "./browserScenarioCaptureValues.js"; +import { z } from "zod"; +import { digestCanonicalValue } from "./canonicalDigest.js"; import { actionState, artifactEvidenceState, captured, eventEvidenceState, eventFamilyCoverageReason, - eventsFor, + prepareScenarioEvents, networkContentCoverageReason, screenshotProjection, type CaptureState, @@ -24,8 +26,14 @@ import { import { commitBrowserScenarioNormalization, digestNormalizedScenarioValue, + normalizeScenarioValue, } from "./browserScenarioNormalization.js"; +const scenarioEventsValueSchema = z.strictObject({ + events: z.array(z.unknown()), + network_transactions: z.array(z.string()), +}); + type ComparableArtifact = { readonly artifact: BrowserScenarioArtifactKind; } & ( @@ -59,6 +67,14 @@ export const compareBrowserScenarios = ( input: CompareBrowserScenariosInput, ): BrowserScenarioDiff => { const normalization = commitBrowserScenarioNormalization(input.normalization); + const beforeEvents = prepareScenarioEvents( + input.before_scenario, + normalization.rules, + ); + const afterEvents = prepareScenarioEvents( + input.after_scenario, + normalization.rules, + ); const alignment = alignScenarioSteps( input.before_scenario, input.after_scenario, @@ -68,6 +84,8 @@ export const compareBrowserScenarios = ( afterCapture: input.after_scenario, alignment, rules: normalization.rules, + beforeEvents, + afterEvents, }); const failures = [...alignment.failures, ...compared.failures]; const alignmentStatus = @@ -105,7 +123,7 @@ export const compareBrowserScenarios = ( limitations: [ "Steps align only by exact, unique step_id; missing, duplicate, or action-mismatched steps are reported and prevent an unchanged claim.", "Only declared literal normalization rules are applied, in canonical rule_id order, to already-redacted durable capture fields.", - "Elapsed time and event sequence/index values are excluded as volatile; screenshot bytes are compared by content digest.", + "Elapsed time, event sequence/index values and capture-local transaction IDs are excluded as volatile; network transactions are compared as an unordered collection in the step that sent their request; screenshot bytes are compared by content digest.", "A changed status proves an observed normalized difference. Missing or truncated evidence, incompatible capture context, or alignment failure prevents an unchanged claim.", ], }); @@ -160,6 +178,8 @@ interface CompareAlignedStepsInput { readonly afterCapture: BrowserScenarioCapture; readonly alignment: ScenarioAlignment; readonly rules: readonly BrowserScenarioNormalizationRule[]; + readonly beforeEvents: ReturnType; + readonly afterEvents: ReturnType; } const compareAlignedSteps = ( @@ -184,6 +204,8 @@ const compareAlignedSteps = ( afterCapture: input.afterCapture, afterStep, rules: input.rules, + beforeEvents: input.beforeEvents, + afterEvents: input.afterEvents, }); const reportable = artifacts.flatMap(({ diff }) => diff === null ? [] : [diff], @@ -266,6 +288,8 @@ interface CompareStepArtifactsInput { readonly afterCapture: BrowserScenarioCapture; readonly afterStep: BrowserScenarioStep; readonly rules: readonly BrowserScenarioNormalizationRule[]; + readonly beforeEvents: ReturnType; + readonly afterEvents: ReturnType; } const compareStepArtifacts = ( @@ -322,32 +346,156 @@ const compareStepArtifacts = ( before: input.beforeStep.artifacts.storage, after: input.afterStep.artifacts.storage, }), - compareCapturedValues({ + compareScenarioEvents({ ...common, artifact: "events", - before: captured( - eventsFor(input.beforeCapture, input.beforeStep.step_index), - ), - after: captured( - eventsFor(input.afterCapture, input.afterStep.step_index), - ), + before: captured(input.beforeEvents.forStep(input.beforeStep.step_index)), + after: captured(input.afterEvents.forStep(input.afterStep.step_index)), beforeState: eventEvidenceState(input.beforeCapture, input.beforeStep), afterState: eventEvidenceState(input.afterCapture, input.afterStep), incomparableReason: eventCoverageReason( input.beforeCapture, input.afterCapture, + input.beforeEvents.coverageReason, + input.afterEvents.coverageReason, + ), + familyIncomparableReason: eventFamilyCoverageReason( + input.beforeCapture, + input.afterCapture, ), }), ]; }; +const compareScenarioEvents = ( + input: CompareCapturedValuesInput<{ + readonly events: readonly unknown[]; + readonly network_transactions: readonly string[]; + }>, +): ComparableArtifact => { + const beforeState = + input.beforeState ?? + artifactEvidenceState(input.artifact, input.before, input.beforeStep); + const afterState = + input.afterState ?? + artifactEvidenceState(input.artifact, input.after, input.afterStep); + if (beforeState === "not_requested" && afterState === "not_requested") + return { artifact: input.artifact, status: "not_compared", diff: null }; + const beforeValue = + input.before.state === "captured" ? input.before.value : undefined; + const afterValue = + input.after.state === "captured" ? input.after.value : undefined; + const isCaptured = + beforeState === "captured" && + afterState === "captured" && + beforeValue !== undefined && + afterValue !== undefined; + const ordinaryBeforeDigest = + beforeValue === undefined + ? null + : digestCanonicalValue( + normalizeScenarioValue(beforeValue.events, "events", input.rules), + "Browser scenario", + ); + const ordinaryAfterDigest = + afterValue === undefined + ? null + : digestCanonicalValue( + normalizeScenarioValue(afterValue.events, "events", input.rules), + "Browser scenario", + ); + const beforeSha256 = + beforeState === "captured" && beforeValue !== undefined + ? digestScenarioEvents(beforeValue, ordinaryBeforeDigest) + : null; + const afterSha256 = + afterState === "captured" && afterValue !== undefined + ? digestScenarioEvents(afterValue, ordinaryAfterDigest) + : null; + const changedOrdinaryEvents = + isCaptured && ordinaryBeforeDigest !== ordinaryAfterDigest; + if (changedOrdinaryEvents && input.familyIncomparableReason === undefined) + return { + artifact: input.artifact, + status: "changed", + diff: { + artifact: input.artifact, + status: "changed", + before_state: beforeState, + after_state: afterState, + before_sha256: beforeSha256, + after_sha256: afterSha256, + reason: null, + }, + }; + if ( + !isCaptured || + input.incomparableReason !== undefined || + input.familyIncomparableReason !== undefined + ) + return { + artifact: input.artifact, + status: "unknown", + diff: { + artifact: input.artifact, + status: "unknown", + before_state: beforeState, + after_state: afterState, + before_sha256: beforeSha256, + after_sha256: afterSha256, + reason: + joinIncomparableReasons( + input.familyIncomparableReason, + input.incomparableReason, + ) ?? + `Artifact coverage is not comparable (${beforeState} before, ${afterState} after).`, + }, + }; + if (beforeSha256 === null || afterSha256 === null) + throw new TypeError("Captured event digest refinement failed"); + if (beforeSha256 === afterSha256) + return { artifact: input.artifact, status: "unchanged", diff: null }; + return { + artifact: input.artifact, + status: "changed", + diff: { + artifact: input.artifact, + status: "changed", + before_state: beforeState, + after_state: afterState, + before_sha256: beforeSha256, + after_sha256: afterSha256, + reason: null, + }, + }; +}; + +const digestScenarioEvents = ( + value: unknown, + ordinaryEventsDigest: string | null, +): string => { + const parsed = scenarioEventsValueSchema.parse(value); + return digestCanonicalValue( + { + ordinary_events_sha256: ordinaryEventsDigest, + // These are already normalized SHA-256 transaction fingerprints. + network_transactions: parsed.network_transactions, + }, + "Browser scenario", + ); +}; + const eventCoverageReason = ( before: BrowserScenarioCapture, after: BrowserScenarioCapture, + beforeNetworkReason: string | undefined, + afterNetworkReason: string | undefined, ): string | undefined => { const reasons = [ eventFamilyCoverageReason(before, after), networkContentCoverageReason(before, after), + beforeNetworkReason, + afterNetworkReason, ].filter((reason) => reason !== undefined); return reasons.length === 0 ? undefined : reasons.join(" "); }; @@ -360,9 +508,11 @@ interface CompareCapturedValuesInput { readonly afterStep: BrowserScenarioStep; readonly rules: readonly BrowserScenarioNormalizationRule[]; readonly project?: (value: Value) => unknown; + readonly order?: (normalized: unknown) => unknown; readonly beforeState?: BrowserScenarioEvidenceState; readonly afterState?: BrowserScenarioEvidenceState; readonly incomparableReason?: string | undefined; + readonly familyIncomparableReason?: string | undefined; } const compareCapturedValues = ( @@ -376,6 +526,7 @@ const compareCapturedValues = ( afterStep, rules, project = (value) => value, + order, } = input; const beforeState = input.beforeState ?? artifactEvidenceState(artifact, before, beforeStep); @@ -385,11 +536,21 @@ const compareCapturedValues = ( return { artifact, status: "not_compared", diff: null }; const beforeSha256 = beforeState === "captured" && before.state === "captured" - ? digestNormalizedScenarioValue(project(before.value), artifact, rules) + ? digestNormalizedScenarioValue( + project(before.value), + artifact, + rules, + order, + ) : null; const afterSha256 = afterState === "captured" && after.state === "captured" - ? digestNormalizedScenarioValue(project(after.value), artifact, rules) + ? digestNormalizedScenarioValue( + project(after.value), + artifact, + rules, + order, + ) : null; if ( beforeState !== "captured" || @@ -409,7 +570,10 @@ const compareCapturedValues = ( before_sha256: beforeSha256, after_sha256: afterSha256, reason: - input.incomparableReason ?? + joinIncomparableReasons( + input.familyIncomparableReason, + input.incomparableReason, + ) ?? `Artifact coverage is not comparable (${beforeState} before, ${afterState} after).`, }, }; @@ -431,3 +595,12 @@ const compareCapturedValues = ( }, }; }; + +const joinIncomparableReasons = ( + ...reasons: (string | undefined)[] +): string | undefined => { + const present = reasons.filter( + (reason): reason is string => reason !== undefined, + ); + return present.length === 0 ? undefined : [...new Set(present)].join(" "); +}; diff --git a/src/domain/browserScenarioDiffEvidence.ts b/src/domain/browserScenarioDiffEvidence.ts index f7f4b45e3a..a6b5a0b5d7 100644 --- a/src/domain/browserScenarioDiffEvidence.ts +++ b/src/domain/browserScenarioDiffEvidence.ts @@ -5,9 +5,16 @@ import type { BrowserStepArtifacts, } from "./browserScenarioCaptureValues.js"; import type { BrowserScenarioCapture } from "./browserScenarioCapture.js"; +import { z } from "zod"; +import { + fingerprintScenarioNetworkTransactions, + scenarioNetworkTransactions, + scenarioNetworkCoverageReason, +} from "./browserScenarioNetworkComparison.js"; import type { BrowserScenarioArtifactKind, BrowserScenarioEvidenceState, + BrowserScenarioNormalizationRule, } from "./browserScenarioDiffValues.js"; /** Capture-state input consumed by browser scenario artifact comparison. */ @@ -94,26 +101,62 @@ export const actionState = (step: BrowserScenarioStep) => ({ error: step.error, }); -/** Select events attributed to one scenario step without sequence metadata. */ -export const eventsFor = ( +/** One step's ordered events and a single indexed network graph. */ +export interface ScenarioStepEvents { + readonly events: readonly unknown[]; + readonly network_transactions: readonly string[]; +} + +/** Build one graph and fingerprint index per capture, then select in linear total work. */ +export const prepareScenarioEvents = ( capture: BrowserScenarioCapture, - stepIndex: number, -): readonly unknown[] => { - const sourceSteps = new Map( - capture.events.items.map(({ sequence, step_index }) => [ - sequence, - step_index, - ]), - ); - return capture.events.items.flatMap( - ({ sequence: _sequence, step_index: eventStepIndex, ...event }) => { - if (event.kind === "network-content") { - const { source_event_sequence: sourceSequence, ...content } = event; - return sourceSteps.get(sourceSequence) === stepIndex ? [content] : []; - } - return eventStepIndex === stepIndex ? [event] : []; - }, - ); + rules: readonly BrowserScenarioNormalizationRule[], +): { + readonly coverageReason: string | undefined; + readonly forStep: (stepIndex: number) => ScenarioStepEvents; +} => { + const eventsByStep = new Map(); + for (const { sequence: _sequence, step_index, ...event } of capture.events + .items) { + if ("transaction_id" in event) continue; + const events = eventsByStep.get(step_index) ?? []; + events.push(event); + eventsByStep.set(step_index, events); + } + const nodes = scenarioNetworkTransactions(capture); + const fingerprints = fingerprintScenarioNetworkTransactions(nodes, rules); + const networkByStep = new Map(); + for (const [index, node] of nodes.entries()) { + const step = networkByStep.get(node.step_index) ?? []; + const fingerprint = fingerprints[index]; + if (fingerprint === undefined) + throw new Error("Network comparison node has no fingerprint"); + step.push(fingerprint); + networkByStep.set(node.step_index, step); + } + for (const fingerprintsForStep of networkByStep.values()) + fingerprintsForStep.sort(); + return { + coverageReason: scenarioNetworkCoverageReason(capture, nodes), + forStep: (stepIndex) => ({ + events: eventsByStep.get(stepIndex) ?? [], + network_transactions: networkByStep.get(stepIndex) ?? [], + }), + }; +}; + +const scenarioStepEventsSchema = z.strictObject({ + events: z.array(z.unknown()), + network_transactions: z.array(z.string()), +}); + +/** Network ancestry is already fingerprinted per capture; keep the collection unordered. */ +export const orderNetworkTransactions = (value: unknown): unknown => { + const events = scenarioStepEventsSchema.parse(value); + return { + events: events.events, + network_transactions: events.network_transactions, + }; }; /** Different content selection is missing comparability, not an observed website change. */ diff --git a/src/domain/browserScenarioDiffValues.ts b/src/domain/browserScenarioDiffValues.ts index ff1c51f2da..0a52061888 100644 --- a/src/domain/browserScenarioDiffValues.ts +++ b/src/domain/browserScenarioDiffValues.ts @@ -123,20 +123,29 @@ const alignmentFailureSchema = z.strictObject({ step_id: z.string().min(1).nullable(), reason: z.string().min(1), }); -const builtInNormalizationSchema = z.enum([ +export const BROWSER_SCENARIO_BUILT_IN_NORMALIZATION_RULES = [ "ignore_step_elapsed_ms", "ignore_event_sequence", "ignore_event_step_index", "compare_screenshot_content_digest", "compare_normalized_text_content", -]); + "ignore_capture_local_network_transaction_ids", + "attribute_network_transactions_to_request_step", + "compare_network_transactions_as_unordered_collection", + "preserve_redirect_transaction_ancestry", +] as const; +const builtInNormalizationSchema = z.enum( + BROWSER_SCENARIO_BUILT_IN_NORMALIZATION_RULES, +); /** Step alignment, recorded normalization, and inline artifact differences. */ export const browserScenarioDiffSchema = z.strictObject({ comparison_kind: z.literal("browser_scenario"), overall_status: z.enum(["changed", "unchanged", "unknown"]), normalization: z.strictObject({ - built_in_rules: z.array(builtInNormalizationSchema).length(5), + built_in_rules: z + .array(builtInNormalizationSchema) + .length(BROWSER_SCENARIO_BUILT_IN_NORMALIZATION_RULES.length), rules: z.array(normalizationRuleSchema), sha256: z.string().regex(/^[a-f0-9]{64}$/u), }), diff --git a/src/domain/browserScenarioNetworkComparison.ts b/src/domain/browserScenarioNetworkComparison.ts new file mode 100644 index 0000000000..56af2fa7ce --- /dev/null +++ b/src/domain/browserScenarioNetworkComparison.ts @@ -0,0 +1,222 @@ +import { z } from "zod"; +import type { BrowserScenarioCapture } from "./browserScenarioCapture.js"; +import type { BrowserScenarioNormalizationRule } from "./browserScenarioDiffValues.js"; +import { digestCanonicalValue } from "./canonicalDigest.js"; +import { normalizeScenarioValue } from "./browserScenarioNormalization.js"; + +/** Numeric links are temporary local indices, never durable transaction identity. */ +export const scenarioNetworkTransactionSchema = z.strictObject({ + events: z.array(z.unknown()), + predecessor: z.number().int().min(-2), + step_index: z.number().int().min(0), +}); +export type ScenarioNetworkTransaction = z.infer< + typeof scenarioNetworkTransactionSchema +>; + +/** Project each transaction once, retaining ancestry as references rather than nested copies. */ +export const scenarioNetworkTransactions = ( + capture: BrowserScenarioCapture, +): ScenarioNetworkTransaction[] => { + const sourceSteps = new Map( + capture.events.items.map((event) => [event.sequence, event.step_index]), + ); + const requests = new Map( + capture.events.items.flatMap((event) => + event.kind === "request" ? [[event.transaction_id, event]] : [], + ), + ); + const grouped = new Map< + string, + { + events: unknown[]; + predecessor: string | null; + predecessorState: "root" | "unknown" | undefined; + predecessorConflict: boolean; + stepIndex: number; + } + >(); + for (const { sequence: _sequence, step_index: step, ...event } of capture + .events.items) { + if (!("transaction_id" in event)) continue; + const id = event.transaction_id; + const request = requests.get(id); + const receiptStep = + event.kind === "network-content" + ? sourceSteps.get(event.source_event_sequence) + : step; + const requestStep = request?.step_index ?? receiptStep; + let evidence: unknown; + let predecessor: string | null; + let predecessorState: "root" | "unknown" | undefined; + if (event.kind === "network-content") { + const { + transaction_id: _id, + source_event_sequence: _source, + ...content + } = event; + evidence = content; + predecessor = request?.redirected_from_transaction_id ?? null; + predecessorState = request?.redirected_from_transaction_state; + } else { + const { + transaction_id: _id, + redirected_from_transaction_id: parent, + redirected_from_transaction_state: _state, + ...network + } = event; + evidence = network; + predecessor = + request === undefined ? parent : request.redirected_from_transaction_id; + predecessorState = + request === undefined + ? event.redirected_from_transaction_state + : request.redirected_from_transaction_state; + } + const previous = grouped.get(id); + if (previous === undefined) + grouped.set(id, { + events: [evidence], + predecessor, + predecessorState, + predecessorConflict: + event.kind !== "network-content" && + request !== undefined && + ((event.redirected_from_transaction_id !== null && + event.redirected_from_transaction_id !== + request.redirected_from_transaction_id) || + (event.redirected_from_transaction_state === "root" && + request.redirected_from_transaction_id !== null)), + stepIndex: requestStep ?? step, + }); + else { + previous.events.push(evidence); + if (event.kind !== "network-content") { + const parent = event.redirected_from_transaction_id; + const state = event.redirected_from_transaction_state; + const knownParent = previous.predecessor; + if ( + parent !== null && + (knownParent !== null + ? parent !== knownParent + : previous.predecessorState === "root") + ) + previous.predecessorConflict = true; + else if (parent !== null && knownParent === null) { + previous.predecessor = parent; + previous.predecessorState = state; + } else if (parent === null && state === "root" && knownParent !== null) + previous.predecessorConflict = true; + else if ( + parent === null && + state === "root" && + previous.predecessorState !== "root" + ) + previous.predecessorState = "root"; + } + } + } + const indices = new Map([...grouped.keys()].map((id, index) => [id, index])); + return [...grouped.values()].map((node) => ({ + events: node.events, + predecessor: node.predecessorConflict + ? -2 + : node.predecessor === null + ? node.predecessorState === "root" + ? -1 + : -2 + : (indices.get(node.predecessor) ?? -2), + step_index: node.stepIndex, + })); +}; + +/** Missing or cyclic ancestry cannot support an unchanged claim. */ +export const scenarioNetworkCoverageReason = ( + capture: BrowserScenarioCapture, + nodes = scenarioNetworkTransactions(capture), +): string | undefined => { + const finished = new Set(); + for (let start = 0; start < nodes.length; start += 1) { + const path = new Set(); + let index = start; + while (index >= 0 && !finished.has(index)) { + if (path.has(index)) + return "Redirect ancestry contains a cycle; network transaction identity remains unknown."; + path.add(index); + const node = nodes[index]; + if (node === undefined) + return "Redirect predecessor evidence is missing; network transaction identity remains unknown."; + index = node.predecessor; + } + if (index === -2) + return "Redirect predecessor evidence is missing; network transaction identity remains unknown."; + for (const item of path) finished.add(item); + } + return undefined; +}; + +/** Hash normalized own evidence and its complete parent fingerprint in linear graph space. */ +export const fingerprintScenarioNetworkTransactions = ( + nodes: readonly ScenarioNetworkTransaction[], + rules: readonly BrowserScenarioNormalizationRule[] = [], +): string[] => { + const own = nodes.map((node) => + node.events + .map((event) => + digestCanonicalValue( + normalizeScenarioValue(event, "events", rules), + "Browser scenario", + ), + ) + .sort(), + ); + const fingerprints = new Map(); + for (let start = 0; start < nodes.length; start += 1) { + const path: number[] = []; + const positions = new Map(); + let index = start; + while ( + index >= 0 && + nodes[index] !== undefined && + !fingerprints.has(index) + ) { + const position = positions.get(index); + if (position !== undefined) { + // These captures are incomparable; retain an explicit cycle observation. + for (const member of path.slice(position)) + fingerprints.set( + member, + digestCanonicalValue( + { events: own[member], redirected_from: { state: "cycle" } }, + "Browser scenario", + ), + ); + break; + } + positions.set(index, path.length); + path.push(index); + index = nodes[index]?.predecessor ?? -2; + } + for (const member of path.reverse()) { + if (fingerprints.has(member)) continue; + const predecessor = nodes[member]?.predecessor ?? -2; + const parent = + predecessor === -1 + ? null + : (fingerprints.get(predecessor) ?? { state: "missing" }); + fingerprints.set( + member, + digestCanonicalValue( + { events: own[member], redirected_from: parent }, + "Browser scenario", + ), + ); + } + } + return nodes.map((_, index) => { + const fingerprint = fingerprints.get(index); + if (fingerprint === undefined) + throw new Error("Network comparison node has no fingerprint"); + return fingerprint; + }); +}; diff --git a/src/domain/browserScenarioNormalization.ts b/src/domain/browserScenarioNormalization.ts index 6af5fb2ac1..5f02514bc1 100644 --- a/src/domain/browserScenarioNormalization.ts +++ b/src/domain/browserScenarioNormalization.ts @@ -1,20 +1,13 @@ import { digestCanonicalValue } from "./canonicalDigest.js"; -import type { - BrowserScenarioArtifactKind, - BrowserScenarioDiff, - BrowserScenarioNormalization, - BrowserScenarioNormalizationRule, +import { + BROWSER_SCENARIO_BUILT_IN_NORMALIZATION_RULES, + type BrowserScenarioArtifactKind, + type BrowserScenarioDiff, + type BrowserScenarioNormalization, + type BrowserScenarioNormalizationRule, } from "./browserScenarioDiffValues.js"; -const BUILT_IN_RULES = [ - "ignore_step_elapsed_ms", - "ignore_event_sequence", - "ignore_event_step_index", - "compare_screenshot_content_digest", - "compare_normalized_text_content", -] as const; - /** Canonically order and commit a parsed browser scenario normalization policy. */ export const commitBrowserScenarioNormalization = ( input: BrowserScenarioNormalization, @@ -27,22 +20,29 @@ export const commitBrowserScenarioNormalization = ( .sort((left, right) => left.rule_id < right.rule_id ? -1 : left.rule_id > right.rule_id ? 1 : 0, ); - const commitment = { built_in_rules: [...BUILT_IN_RULES], rules }; + const commitment = { + built_in_rules: [...BROWSER_SCENARIO_BUILT_IN_NORMALIZATION_RULES], + rules, + }; return { ...commitment, sha256: digestCanonicalValue(commitment, "Browser scenario"), }; }; -/** Digest one artifact after applying committed exact-literal rules. */ +/** Digest one artifact after applying committed exact-literal rules, then any artifact ordering. */ export const digestNormalizedScenarioValue = ( value: unknown, artifact: BrowserScenarioArtifactKind, rules: readonly BrowserScenarioNormalizationRule[], + order: (normalized: unknown) => unknown = (normalized) => normalized, ): string => - digestCanonicalValue(normalize(value, artifact, rules), "Browser scenario"); + digestCanonicalValue( + order(normalizeScenarioValue(value, artifact, rules)), + "Browser scenario", + ); -const normalize = ( +export const normalizeScenarioValue = ( value: unknown, artifact: BrowserScenarioArtifactKind, rules: readonly BrowserScenarioNormalizationRule[], @@ -57,12 +57,12 @@ const normalize = ( value, ); if (Array.isArray(value)) - return value.map((item) => normalize(item, artifact, rules)); + return value.map((item) => normalizeScenarioValue(item, artifact, rules)); if (typeof value !== "object" || value === null) return value; return Object.fromEntries( Object.entries(value).map(([key, item]) => [ key, - normalize(item, artifact, rules), + normalizeScenarioValue(item, artifact, rules), ]), ); }; diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index d8ce6d9e21..ba23228b11 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -207,12 +207,7 @@ const compareWebCaptureDimensions = ( sectionsComplete(after, ["metadata"]), "Safe metadata capture was incomplete in at least one observation.", ), - webmcp: compareDimension( - webMcpMap(input.before.webmcp), - webMcpMap(input.after.webmcp), - webMcpComplete(input.before.webmcp) && webMcpComplete(input.after.webmcp), - "WebMCP discovery was unavailable or incomplete in at least one capture.", - ), + webmcp: webMcpDimension(input.before.webmcp, input.after.webmcp), accessibility: accessibilityDimension(before, after), storage: storageDimension(before, after), }; @@ -532,12 +527,51 @@ const webSocketPayloadMaps = ( return [left, right]; }; -const webMcpMap = ( - discovery: WebMcpDiscovery | null, -): ReadonlyMap => - discovery === null - ? new Map() - : keyed(discovery.tools.items, (tool) => tool.tool_key); +const webMcpDimension = ( + before: WebMcpDiscovery | null, + after: WebMcpDiscovery | null, +): Dimension => { + const afterTools = new Map( + after?.tools.items.map((tool) => [tool.tool_key, tool]), + ); + const unknownHints = new Set( + (before?.tools.items ?? []) + .filter((tool) => { + const other = afterTools.get(tool.tool_key); + return ( + other !== undefined && + (tool.annotations.consequential === undefined || + other.annotations.consequential === undefined) + ); + }) + .map((tool) => tool.tool_key), + ); + const project = ( + discovery: WebMcpDiscovery | null, + ): ReadonlyMap => + keyed( + (discovery?.tools.items ?? []).map((tool) => { + if (!unknownHints.has(tool.tool_key)) return tool; + const { consequential: _unknown, ...annotations } = tool.annotations; + return { ...tool, annotations }; + }), + (tool) => tool.tool_key, + ); + const result = compareDimension( + project(before), + project(after), + webMcpComplete(before) && webMcpComplete(after), + "WebMCP discovery was unavailable or incomplete in at least one capture.", + ); + return result.status === "unchanged" && unknownHints.size > 0 + ? { + ...result, + status: "unknown", + reason: + "Consequential annotations were not recorded or were invalid in at least one capture.", + } + : result; +}; const webMcpComplete = (discovery: WebMcpDiscovery | null): boolean => discovery !== null && diff --git a/src/domain/webMcpDiscovery.test.ts b/src/domain/webMcpDiscovery.test.ts new file mode 100644 index 0000000000..452038d6b5 --- /dev/null +++ b/src/domain/webMcpDiscovery.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { createEvidence } from "./evidence.js"; +import { + createEvidenceBundle, + parseEvidenceBundle, + serializeEvidenceBundle, +} from "./evidenceBundle.js"; +import { jsonValueSchema } from "./jsonValue.js"; +import { + webMcpDiscoverySchema, + type WebMcpDiscovery, +} from "./webMcpDiscovery.js"; + +const PROVIDER = { id: "fixture", name: "Fixture provider", version: "1" }; + +const discovery = (annotations: Record): WebMcpDiscovery => + webMcpDiscoverySchema.parse({ + browser: { + product: "Chrome", + protocol_version: "1.3", + revision: "test", + user_agent: "test", + js_version: "test", + }, + target: { + target_id: "page-1", + url: "http://127.0.0.1:3000/", + origin: "http://127.0.0.1:3000", + }, + status: "available", + tools: { + total: 1, + items: [ + { + tool_key: `webmcp_${"a".repeat(64)}`, + name: "book_flight", + description: "Book a flight", + frame_id: "frame-main", + frame_url: "http://127.0.0.1:3000/", + owner_origin: "http://127.0.0.1:3000", + declaration_kind: "imperative", + input_schema: null, + input_schema_sha256: null, + annotations, + registration_source: null, + trust: "page-declared-untrusted", + }, + ], + }, + completeness: { + status: "complete_within_window", + conditions: ["complete_within_window"], + policy_filtered_sections: [], + attach_limited_sections: [], + truncated_sections: [], + unavailable_sections: [], + excluded: [], + dropped_events: { + scripts: 0, + network_requests: 0, + console_events: 0, + websocket_connections: 0, + websocket_frames: 0, + webmcp_tools: 0, + timeline_events: 0, + total: 0, + }, + }, + limitations: [], + }); + +describe("WebMCP discovery consequential annotation", () => { + it.each([true, false, null])( + "preserves a declared consequential value of %s", + (consequential) => { + const parsed = discovery({ + read_only: null, + untrusted_content: null, + consequential, + autosubmit: null, + }); + expect(parsed.tools.items[0]?.annotations.consequential).toBe( + consequential, + ); + }, + ); + + it("admits historical captures recorded before consequential was surfaced", () => { + const parsed = discovery({ + read_only: true, + untrusted_content: null, + autosubmit: false, + }); + expect(parsed.tools.items[0]?.annotations.consequential).toBeUndefined(); + }); + + it("survives the Evidence bundle export and re-read round-trip", () => { + const result = discovery({ + read_only: null, + untrusted_content: null, + consequential: true, + autosubmit: null, + }); + const evidence = createEvidence(undefined, PROVIDER, { + predicateType: "rea.webmcp-discovery", + operation: "discover_webmcp_tools", + parameters: {}, + result: jsonValueSchema.parse(result), + }); + const bundle = createEvidenceBundle([evidence]); + const reread = parseEvidenceBundle( + JSON.parse(serializeEvidenceBundle(bundle)), + ); + const record = reread.records[0]; + expect(record?.normalized_result).toMatchObject({ + tools: { + items: [ + { + annotations: { + consequential: true, + }, + }, + ], + }, + }); + }); +}); diff --git a/src/domain/webMcpDiscovery.ts b/src/domain/webMcpDiscovery.ts index 0924f5b782..0edf81dd3c 100644 --- a/src/domain/webMcpDiscovery.ts +++ b/src/domain/webMcpDiscovery.ts @@ -33,6 +33,9 @@ const webMcpToolSchema = z.object({ annotations: z.object({ read_only: z.boolean().nullable(), untrusted_content: z.boolean().nullable(), + // Absent when this annotation was not recorded or its protocol value was invalid; null + // distinguishes a declaration that omitted it from a declared false. + consequential: z.boolean().nullable().optional(), autosubmit: z.boolean().nullable(), }), registration_source: z diff --git a/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts b/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts index 5fd1e061bb..5342dc9eeb 100644 --- a/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.document_script.test.ts @@ -256,63 +256,70 @@ describe("CdpBrowserProvider: transient WebMCP frames", () => { }); describe("CdpBrowserProvider: WebMCP discovery and completeness", () => { - it("discovers untrusted WebMCP declarations without registering or invoking them", async () => { - const browser = await startFakeCdpBrowser({ webMcpTools: true }); - trackBrowser(browser); - const result = await new CdpBrowserProvider().discoverWebMcpTools( - discoverWebMcpToolsInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - observation_ms: 0, - }), - ); + it.each([true, false])( + "discovers untrusted WebMCP declarations with consequential=%s without invoking them", + async (consequential) => { + const browser = await startFakeCdpBrowser({ + webMcpTools: true, + webMcpConsequential: consequential, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().discoverWebMcpTools( + discoverWebMcpToolsInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + }), + ); - if (!result.ok) throw result.error; - expect(result.value).toMatchObject({ - status: "available", - tools: { - total: 1, - items: [ - { - name: "search_orders", - description: "Search orders; authorization=Bearer tool-secret", - declaration_kind: "declarative", - owner_origin: browser.allowedOrigin, - annotations: { - read_only: true, - untrusted_content: true, - autosubmit: false, + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + status: "available", + tools: { + total: 1, + items: [ + { + name: "search_orders", + description: "Search orders; authorization=Bearer tool-secret", + declaration_kind: "declarative", + owner_origin: browser.allowedOrigin, + annotations: { + read_only: true, + untrusted_content: true, + consequential, + autosubmit: false, + }, + trust: "page-declared-untrusted", + registration_source: { + url: `${browser.allowedOrigin}/app.js?token=tool-source-secret`, + line: 12, + column: 4, + }, }, - trust: "page-declared-untrusted", - registration_source: { - url: `${browser.allowedOrigin}/app.js?token=tool-source-secret`, - line: 12, - column: 4, - }, - }, - ], - }, - }); - expect(result.value.completeness.excluded).toContainEqual( - expect.objectContaining({ - section: "webmcp_tools", - reason: "out_of_target_scope", - }), - ); - expect(result.value.completeness.unavailable_sections).not.toContain( - "webmcp_tools", - ); - const serialized = JSON.stringify(result.value); - expect(serialized).toContain("tool-secret"); - expect(serialized).toContain("tool-source-secret"); - expect(serialized).toContain("schema-secret"); - expect(serialized).not.toContain("private-tool-secret"); - const methods = browser.commands.map(({ method }) => method); - expect(methods).toContain("WebMCP.enable"); - expect(methods).not.toContain("WebMCP.invokeTool"); - expect(methods).not.toContain("Runtime.evaluate"); - }); + ], + }, + }); + expect(result.value.completeness.excluded).toContainEqual( + expect.objectContaining({ + section: "webmcp_tools", + reason: "out_of_target_scope", + }), + ); + expect(result.value.completeness.unavailable_sections).not.toContain( + "webmcp_tools", + ); + const serialized = JSON.stringify(result.value); + expect(serialized).toContain("tool-secret"); + expect(serialized).toContain("tool-source-secret"); + expect(serialized).toContain("schema-secret"); + expect(serialized).not.toContain("private-tool-secret"); + const methods = browser.commands.map(({ method }) => method); + expect(methods).toContain("WebMCP.enable"); + expect(methods).not.toContain("WebMCP.invokeTool"); + expect(methods).not.toContain("Runtime.evaluate"); + }, + ); it("reports WebMCP unavailable when the experimental domain is absent", async () => { const browser = await startFakeCdpBrowser({ @@ -756,6 +763,15 @@ describe("CdpBrowserProvider: WebMCP registration owners", () => { const tools = result.value.tools.items; expect(tools).toHaveLength(mode === "retain" ? 2 : 1); expect(result.value.tools.total).toBe(tools.length); + // Declarations without annotations report absent hints as null, never + // collapsed to a declared false. + for (const tool of tools) + expect(tool.annotations).toEqual({ + read_only: null, + untrusted_content: null, + consequential: null, + autosubmit: null, + }); expect(tools).toContainEqual( expect.objectContaining({ name: "read_item", diff --git a/tests/boundary/browser/historicalWebCapture.test.ts b/tests/boundary/browser/historicalWebCapture.test.ts index 781172cd34..815810a9e0 100644 --- a/tests/boundary/browser/historicalWebCapture.test.ts +++ b/tests/boundary/browser/historicalWebCapture.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { mkdtemp, writeFile, rm, readFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { expect, it, onTestFinished } from "vitest"; @@ -44,6 +44,66 @@ const cli = async ( }; import { historicalHar } from "../../fixtures/historicalHar.js"; +it.each([ + { encoded: "1e3", literal: "1000" }, + { encoded: "0.0000001", literal: "1e-7" }, +])( + "excludes normalized HAR numbers through CLI and MCP: $encoded", + async ({ encoded, literal }) => { + const root = await createTestTempDirectory("rea-har-normalized-number-"); + const path = join(root, "capture.har"); + const original = JSON.stringify(historicalHar()) + .replace('"log":{', `"_number":${encoded},"log":{`) + .replace('"cache":{}', `"cache":{},"_number":${encoded}`); + await writeFile(path, original); + const { call } = await connectLocalToolsMcp(); + for (const marked of [false, true]) { + const fromCli = await cli( + path, + "har", + marked ? ["--sensitive-value", literal] : [], + ); + expect(fromCli.ok, fromCli.stdout).toBe(true); + const evidence = parseEvidence(JSON.parse(fromCli.stdout)); + const response = await call("inspect_web_network_capture", { + capture_path: path, + format: "har", + ...(marked ? { sensitive_values: [literal] } : {}), + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const mcp = parseEvidence(response.structuredContent); + expect(mcp.evidence_id).toBe(evidence.evidence_id); + expect(mcp.normalized_result).toEqual(evidence.normalized_result); + const capture = webNetworkCaptureSchema.parse(evidence.normalized_result); + for (const projection of [capture.container, ...capture.records]) { + expect(projection.reported).toMatchObject({ + _number: marked ? null : Number(encoded), + }); + const numbers = projection.numeric_literals.filter( + (number) => number.pointer === "/_number", + ); + expect(numbers).toEqual( + marked + ? [] + : [ + { + pointer: "/_number", + producer_type: "json-number", + literal: encoded, + }, + ], + ); + if (marked) + expect(projection.redactions).toContainEqual({ + pointer: "/_number", + reason: "explicit-sensitive-value", + }); + } + } + expect(await readFile(path, "utf8")).toBe(original); + }, +); + it("excludes declared binary lengths through CLI, MCP and retained capture pages", async () => { const root = await createTestTempDirectory("rea-har-byte-length-"); const path = join(root, "capture.har"); diff --git a/tests/boundary/browser/webCaptureDiff.test.ts b/tests/boundary/browser/webCaptureDiff.test.ts index 99f347c815..4d372650e3 100644 --- a/tests/boundary/browser/webCaptureDiff.test.ts +++ b/tests/boundary/browser/webCaptureDiff.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; import type { WebPageInspection } from "../../../src/domain/browserObservationSchemas.js"; +import { discoverWebMcpToolsInputSchema } from "../../../src/domain/webMcpDiscovery.js"; import { compareWebCaptures } from "../../../src/domain/webCaptureDiff.js"; import { compareWebCapturesInputSchema, @@ -202,6 +203,64 @@ afterEach(async () => { }); describe("web capture diff", () => { + it.each([ + { missing: "before", changed: false, expected: "unknown" }, + { missing: "after", changed: false, expected: "unknown" }, + { missing: "before", changed: true, expected: "changed" }, + { missing: "after", changed: true, expected: "changed" }, + { missing: "neither", changed: false, expected: "changed" }, + ] as const)( + "preserves consequential uncertainty: $missing, other change $changed", + async ({ missing, changed, expected }) => { + const browser = await startFakeCdpBrowser({ webMcpTools: true }); + browsers.push(browser); + const provider = new CdpBrowserProvider(); + const request = { + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 0, + }; + const inspection = await provider.inspectPage( + inspectWebPageInputSchema.parse(request), + ); + const discovery = await provider.discoverWebMcpTools( + discoverWebMcpToolsInputSchema.parse(request), + ); + if (!inspection.ok) throw inspection.error; + if (!discovery.ok) throw discovery.error; + const before = structuredClone(discovery.value); + const after = structuredClone(discovery.value); + markSectionsComplete(before, ["webmcp_tools"]); + markSectionsComplete(after, ["webmcp_tools"]); + const left = before.tools.items[0]; + const right = after.tools.items[0]; + if (left === undefined || right === undefined) + throw new Error("missing WebMCP registration fixture"); + left.annotations.consequential = true; + right.annotations.consequential = false; + if (missing === "before") delete left.annotations.consequential; + if (missing === "after") delete right.annotations.consequential; + if (changed) right.description += " changed"; + const result = compareWebCaptures( + compareWebCapturesInputSchema.parse({ + before: { inspection: inspection.value, webmcp: before }, + after: { inspection: inspection.value, webmcp: after }, + }), + ); + expect(result.dimensions.webmcp).toMatchObject({ + status: expected, + total_changes: expected === "changed" ? 1 : 0, + }); + if (expected === "changed") + expect(result.dimensions.webmcp.changes[0]).toMatchObject({ + change: "modified", + }); + }, + ); +}); + +describe("web capture dimension uncertainty", () => { it("reports stable observed changes while preserving unknown dimensions", async () => { const browser = await startFakeCdpBrowser(); browsers.push(browser); diff --git a/tests/boundary/browser/webSourceMapFetcher.test.ts b/tests/boundary/browser/webSourceMapFetcher.test.ts index b5a15e1208..84ef1506fe 100644 --- a/tests/boundary/browser/webSourceMapFetcher.test.ts +++ b/tests/boundary/browser/webSourceMapFetcher.test.ts @@ -1115,6 +1115,34 @@ describe("source-map dependency coverage of hard-to-parse sources", () => { ).toEqual(expected); }); + it("selects parser mode from the original source URL pathname", async () => { + const result = await fetchWebSourceMaps([request], input(), undefined, { + fetch: () => + Promise.resolve( + new Response( + JSON.stringify({ + version: 3, + names: [], + sources: ["main.ts?revision=2#view.tsx"], + sourcesContent: [`import "./dep.ts"; const value = 1;`], + mappings: "AAAA", + }), + ), + ), + }); + expect(result.items[0]?.status).toBe("included"); + expect(result.items[0]?.limitation).toBeNull(); + expect(result.items[0]?.original_sources[0]?.source).toBe( + `${origin}/assets/main.ts?revision=2#view.tsx`, + ); + expect(result.items[0]?.original_module_edges).toEqual([ + expect.objectContaining({ + specifier: "./dep.ts", + resolved_source: `${origin}/assets/dep.ts`, + }), + ]); + }); + it("keeps recovered edges from a partly recovered source and reports them as partial", async () => { const result = await fetchWithSources([ 'with (scope) { require("./c.js"); }\nrequire("./b.js");\nimport "./a.js";', diff --git a/tests/boundary/cli/browserCli.test.ts b/tests/boundary/cli/browserCli.test.ts index 351d075125..56daccafb1 100644 --- a/tests/boundary/cli/browserCli.test.ts +++ b/tests/boundary/cli/browserCli.test.ts @@ -411,7 +411,14 @@ describe("browser CLI capture parity", () => { operation: "discover_webmcp_tools", normalized_result: { tools: { - items: [expect.objectContaining({ name: "search_orders" })], + items: [ + expect.objectContaining({ + name: "search_orders", + annotations: expect.objectContaining({ + consequential: true, + }), + }), + ], }, }, }); diff --git a/tests/fixtures/fakeCdpBrowserEvents.ts b/tests/fixtures/fakeCdpBrowserEvents.ts index 39a0881720..294c2ad567 100644 --- a/tests/fixtures/fakeCdpBrowserEvents.ts +++ b/tests/fixtures/fakeCdpBrowserEvents.ts @@ -16,6 +16,13 @@ export const emitEvents = ( emitCaptureNavigation(socket, command, port, options); }; +const webMcpToolAnnotations = { + readOnly: true, + untrustedContent: true, + consequential: true, + autosubmit: false, +}; + const emitWebMcpEvents = ( socket: WebSocket, command: FakeCdpCommand, @@ -29,142 +36,160 @@ const emitWebMcpEvents = ( emitSameUrlWebMcpRegistrations(socket, command, options); return; } - if (command.method === "WebMCP.enable" && options.webMcpTools === true) { - const inputSchemaProperties = - options.webMcpSchemaPropertyCount === undefined - ? { - orderId: { type: "string", example: "schema-secret" }, - includeItems: { type: "boolean", default: true }, - } - : Object.fromEntries( - Array.from( - { length: options.webMcpSchemaPropertyCount }, - (_value, index) => [`field_${String(index)}`, { type: "string" }], - ), - ); + if (command.method !== "WebMCP.enable" || options.webMcpTools !== true) + return; + emitMainWebMcpTools(socket, command, port, options); + emitChildWebMcpNavigations(socket, command, port, options); +}; + +const emitMainWebMcpTools = ( + socket: WebSocket, + command: FakeCdpCommand, + port: number, + options: FakeOptions, +): void => { + const inputSchemaProperties = + options.webMcpSchemaPropertyCount === undefined + ? { + orderId: { type: "string", example: "schema-secret" }, + includeItems: { type: "boolean", default: true }, + } + : Object.fromEntries( + Array.from( + { length: options.webMcpSchemaPropertyCount }, + (_value, index) => [`field_${String(index)}`, { type: "string" }], + ), + ); + event(socket, "WebMCP.toolsAdded", command.sessionId, { + tools: [ + { + name: "search_orders", + description: "Search orders; authorization=Bearer tool-secret", + frameId: "frame-main", + backendNodeId: 42, + inputSchema: options.webMcpInputSchema ?? { + type: "object", + properties: inputSchemaProperties, + required: ["orderId"], + }, + annotations: { + ...webMcpToolAnnotations, + consequential: Object.hasOwn(options, "webMcpConsequential") + ? options.webMcpConsequential + : true, + }, + stackTrace: { + callFrames: [ + { + url: `http://127.0.0.1:${String(port)}/app.js?token=tool-source-secret`, + lineNumber: 12, + columnNumber: 4, + }, + ], + }, + }, + { + name: "private_tool", + description: "private-tool-secret", + frameId: "frame-private", + }, + ...(options.extraCollections === true + ? [ + { + name: "update_order", + description: "Update an order", + frameId: "frame-main", + inputSchema: { + type: "object", + properties: { orderId: { type: "string" } }, + }, + }, + ] + : []), + ...(options.webMcpFrameCount !== undefined && options.webMcpFrameCount > 0 + ? [ + { + name: "beyond_frame_limit_tool", + description: "Registered in an in-scope frame after index 999", + frameId: `webmcp-frame-${String(options.webMcpFrameCount - 1)}`, + }, + ] + : []), + ...(options.webMcpChildLeavesScope === true || + options.webMcpChildTransientBlank === true + ? [ + { + name: "child_tool", + description: "Must be removed after child navigation", + frameId: "frame-child", + }, + ] + : []), + ], + }); +}; + +const emitChildWebMcpNavigations = ( + socket: WebSocket, + command: FakeCdpCommand, + port: number, + options: FakeOptions, +): void => { + if (options.webMcpChildLeavesScope === true) { + event(socket, "Page.frameNavigated", command.sessionId, { + frame: { + id: "frame-child", + parentId: "frame-main", + url: "https://private.example.test/escaped", + }, + }); event(socket, "WebMCP.toolsAdded", command.sessionId, { tools: [ { - name: "search_orders", - description: "Search orders; authorization=Bearer tool-secret", - frameId: "frame-main", - backendNodeId: 42, - inputSchema: options.webMcpInputSchema ?? { - type: "object", - properties: inputSchemaProperties, - required: ["orderId"], - }, - annotations: { - readOnly: true, - untrustedContent: true, - autosubmit: false, - }, - stackTrace: { - callFrames: [ - { - url: `http://127.0.0.1:${String(port)}/app.js?token=tool-source-secret`, - lineNumber: 12, - columnNumber: 4, - }, - ], - }, + name: "escaped_child_tool", + description: "cross-origin-child-secret", + frameId: "frame-child", }, - { - name: "private_tool", - description: "private-tool-secret", - frameId: "frame-private", - }, - ...(options.extraCollections === true - ? [ - { - name: "update_order", - description: "Update an order", - frameId: "frame-main", - inputSchema: { - type: "object", - properties: { orderId: { type: "string" } }, - }, - }, - ] - : []), - ...(options.webMcpFrameCount !== undefined && - options.webMcpFrameCount > 0 - ? [ - { - name: "beyond_frame_limit_tool", - description: "Registered in an in-scope frame after index 999", - frameId: `webmcp-frame-${String(options.webMcpFrameCount - 1)}`, - }, - ] - : []), - ...(options.webMcpChildLeavesScope === true || - options.webMcpChildTransientBlank === true - ? [ - { - name: "child_tool", - description: "Must be removed after child navigation", - frameId: "frame-child", - }, - ] - : []), ], }); - if (options.webMcpChildLeavesScope === true) { - event(socket, "Page.frameNavigated", command.sessionId, { - frame: { - id: "frame-child", - parentId: "frame-main", - url: "https://private.example.test/escaped", - }, - }); - event(socket, "WebMCP.toolsAdded", command.sessionId, { - tools: [ - { - name: "escaped_child_tool", - description: "cross-origin-child-secret", - frameId: "frame-child", - }, - ], - }); - } - if (options.webMcpChildTransientBlank === true) - event(socket, "Page.frameNavigated", command.sessionId, { - frame: { - id: "frame-child", - parentId: "frame-main", - loaderId: "loader-transient", - url: "about:blank", - }, - }); - if (options.webMcpChildRecoversAfterTransient === true) { - event(socket, "Page.frameNavigated", command.sessionId, { - frame: { - id: "frame-child", - parentId: "frame-main", - loaderId: "loader-child-recovered", - url: `http://127.0.0.1:${String(port)}/child-recovered`, - }, - }); - event(socket, "WebMCP.toolsAdded", command.sessionId, { - tools: [ - { - name: "child_tool", - description: "Replayed from the current child document", - frameId: "frame-child", - }, - ], - }); - } - if (options.webMcpChildNavigatesAllowed === true) - event(socket, "Page.frameNavigated", command.sessionId, { - frame: { - id: "frame-child", - parentId: "frame-main", - loaderId: "loader-child-replacement", - url: `http://127.0.0.1:${String(port)}/child-replacement`, - }, - }); } + if (options.webMcpChildTransientBlank === true) + event(socket, "Page.frameNavigated", command.sessionId, { + frame: { + id: "frame-child", + parentId: "frame-main", + loaderId: "loader-transient", + url: "about:blank", + }, + }); + if (options.webMcpChildRecoversAfterTransient === true) { + event(socket, "Page.frameNavigated", command.sessionId, { + frame: { + id: "frame-child", + parentId: "frame-main", + loaderId: "loader-child-recovered", + url: `http://127.0.0.1:${String(port)}/child-recovered`, + }, + }); + event(socket, "WebMCP.toolsAdded", command.sessionId, { + tools: [ + { + name: "child_tool", + description: "Replayed from the current child document", + frameId: "frame-child", + }, + ], + }); + } + if (options.webMcpChildNavigatesAllowed === true) + event(socket, "Page.frameNavigated", command.sessionId, { + frame: { + id: "frame-child", + parentId: "frame-main", + loaderId: "loader-child-replacement", + url: `http://127.0.0.1:${String(port)}/child-replacement`, + }, + }); }; const emitSameUrlWebMcpRegistrations = ( diff --git a/tests/fixtures/fakeCdpBrowserTypes.ts b/tests/fixtures/fakeCdpBrowserTypes.ts index e75e0dc482..69a3c8ec74 100644 --- a/tests/fixtures/fakeCdpBrowserTypes.ts +++ b/tests/fixtures/fakeCdpBrowserTypes.ts @@ -107,6 +107,7 @@ export interface FakeOptions { readonly cachedResponseBody?: string; readonly invalidResponseBodyBase64?: boolean; readonly webMcpTools?: boolean; + readonly webMcpConsequential?: JsonValue; readonly webMcpSameUrlRegistrations?: "retain" | "remove-second"; readonly webMcpFrameCount?: number; readonly webMcpSchemaPropertyCount?: number; diff --git a/tests/fixtures/webScriptCapture.ts b/tests/fixtures/webScriptCapture.ts index 0c6318531c..f7c021e665 100644 --- a/tests/fixtures/webScriptCapture.ts +++ b/tests/fixtures/webScriptCapture.ts @@ -19,6 +19,7 @@ export const scriptScenarioFixture = ( const request = { transaction_id: `request-${index + 1}`, redirected_from_transaction_id: null, + redirected_from_transaction_state: "root", method: "GET", url: sanitizeBrowserUrl(url), resource_type: "script",