diff --git a/.claude/rules/development.md b/.claude/rules/development.md index 88ae0b5..fa7cf8d 100644 --- a/.claude/rules/development.md +++ b/.claude/rules/development.md @@ -47,6 +47,10 @@ real browser. Proxy credentials are read only through a slot's named environment and never enter a manifest or log. Dotenv interpolation may derive routed proxy URLs from one secret key without mutating the process environment. A selected proxy context may accept that proxy's TLS interception certificate; direct contexts keep normal TLS checks. +ThorData rotating endpoints are pinned to one manifest-derived sticky session for the +life of a Web batch so one browser context does not change exit identity between page +requests. This is route continuity, not account or IP failover; a refusal still ends on +the selected slot and never selects another route automatically. Upload readiness is surface-specific: OpenAI reaches it at page load but keeps background requests alive, while Microsoft and Meta require network idle. Preserve exact provider errors as indeterminate evidence rather than retrying or interpreting them as clean. diff --git a/.claude/skills/provider-oracles/SKILL.md b/.claude/skills/provider-oracles/SKILL.md index 41f2fba..0005d17 100644 --- a/.claude/skills/provider-oracles/SKILL.md +++ b/.claude/skills/provider-oracles/SKILL.md @@ -88,6 +88,14 @@ OpenAI, Microsoft, and Meta Web slots may each name one of them through verify egress before relying on it, and never automatically change route after a refusal. Bound a batch before starting. +ThorData user-and-password endpoints without `sessid` rotate the exit on each +request, which breaks a browser page whose resources must share one network +identity. The runner therefore derives one sticky `sessid` from the immutable +manifest hash and keeps it for that batch. A new prepared batch gets a new +session; rerunning or resuming the same batch keeps its session while the proxy +retains it. An explicit `sessid` in the configured route is preserved. This is +session continuity, not permission to retry a refused batch on another route. + ### OpenAI API Use `check-openai` with one explicit upload acknowledgement. Keep named API keys diff --git a/.env.example b/.env.example index a418ca3..2c05aaf 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,7 @@ # ThorData residential proxies (optional; only for explicitly selected Web oracle routes) # Generate these URLs in the ThorData endpoint generator; do not commit credentials. +# The Web runner pins a rotating user-and-password route to one sticky session per manifest. # THORDATA_ROUTE_US=http://td-customer-USERNAME-country-US:PASSWORD@HOST.pr.thordata.net:9999 # THORDATA_ROUTE_DE=http://td-customer-USERNAME-country-DE:PASSWORD@HOST.pr.thordata.net:9999 # THORDATA_ROUTE_GB=http://td-customer-USERNAME-country-GB:PASSWORD@HOST.pr.thordata.net:9999 diff --git a/docs/provider-oracles.md b/docs/provider-oracles.md index 53671f5..38fb19c 100644 --- a/docs/provider-oracles.md +++ b/docs/provider-oracles.md @@ -386,6 +386,16 @@ navigation before each upload, does not retry, and stores the settled page text. Each completed verdict is persisted immediately with its own observation timestamp. A later navigation or upload failure preserves those completed rows and leaves unobserved rows unresolved. +ThorData [user-and-password endpoints without a `sessid` rotate the exit on each +request](https://doc.thordata.com/doc/proxies/residential-proxies/session-control). +That is unsuitable inside one browser context: page resources can +arrive through different exits and trigger the provider's bot checks. For a +ThorData hostname, the runner derives a non-secret sticky session id from the +immutable manifest hash and appends a 90-minute session window when the +configured username has no explicit session. The same batch therefore keeps +one route while the provider retains it, a new prepared batch gets a distinct +route, and an operator-supplied `sessid` remains unchanged. This pins a selected +route; it does not retry or fail over after a refusal. For a selected proxy route it enables certificate handling in that isolated Chromium launch and context; direct launches keep Playwright's normal TLS verification. A 2026-09-07 preflight sent one read-only request through each @@ -423,6 +433,17 @@ for the same processed source hash. Keep each route in its own immutable batch; do not splice an isolated-runner failure and a first-party browser or API verdict into one response. +On 2026-09-16, a rotating ThorData browser batch demonstrated the failure mode: +one upload reached OpenAI's generic error, four encountered Cloudflare, and ten +became unreachable after the browser context closed. Five fresh connections to +the same rotating endpoint had five distinct exits, while five connections with +one sticky session had one exit. After session pinning, one direct route and +five independently prepared country routes all reached the upload handler with +the same prepared test image, but every route returned OpenAI's exact generic +error. Record those responses as `indeterminate`. The result distinguishes the +fixed session-continuity defect from the remaining anonymous-Web availability +problem; further route changes are not a substitute for an API verdict. + ## OpenAI API The one automated path calls the hardened metadata-independent adapter already diff --git a/scripts/provider_oracle_web.py b/scripts/provider_oracle_web.py index e5d05c2..f372620 100644 --- a/scripts/provider_oracle_web.py +++ b/scripts/provider_oracle_web.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging import os +import re from dataclasses import dataclass from datetime import UTC, datetime from time import monotonic @@ -125,8 +126,10 @@ def parse_provider_result(surface: str, page_text: str) -> WebVerdict: def proxy_settings( slot: oracles.ExecutionSlot, environ: Mapping[str, str] = os.environ, + *, + sticky_session_id: str | None = None, ) -> ProxySettings | None: - """Resolve a secret proxy URL without putting it in a manifest or log.""" + """Resolve a secret proxy URL and pin rotating ThorData routes per batch.""" variable = slot.get("proxy_url_env") if variable is None: return None @@ -142,7 +145,13 @@ def proxy_settings( server = f"{server}:{parsed.port}" settings: ProxySettings = {"server": server} if parsed.username is not None: - settings["username"] = unquote(parsed.username) + username = unquote(parsed.username) + is_thordata = parsed.hostname.endswith((".thordata.net", ".thordata.online")) + if is_thordata and sticky_session_id is not None and "-sessid-" not in username.casefold(): + if re.fullmatch(r"[A-Za-z0-9]{1,32}", sticky_session_id) is None: + raise ValueError("sticky_session_id must be 1-32 ASCII letters or digits") + username = f"{username}-sessid-{sticky_session_id}-sesstime-90" + settings["username"] = username if parsed.password is not None: settings["password"] = unquote(parsed.password) return settings @@ -287,7 +296,11 @@ def run_web_batch( if slot is None: raise ValueError("Playwright batches require an explicit execution slot") secret_values = dict(environ) if env_file is None else oracles.secret_environment(env_file, environ=environ) - proxy = proxy_settings(slot, secret_values) + proxy = proxy_settings( + slot, + secret_values, + sticky_session_id=f"oracle{results['manifest_sha256'][:16]}", + ) pending = [ (manifest_row, result_row) for manifest_row, result_row in zip(manifest["rows"], results["rows"], strict=True) diff --git a/tests/test_evaluation_audit_regressions.py b/tests/test_evaluation_audit_regressions.py index 4b87ed8..e03ad1b 100644 --- a/tests/test_evaluation_audit_regressions.py +++ b/tests/test_evaluation_audit_regressions.py @@ -80,11 +80,14 @@ def test_web_results_survive_later_browser_failure(monkeypatch, tmp_path): "slot": {}, "rows": [{"artifact_id": "first", "upload_path": "one"}, {"artifact_id": "second", "upload_path": "two"}], } - results = {"rows": [{"watermark_result": None}, {"watermark_result": None}]} + results = { + "manifest_sha256": "0" * 64, + "rows": [{"watermark_result": None}, {"watermark_result": None}], + } saved = [] monkeypatch.setattr(web.oracles, "verify_batch", lambda *a, **k: {}) monkeypatch.setattr(web.oracles, "load_batch", lambda *a: (manifest, results)) - monkeypatch.setattr(web, "proxy_settings", lambda *a: None) + monkeypatch.setattr(web, "proxy_settings", lambda *a, **k: None) monkeypatch.setattr(web.oracles, "record_result", lambda *a, **k: saved.append(k)) def submit(*args, **kwargs): diff --git a/tests/test_provider_oracle_web.py b/tests/test_provider_oracle_web.py index 0fbeb83..51d3ef0 100644 --- a/tests/test_provider_oracle_web.py +++ b/tests/test_provider_oracle_web.py @@ -13,6 +13,18 @@ if TYPE_CHECKING: from pathlib import Path +def _thordata_slot() -> oracles.ExecutionSlot: + return { + "name": "openai-thordata-us", + "surface": "openai-web", + "account_label": None, + "browser_profile": None, + "google_account_index": None, + "network_label": "thordata-us-residential", + "proxy_url_env": "THORDATA_ROUTE_US", + } + + @pytest.mark.parametrize( ("surface", "page_text", "watermark", "provenance"), [ @@ -171,6 +183,31 @@ def test_proxy_settings_are_loaded_from_an_environment_reference() -> None: } +def test_thordata_proxy_settings_pin_one_exit_for_the_browser_session() -> None: + proxy = web.proxy_settings( + _thordata_slot(), + {"THORDATA_ROUTE_US": "http://route:secret@gateway.pr.thordata.net:9999"}, + sticky_session_id="oracle0123456789abcdef", + ) + + assert proxy == { + "server": "http://gateway.pr.thordata.net:9999", + "username": "route-sessid-oracle0123456789abcdef-sesstime-90", + "password": "secret", + } + + +def test_thordata_proxy_settings_preserve_an_explicit_session() -> None: + proxy = web.proxy_settings( + _thordata_slot(), + {"THORDATA_ROUTE_US": ("http://route-sessid-manual-sesstime-15:secret@gateway.pr.thordata.net:9999")}, + sticky_session_id="oracle0123456789abcdef", + ) + + assert proxy is not None + assert proxy["username"] == "route-sessid-manual-sesstime-15" + + def test_proxy_settings_do_not_treat_a_missing_secret_as_direct_access() -> None: slot: oracles.ExecutionSlot = { "name": "openai-west", @@ -290,27 +327,18 @@ def test_run_web_batch_loads_the_selected_proxy_from_dotenv( tmp_path: Path, tmp_clean_png: Path, ) -> None: - slot: oracles.ExecutionSlot = { - "name": "openai-thordata-us", - "surface": "openai-web", - "account_label": None, - "browser_profile": None, - "google_account_index": None, - "network_label": "thordata-us-residential", - "proxy_url_env": "THORDATA_ROUTE_US", - } manifest_path = oracles.prepare_batch( "openai-web", [tmp_clean_png], output_dir=tmp_path / "oracle-batch", repository_root=oracles.REPOSITORY_ROOT, - slot=slot, + slot=_thordata_slot(), ) env_path = tmp_path / ".env" env_path.write_text( "THORDATA_PASSWORD=test-thordata-password\n" "THORDATA_ROUTE_US=http://td-customer-user-country-US:${THORDATA_PASSWORD}" - "@proxy.thordata.test:9999\n", + "@proxy.pr.thordata.net:9999\n", encoding="utf-8", ) proxies: list[dict[str, str] | None] = [] @@ -339,10 +367,11 @@ def test_run_web_batch_loads_the_selected_proxy_from_dotenv( ) assert report["watermark_results"] == {"not_detected": 1} + manifest_sha256 = json.loads((manifest_path.parent / "results.json").read_text(encoding="utf-8"))["manifest_sha256"] assert proxies == [ { - "server": "http://proxy.thordata.test:9999", - "username": "td-customer-user-country-US", + "server": "http://proxy.pr.thordata.net:9999", + "username": (f"td-customer-user-country-US-sessid-oracle{manifest_sha256[:16]}-sesstime-90"), "password": "test-thordata-password", } ]