From 27dd9794fcfee737f53ddb541d3b8785f4fbb338 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:15:25 +0000 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'docs/'?= =?UTF-8?q?=20with=20remote=20'docs/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/224 --- docs/PARITY.md | 4 +- docs/REMOTE_BROWSERS.md | 491 +++++++ docs/remote-browser/LICENSE.chrome-seccomp | 20 + docs/remote-browser/chrome-seccomp.json | 1535 ++++++++++++++++++++ 4 files changed, 2048 insertions(+), 2 deletions(-) create mode 100644 docs/REMOTE_BROWSERS.md create mode 100644 docs/remote-browser/LICENSE.chrome-seccomp create mode 100644 docs/remote-browser/chrome-seccomp.json diff --git a/docs/PARITY.md b/docs/PARITY.md index 9709d11..081491b 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -40,8 +40,8 @@ Legend: ✅ present and statically resolved in a registered parity case; 🟡 pr | `Playwright.stop` | method | 🟡 present, not exercised | 🟡 present, not exercised | | `Playwright.webkit` | property | 🟡 present, not exercised | 🟡 present, not exercised | | `PlaywrightContextManager.start` | method | 🟡 present, not exercised | 🟡 present, not exercised | -| `BrowserType.connect` | method | 🟡 present, not exercised | 🟡 present, not exercised | -| `BrowserType.connect_over_cdp` | method | ✅ present + exercised | 🟡 present, not exercised | +| `BrowserType.connect` | method | 🟡 present, intentionally unsupported | 🟡 present, intentionally unsupported | +| `BrowserType.connect_over_cdp` | method | ✅ present + exercised | ✅ present + exercised | | `BrowserType.executable_path` | property | ✅ present + exercised | 🟡 present, not exercised | | `BrowserType.launch` | method | ✅ present + exercised | 🟡 present, not exercised | | `BrowserType.launch_persistent_context` | method | ✅ present + exercised | 🟡 present, not exercised | diff --git a/docs/REMOTE_BROWSERS.md b/docs/REMOTE_BROWSERS.md new file mode 100644 index 0000000..7ceb62c --- /dev/null +++ b/docs/REMOTE_BROWSERS.md @@ -0,0 +1,491 @@ +# Remote browsers + +Rustwright supports remote Chromium through raw Chrome DevTools Protocol (CDP). +Use `chromium.connect_over_cdp()` with an HTTP discovery URL or a direct CDP +WebSocket URL. Rustwright does not implement Playwright's internal wire protocol. + +## API contract + +```python +from rustwright.sync_api import sync_playwright + +with sync_playwright() as playwright: + browser = playwright.chromium.connect_over_cdp("http://browser:9222") +``` + +```python +from rustwright.async_api import async_playwright + +async with async_playwright() as playwright: + browser = await playwright.chromium.connect_over_cdp("http://browser:9222") +``` + +`BrowserType.connect()` is intentionally unsupported. It rejects every endpoint +before network I/O. This is a breaking change for Rustwright 0.3.0. Replace: + +```python +browser = playwright.chromium.connect(endpoint) +``` + +with: + +```python +browser = playwright.chromium.connect_over_cdp(endpoint) +``` + +The remote service must expose raw Chromium CDP. Endpoints from +`playwright run-server` and `BrowserType.launchServer()` use the Playwright wire +protocol and are unsupported. Direct WebSocket protocol detection is heuristic. +Use an HTTP discovery URL when you need a definitive protocol diagnosis. + +For HTTP endpoints, Rustwright requests `/json/version` first. It uses a valid +`webSocketDebuggerUrl` from that response. Otherwise, it requests `/json` only +to detect Playwright's `wsEndpointPath`. Both requests share one connection +deadline and receive the caller's headers. + +## Open WebUI migration + +Change both Open WebUI loader calls: + +```python +browser = p.chromium.connect_over_cdp(self.playwright_ws_url) +browser = await p.chromium.connect_over_cdp(self.playwright_ws_url) +``` + +Use this demo-grade compose profile. The image runs as root and its default +entrypoint starts Chrome with `--no-sandbox`. Do not use this default profile as +a production security claim. + +```yaml +services: + playwright: + image: docker.io/chromedp/headless-shell:148.0.7778.96@sha256:9ca10461026046ce0e304b9d6e0460257ea60d7d77987f0659562c0e29779c4d + init: true + shm_size: "2gb" + expose: + - "9222" + healthcheck: + test: + [ + "CMD", + "/bin/bash", + "-c", + "exec 3<>/dev/tcp/127.0.0.1/9222 && printf 'GET /json/version HTTP/1.0\r\nHost: localhost\r\n\r\n' >&3 && IFS= read -r status <&3 && [[ "$$status" == *' 200 '* ]]", + ] + interval: 2s + timeout: 2s + retries: 30 + start_period: 5s + networks: + - remote-browser + + open-webui: + depends_on: + playwright: + condition: service_healthy + environment: + - "WEB_LOADER_ENGINE=playwright" + - "PLAYWRIGHT_WS_URL=http://playwright:9222" + networks: + - remote-browser + +networks: + remote-browser: +``` + +The profile does not publish CDP to the host. The HTTP URL is intentional. +Rustwright reads the advertised browser WebSocket from `/json/version`. + +## Production-evaluation variant + +The following override removes `--no-sandbox` and runs the image as an +unprivileged user. It remains an evaluation candidate until the seccomp and +native-architecture gates below pass. + +```yaml +services: + playwright: + user: "65534:65534" + entrypoint: ["/headless-shell/headless-shell"] + command: + - "--remote-debugging-address=0.0.0.0" + - "--remote-debugging-port=9222" + - "--disable-gpu" + - "--enable-unsafe-swiftshader" + - "--headless" + security_opt: + - "seccomp=./docs/remote-browser/chrome-seccomp.json" + cap_drop: + - ALL + read_only: true + tmpfs: + - "/tmp:size=512m,mode=1777" +``` + +The vendored `docs/remote-browser/chrome-seccomp.json` comes from +`jfrazelle/dotfiles` commit `94c5f2bc4178d4000ff6f1ae5cc585799ef25d37`, +file `etc/docker/seccomp/chrome.json`. Jessie Frazelle published the source +under the MIT License. The complete notice is preserved in +[`docs/remote-browser/LICENSE.chrome-seccomp`](remote-browser/LICENSE.chrome-seccomp). +The immutable source is +. +The allowlist still requires review against the pinned browser before a +production claim. + +Keep CDP on a private network. Deny access to cloud metadata and internal +control planes. Restrict browser egress where possible. Use a disposable, +single-tenant browser container. Put authentication in front of any intentional +public exposure. User-supplied URLs still create browser and SSRF risk. + +## Compose verification gate + +**VERIFICATION-PENDING.** No container was launched for this change. Run this +complete, fail-closed verifier on native Linux. Do not split it into manual +steps or continue after a failure. + +Required inputs: + +- `RUSTWRIGHT_ROOT`: the Rustwright checkout with this change. +- `OPEN_WEBUI_ROOT`: an Open WebUI checkout with both loader calls migrated. +- `RW_PLATFORM`: exactly `linux/arm64` or `linux/amd64`, matching the host. +- `RW_OPEN_WEBUI_IMAGE`: a pinned Open WebUI image digest whose installed + dependencies match the checkout. + +```bash +#!/usr/bin/env bash +set -euo pipefail + +: "${RUSTWRIGHT_ROOT:?set RUSTWRIGHT_ROOT to the Rustwright checkout}" +: "${OPEN_WEBUI_ROOT:?set OPEN_WEBUI_ROOT to the revised Open WebUI checkout}" +: "${RW_PLATFORM:?set RW_PLATFORM to linux/arm64 or linux/amd64}" +: "${RW_OPEN_WEBUI_IMAGE:?set RW_OPEN_WEBUI_IMAGE to a pinned image digest}" +[[ "$RW_OPEN_WEBUI_IMAGE" == *@sha256:* ]] + +RW_IMAGE='docker.io/chromedp/headless-shell:148.0.7778.96@sha256:9ca10461026046ce0e304b9d6e0460257ea60d7d77987f0659562c0e29779c4d' +RW_ROOT="$(cd "$RUSTWRIGHT_ROOT" && pwd -P)" +OWUI_ROOT="$(cd "$OPEN_WEBUI_ROOT" && pwd -P)" + +case "$RW_PLATFORM:$(uname -m)" in + linux/arm64:arm64|linux/arm64:aarch64|linux/amd64:x86_64|linux/amd64:amd64) ;; + *) + printf 'RW_PLATFORM must match the native host architecture; emulation does not close this gate\n' >&2 + exit 2 + ;; +esac + +for command_name in cargo docker python3; do + command -v "$command_name" >/dev/null +done +docker compose version >/dev/null +docker version >/dev/null + +[[ -f "$RW_ROOT/pyproject.toml" ]] +[[ -f "$RW_ROOT/docs/remote-browser/chrome-seccomp.json" ]] +[[ -f "$OWUI_ROOT/pyproject.toml" ]] +[[ -f "$OWUI_ROOT/backend/open_webui/retrieval/web/utils.py" ]] + +PROJECT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/rw-connect-verifier.XXXXXX")" +PROJECT_NAME="rw-connect-verifier-$$" +ACTIVE_PROJECT='' +ACTIVE_FILES=() + +compose() { + docker compose --project-name "$ACTIVE_PROJECT" "${ACTIVE_FILES[@]}" "$@" +} + +cleanup() { + status=$? + trap - EXIT INT TERM + set +e + if [[ -n "$ACTIVE_PROJECT" && ${#ACTIVE_FILES[@]} -gt 0 ]]; then + compose down --volumes --remove-orphans + fi + rm -rf -- "$PROJECT_DIR" + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +BUILD_VENV="$PROJECT_DIR/rustwright-build-venv" +WHEEL_DIR="$PROJECT_DIR/wheels" +python3 -m venv "$BUILD_VENV" +"$BUILD_VENV/bin/python" -m pip install --disable-pip-version-check 'maturin>=1.7,<2' +mkdir -p "$WHEEL_DIR" +( + cd "$RW_ROOT" + "$BUILD_VENV/bin/maturin" build --release --out "$WHEEL_DIR" +) +wheels=("$WHEEL_DIR"/rustwright-*.whl) +[[ ${#wheels[@]} -eq 1 && -f "${wheels[0]}" ]] + +# Compose resolves seccomp paths from the first compose file's directory. +# Copy the vendored profile into that project so the daemon can load it. +cp "$RW_ROOT/docs/remote-browser/chrome-seccomp.json" "$PROJECT_DIR/chrome-seccomp.json" + +cat >"$PROJECT_DIR/probe-entrypoint.sh" <<'ENTRYPOINT' +#!/usr/bin/env bash +set -euo pipefail +wheels=(/wheels/rustwright-*.whl) +[[ ${#wheels[@]} -eq 1 && -f "${wheels[0]}" ]] +PROBE_VENV=/tmp/rustwright-probe-venv +python3 -m venv --system-site-packages "$PROBE_VENV" +"$PROBE_VENV/bin/python" -m pip install --disable-pip-version-check --no-deps "${wheels[0]}" +exec "$PROBE_VENV/bin/python" /project/probe.py +ENTRYPOINT + +cat >"$PROJECT_DIR/probe.py" <<'PY' +import asyncio +import json +import socket +import urllib.request + +ENDPOINT = "http://playwright:9222" +FIXTURE_URL = "http://fixture:8000/index.html" +MARKER = "rustwright-open-webui-loader-marker" + +addresses = sorted( + { + address[4][0] + for address in socket.getaddrinfo("playwright", 9222, type=socket.SOCK_STREAM) + } +) +assert addresses, "compose hostname playwright did not resolve" +print("RESOLVED_PLAYWRIGHT", ",".join(addresses)) + +with urllib.request.urlopen(f"{ENDPOINT}/json/version", timeout=5) as response: + discovery = json.load(response) +assert discovery["webSocketDebuggerUrl"].startswith(("ws://", "wss://")) +print("HTTP_DISCOVERY", json.dumps(discovery, sort_keys=True)) + +from rustwright.async_api import async_playwright +from rustwright.sync_api import sync_playwright + +with sync_playwright() as playwright: + browser = playwright.chromium.connect_over_cdp(ENDPOINT) + page = browser.new_page() + page.goto(FIXTURE_URL) + assert page.title() == "Rustwright remote fixture" + browser.close() + assert not browser.is_connected() +print("SYNC_CONNECT_NAVIGATE_DISCONNECT ok") + + +async def direct_async_probe() -> None: + async with async_playwright() as playwright: + browser = await playwright.chromium.connect_over_cdp(ENDPOINT) + page = await browser.new_page() + await page.goto(FIXTURE_URL) + assert await page.title() == "Rustwright remote fixture" + await browser.close() + assert not browser.is_connected() + + +asyncio.run(direct_async_probe()) +print("ASYNC_CONNECT_NAVIGATE_DISCONNECT ok") + +import rustwright + +rustwright.enable_playwright_compat() +from open_webui.retrieval.web.utils import SafePlaywrightURLLoader + +sync_loader = SafePlaywrightURLLoader( + web_paths=[FIXTURE_URL], + continue_on_failure=False, + playwright_ws_url=ENDPOINT, +) +sync_documents = list(sync_loader.lazy_load()) +assert sync_documents +assert MARKER in "\n".join(document.page_content for document in sync_documents) +print("OPEN_WEBUI_SYNC_LOADER ok") + + +async def open_webui_async_probe() -> None: + loader = SafePlaywrightURLLoader( + web_paths=[FIXTURE_URL], + continue_on_failure=False, + playwright_ws_url=ENDPOINT, + ) + documents = [document async for document in loader.alazy_load()] + assert documents + assert MARKER in "\n".join(document.page_content for document in documents) + + +asyncio.run(open_webui_async_probe()) +print("OPEN_WEBUI_ASYNC_LOADER ok") +PY + +cat >"$PROJECT_DIR/compose.yaml" <<'YAML' +services: + playwright: + image: ${RW_IMAGE} + platform: ${RW_PLATFORM} + init: true + shm_size: "2gb" + expose: + - "9222" + healthcheck: + test: + [ + "CMD", + "/bin/bash", + "-c", + "exec 3<>/dev/tcp/127.0.0.1/9222 && printf 'GET /json/version HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n' >&3 && IFS= read -r status <&3 && [[ \"$$status\" == *' 200 '* ]]", + ] + interval: 2s + timeout: 2s + retries: 45 + start_period: 5s + networks: [remote-browser] + + fixture: + image: docker.io/library/python:3.11-slim-bookworm + platform: ${RW_PLATFORM} + command: + - /bin/sh + - -euc + - | + mkdir -p /tmp/site + printf '%s\n' 'Rustwright remote fixturerustwright-open-webui-loader-marker' >/tmp/site/index.html + exec python3 -m http.server 8000 --bind 0.0.0.0 --directory /tmp/site + networks: [remote-browser] + + rustwright-probe: + image: ${RW_OPEN_WEBUI_IMAGE} + platform: ${RW_PLATFORM} + init: true + environment: + ENABLE_RAG_LOCAL_WEB_FETCH: "true" + entrypoint: ["/bin/bash", "/project/probe-entrypoint.sh"] + volumes: + - type: bind + source: ${RW_WHEEL_DIR} + target: /wheels + read_only: true + - type: bind + source: ${RW_OPEN_WEBUI_LOADER} + target: /app/backend/open_webui/retrieval/web/utils.py + read_only: true + - type: bind + source: ${RW_PROJECT_DIR} + target: /project + read_only: true + networks: [remote-browser] + +networks: + remote-browser: + internal: true +YAML + +cat >"$PROJECT_DIR/compose.unprivileged.yaml" <<'YAML' +services: + playwright: + user: "65534:65534" + entrypoint: ["/headless-shell/headless-shell"] + command: + - "--remote-debugging-address=0.0.0.0" + - "--remote-debugging-port=9222" + - "--disable-gpu" + - "--enable-unsafe-swiftshader" + - "--headless" + security_opt: + - "seccomp=./chrome-seccomp.json" + cap_drop: + - ALL + read_only: true + tmpfs: + - "/tmp:size=512m,mode=1777" +YAML + +export RW_IMAGE RW_PLATFORM RW_OPEN_WEBUI_IMAGE +export RW_WHEEL_DIR="$WHEEL_DIR" +export RW_OPEN_WEBUI_LOADER="$OWUI_ROOT/backend/open_webui/retrieval/web/utils.py" +export RW_PROJECT_DIR="$PROJECT_DIR" + +docker pull --platform "$RW_PLATFORM" "$RW_IMAGE" +docker image inspect "$RW_IMAGE" --format 'IMAGE={{.Id}} OS={{.Os}} ARCH={{.Architecture}}' +expected_arch="${RW_PLATFORM#linux/}" +actual_arch="$(docker image inspect "$RW_IMAGE" --format '{{.Architecture}}')" +[[ "$actual_arch" == "$expected_arch" ]] + +run_variant() { + label="$1" + shift + ACTIVE_PROJECT="$PROJECT_NAME-$label" + ACTIVE_FILES=("$@") + compose config --quiet + compose pull fixture rustwright-probe + compose up --detach playwright fixture + + deadline=$((SECONDS + 120)) + discovery_file="$PROJECT_DIR/$label-json-version.json" + readiness_error="$PROJECT_DIR/$label-readiness.err" + while true; do + playwright_id="$(compose ps --quiet playwright 2>/dev/null || true)" + health='' + if [[ -n "$playwright_id" ]]; then + health="$(docker inspect --format '{{.State.Health.Status}}' "$playwright_id" 2>/dev/null || true)" + fi + if [[ "$health" == "healthy" ]] && + compose exec -T fixture python3 -c \ + 'import urllib.request; print(urllib.request.urlopen("http://playwright:9222/json/version", timeout=3).read().decode())' \ + >"$discovery_file" 2>"$readiness_error"; then + break + fi + if ((SECONDS >= deadline)); then + printf 'readiness timeout for %s\n' "$label" >&2 + compose ps >&2 || true + compose logs --no-color playwright fixture >&2 || true + cat "$readiness_error" >&2 || true + return 1 + fi + sleep 2 + done + + printf 'VARIANT=%s HEALTH=healthy\n' "$label" + cat "$discovery_file" + compose run --rm --no-deps rustwright-probe | tee "$PROJECT_DIR/$label-probe.log" + compose ps + playwright_id="$(compose ps --quiet playwright)" + health_json="$(docker inspect --format '{{json .State.Health}}' "$playwright_id")" + printf '%s\n' "$health_json" + final_health="$(docker inspect --format '{{.State.Health.Status}}' "$playwright_id")" + printf 'FINAL_HEALTH=%s\n' "$final_health" + [[ "$final_health" == "healthy" ]] + compose down --volumes --remove-orphans + ACTIVE_PROJECT='' + ACTIVE_FILES=() +} + +run_variant default \ + --file "$PROJECT_DIR/compose.yaml" +run_variant unprivileged \ + --file "$PROJECT_DIR/compose.yaml" \ + --file "$PROJECT_DIR/compose.unprivileged.yaml" +``` + +Runtime status: + +- Native `linux/arm64`: **VERIFICATION-PENDING**. Container execution was + prohibited on the arm64 implementation workstation. +- Native `linux/amd64`: **VERIFICATION-PENDING**. A native amd64 verifier must + run the same script with `RW_PLATFORM=linux/amd64`. +- Emulation does not close either architecture gate. + +A passing transcript must include the image OS and architecture, +`HEALTH=healthy`, `RESOLVED_PLAYWRIGHT`, `HTTP_DISCOVERY`, both +`CONNECT_NAVIGATE_DISCONNECT ok` lines, both `OPEN_WEBUI_*_LOADER ok` lines, +the full final health JSON, `FINAL_HEALTH=healthy`, and clean teardown. + +## Troubleshooting + +| Error | Meaning | Action | +|---|---|---| +| `This endpoint speaks the Playwright wire protocol, not CDP.` | Ordered HTTP discovery found `wsEndpointPath`. | Replace the service with raw Chromium CDP and use its HTTP discovery URL. | +| `The endpoint's first response resembles the Playwright wire protocol, not CDP.` | A direct WebSocket first reply had a Playwright-like nested error shape. This is not conclusive. | Use an HTTP discovery URL or confirm the service protocol. | +| `Remote browser discovery failed.` | Neither discovery route produced a valid raw-CDP URL or Playwright discovery evidence. | Check the service, base path, authentication headers, and `/json/version`. | +| `CDP connection failed` | Discovery succeeded, or a direct WebSocket was used, but the Upgrade or setup failed. | Check reachability, TLS, authentication, and whether the URL is a raw-CDP endpoint. | + +Rustwright removes URL userinfo, query data, credential headers, discovery +bodies, Upgrade rejection bodies, and nested peer text from public connection +errors. diff --git a/docs/remote-browser/LICENSE.chrome-seccomp b/docs/remote-browser/LICENSE.chrome-seccomp new file mode 100644 index 0000000..1056d36 --- /dev/null +++ b/docs/remote-browser/LICENSE.chrome-seccomp @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2015 Jessie Frazelle + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/remote-browser/chrome-seccomp.json b/docs/remote-browser/chrome-seccomp.json new file mode 100644 index 0000000..e5b0f2d --- /dev/null +++ b/docs/remote-browser/chrome-seccomp.json @@ -0,0 +1,1535 @@ +{ + "defaultAction": "SCMP_ACT_ERRNO", + "syscalls": [ + { + "name": "accept", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "accept4", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "access", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "alarm", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "arch_prctl", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "bind", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "brk", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "capget", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "capset", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "chdir", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "chmod", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "chown", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "chown32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "chroot", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "clock_getres", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "clock_gettime", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "clock_nanosleep", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "clone", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "close", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "connect", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "creat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "dup", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "dup2", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "dup3", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "epoll_create", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "epoll_create1", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "epoll_ctl", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "epoll_ctl_old", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "epoll_pwait", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "epoll_wait", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "epoll_wait_old", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "eventfd", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "eventfd2", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "execve", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "execveat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "exit", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "exit_group", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "faccessat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fadvise64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fadvise64_64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fallocate", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fanotify_init", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fanotify_mark", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fchdir", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fchmod", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fchmodat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fchown", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fchown32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fchownat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fcntl", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fcntl64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fdatasync", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fgetxattr", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "flistxattr", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "flock", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fork", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fremovexattr", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fsetxattr", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fstat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fstat64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fstatat64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fstatfs", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fstatfs64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "fsync", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "ftruncate", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "ftruncate64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "futex", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "futimesat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getcpu", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getcwd", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getdents", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getdents64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getegid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getegid32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "geteuid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "geteuid32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getgid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getgid32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getgroups", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getgroups32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getitimer", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getpeername", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getpgid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getpgrp", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getpid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getppid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getpriority", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getrandom", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getresgid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getresgid32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getresuid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getresuid32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getrlimit", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "get_robust_list", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getrusage", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getsid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getsockname", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getsockopt", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "get_thread_area", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "gettid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "gettimeofday", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getuid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getuid32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "getxattr", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "inotify_add_watch", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "inotify_init", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "inotify_init1", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "inotify_rm_watch", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "io_cancel", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "ioctl", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "io_destroy", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "io_getevents", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "ioprio_get", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "ioprio_set", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "io_setup", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "io_submit", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "kill", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "lchown", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "lchown32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "lgetxattr", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "link", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "linkat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "listen", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "listxattr", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "llistxattr", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "_llseek", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "lremovexattr", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "lseek", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "lsetxattr", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "lstat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "lstat64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "madvise", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "memfd_create", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mincore", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mkdir", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mkdirat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mknod", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mknodat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mlock", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mlockall", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mmap", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mmap2", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mprotect", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mq_getsetattr", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mq_notify", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mq_open", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mq_timedreceive", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mq_timedsend", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mq_unlink", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "mremap", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "msgctl", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "msgget", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "msgrcv", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "msgsnd", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "msync", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "munlock", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "munlockall", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "munmap", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "name_to_handle_at", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "nanosleep", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "newfstatat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "_newselect", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "open", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "open_by_handle_at", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "openat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "pause", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "pipe", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "pipe2", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "poll", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "ppoll", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "prctl", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "pread64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "preadv", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "prlimit64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "pselect6", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "pwrite64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "pwritev", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "read", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "readahead", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "readlink", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "readlinkat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "readv", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "recvfrom", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "recvmmsg", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "recvmsg", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "remap_file_pages", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "removexattr", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "rename", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "renameat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "renameat2", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "rmdir", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "rt_sigaction", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "rt_sigpending", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "rt_sigprocmask", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "rt_sigqueueinfo", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "rt_sigreturn", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "rt_sigsuspend", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "rt_sigtimedwait", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "rt_tgsigqueueinfo", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sched_getaffinity", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sched_getattr", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sched_getparam", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sched_get_priority_max", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sched_get_priority_min", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sched_getscheduler", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sched_rr_get_interval", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sched_setaffinity", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sched_setattr", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sched_setparam", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sched_setscheduler", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sched_yield", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "seccomp", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "select", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "semctl", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "semget", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "semop", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "semtimedop", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sendfile", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sendfile64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sendmmsg", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sendmsg", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sendto", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setdomainname", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setfsgid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setfsgid32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setfsuid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setfsuid32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setgid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setgid32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setgroups", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setgroups32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sethostname", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setitimer", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setns", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setpgid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setpriority", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setregid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setregid32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setresgid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setresgid32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setresuid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setresuid32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setreuid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setreuid32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setrlimit", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "set_robust_list", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setsid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setsockopt", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "set_thread_area", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "set_tid_address", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setuid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setuid32", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "setxattr", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "shmat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "shmctl", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "shmdt", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "shmget", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "shutdown", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sigaltstack", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "signalfd", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "signalfd4", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "socket", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "socketpair", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "splice", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "stat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "stat64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "statfs", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "statfs64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "symlink", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "symlinkat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sync", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sync_file_range", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "syncfs", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "sysinfo", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "syslog", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "tee", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "tgkill", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "time", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "timer_create", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "timer_delete", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "timerfd_create", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "timerfd_gettime", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "timerfd_settime", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "timer_getoverrun", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "timer_gettime", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "timer_settime", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "times", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "tkill", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "truncate", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "truncate64", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "ugetrlimit", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "umask", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "uname", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "unlink", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "unlinkat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "unshare", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "utime", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "utimensat", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "utimes", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "vfork", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "vhangup", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "vmsplice", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "wait4", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "waitid", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "write", + "action": "SCMP_ACT_ALLOW", + "args": null + }, + { + "name": "writev", + "action": "SCMP_ACT_ALLOW", + "args": null + } + ] +} From a881230b5de9b011dce6bd09f5c1bb2be0421e38 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:15:25 +0000 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'python/'?= =?UTF-8?q?=20with=20remote=20'python/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/224 --- python/rustwright/_async_generated.py | 2 +- python/rustwright/pytest_plugin.py | 2 +- python/rustwright/sync_api.py | 21 ++++++++++----------- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/python/rustwright/_async_generated.py b/python/rustwright/_async_generated.py index a6fcdf8..2870aa0 100644 --- a/python/rustwright/_async_generated.py +++ b/python/rustwright/_async_generated.py @@ -1,5 +1,5 @@ # This file is generated by tools/generate_async_api.py. Do not edit. -# sync_api.py sha256: ea3c8280513eef43c9f177ffa60f88315945b86a549b42513aa9249933d28240 +# sync_api.py sha256: 815282b57f5b5c4b11fc2ce5671f1ab53b79f99a597e2814c1c9043b9e7acbfe from __future__ import annotations from pathlib import Path diff --git a/python/rustwright/pytest_plugin.py b/python/rustwright/pytest_plugin.py index 56f14ce..75dbf34 100644 --- a/python/rustwright/pytest_plugin.py +++ b/python/rustwright/pytest_plugin.py @@ -301,7 +301,7 @@ def launch_browser( ) -> Callable[..., Browser]: def launch(**kwargs: Any) -> Browser: if connect_options: - return browser_type.connect(**connect_options) + return browser_type.connect_over_cdp(**connect_options) options = dict(browser_type_launch_args) options.update(kwargs) return browser_type.launch(**options) diff --git a/python/rustwright/sync_api.py b/python/rustwright/sync_api.py index 32e0026..a971ac1 100644 --- a/python/rustwright/sync_api.py +++ b/python/rustwright/sync_api.py @@ -10791,24 +10791,23 @@ def connect( timeout: Optional[float] = None, expose_network: Optional[str] = None, ) -> "Browser": - if self.name != "chromium": - raise Error(f"{self.name} connect is not implemented; Rustwright currently supports Chromium over CDP") method = "BrowserType.connect" - endpoint = _normalize_string_option(endpoint, method=method, name="endpoint") + _normalize_string_option(endpoint, method=method, name="endpoint") if slow_mo is not None: _normalize_float_option(slow_mo, method=method, name="slow_mo") if timeout is not None: _normalize_float_option(timeout, method=method, name="timeout") if expose_network is not None: _normalize_string_option(expose_network, method=method, name="expose_network") - try: - return self.connect_over_cdp(endpoint, headers=headers, slow_mo=slow_mo, timeout=timeout) - except Error as exc: - message = str(exc) - prefix = "BrowserType.connect_over_cdp: " - if message.startswith(prefix): - raise Error(f"{method}: {message[len(prefix):]}") from None - raise + if headers is not None: + _normalize_connect_headers(headers, method=method) + raise Error( + f"{method}: Rustwright does not support the Playwright wire protocol " + "(playwright run-server or BrowserType.launchServer). Use " + "chromium.connect_over_cdp() with a raw Chromium CDP endpoint such as " + "http://browser:9222. See " + "https://github.com/Skyvern-AI/rustwright/blob/main/docs/REMOTE_BROWSERS.md" + ) class Playwright(_EventEmitter): From ebcd958b745009542f7b9f9045929ebe2f092255 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:15:25 +0000 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'src/'=20?= =?UTF-8?q?with=20remote=20'src/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/224 --- src/lib.rs | 974 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 809 insertions(+), 165 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 205a10e..bbc5dcd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,8 @@ use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::{Child, Command, ExitStatus, Stdio}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}; +#[cfg(feature = "test-support")] +use std::sync::LazyLock; #[cfg(feature = "python")] use std::sync::OnceLock; use std::sync::{Arc, Condvar, Mutex, Weak}; @@ -35,7 +37,7 @@ use tokio::sync::{broadcast, mpsc, oneshot, watch}; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::uri::PathAndQuery; use tokio_tungstenite::tungstenite::http::{HeaderName, HeaderValue, Uri}; -use tokio_tungstenite::tungstenite::{Error as WsError, Message}; +use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::{connect_async, MaybeTlsStream}; mod startup_timing; @@ -44,6 +46,14 @@ mod telemetry; pub type RwResult = Result; type CdpPendingMap = Arc>>>>; type CdpOutstandingMap = Arc>>; +#[cfg(feature = "test-support")] +static CONNECT_DNS_RESOLUTION_COUNT: AtomicU64 = AtomicU64::new(0); +#[cfg(feature = "test-support")] +static CONNECT_DISCOVERY_REQUEST_COUNT: AtomicU64 = AtomicU64::new(0); +#[cfg(feature = "test-support")] +static CONNECT_WEBSOCKET_CONNECTOR_COUNT: AtomicU64 = AtomicU64::new(0); +#[cfg(feature = "test-support")] +static CONNECT_NATIVE_BINDING_COUNT: AtomicU64 = AtomicU64::new(0); /// A thread-safe cancellation signal for synchronous facade operations. /// @@ -2411,12 +2421,38 @@ pub enum ActionabilityError { Detached, } +/// Definitive evidence that a remote endpoint uses an unsupported protocol. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum RemoteProtocolEvidence { + #[error( + "This endpoint speaks the Playwright wire protocol, not CDP. Point chromium.connect_over_cdp() at a raw Chromium CDP endpoint such as http://browser:9222. See https://github.com/Skyvern-AI/rustwright/blob/main/docs/REMOTE_BROWSERS.md" + )] + PlaywrightHttpDiscovery, +} + +/// A protocol shape that is suggestive but not conclusive. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum RemoteProtocolSuspicion { + #[error( + "The endpoint's first response resembles the Playwright wire protocol, not CDP. Rustwright cannot determine the protocol conclusively for a direct WebSocket URL. Use an HTTP discovery URL or a raw Chromium CDP endpoint. See https://github.com/Skyvern-AI/rustwright/blob/main/docs/REMOTE_BROWSERS.md" + )] + PlaywrightLikeFirstReply, +} + #[derive(Debug, Error)] pub enum RwError { #[error("{0}")] Message(String), #[error("CDP connection failed")] ConnectFailed, + #[error("{0}")] + UnsupportedRemoteProtocol(RemoteProtocolEvidence), + #[error("{0}")] + RemoteProtocolSuspected(RemoteProtocolSuspicion), + #[error( + "Remote browser discovery failed. The endpoint exposed neither raw CDP at /json/version nor Playwright discovery at /json. See https://github.com/Skyvern-AI/rustwright/blob/main/docs/REMOTE_BROWSERS.md" + )] + RemoteDiscoveryFailed, #[error("Protocol error ({method}): {message}")] Cdp { method: String, message: String }, #[error("timed out after {0} ms")] @@ -17380,6 +17416,305 @@ multiline-compatible = """4.5.6""" assert!(error.to_string().contains("duplicate field")); } + #[test] + fn remote_discovery_url_derivation_preserves_query_and_removes_known_suffix() { + for endpoint in [ + "http://example.test/tenant/root?token=secret", + "http://example.test/tenant/root/json/version?token=secret", + "http://example.test/tenant/root/json?token=secret", + ] { + let (version, json) = remote_discovery_urls(endpoint).unwrap(); + assert_eq!( + version.as_str(), + "http://example.test/tenant/root/json/version?token=secret" + ); + assert_eq!( + json.as_str(), + "http://example.test/tenant/root/json?token=secret" + ); + } + } + + #[test] + fn direct_websocket_endpoint_resolution_performs_no_http_discovery() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + for endpoint in [ + "ws://127.0.0.1:1/devtools/browser/test", + "wss://example.test/devtools/browser/test", + ] { + let resolved = runtime + .block_on(resolve_ws_endpoint( + endpoint, + tokio::time::Instant::now() + Duration::from_secs(1), + Duration::from_secs(1), + &[], + )) + .unwrap(); + assert_eq!(resolved, endpoint); + } + } + + #[test] + fn nested_first_reply_constructs_only_the_suspected_protocol_variant() { + let pending: CdpPendingMap = Arc::new(Mutex::new(HashMap::new())); + let (sender, receiver) = oneshot::channel(); + pending.lock().unwrap().insert(1, sender); + let detector = AtomicBool::new(true); + let (events, _) = broadcast::channel(4); + dispatch_cdp_payload_with_diagnostics( + json!({ + "id": 1, + "error": {"error": {"message": "remote secret"}} + }), + pending, + Arc::new(Mutex::new(HashMap::new())), + events, + Arc::new(Mutex::new(CdpEventLog::new())), + Arc::new(Mutex::new(CdpTrafficLog::new())), + Arc::new(Mutex::new(CdpRuntimeState::new(None))), + Some(&detector), + ); + let error = receiver.blocking_recv().unwrap().unwrap_err(); + assert!(matches!( + &error, + RwError::RemoteProtocolSuspected(RemoteProtocolSuspicion::PlaywrightLikeFirstReply) + )); + assert!(!matches!(&error, RwError::UnsupportedRemoteProtocol(_))); + assert!(!error.to_string().contains("remote secret")); + } + + #[test] + fn valid_cdp_event_disarms_nested_reply_protocol_detection() { + let pending: CdpPendingMap = Arc::new(Mutex::new(HashMap::new())); + let outstanding: CdpOutstandingMap = Arc::new(Mutex::new(HashMap::new())); + let (sender, receiver) = oneshot::channel(); + pending.lock().unwrap().insert(1, sender); + let detector = AtomicBool::new(true); + let (events, _) = broadcast::channel(4); + let event_log = Arc::new(Mutex::new(CdpEventLog::new())); + let traffic_log = Arc::new(Mutex::new(CdpTrafficLog::new())); + dispatch_cdp_payload_with_diagnostics( + json!({"method": "Target.targetCreated", "params": {}}), + Arc::clone(&pending), + Arc::clone(&outstanding), + events.clone(), + Arc::clone(&event_log), + Arc::clone(&traffic_log), + Arc::new(Mutex::new(CdpRuntimeState::new(None))), + Some(&detector), + ); + dispatch_cdp_payload_with_diagnostics( + json!({ + "id": 1, + "error": {"error": {"message": "late remote secret"}} + }), + pending, + outstanding, + events, + event_log, + traffic_log, + Arc::new(Mutex::new(CdpRuntimeState::new(None))), + Some(&detector), + ); + let error = receiver.blocking_recv().unwrap().unwrap_err(); + assert!(matches!(&error, RwError::Message(message) if message == "unknown CDP error")); + assert!(!error.to_string().contains("late remote secret")); + } + + #[test] + fn normal_cdp_error_shape_keeps_ordinary_message_for_command_wrapping() { + let pending: CdpPendingMap = Arc::new(Mutex::new(HashMap::new())); + let (sender, receiver) = oneshot::channel(); + pending.lock().unwrap().insert(1, sender); + let detector = AtomicBool::new(true); + let (events, _) = broadcast::channel(4); + dispatch_cdp_payload_with_diagnostics( + json!({"id": 1, "error": {"message": "ordinary CDP failure"}}), + pending, + Arc::new(Mutex::new(HashMap::new())), + events, + Arc::new(Mutex::new(CdpEventLog::new())), + Arc::new(Mutex::new(CdpTrafficLog::new())), + Arc::new(Mutex::new(CdpRuntimeState::new(None))), + Some(&detector), + ); + let error = receiver.blocking_recv().unwrap().unwrap_err(); + assert!(matches!( + &error, + RwError::Message(message) if message == "ordinary CDP failure" + )); + } + + #[test] + fn typed_remote_protocol_messages_are_exact_and_do_not_include_peer_text() { + assert_eq!( + RwError::UnsupportedRemoteProtocol( + RemoteProtocolEvidence::PlaywrightHttpDiscovery + ) + .to_string(), + "This endpoint speaks the Playwright wire protocol, not CDP. Point chromium.connect_over_cdp() at a raw Chromium CDP endpoint such as http://browser:9222. See https://github.com/Skyvern-AI/rustwright/blob/main/docs/REMOTE_BROWSERS.md" + ); + assert_eq!( + RwError::RemoteProtocolSuspected( + RemoteProtocolSuspicion::PlaywrightLikeFirstReply + ) + .to_string(), + "The endpoint's first response resembles the Playwright wire protocol, not CDP. Rustwright cannot determine the protocol conclusively for a direct WebSocket URL. Use an HTTP discovery URL or a raw Chromium CDP endpoint. See https://github.com/Skyvern-AI/rustwright/blob/main/docs/REMOTE_BROWSERS.md" + ); + assert_eq!( + RwError::RemoteDiscoveryFailed.to_string(), + "Remote browser discovery failed. The endpoint exposed neither raw CDP at /json/version nor Playwright discovery at /json. See https://github.com/Skyvern-AI/rustwright/blob/main/docs/REMOTE_BROWSERS.md" + ); + } + + #[test] + fn native_connection_boundary_redacts_upgrade_url_headers_and_body() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let requests = Arc::new(AtomicUsize::new(0)); + let requests_server = Arc::clone(&requests); + let server = std::thread::spawn(move || { + let (mut connection, _) = listener.accept().unwrap(); + requests_server.fetch_add(1, Ordering::SeqCst); + let mut request = [0_u8; 4096]; + let _ = connection.read(&mut request); + let body = b"user:password token=query-secret header-secret"; + write!( + connection, + "HTTP/1.1 502 Bad Gateway\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .unwrap(); + connection.write_all(body).unwrap(); + }); + let endpoint = format!("ws://user:password@{address}/socket?token=query-secret"); + let error = match rustwright_connect_over_cdp( + &endpoint, + &[ + ( + "Authorization".to_string(), + "Bearer header-secret".to_string(), + ), + ( + "Proxy-Authorization".to_string(), + "Basic proxy-auth-native-sentinel-4f91c7".to_string(), + ), + ("Cookie".to_string(), "session=cookie-secret".to_string()), + ], + Duration::from_secs(1), + ) { + Err(error) => error, + Ok(_) => panic!("upgrade rejection unexpectedly connected"), + }; + server.join().unwrap(); + assert!(matches!(&error, RwError::ConnectFailed)); + let message = error.to_string(); + for secret in [ + endpoint.as_str(), + "user", + "password", + "query-secret", + "header-secret", + "proxy-auth-native-sentinel-4f91c7", + "cookie-secret", + ] { + assert!(!message.contains(secret)); + } + assert_eq!(requests.load(Ordering::SeqCst), 1); + } + + #[test] + fn native_empty_upgrade_rejection_performs_no_fallback_get() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let requests = Arc::new(AtomicUsize::new(0)); + let requests_server = Arc::clone(&requests); + let server = std::thread::spawn(move || { + let (mut connection, _) = listener.accept().unwrap(); + requests_server.fetch_add(1, Ordering::SeqCst); + let mut request = [0_u8; 4096]; + let _ = connection.read(&mut request); + connection + .write_all( + b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + drop(connection); + + listener.set_nonblocking(true).unwrap(); + let deadline = Instant::now() + Duration::from_millis(250); + while Instant::now() < deadline { + match listener.accept() { + Ok((mut fallback, _)) => { + requests_server.fetch_add(1, Ordering::SeqCst); + let _ = fallback.read(&mut request); + let sentinel = b"native-fallback-secret-sentinel"; + write!( + fallback, + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + sentinel.len() + ) + .unwrap(); + fallback.write_all(sentinel).unwrap(); + break; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("fallback accept failed: {error}"), + } + } + }); + let endpoint = format!("ws://{address}/socket"); + let error = match rustwright_connect_over_cdp(&endpoint, &[], Duration::from_secs(1)) { + Err(error) => error, + Ok(_) => panic!("upgrade rejection unexpectedly connected"), + }; + server.join().unwrap(); + assert!(matches!(&error, RwError::ConnectFailed)); + assert!(!error + .to_string() + .contains("native-fallback-secret-sentinel")); + assert_eq!(requests.load(Ordering::SeqCst), 1); + } + + #[test] + fn typed_protocol_errors_bypass_cdp_message_wrapping() { + let definitive = wrap_cdp_command_error( + "Target.setAutoAttach", + RwError::UnsupportedRemoteProtocol(RemoteProtocolEvidence::PlaywrightHttpDiscovery), + ); + assert!(matches!( + &definitive, + RwError::UnsupportedRemoteProtocol(RemoteProtocolEvidence::PlaywrightHttpDiscovery) + )); + + let suspected = wrap_cdp_command_error( + "Target.setAutoAttach", + RwError::RemoteProtocolSuspected(RemoteProtocolSuspicion::PlaywrightLikeFirstReply), + ); + assert!(matches!( + &suspected, + RwError::RemoteProtocolSuspected(RemoteProtocolSuspicion::PlaywrightLikeFirstReply) + )); + + let ordinary = wrap_cdp_command_error( + "Target.setAutoAttach", + RwError::Message("ordinary failure".to_string()), + ); + assert!(matches!( + &ordinary, + RwError::Cdp { method, message } + if method == "Target.setAutoAttach" && message == "ordinary failure" + )); + assert_eq!( + ordinary.to_string(), + "Protocol error (Target.setAutoAttach): ordinary failure" + ); + } } #[derive(Debug, Deserialize, PartialEq)] @@ -18330,6 +18665,16 @@ struct CdpSendOutcome { write_status: TransportWriteStatus, } +fn wrap_cdp_command_error(method: &str, error: RwError) -> RwError { + match error { + RwError::Message(message) => RwError::Cdp { + method: method.to_string(), + message, + }, + other => other, + } +} + struct CdpEventLog { next_seq: u64, events: VecDeque<(u64, Value)>, @@ -18431,77 +18776,6 @@ impl CdpEventLog { } } -fn format_websocket_status(status: tokio_tungstenite::tungstenite::http::StatusCode) -> String { - match status.canonical_reason() { - Some(reason) if !reason.is_empty() => format!("{} {}", status.as_u16(), reason), - _ => status.as_u16().to_string(), - } -} - -fn websocket_endpoint_as_http_url(ws_endpoint: &str) -> Option { - ws_endpoint - .strip_prefix("ws://") - .map(|rest| format!("http://{rest}")) - .or_else(|| { - ws_endpoint - .strip_prefix("wss://") - .map(|rest| format!("https://{rest}")) - }) -} - -async fn fetch_websocket_http_error_body( - ws_endpoint: &str, - headers: &[(String, String)], -) -> Option> { - let http_endpoint = websocket_endpoint_as_http_url(ws_endpoint)?; - let client = reqwest::Client::builder() - .timeout(Duration::from_millis(1_000)) - .no_proxy() - .build() - .ok()?; - let mut request = client.get(http_endpoint); - for (name, value) in headers { - request = request.header(name, value); - } - let response = request.send().await.ok()?; - let body = response.bytes().await.ok()?; - if body.is_empty() { - None - } else { - Some(body.to_vec()) - } -} - -fn cdp_websocket_connect_error( - ws_endpoint: &str, - error: WsError, - fallback_body: Option>, -) -> RwError { - match error { - WsError::Http(response) => { - let status_text = format_websocket_status(response.status()); - let mut message = format!("WebSocket error: {ws_endpoint} {status_text}"); - let response_body = response - .body() - .as_ref() - .filter(|body| !body.is_empty()) - .map(|body| body.as_slice()) - .or_else(|| fallback_body.as_deref()); - if let Some(body) = response_body { - message.push('\n'); - message.push_str(&String::from_utf8_lossy(body)); - } - message.push_str("\nCall log:"); - message.push_str(&format!("\n - {ws_endpoint}")); - message.push_str(&format!( - "\n - {ws_endpoint} {status_text}" - )); - RwError::Message(message) - } - other => RwError::WebSocket(other), - } -} - fn dispatch_cdp_payload_with_diagnostics( mut payload: Value, pending: CdpPendingMap, @@ -18510,6 +18784,7 @@ fn dispatch_cdp_payload_with_diagnostics( event_log: Arc>, traffic_log: Arc>, runtime_state: Arc>, + playwright_like_first_reply: Option<&AtomicBool>, ) { if let Some(id) = payload.get("id").and_then(Value::as_u64) { let sender = pending.lock().unwrap().remove(&id); @@ -18522,14 +18797,35 @@ fn dispatch_cdp_payload_with_diagnostics( command.as_ref(), )); if let Some(sender) = sender { + let detector_armed = + playwright_like_first_reply.is_some_and(|detector| detector.load(Ordering::SeqCst)); let result = if let Some(error) = payload.get("error") { - let message = error - .get("message") - .and_then(Value::as_str) - .unwrap_or("unknown CDP error") - .to_string(); - Err(RwError::Message(message)) + let normal_cdp_message = error.get("message").and_then(Value::as_str); + let nested_message = error + .get("error") + .and_then(|nested| nested.get("message")) + .and_then(Value::as_str); + if detector_armed && normal_cdp_message.is_none() && nested_message.is_some() { + if let Some(detector) = playwright_like_first_reply { + detector.store(false, Ordering::SeqCst); + } + Err(RwError::RemoteProtocolSuspected( + RemoteProtocolSuspicion::PlaywrightLikeFirstReply, + )) + } else { + if let Some(detector) = playwright_like_first_reply { + detector.store(false, Ordering::SeqCst); + } + Err(RwError::Message( + normal_cdp_message + .unwrap_or("unknown CDP error") + .to_string(), + )) + } } else { + if let Some(detector) = playwright_like_first_reply { + detector.store(false, Ordering::SeqCst); + } Ok(payload .as_object_mut() .and_then(|object| object.remove("result")) @@ -18539,6 +18835,12 @@ fn dispatch_cdp_payload_with_diagnostics( } } else { runtime_state.lock().unwrap().observe_event(&payload); + if payload.get("guid").is_none() && payload.get("method").and_then(Value::as_str).is_some() + { + if let Some(detector) = playwright_like_first_reply { + detector.store(false, Ordering::SeqCst); + } + } traffic_log .lock() .unwrap() @@ -18564,6 +18866,7 @@ fn dispatch_cdp_payload( event_log, Arc::new(Mutex::new(CdpTrafficLog::new())), Arc::new(Mutex::new(CdpRuntimeState::new(None))), + None, ); } @@ -18759,12 +19062,54 @@ impl CdpClient { } async fn connect(ws_endpoint: &str) -> RwResult> { - Self::connect_with_headers(ws_endpoint, &[]).await + Self::connect_with_headers_and_protocol_detection(ws_endpoint, &[], false).await } - async fn connect_with_headers( + async fn connect_with_headers_and_protocol_detection( ws_endpoint: &str, headers: &[(String, String)], + detect_playwright_like_first_reply: bool, + ) -> RwResult> { + #[cfg(feature = "test-support")] + { + return Self::connect_with_headers_protocol_detection_inner( + ws_endpoint, + headers, + detect_playwright_like_first_reply, + None, + ) + .await; + } + #[cfg(not(feature = "test-support"))] + Self::connect_with_headers_protocol_detection_inner( + ws_endpoint, + headers, + detect_playwright_like_first_reply, + ) + .await + } + + #[cfg(feature = "test-support")] + async fn connect_with_headers_protocol_detection_and_test_ca( + ws_endpoint: &str, + headers: &[(String, String)], + detect_playwright_like_first_reply: bool, + test_ca_pem: Option<&str>, + ) -> RwResult> { + Self::connect_with_headers_protocol_detection_inner( + ws_endpoint, + headers, + detect_playwright_like_first_reply, + test_ca_pem, + ) + .await + } + + async fn connect_with_headers_protocol_detection_inner( + ws_endpoint: &str, + headers: &[(String, String)], + detect_playwright_like_first_reply: bool, + #[cfg(feature = "test-support")] test_ca_pem: Option<&str>, ) -> RwResult> { let mut request = ws_endpoint.into_client_request()?; ensure_ws_request_path(&mut request); @@ -18777,30 +19122,76 @@ impl CdpClient { })?; request.headers_mut().insert(header_name, header_value); } - let (mut stream, _) = match connect_async(request).await { - Ok(result) => result, - Err(error) => { - let fallback_body = match &error { - WsError::Http(response) - if response - .body() - .as_ref() - .map_or(true, |body| body.is_empty()) => - { - fetch_websocket_http_error_body(ws_endpoint, headers).await - } - _ => None, - }; - return Err(cdp_websocket_connect_error( - ws_endpoint, - error, - fallback_body, - )); + + #[cfg(feature = "test-support")] + { + CONNECT_WEBSOCKET_CONNECTOR_COUNT.fetch_add(1, Ordering::SeqCst); + CONNECT_DNS_RESOLUTION_COUNT.fetch_add(1, Ordering::SeqCst); + } + + #[cfg(feature = "test-support")] + if request.uri().scheme_str() == Some("wss") { + if let Some(test_ca_pem) = test_ca_pem { + use rustls_pki_types::pem::PemObject as _; + + let host = request + .uri() + .host() + .ok_or(RwError::ConnectFailed)? + .to_string(); + let port = request.uri().port_u16().unwrap_or(443); + let tcp_stream = tokio::net::TcpStream::connect((host.as_str(), port)) + .await + .map_err(|_| RwError::ConnectFailed)?; + let _ = tcp_stream.set_nodelay(true); + + let certificates = + rustls_pki_types::CertificateDer::pem_slice_iter(test_ca_pem.as_bytes()) + .collect::, _>>() + .map_err(|_| RwError::ConnectFailed)?; + if certificates.is_empty() { + return Err(RwError::ConnectFailed); + } + let mut root_store = rustls::RootCertStore::empty(); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + for certificate in certificates { + root_store + .add(certificate) + .map_err(|_| RwError::ConnectFailed)?; + } + let tls_config = rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + let connector = tokio_tungstenite::Connector::Rustls(Arc::new(tls_config)); + let (stream, _) = tokio_tungstenite::client_async_tls_with_config( + request, + tcp_stream, + None, + Some(connector), + ) + .await + .map_err(|_| RwError::ConnectFailed)?; + return Self::from_websocket_stream(stream, detect_playwright_like_first_reply) + .await; } - }; + } + + let (mut stream, _) = connect_async(request) + .await + .map_err(|_| RwError::ConnectFailed)?; if let MaybeTlsStream::Plain(tcp_stream) = stream.get_mut() { let _ = tcp_stream.set_nodelay(true); } + Self::from_websocket_stream(stream, detect_playwright_like_first_reply).await + } + + async fn from_websocket_stream( + stream: tokio_tungstenite::WebSocketStream, + detect_playwright_like_first_reply: bool, + ) -> RwResult> + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, + { let (mut write, mut read) = stream.split(); let (write_tx, mut write_rx) = mpsc::unbounded_channel::(); let pending: CdpPendingMap = Arc::new(Mutex::new(HashMap::new())); @@ -18815,6 +19206,9 @@ impl CdpClient { let traffic_log = Arc::new(Mutex::new(CdpTrafficLog::new())); let traffic_log_writer = Arc::clone(&traffic_log); let traffic_log_reader = Arc::clone(&traffic_log); + let playwright_like_first_reply = + detect_playwright_like_first_reply.then(|| Arc::new(AtomicBool::new(true))); + let playwright_like_first_reply_reader = playwright_like_first_reply.clone(); let (serializer_release_tx, serializer_release_rx) = mpsc::unbounded_channel(); let runtime_state = Arc::new(Mutex::new(CdpRuntimeState::new(Some( serializer_release_tx, @@ -18895,6 +19289,7 @@ impl CdpClient { Arc::clone(&event_log_reader), Arc::clone(&traffic_log_reader), Arc::clone(&runtime_state_reader), + playwright_like_first_reply_reader.as_deref(), ); } alive_reader.store(false, Ordering::SeqCst); @@ -19032,6 +19427,7 @@ impl CdpClient { Arc::clone(&event_log_dispatcher), Arc::clone(&traffic_log_dispatcher), Arc::clone(&runtime_state_dispatcher), + None, ); } alive_dispatcher.store(false, Ordering::SeqCst); @@ -19388,13 +19784,7 @@ impl CdpClient { self.record_sent_command(method); match tokio::time::timeout(timeout, rx).await { - Ok(Ok(result)) => result.map_err(|error| match error { - RwError::Message(message) => RwError::Cdp { - method: method.to_string(), - message, - }, - other => other, - }), + Ok(Ok(result)) => result.map_err(|error| wrap_cdp_command_error(method, error)), Ok(Err(_)) => Err(RwError::Disconnected), Err(_) => Err(RwError::Timeout(timeout.as_millis() as u64)), } @@ -34193,6 +34583,57 @@ fn launch_chromium_async(py: Python<'_>, options_json: &str) -> PyResult; + +#[cfg(feature = "test-support")] +#[derive(Clone, Default)] +struct ConnectTestOptions { + observer: Option, + tls_ca_pem: Option, +} + +#[cfg(feature = "test-support")] +impl ConnectTestOptions { + fn observe(&self, stage: &'static str, remaining: Duration) { + if let Some(observer) = &self.observer { + observer(stage, remaining); + } + } +} + +// This test-only one-shot slot does not support concurrent arm-and-connect +// sequences. Test helpers must serialize each arm with its matching connect. +#[cfg(feature = "test-support")] +static CONNECT_TEST_OPTIONS: LazyLock>> = + LazyLock::new(|| Mutex::new(None)); + +#[cfg(feature = "test-support")] +fn install_connect_test_options(options: ConnectTestOptions) { + let mut slot = CONNECT_TEST_OPTIONS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *slot = Some(options); +} + +#[cfg(feature = "test-support")] +fn take_connect_test_options() -> Option { + CONNECT_TEST_OPTIONS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() +} +#[cfg(feature = "test-support")] +fn connect_stage_observer(observer: Option>) -> Option { + observer.map(|observer| { + Arc::new(move |stage: &'static str, remaining: Duration| { + Python::attach(|py| { + let _ = observer.call1(py, (stage, remaining.as_secs_f64())); + }); + }) as ConnectStageObserver + }) +} + #[cfg(feature = "python")] #[pyfunction] #[pyo3(signature = (endpoint, timeout_ms=None, headers_json=None))] @@ -34202,21 +34643,72 @@ fn connect_over_cdp( timeout_ms: Option, headers_json: Option<&str>, ) -> PyResult { + #[cfg(feature = "test-support")] + CONNECT_NATIVE_BINDING_COUNT.fetch_add(1, Ordering::SeqCst); let timeout = BrowserInner::command_timeout(timeout_ms); let headers = parse_header_pairs(headers_json).map_err(py_err)?; let endpoint = endpoint.to_string(); let inner = py - .detach(move || connect_browser_over_cdp(endpoint, headers, timeout)) + .detach(move || connect_browser_over_cdp_sanitized(endpoint, headers, timeout, None)) .map_err(py_err)?; Ok(PyBrowser { inner }) } -fn connect_browser_over_cdp( - endpoint: String, - headers: Vec<(String, String)>, - timeout: Duration, -) -> RwResult> { - connect_browser_over_cdp_cancelable(endpoint, headers, timeout, None) +#[cfg(feature = "test-support")] +#[pyfunction(name = "_test_set_connect_options")] +#[pyo3(signature = (observer=None, ca_pem=None))] +fn test_set_connect_options(observer: Option>, ca_pem: Option<&str>) { + install_connect_test_options(ConnectTestOptions { + observer: connect_stage_observer(observer), + tls_ca_pem: ca_pem.map(str::to_string), + }); +} + +#[cfg(feature = "test-support")] +#[pyfunction(name = "_test_connect_over_cdp")] +#[pyo3(signature = (endpoint, observer, timeout_ms=None, headers_json=None, ca_pem=None))] +fn test_connect_over_cdp( + py: Python<'_>, + endpoint: &str, + observer: Py, + timeout_ms: Option, + headers_json: Option<&str>, + ca_pem: Option<&str>, +) -> PyResult { + let timeout = BrowserInner::command_timeout(timeout_ms); + let headers = parse_header_pairs(headers_json).map_err(py_err)?; + let endpoint = endpoint.to_string(); + let options = ConnectTestOptions { + observer: connect_stage_observer(Some(observer)), + tls_ca_pem: ca_pem.map(str::to_string), + }; + let inner = py + .detach(move || { + install_connect_test_options(options); + connect_browser_over_cdp_sanitized(endpoint, headers, timeout, None) + }) + .map_err(py_err)?; + Ok(PyBrowser { inner }) +} + +#[cfg(feature = "test-support")] +#[pyfunction(name = "_test_reset_connect_boundary_counters")] +fn test_reset_connect_boundary_counters() { + CONNECT_DNS_RESOLUTION_COUNT.store(0, Ordering::SeqCst); + CONNECT_DISCOVERY_REQUEST_COUNT.store(0, Ordering::SeqCst); + CONNECT_WEBSOCKET_CONNECTOR_COUNT.store(0, Ordering::SeqCst); + CONNECT_NATIVE_BINDING_COUNT.store(0, Ordering::SeqCst); +} + +#[cfg(feature = "test-support")] +#[pyfunction(name = "_test_connect_boundary_counters")] +fn test_connect_boundary_counters() -> (u64, u64, u64, u64) { + ( + CONNECT_DNS_RESOLUTION_COUNT.load(Ordering::SeqCst), + CONNECT_DISCOVERY_REQUEST_COUNT.load(Ordering::SeqCst), + CONNECT_WEBSOCKET_CONNECTOR_COUNT.load(Ordering::SeqCst), + CONNECT_NATIVE_BINDING_COUNT.load(Ordering::SeqCst), + ) } fn connect_browser_over_cdp_cancelable( @@ -34224,28 +34716,59 @@ fn connect_browser_over_cdp_cancelable( headers: Vec<(String, String)>, timeout: Duration, cancel: Option, + started: Instant, + #[cfg(feature = "test-support")] test_options: Option<&ConnectTestOptions>, ) -> RwResult> { - let started = Instant::now(); let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .worker_threads(2) .build() .map_err(|error| RwError::Message(error.to_string()))?; + let initial_remaining = timeout.saturating_sub(started.elapsed()); + if initial_remaining.is_zero() { + return Err(RwError::Timeout(duration_millis_u64(timeout))); + } + let deadline = tokio::time::Instant::now() + initial_remaining; + let detect_playwright_like_first_reply = + endpoint.starts_with("ws://") || endpoint.starts_with("wss://"); let connect = async { - let ws_endpoint = resolve_ws_endpoint(&endpoint, timeout, &headers).await?; - let client = CdpClient::connect_with_headers(&ws_endpoint, &headers).await?; + let ws_endpoint = resolve_ws_endpoint(&endpoint, deadline, timeout, &headers).await?; + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(RwError::Timeout(duration_millis_u64(timeout))); + } + #[cfg(feature = "test-support")] + if let Some(options) = test_options { + options.observe("Upgrade", remaining); + } + #[cfg(feature = "test-support")] + let websocket_connect = CdpClient::connect_with_headers_protocol_detection_and_test_ca( + &ws_endpoint, + &headers, + detect_playwright_like_first_reply, + test_options.and_then(|options| options.tls_ca_pem.as_deref()), + ); + #[cfg(not(feature = "test-support"))] + let websocket_connect = CdpClient::connect_with_headers_and_protocol_detection( + &ws_endpoint, + &headers, + detect_playwright_like_first_reply, + ); + let client = tokio::time::timeout(remaining, websocket_connect) + .await + .map_err(|_| RwError::Timeout(duration_millis_u64(timeout)))??; Ok::<_, RwError>((ws_endpoint, client)) }; - let (ws_endpoint, client) = runtime.block_on(cancelable(cancel.clone(), async { - tokio::time::timeout(timeout, connect) - .await - .map_err(|_| RwError::Timeout(duration_millis_u64(timeout)))? - }))?; - let remaining = timeout.saturating_sub(started.elapsed()); + let (ws_endpoint, client) = runtime.block_on(cancelable(cancel.clone(), connect))?; + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); if remaining.is_zero() { client.close(); return Err(RwError::Timeout(duration_millis_u64(timeout))); } + #[cfg(feature = "test-support")] + if let Some(options) = test_options { + options.observe("Target.setAutoAttach", remaining); + } if let Err(error) = start_service_worker_stealth_auto_attach_cancelable( &runtime, Arc::clone(&client), @@ -34952,12 +35475,15 @@ pub fn rustwright_connect_over_cdp( } /// Attach to a CDP endpoint with an optional cancellation signal. -pub fn rustwright_connect_over_cdp_with_cancel( - endpoint: &str, - headers: &[(String, String)], +fn connect_browser_over_cdp_sanitized( + endpoint: String, + headers: Vec<(String, String)>, timeout: Duration, - cancel: Option<&CancelToken>, -) -> RwResult { + cancel: Option, +) -> RwResult> { + #[cfg(feature = "test-support")] + let test_options = take_connect_test_options(); + let started = Instant::now(); if timeout.is_zero() { return Err(RwError::InvalidInput( "CDP timeout must be greater than zero".to_string(), @@ -34971,7 +35497,7 @@ pub fn rustwright_connect_over_cdp_with_cancel( "CDP endpoint must use ws, wss, http, or https".to_string(), )); } - for (name, value) in headers { + for (name, value) in &headers { if HeaderName::from_bytes(name.as_bytes()).is_err() || HeaderValue::from_str(value).is_err() { return Err(RwError::InvalidInput( @@ -34979,18 +35505,45 @@ pub fn rustwright_connect_over_cdp_with_cancel( )); } } - match connect_browser_over_cdp_cancelable( + #[cfg(feature = "test-support")] + let result = connect_browser_over_cdp_cancelable( + endpoint, + headers, + timeout, + cancel, + started, + test_options.as_ref(), + ); + #[cfg(not(feature = "test-support"))] + let result = connect_browser_over_cdp_cancelable(endpoint, headers, timeout, cancel, started); + match result { + Ok(inner) => Ok(inner), + Err( + error @ (RwError::Timeout(_) + | RwError::Cancelled + | RwError::InvalidInput(_) + | RwError::UnsupportedRemoteProtocol(_) + | RwError::RemoteProtocolSuspected(_) + | RwError::RemoteDiscoveryFailed), + ) => Err(error), + Err(_) => Err(RwError::ConnectFailed), + } +} + +/// Attach to a CDP endpoint with an optional cancellation signal. +pub fn rustwright_connect_over_cdp_with_cancel( + endpoint: &str, + headers: &[(String, String)], + timeout: Duration, + cancel: Option<&CancelToken>, +) -> RwResult { + connect_browser_over_cdp_sanitized( endpoint.to_string(), headers.to_vec(), timeout, cancel.cloned(), - ) { - Ok(inner) => Ok(RustwrightBrowser { inner }), - Err(error @ (RwError::Timeout(_) | RwError::Cancelled | RwError::InvalidInput(_))) => { - Err(error) - } - Err(_) => Err(RwError::ConnectFailed), - } + ) + .map(|inner| RustwrightBrowser { inner }) } pub fn rustwright_chromium_executable_path() -> Option { @@ -41504,7 +42057,14 @@ async fn poll_ws_endpoint( ))); } let request_timeout = std::cmp::min(Duration::from_secs(2), deadline - now); - match resolve_ws_endpoint(version_url, request_timeout, &[]).await { + match resolve_ws_endpoint( + version_url, + tokio::time::Instant::now() + request_timeout, + request_timeout, + &[], + ) + .await + { Ok(endpoint) => return Ok(endpoint), Err(error) => { if tokio::time::Instant::now() >= deadline { @@ -41649,44 +42209,117 @@ fn parse_header_pairs(headers_json: Option<&str>) -> RwResult RwResult<(reqwest::Url, reqwest::Url)> { + let mut base = reqwest::Url::parse(endpoint) + .map_err(|_| RwError::InvalidInput("CDP endpoint URL is invalid".to_string()))?; + if !matches!(base.scheme(), "http" | "https") || !base.has_host() { + return Err(RwError::InvalidInput( + "CDP endpoint URL is invalid".to_string(), + )); + } + base.set_fragment(None); + let path = base.path().trim_end_matches('/'); + let base_path = path + .strip_suffix("/json/version") + .or_else(|| path.strip_suffix("/json")) + .unwrap_or(path) + .to_string(); + let route_path = |suffix: &str| { + if base_path.is_empty() || base_path == "/" { + format!("/{suffix}") + } else { + format!("{base_path}/{suffix}") + } + }; + let mut version_url = base.clone(); + version_url.set_path(&route_path("json/version")); + let mut json_url = base; + json_url.set_path(&route_path("json")); + Ok((version_url, json_url)) +} + +async fn fetch_remote_discovery_json( + client: &reqwest::Client, + url: reqwest::Url, + headers: &[(String, String)], + deadline: tokio::time::Instant, + timeout: Duration, +) -> RwResult> { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(RwError::Timeout(duration_millis_u64(timeout))); + } + let request = async { + let mut request = client.get(url); + for (name, value) in headers { + request = request.header(name, value); + } + #[cfg(feature = "test-support")] + { + CONNECT_DISCOVERY_REQUEST_COUNT.fetch_add(1, Ordering::SeqCst); + CONNECT_DNS_RESOLUTION_COUNT.fetch_add(1, Ordering::SeqCst); + } + let response = match request.send().await { + Ok(response) => response, + Err(_) => return None, + }; + if !response.status().is_success() { + return None; + } + response.json::().await.ok() + }; + tokio::time::timeout(remaining, request) + .await + .map_err(|_| RwError::Timeout(duration_millis_u64(timeout))) +} + +fn valid_websocket_debugger_url(payload: &Value) -> Option { + let value = payload.get("webSocketDebuggerUrl")?.as_str()?; + if value.is_empty() { + return None; + } + let url = reqwest::Url::parse(value).ok()?; + (matches!(url.scheme(), "ws" | "wss") && url.has_host()).then(|| value.to_string()) +} + async fn resolve_ws_endpoint( endpoint: &str, + deadline: tokio::time::Instant, timeout: Duration, headers: &[(String, String)], ) -> RwResult { if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") { return Ok(endpoint.to_string()); } - let version_url = if endpoint.ends_with("/json/version") { - endpoint.to_string() - } else { - format!("{}/json/version", endpoint.trim_end_matches('/')) - }; + let (version_url, json_url) = remote_discovery_urls(endpoint)?; let client = reqwest::Client::builder() - .timeout(timeout) .no_proxy() - .build()?; - let mut request = client.get(&version_url); - for (name, value) in headers { - request = request.header(name, value); - } - let response = request.send().await?; - let status = response.status(); - if !status.is_success() { - return Err(RwError::Message(format!( - "Unexpected status {} when connecting to {}/.", - status.as_u16(), - version_url.trim_end_matches('/') - ))); + .build() + .map_err(|_| RwError::ConnectFailed)?; + + if let Some(payload) = + fetch_remote_discovery_json(&client, version_url, headers, deadline, timeout).await? + { + if let Some(ws_endpoint) = valid_websocket_debugger_url(&payload) { + return Ok(ws_endpoint); + } } - let payload: Value = response.json().await?; - payload - .get("webSocketDebuggerUrl") - .and_then(Value::as_str) - .map(ToString::to_string) - .ok_or_else(|| { - RwError::Message("CDP endpoint did not return webSocketDebuggerUrl".to_string()) - }) + + if let Some(payload) = + fetch_remote_discovery_json(&client, json_url, headers, deadline, timeout).await? + { + if payload + .get("wsEndpointPath") + .and_then(Value::as_str) + .is_some_and(|path| !path.is_empty()) + { + return Err(RwError::UnsupportedRemoteProtocol( + RemoteProtocolEvidence::PlaywrightHttpDiscovery, + )); + } + } + + Err(RwError::RemoteDiscoveryFailed) } fn pick_unused_port() -> RwResult { @@ -48333,6 +48966,17 @@ fn _rustwright(py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_function(wrap_pyfunction!(launch_chromium, module)?)?; module.add_function(wrap_pyfunction!(launch_chromium_async, module)?)?; module.add_function(wrap_pyfunction!(connect_over_cdp, module)?)?; + #[cfg(feature = "test-support")] + module.add_function(wrap_pyfunction!(test_connect_over_cdp, module)?)?; + #[cfg(feature = "test-support")] + module.add_function(wrap_pyfunction!(test_set_connect_options, module)?)?; + #[cfg(feature = "test-support")] + module.add_function(wrap_pyfunction!( + test_reset_connect_boundary_counters, + module + )?)?; + #[cfg(feature = "test-support")] + module.add_function(wrap_pyfunction!(test_connect_boundary_counters, module)?)?; module.add_function(wrap_pyfunction!(chromium_executable_path, module)?)?; module.add( "_LOCATOR_TARGET_STATE_TEMPLATE", From d0d223b4ead7284008f4dda76b121f74b181c373 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:15:25 +0000 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'tests/'?= =?UTF-8?q?=20with=20remote=20'tests/'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/224 --- tests/test_playwright_compat_optin.py | 51 + tests/test_rustwright_sync_api.py | 1290 ++++++++++++++++++++++++- 2 files changed, 1317 insertions(+), 24 deletions(-) diff --git a/tests/test_playwright_compat_optin.py b/tests/test_playwright_compat_optin.py index f1bffcd..9b639d6 100644 --- a/tests/test_playwright_compat_optin.py +++ b/tests/test_playwright_compat_optin.py @@ -873,6 +873,57 @@ def test_override_applies(browser_context_args): assert "ScopeMismatch" not in result.stdout + result.stderr + +def test_opt_in_playwright_connect_uses_exact_unsupported_contract_sync_and_async(): + report = _run_probe( + """ + import asyncio + import json + + import rustwright + + rustwright.enable_playwright_compat() + from playwright.async_api import async_playwright + from playwright.sync_api import sync_playwright + + expected = ( + "BrowserType.connect: Rustwright does not support the Playwright wire protocol " + "(playwright run-server or BrowserType.launchServer). Use " + "chromium.connect_over_cdp() with a raw Chromium CDP endpoint such as " + "http://browser:9222. See " + "https://github.com/Skyvern-AI/rustwright/blob/main/docs/REMOTE_BROWSERS.md" + ) + + sync_messages = {} + with sync_playwright() as playwright: + for name in ("chromium", "firefox", "webkit"): + try: + getattr(playwright, name).connect("ws://127.0.0.1:1/devtools/browser/test") + except Exception as error: + sync_messages[name] = str(error) + + async def collect_async(): + messages = {} + async with async_playwright() as playwright: + for name in ("chromium", "firefox", "webkit"): + try: + await getattr(playwright, name).connect( + "ws://127.0.0.1:1/devtools/browser/test" + ) + except Exception as error: + messages[name] = str(error) + return messages + + print(json.dumps({ + "expected": expected, + "sync": sync_messages, + "async": asyncio.run(collect_async()), + }, sort_keys=True)) + """ + ) + expected = report["expected"] + assert report["sync"] == {name: expected for name in ("chromium", "firefox", "webkit")} + assert report["async"] == {name: expected for name in ("chromium", "firefox", "webkit")} def test_conftest_root_pytest_plugin_alias_collects(tmp_path): conftest = tmp_path / "conftest.py" conftest.write_text( diff --git a/tests/test_rustwright_sync_api.py b/tests/test_rustwright_sync_api.py index 2d9e925..404c60f 100644 --- a/tests/test_rustwright_sync_api.py +++ b/tests/test_rustwright_sync_api.py @@ -21,6 +21,7 @@ import re import shlex import shutil +import select import socket import ssl import subprocess @@ -46,6 +47,32 @@ ONE_PIXEL_GIF_BYTES = base64.b64decode("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==") +UNSUPPORTED_CONNECT = ( + "BrowserType.connect: Rustwright does not support the Playwright wire protocol " + "(playwright run-server or BrowserType.launchServer). Use " + "chromium.connect_over_cdp() with a raw Chromium CDP endpoint such as " + "http://browser:9222. See " + "https://github.com/Skyvern-AI/rustwright/blob/main/docs/REMOTE_BROWSERS.md" +) +DEFINITIVE_PLAYWRIGHT_DISCOVERY = ( + "BrowserType.connect_over_cdp: This endpoint speaks the Playwright wire protocol, " + "not CDP. Point chromium.connect_over_cdp() at a raw Chromium CDP endpoint such as " + "http://browser:9222. See " + "https://github.com/Skyvern-AI/rustwright/blob/main/docs/REMOTE_BROWSERS.md" +) +SUSPECTED_PLAYWRIGHT_REPLY = ( + "BrowserType.connect_over_cdp: The endpoint's first response resembles the " + "Playwright wire protocol, not CDP. Rustwright cannot determine the protocol " + "conclusively for a direct WebSocket URL. Use an HTTP discovery URL or a raw " + "Chromium CDP endpoint. See " + "https://github.com/Skyvern-AI/rustwright/blob/main/docs/REMOTE_BROWSERS.md" +) +REMOTE_DISCOVERY_FAILED = ( + "BrowserType.connect_over_cdp: Remote browser discovery failed. The endpoint " + "exposed neither raw CDP at /json/version nor Playwright discovery at /json. See " + "https://github.com/Skyvern-AI/rustwright/blob/main/docs/REMOTE_BROWSERS.md" +) +SAFE_CONNECT_FAILED = "BrowserType.connect_over_cdp: CDP connection failed" @pytest.fixture(scope="session") @@ -88,6 +115,387 @@ def runtime_state_test_data(page: Any) -> dict[str, Any]: def _serve_forever_fast(server: ThreadingHTTPServer) -> None: server.serve_forever(poll_interval=0.01) +def _start_remote_http_fixture(responder): + requests: list[tuple[str, dict[str, str]]] = [] + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *_): + pass + + def do_GET(self): + headers = {name.lower(): value for name, value in self.headers.items()} + requests.append((self.path, headers)) + response = responder(self.path, headers, len(requests)) + if response is None: + self.close_connection = True + try: + self.connection.shutdown(socket.SHUT_RDWR) + except OSError: + pass + self.connection.close() + return + status, content_type, body, delay = response + if delay: + time.sleep(delay) + if isinstance(body, str): + body = body.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=_serve_forever_fast, args=(server,), daemon=True) + thread.start() + host, port = server.server_address + return server, f"http://{host}:{port}", requests + + +def _read_websocket_text(connection: socket.socket) -> str: + header = connection.recv(2) + if len(header) != 2: + raise AssertionError("missing WebSocket frame header") + length = header[1] & 0x7F + if length == 126: + length = int.from_bytes(connection.recv(2), "big") + elif length == 127: + length = int.from_bytes(connection.recv(8), "big") + mask = connection.recv(4) if header[1] & 0x80 else b"" + payload = bytearray() + while len(payload) < length: + chunk = connection.recv(length - len(payload)) + if not chunk: + raise AssertionError("WebSocket frame ended early") + payload.extend(chunk) + if mask: + for index in range(len(payload)): + payload[index] ^= mask[index % 4] + return payload.decode("utf-8") + + +def _send_websocket_json(connection: socket.socket, payload: dict[str, Any]) -> None: + body = json.dumps(payload, separators=(",", ":")).encode("utf-8") + if len(body) < 126: + header = bytes((0x81, len(body))) + elif len(body) <= 0xFFFF: + header = bytes((0x81, 126)) + len(body).to_bytes(2, "big") + else: + header = bytes((0x81, 127)) + len(body).to_bytes(8, "big") + connection.sendall(header + body) + + +def _start_websocket_reply_fixture( + reply_payloads, *, handshake_delay: float = 0, reply_delay: float = 0 +): + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + host, port = listener.getsockname() + requests: list[str] = [] + + def serve(): + try: + connection, _ = listener.accept() + except OSError: + return + with connection: + connection.settimeout(3) + chunks = bytearray() + while b"\r\n\r\n" not in chunks: + chunk = connection.recv(4096) + if not chunk: + return + chunks.extend(chunk) + request = chunks.decode("iso-8859-1") + requests.append(request) + key = next( + line.split(":", 1)[1].strip() + for line in request.splitlines() + if line.lower().startswith("sec-websocket-key:") + ) + accept = base64.b64encode( + hashlib.sha1( + (key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii") + ).digest() + ).decode("ascii") + if handshake_delay: + time.sleep(handshake_delay) + connection.sendall( + ( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept}\r\n\r\n" + ).encode("ascii") + ) + command = json.loads(_read_websocket_text(connection)) + if reply_delay: + time.sleep(reply_delay) + try: + for payload in reply_payloads(command): + _send_websocket_json(connection, payload) + except OSError: + pass + time.sleep(0.05) + + thread = threading.Thread(target=serve, daemon=True) + thread.start() + return f"ws://{host}:{port}/devtools/browser/test", requests, listener + +def _start_tls_cdp_proxy( + upstream_endpoint: str, + tmp_path: Path, + *, + broken_handshake: bool = False, +): + ca_cert_path = tmp_path / "cdp-wss-ca-cert.pem" + ca_key_path = tmp_path / "cdp-wss-ca-key.pem" + cert_path = tmp_path / "cdp-wss-cert.pem" + key_path = tmp_path / "cdp-wss-key.pem" + csr_path = tmp_path / "cdp-wss.csr" + extensions_path = tmp_path / "cdp-wss-extensions.cnf" + extensions_path.write_text( + "subjectAltName=IP:127.0.0.1,DNS:localhost\n" + "basicConstraints=critical,CA:FALSE\n" + "keyUsage=critical,digitalSignature,keyEncipherment\n" + "extendedKeyUsage=serverAuth\n" + ) + commands = [ + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + str(ca_key_path), + "-out", + str(ca_cert_path), + "-days", + "1", + "-subj", + "/CN=Rustwright Test CA", + "-addext", + "basicConstraints=critical,CA:TRUE", + "-addext", + "keyUsage=critical,keyCertSign,cRLSign", + ], + [ + "openssl", + "req", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + str(key_path), + "-out", + str(csr_path), + "-subj", + "/CN=localhost", + ], + [ + "openssl", + "x509", + "-req", + "-in", + str(csr_path), + "-CA", + str(ca_cert_path), + "-CAkey", + str(ca_key_path), + "-CAcreateserial", + "-out", + str(cert_path), + "-days", + "1", + "-extfile", + str(extensions_path), + ], + ] + for command in commands: + proc = subprocess.run(command, text=True, capture_output=True, timeout=10) + if proc.returncode != 0: + pytest.skip(f"openssl could not create a local TLS certificate: {proc.stderr or proc.stdout}") + + parsed = urlparse(upstream_endpoint) + assert parsed.scheme == "ws" + assert parsed.hostname is not None + assert parsed.port is not None + upstream_address = (parsed.hostname, parsed.port) + upstream_host = f"{parsed.hostname}:{parsed.port}" + request_target = parsed.path or "/" + if parsed.query: + request_target += f"?{parsed.query}" + + server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.load_cert_chain(certfile=str(cert_path), keyfile=str(key_path)) + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + host, port = listener.getsockname() + requests: list[str] = [] + errors: list[str] = [] + + def serve() -> None: + try: + connection, _ = listener.accept() + except OSError: + return + try: + with server_context.wrap_socket(connection, server_side=True) as tls_connection: + tls_connection.settimeout(2) + request = bytearray() + while b"\r\n\r\n" not in request: + chunk = tls_connection.recv(4096) + if not chunk: + raise AssertionError("TLS CDP client closed before WebSocket Upgrade") + request.extend(chunk) + request_text = request.decode("iso-8859-1") + requests.append(request_text) + if broken_handshake: + key = next( + line.split(":", 1)[1].strip() + for line in request_text.splitlines() + if line.lower().startswith("sec-websocket-key:") + ) + accept = base64.b64encode( + hashlib.sha1( + (key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii") + ).digest() + ).decode("ascii") + tls_connection.sendall( + ( + "HTTP/1.1 101 Switching Protocols\r\n" + f"Sec-WebSocket-Accept: {accept}\r\n\r\n" + ).encode("ascii") + ) + return + with socket.create_connection(upstream_address, timeout=2) as upstream: + rewritten_lines = [] + for line in request_text.split("\r\n"): + if line.lower().startswith("host:"): + rewritten_lines.append(f"Host: {upstream_host}") + else: + rewritten_lines.append(line) + upstream.sendall("\r\n".join(rewritten_lines).encode("iso-8859-1")) + tls_connection.settimeout(None) + upstream.settimeout(None) + while True: + readable, _, _ = select.select([tls_connection, upstream], [], [], 0.1) + if not readable: + continue + for source in readable: + target = upstream if source is tls_connection else tls_connection + try: + chunk = source.recv(65536) + except OSError: + return + if not chunk: + return + try: + target.sendall(chunk) + except OSError: + return + except Exception as error: + errors.append(repr(error)) + connection.close() + + thread = threading.Thread(target=serve, daemon=True) + thread.start() + endpoint = f"wss://{host}:{port}{request_target}" + return endpoint, ca_cert_path.read_text(), requests, errors, listener, thread + + +def _surface_connection_error( + surface: str, + browser_name: str, + method: str, + endpoint, + **kwargs, +) -> str: + module = importlib.import_module(surface) + manager_name = "async_playwright" if surface.endswith("async_api") else "sync_playwright" + manager = getattr(module, manager_name) + if surface.endswith("async_api"): + + async def run() -> str: + try: + async with manager() as playwright_instance: + await getattr(getattr(playwright_instance, browser_name), method)(endpoint, **kwargs) + except module.Error as error: + return str(error) + raise AssertionError("connection unexpectedly succeeded") + + return asyncio.run(run()) + + try: + with manager() as playwright_instance: + getattr(getattr(playwright_instance, browser_name), method)(endpoint, **kwargs) + except module.Error as error: + return str(error) + raise AssertionError("connection unexpectedly succeeded") + +CONNECT_BOUNDARY_NAMES = ( + "DNS resolution", + "discovery HTTP request", + "WebSocket connector", + "native connect binding", +) + + +def _require_connect_test_support(): + from rustwright import _rustwright + + required = ( + "_test_connect_over_cdp", + "_test_set_connect_options", + "_test_reset_connect_boundary_counters", + "_test_connect_boundary_counters", + ) + missing = [name for name in required if not hasattr(_rustwright, name)] + if missing: + pytest.skip( + "requires a test-support wheel: " + "`maturin develop --features test-support`; " + f"missing {', '.join(missing)}" + ) + return _rustwright + + +def _connect_boundary_counters() -> dict[str, int]: + _rustwright = _require_connect_test_support() + return dict(zip(CONNECT_BOUNDARY_NAMES, _rustwright._test_connect_boundary_counters())) + + +def _arm_browser_type_connect_no_io_sentinels(monkeypatch) -> Counter: + _rustwright = _require_connect_test_support() + _rustwright._test_reset_connect_boundary_counters() + native_calls: Counter = Counter() + + def fail_native_connect(*_args, **_kwargs): + native_calls["native connect monkeypatch"] += 1 + raise AssertionError("BrowserType.connect crossed the native connect binding") + + monkeypatch.setattr(_rustwright, "connect_over_cdp", fail_native_connect) + return native_calls + + +def _assert_browser_type_connect_did_not_cross_io(native_calls: Counter) -> None: + assert native_calls == Counter(), f"native sentinel calls: {native_calls}" + counters = _connect_boundary_counters() + expected = {boundary: 0 for boundary in CONNECT_BOUNDARY_NAMES} + assert counters == expected, f"Rust connect boundary counters changed: {counters}" + def png_size(data: bytes) -> tuple[int, int]: assert data.startswith(b"\x89PNG") @@ -2878,20 +3286,155 @@ def test_evaluate_runtime_probe_matches_playwright_classification(page): assert page.evaluate("(function() { 'use strict'; return this === undefined; })") is True -def test_chromium_connect_uses_direct_cdp_endpoint(playwright): +def test_chromium_connect_over_cdp_uses_direct_cdp_endpoint(playwright): launched = playwright.chromium.launch(headless=True) connected = None try: - connected = playwright.chromium.connect(launched._ws_endpoint, slow_mo=12) + connected = playwright.chromium.connect_over_cdp(launched._ws_endpoint, slow_mo=12) assert connected._slow_mo_ms == 12 page = connected.new_page() page.set_content("Connected") assert page.title() == "Connected" + connected.close() + assert not connected.is_connected() finally: if connected is not None: connected.close() - else: - launched.close() + launched.close() + +def test_chromium_connect_over_cdp_wss_rejects_untrusted_private_ca( + playwright, + tmp_path: Path, +): + launched = playwright.chromium.launch(headless=True) + listener = None + thread = None + try: + endpoint, _cert_pem, requests, _server_errors, listener, thread = _start_tls_cdp_proxy( + launched._ws_endpoint, tmp_path + ) + with pytest.raises(Error) as exc: + playwright.chromium.connect_over_cdp(endpoint, timeout=2_000) + assert str(exc.value) == SAFE_CONNECT_FAILED + thread.join(timeout=2) + assert not thread.is_alive() + assert requests == [] + finally: + if listener is not None: + listener.close() + if thread is not None: + thread.join(timeout=2) + launched.close() + + +def test_connect_test_options_are_consumed_by_preflight_rejection( + playwright, + tmp_path: Path, +): + _rustwright = _require_connect_test_support() + launched = playwright.chromium.launch(headless=True) + listener = None + thread = None + try: + endpoint, cert_pem, requests, _server_errors, listener, thread = _start_tls_cdp_proxy( + launched._ws_endpoint, tmp_path + ) + _rustwright._test_set_connect_options(None, cert_pem) + with pytest.raises(Error) as invalid_exc: + playwright.chromium.connect_over_cdp("ftp://127.0.0.1:1", timeout=2_000) + assert ( + str(invalid_exc.value) + == "BrowserType.connect_over_cdp: CDP endpoint must use ws, wss, http, or https" + ) + + with pytest.raises(Error) as exc: + playwright.chromium.connect_over_cdp(endpoint, timeout=2_000) + assert str(exc.value) == SAFE_CONNECT_FAILED + thread.join(timeout=2) + assert not thread.is_alive() + assert requests == [] + finally: + if listener is not None: + listener.close() + if thread is not None: + thread.join(timeout=2) + launched.close() + + +def test_chromium_connect_over_cdp_wss_runs_complete_flow_without_http_discovery( + playwright, + tmp_path: Path, +): + _rustwright = _require_connect_test_support() + launched = playwright.chromium.launch(headless=True) + connected = None + listener = None + thread = None + try: + endpoint, cert_pem, requests, server_errors, listener, thread = _start_tls_cdp_proxy( + launched._ws_endpoint, tmp_path + ) + stages: list[str] = [] + _rustwright._test_set_connect_options( + lambda stage, _remaining: stages.append(stage), + cert_pem, + ) + connected = playwright.chromium.connect_over_cdp(endpoint, timeout=2_000) + page = connected.new_page() + page.set_content("Secure CDP") + assert page.title() == "Secure CDP" + assert stages == ["Upgrade", "Target.setAutoAttach"] + + connected.close() + assert not connected.is_connected() + assert launched.is_connected() + thread.join(timeout=2) + assert not thread.is_alive() + request_lines = [request.splitlines()[0] for request in requests] + expected_target = urlparse(launched._ws_endpoint).path + assert request_lines == [f"GET {expected_target} HTTP/1.1"] + assert all("/json" not in request_line for request_line in request_lines) + assert sum("upgrade: websocket" in request.lower() for request in requests) == 1 + assert server_errors == [] + finally: + if connected is not None: + connected.close() + if listener is not None: + listener.close() + if thread is not None: + thread.join(timeout=2) + launched.close() + + +def test_chromium_connect_over_cdp_wss_rejects_incomplete_upgrade_response( + playwright, + tmp_path: Path, +): + _rustwright = _require_connect_test_support() + launched = playwright.chromium.launch(headless=True) + listener = None + thread = None + try: + endpoint, cert_pem, requests, server_errors, listener, thread = _start_tls_cdp_proxy( + launched._ws_endpoint, + tmp_path, + broken_handshake=True, + ) + _rustwright._test_set_connect_options(None, cert_pem) + with pytest.raises(Error) as exc: + playwright.chromium.connect_over_cdp(endpoint, timeout=2_000) + assert str(exc.value) == SAFE_CONNECT_FAILED + thread.join(timeout=2) + assert not thread.is_alive() + assert len(requests) == 1 + assert "sec-websocket-key:" in requests[0].lower() + assert server_errors == [] + finally: + if listener is not None: + listener.close() + if thread is not None: + thread.join(timeout=2) + launched.close() def test_chromium_connect_over_cdp_adopts_default_context_pages_and_disconnects_only(playwright): @@ -2948,7 +3491,7 @@ def test_chromium_connect_over_cdp_forwards_headers(playwright): websocket_listener.bind(("127.0.0.1", 0)) websocket_listener.listen(1) websocket_host, websocket_port = websocket_listener.getsockname() - http_seen: list[str | None] = [] + http_seen: list[tuple[str, str | None]] = [] websocket_seen: list[str] = [] def websocket_server(): @@ -2975,7 +3518,7 @@ def log_message(self, *_): pass def do_GET(self): - http_seen.append(self.headers.get("X-CDP-Test")) + http_seen.append((self.path, self.headers.get("Authorization"))) body = json.dumps( { "webSocketDebuggerUrl": ( @@ -2997,15 +3540,19 @@ def do_GET(self): ws_thread.start() try: - with pytest.raises(Error): + with pytest.raises(Error) as exc: playwright.chromium.connect_over_cdp( f"http://{http_host}:{http_port}", - headers={"X-CDP-Test": "seen"}, + headers={"Authorization": "Bearer forwarding-secret"}, timeout=1_000, ) - assert wait_until(lambda: http_seen)[0] == "seen" + assert wait_until(lambda: http_seen) == [ + ("/json/version", "Bearer forwarding-secret") + ] raw_websocket_request = wait_until(lambda: websocket_seen)[0].lower() - assert "x-cdp-test: seen" in raw_websocket_request + assert "authorization: bearer forwarding-secret" in raw_websocket_request + assert str(exc.value) == SAFE_CONNECT_FAILED + assert "forwarding-secret" not in str(exc.value) finally: http_server.shutdown() http_server.server_close() @@ -3084,10 +3631,7 @@ def do_GET(self): http_server.shutdown() http_server.server_close() - assert str(exc.value).splitlines()[0] == ( - "BrowserType.connect_over_cdp: Unexpected status 500 when connecting to " - f"{endpoint}/json/version/." - ) + assert str(exc.value) == REMOTE_DISCOVERY_FAILED def test_chromium_connect_over_cdp_websocket_status_error_matches_playwright(playwright): @@ -3120,10 +3664,10 @@ def do_GET(self): http_server.shutdown() http_server.server_close() - lines = str(exc.value).splitlines() - assert lines[0] == f"BrowserType.connect_over_cdp: WebSocket error: {endpoint} 502 Bad Gateway" - assert lines[1] == body.decode("utf-8") - assert f" - {endpoint} 502 Bad Gateway" in lines + message = str(exc.value) + assert message == SAFE_CONNECT_FAILED + assert endpoint not in message + assert body.decode("utf-8") not in message def test_chromium_connect_option_validation_matches_playwright(playwright): @@ -3152,6 +3696,704 @@ def test_chromium_connect_option_validation_matches_playwright(playwright): ) +@pytest.mark.parametrize( + "surface", + [ + "rustwright.sync_api", + "rustwright.async_api", + "playwright.sync_api", + "playwright.async_api", + ], +) +@pytest.mark.parametrize( + "endpoint", + [ + "ws://127.0.0.1:1/devtools/browser/test", + "http://127.0.0.1:1", + ], +) +@pytest.mark.parametrize("engine_name", ["chromium", "firefox", "webkit"]) +def test_browser_type_connect_unsupported_message_matrix( + monkeypatch, surface: str, engine_name: str, endpoint: str +): + calls = _arm_browser_type_connect_no_io_sentinels(monkeypatch) + assert ( + _surface_connection_error( + surface, + engine_name, + "connect", + endpoint, + ) + == UNSUPPORTED_CONNECT + ) + _assert_browser_type_connect_did_not_cross_io(calls) + + +@pytest.mark.parametrize( + ("endpoint", "kwargs", "expected"), + [ + (123, {}, "BrowserType.connect: endpoint: expected string, got number"), + ( + "ws://127.0.0.1:1/devtools/browser/test", + {"slow_mo": True}, + "BrowserType.connect: slow_mo: expected float, got boolean", + ), + ( + "ws://127.0.0.1:1/devtools/browser/test", + {"timeout": "10"}, + "BrowserType.connect: timeout: expected float, got string", + ), + ( + "ws://127.0.0.1:1/devtools/browser/test", + {"expose_network": 1}, + "BrowserType.connect: expose_network: expected string, got number", + ), + ( + "ws://127.0.0.1:1/devtools/browser/test", + {"headers": {1: "value"}}, + "BrowserType.connect: headers[0].name: expected string, got number", + ), + ( + "ws://127.0.0.1:1/devtools/browser/test", + {"headers": {"Authorization": 1}}, + "BrowserType.connect: headers[0].value: expected string, got number", + ), + ], +) +@pytest.mark.parametrize( + "surface", + [ + "rustwright.sync_api", + "rustwright.async_api", + "playwright.sync_api", + "playwright.async_api", + ], +) +@pytest.mark.parametrize("engine_name", ["chromium", "firefox", "webkit"]) +def test_browser_type_connect_validation_precedes_unsupported_without_io( + monkeypatch, + endpoint, + kwargs: dict[str, Any], + expected: str, + surface: str, + engine_name: str, +): + calls = _arm_browser_type_connect_no_io_sentinels(monkeypatch) + assert ( + _surface_connection_error(surface, engine_name, "connect", endpoint, **kwargs) + == expected + ) + _assert_browser_type_connect_did_not_cross_io(calls) + + +def test_connect_over_cdp_http_raw_cdp_uses_json_version_only(playwright): + launched = playwright.chromium.launch(headless=True) + connected = None + + def responder(path, _headers, _count): + assert path == "/json/version" + return ( + 200, + "application/json", + json.dumps({"webSocketDebuggerUrl": launched._ws_endpoint}), + 0, + ) + + server, endpoint, requests = _start_remote_http_fixture(responder) + try: + connected = playwright.chromium.connect_over_cdp(endpoint) + page = connected.new_page() + page.set_content("HTTP discovery") + assert page.title() == "HTTP discovery" + assert [path for path, _ in requests] == ["/json/version"] + finally: + if connected is not None: + connected.close() + launched.close() + server.shutdown() + server.server_close() + +def test_connect_boundary_counters_cover_public_http_discovery(playwright): + _rustwright = _require_connect_test_support() + launched = playwright.chromium.launch(headless=True) + connected = None + + def responder(path, _headers, _count): + assert path == "/json/version" + return ( + 200, + "application/json", + json.dumps({"webSocketDebuggerUrl": launched._ws_endpoint}), + 0, + ) + + server, endpoint, requests = _start_remote_http_fixture(responder) + _rustwright._test_reset_connect_boundary_counters() + try: + connected = playwright.chromium.connect_over_cdp(endpoint) + assert [path for path, _ in requests] == ["/json/version"] + assert _connect_boundary_counters() == { + "DNS resolution": 2, + "discovery HTTP request": 1, + "WebSocket connector": 1, + "native connect binding": 1, + } + finally: + if connected is not None: + connected.close() + launched.close() + server.shutdown() + server.server_close() + + +@pytest.mark.parametrize( + "version_body", + [ + "Running", + json.dumps({"Browser": "Chromium"}), + ], +) +def test_connect_over_cdp_http_playwright_discovery_is_definitive(version_body: str): + def responder(path, _headers, _count): + if path == "/json/version": + return 200, "application/json", version_body, 0 + assert path == "/json" + return 200, "application/json", json.dumps({"wsEndpointPath": "/"}), 0 + + server, endpoint, requests = _start_remote_http_fixture(responder) + try: + assert ( + _surface_connection_error( + "rustwright.sync_api", + "chromium", + "connect_over_cdp", + endpoint, + timeout=1_000, + ) + == DEFINITIVE_PLAYWRIGHT_DISCOVERY + ) + assert [path for path, _ in requests] == ["/json/version", "/json"] + finally: + server.shutdown() + server.server_close() + + +def test_connect_over_cdp_http_malformed_discovery_is_neutral(): + def responder(path, _headers, _count): + body = "{" if path == "/json/version" else json.dumps(["wrong shape"]) + return 200, "application/json", body, 0 + + server, endpoint, requests = _start_remote_http_fixture(responder) + try: + assert ( + _surface_connection_error( + "rustwright.sync_api", + "chromium", + "connect_over_cdp", + endpoint, + timeout=1_000, + ) + == REMOTE_DISCOVERY_FAILED + ) + assert [path for path, _ in requests] == ["/json/version", "/json"] + finally: + server.shutdown() + server.server_close() + + +@pytest.mark.parametrize( + ("first_response", "second_response", "expected"), + [ + ( + None, + (200, "application/json", json.dumps({"wsEndpointPath": "/"}), 0), + DEFINITIVE_PLAYWRIGHT_DISCOVERY, + ), + ( + None, + (502, "text/plain", "proxy secret", 0), + REMOTE_DISCOVERY_FAILED, + ), + ( + (500, "text/plain", "first secret", 0), + None, + REMOTE_DISCOVERY_FAILED, + ), + ], +) +def test_connect_over_cdp_http_transport_failures_follow_ordered_classifier( + first_response, second_response, expected: str +): + def responder(path, _headers, _count): + return first_response if path == "/json/version" else second_response + + server, endpoint, requests = _start_remote_http_fixture(responder) + try: + assert ( + _surface_connection_error( + "rustwright.sync_api", + "chromium", + "connect_over_cdp", + endpoint, + timeout=1_000, + ) + == expected + ) + assert [path for path, _ in requests] == ["/json/version", "/json"] + finally: + server.shutdown() + server.server_close() + + +def test_connect_over_cdp_http_mixed_401_forwards_headers_and_detects_playwright(): + secret = "Bearer discovery-secret" + + def responder(path, headers, _count): + assert headers["authorization"] == secret + if path == "/json/version": + return 401, "text/plain", "unauthorized", 0 + return 200, "application/json", json.dumps({"wsEndpointPath": "/"}), 0 + + server, endpoint, requests = _start_remote_http_fixture(responder) + try: + message = _surface_connection_error( + "rustwright.sync_api", + "chromium", + "connect_over_cdp", + endpoint, + timeout=1_000, + headers={"Authorization": secret}, + ) + assert message == DEFINITIVE_PLAYWRIGHT_DISCOVERY + assert [path for path, _ in requests] == ["/json/version", "/json"] + assert secret not in message + finally: + server.shutdown() + server.server_close() + + +@pytest.mark.parametrize( + "surface", + [ + "rustwright.sync_api", + "rustwright.async_api", + "playwright.sync_api", + "playwright.async_api", + ], +) +def test_connect_over_cdp_http_two_401_bodies_are_neutral_and_redacted(surface: str): + secrets = { + "authorization": "Bearer authorization-secret", + "proxy-authorization": "Basic proxy-secret", + "cookie": "session=cookie-secret", + } + body = "https://user:password@upstream.invalid/?token=body-secret" + + def responder(_path, headers, _count): + for name, value in secrets.items(): + assert headers[name] == value + return 401, "text/plain", body, 0 + + server, endpoint, requests = _start_remote_http_fixture(responder) + try: + message = _surface_connection_error( + surface, + "chromium", + "connect_over_cdp", + endpoint, + timeout=1_000, + headers={ + "Authorization": secrets["authorization"], + "Proxy-Authorization": secrets["proxy-authorization"], + "Cookie": secrets["cookie"], + }, + ) + assert message == REMOTE_DISCOVERY_FAILED + assert len(requests) == 2 + for secret in [*secrets.values(), body, "body-secret", "password"]: + assert secret not in message + finally: + server.shutdown() + server.server_close() + + +@pytest.mark.parametrize( + ("input_suffix", "expected_paths"), + [ + ( + "/tenant/root?token=query-secret", + [ + "/tenant/root/json/version?token=query-secret", + "/tenant/root/json?token=query-secret", + ], + ), + ( + "/tenant/root/json/version?token=query-secret", + [ + "/tenant/root/json/version?token=query-secret", + "/tenant/root/json?token=query-secret", + ], + ), + ( + "/tenant/root/json?token=query-secret", + [ + "/tenant/root/json/version?token=query-secret", + "/tenant/root/json?token=query-secret", + ], + ), + ( + "/tenant/root/json/version", + [ + "/tenant/root/json/version", + "/tenant/root/json", + ], + ), + ( + "/tenant/root/json", + [ + "/tenant/root/json/version", + "/tenant/root/json", + ], + ), + ], +) +def test_connect_over_cdp_http_derives_routes_with_path_query_and_suffix( + input_suffix: str, expected_paths: list[str] +): + def responder(_path, _headers, _count): + return 200, "application/json", "{}", 0 + + server, endpoint, requests = _start_remote_http_fixture(responder) + try: + message = _surface_connection_error( + "rustwright.sync_api", + "chromium", + "connect_over_cdp", + endpoint + input_suffix, + timeout=1_000, + ) + assert message == REMOTE_DISCOVERY_FAILED + assert [path for path, _ in requests] == expected_paths + assert "query-secret" not in message + finally: + server.shutdown() + server.server_close() + + +def test_connect_over_cdp_http_deadline_is_shared_across_both_routes(): + def responder(path, _headers, _count): + delay = 0.12 if path == "/json/version" else 0.20 + return 200, "application/json", "{}", delay + + server, endpoint, requests = _start_remote_http_fixture(responder) + started = time.monotonic() + try: + with sync_playwright() as playwright_instance: + with pytest.raises(TimeoutError): + playwright_instance.chromium.connect_over_cdp(endpoint, timeout=250) + elapsed = time.monotonic() - started + assert 0.20 <= elapsed < 0.36 + assert [path for path, _ in requests] == ["/json/version", "/json"] + finally: + server.shutdown() + server.server_close() + + +def test_connect_over_cdp_http_deadline_continues_through_upgrade_and_setup(playwright): + launched = playwright.chromium.launch(headless=True) + connected = None + + def responder(path, _headers, _count): + assert path == "/json/version" + return ( + 200, + "application/json", + json.dumps({"webSocketDebuggerUrl": launched._ws_endpoint}), + 0.10, + ) + + server, endpoint, requests = _start_remote_http_fixture(responder) + started = time.monotonic() + try: + connected = playwright.chromium.connect_over_cdp(endpoint, timeout=1_000) + assert time.monotonic() - started < 1.0 + assert [path for path, _ in requests] == ["/json/version"] + finally: + if connected is not None: + connected.close() + launched.close() + server.shutdown() + server.server_close() + + +def test_connect_over_cdp_http_deadline_expires_across_discovery_upgrade_and_setup(): + from rustwright import _rustwright + + stage_budgets: list[tuple[str, float]] = [] + + def observe_stage(stage: str, remaining: float) -> None: + stage_budgets.append((stage, remaining)) + + + def replies(command): + return [{"id": command["id"], "result": {}}] + + websocket_endpoint, websocket_requests, listener = _start_websocket_reply_fixture( + replies, + handshake_delay=0.05, + reply_delay=0.30, + ) + + def responder(path, _headers, _count): + assert path == "/json/version" + return ( + 200, + "application/json", + json.dumps({"webSocketDebuggerUrl": websocket_endpoint}), + 0.05, + ) + + server, endpoint, requests = _start_remote_http_fixture(responder) + _rustwright = _require_connect_test_support() + _rustwright._test_set_connect_options(observe_stage) + started = time.monotonic() + try: + with sync_playwright() as playwright_instance: + with pytest.raises(TimeoutError): + playwright_instance.chromium.connect_over_cdp(endpoint, timeout=250) + elapsed = time.monotonic() - started + assert 0.20 <= elapsed < 0.30 + assert [path for path, _ in requests] == ["/json/version"] + assert len(websocket_requests) == 1 + assert [stage for stage, _ in stage_budgets] == [ + "Upgrade", + "Target.setAutoAttach", + ] + upgrade_budget = stage_budgets[0][1] + setup_budget = stage_budgets[1][1] + assert 0 < upgrade_budget < 0.225 + assert 0 < setup_budget < upgrade_budget - 0.025 + finally: + listener.close() + server.shutdown() + server.server_close() + + +@pytest.mark.parametrize( + "surface", + [ + "rustwright.sync_api", + "rustwright.async_api", + "playwright.sync_api", + "playwright.async_api", + ], +) +def test_connect_over_cdp_definitive_error_matches_all_public_surfaces(surface: str): + def responder(path, _headers, _count): + if path == "/json/version": + return 200, "text/plain", "Running", 0 + return 200, "application/json", json.dumps({"wsEndpointPath": "/"}), 0 + + server, endpoint, requests = _start_remote_http_fixture(responder) + try: + assert ( + _surface_connection_error( + surface, + "chromium", + "connect_over_cdp", + endpoint, + timeout=1_000, + ) + == DEFINITIVE_PLAYWRIGHT_DISCOVERY + ) + assert len(requests) == 2 + finally: + server.shutdown() + server.server_close() + + +@pytest.mark.parametrize( + "surface", + [ + "rustwright.sync_api", + "rustwright.async_api", + "playwright.sync_api", + "playwright.async_api", + ], +) +def test_connect_over_cdp_suspected_error_matches_all_public_surfaces(surface: str): + nested_secret = "gateway-secret-must-not-leak" + + def replies(command): + return [ + { + "id": command["id"], + "error": {"error": {"message": nested_secret}}, + } + ] + + endpoint, requests, listener = _start_websocket_reply_fixture(replies) + try: + message = _surface_connection_error( + surface, + "chromium", + "connect_over_cdp", + endpoint, + timeout=1_000, + ) + assert message == SUSPECTED_PLAYWRIGHT_REPLY + assert "resembles" in message + assert "speaks the Playwright" not in message + assert nested_secret not in message + assert len(requests) == 1 + finally: + listener.close() + + +def test_connect_over_cdp_valid_cdp_event_disarms_suspected_protocol_heuristic(): + nested_secret = "late-nested-secret" + + def replies(command): + return [ + {"method": "Target.targetCreated", "params": {"targetInfo": {}}}, + { + "id": command["id"], + "error": {"error": {"message": nested_secret}}, + }, + ] + + endpoint, requests, listener = _start_websocket_reply_fixture(replies) + try: + message = _surface_connection_error( + "rustwright.sync_api", + "chromium", + "connect_over_cdp", + endpoint, + timeout=1_000, + ) + assert message == SAFE_CONNECT_FAILED + assert "Playwright wire protocol" not in message + assert nested_secret not in message + assert len(requests) == 1 + finally: + listener.close() + + +@pytest.mark.parametrize( + "surface", + ["rustwright.sync_api", "rustwright.async_api"], +) +def test_connect_over_cdp_upgrade_rejection_redacts_url_userinfo_query_and_body( + surface: str, +): + body = "user:password token=query-secret authorization=Bearer body-secret" + request_count = 0 + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *_): + pass + + def do_GET(self): + nonlocal request_count + request_count += 1 + self.send_response(502) + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body.encode("utf-8")) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=_serve_forever_fast, args=(server,), daemon=True) + thread.start() + host, port = server.server_address + endpoint = f"ws://user:password@{host}:{port}/socket?token=query-secret" + try: + message = _surface_connection_error( + surface, + "chromium", + "connect_over_cdp", + endpoint, + timeout=1_000, + headers={ + "Authorization": "Bearer authorization-secret", + "Proxy-Authorization": "Basic proxy-secret", + "Cookie": "session=cookie-secret", + }, + ) + assert message == SAFE_CONNECT_FAILED + assert request_count == 1 + for secret in [ + endpoint, + "user", + "password", + "query-secret", + "authorization-secret", + "proxy-secret", + "cookie-secret", + "body-secret", + ]: + assert secret not in message + finally: + server.shutdown() + server.server_close() + + +@pytest.mark.parametrize( + "surface", + [ + "rustwright.sync_api", + "rustwright.async_api", + "playwright.sync_api", + "playwright.async_api", + ], +) +def test_connect_over_cdp_empty_upgrade_rejection_never_falls_back_to_get(surface: str): + sentinel = "fallback-get-secret-sentinel" + requests: list[str] = [] + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *_): + pass + + def do_GET(self): + requests.append(self.path) + if len(requests) == 1: + self.send_response(502) + self.send_header("Content-Length", "0") + self.send_header("Connection", "close") + self.end_headers() + return + body = sentinel.encode("utf-8") + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=_serve_forever_fast, args=(server,), daemon=True) + thread.start() + host, port = server.server_address + endpoint = f"ws://{host}:{port}/socket" + try: + message = _surface_connection_error( + surface, + "chromium", + "connect_over_cdp", + endpoint, + timeout=1_000, + ) + assert message == SAFE_CONNECT_FAILED + assert requests == ["/socket"] + assert sentinel not in message + finally: + server.shutdown() + server.server_close() + + def test_browser_close_is_idempotent_and_emits_disconnected_once(playwright): browser = playwright.chromium.launch(headless=True) seen = [] @@ -29293,7 +30535,7 @@ def test_new_context_success_artifacts_are_discarded(new_context): assert not list(output_dir.glob("**/videos/*")) -def test_pytest_plugin_connect_options_use_browser_type_connect(tmp_path: Path): +def test_pytest_plugin_connect_options_use_browser_type_connect_over_cdp(tmp_path: Path): test_file = tmp_path / "test_plugin_connect_options.py" test_file.write_text( """ @@ -29318,7 +30560,7 @@ def launch(self, **kwargs): self.launch_calls.append(kwargs) return self.browser - def connect(self, **kwargs): + def connect_over_cdp(self, **kwargs): self.connect_calls.append(kwargs) return self.browser @@ -29334,19 +30576,19 @@ def browser_type(): @pytest.fixture(scope="session") def connect_options(): return { - "endpoint": "ws://127.0.0.1:9222/devtools/browser/test", + "endpoint_url": "ws://127.0.0.1:9222/devtools/browser/test", "headers": {"x-test": "yes"}, "slow_mo": 17, } -def test_launch_browser_uses_connect(launch_browser): +def test_launch_browser_uses_connect_over_cdp(launch_browser): browser = launch_browser(headless=False) assert browser is FAKE_BROWSER_TYPE.browser assert FAKE_BROWSER_TYPE.launch_calls == [] assert FAKE_BROWSER_TYPE.connect_calls == [ { - "endpoint": "ws://127.0.0.1:9222/devtools/browser/test", + "endpoint_url": "ws://127.0.0.1:9222/devtools/browser/test", "headers": {"x-test": "yes"}, "slow_mo": 17, } @@ -34008,7 +35250,7 @@ def test_browser_bind_returns_direct_cdp_endpoint(playwright): result = launched.bind("local-session", host="127.0.0.1", port=0) assert result == {"endpoint": launched._ws_endpoint} - connected = playwright.chromium.connect(result["endpoint"]) + connected = playwright.chromium.connect_over_cdp(result["endpoint"]) page = connected.new_page() page.set_content("Bound") assert page.title() == "Bound" From e37c5b1545f4e7b9cd9b70a05bf4a06789a88afd Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:15:25 +0000 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'CHANGELO?= =?UTF-8?q?G.md'=20with=20remote=20'CHANGELOG.md'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/224 --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3577c78..cc53099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable user-facing changes to Rustwright are documented in this file. ## [Unreleased] +### Breaking + +- **Release target: 0.3.0.** `BrowserType.connect()` no longer forwards raw-CDP + endpoints to `connect_over_cdp()`. It now rejects every endpoint before + network I/O because Rustwright does not implement Playwright wire. Migrate + sync code from `chromium.connect(endpoint)` to + `chromium.connect_over_cdp(endpoint)`. Migrate async code from + `await chromium.connect(endpoint)` to + `await chromium.connect_over_cdp(endpoint)`. Services that use + `playwright run-server` or `BrowserType.launchServer()` must change to a raw + Chromium CDP service. See + [`docs/REMOTE_BROWSERS.md`](docs/REMOTE_BROWSERS.md). + ### Changed - Reduced steady-state object-valued `evaluate()` results to one browser-protocol command after per-realm serializer setup; the first call in a new or recreated realm re-establishes that setup. From ac7f1be8898764b105fea8dc10a6b85b1fca9986 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:15:25 +0000 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'CONTRIBU?= =?UTF-8?q?TING.md'=20with=20remote=20'CONTRIBUTING.md'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/224 --- CONTRIBUTING.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b38124..8e6118e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,13 @@ iterating: pytest tests/test_rustwright_sync_api.py -k "launch_goto_title_and_url or click_updates_dom or fill_sets_value_and_dispatches_events or frame_locator_scopes_locators_to_iframe_content" ``` +The connect test-support cases require a feature-enabled native extension: + +```bash +maturin develop --features test-support +pytest tests/test_rustwright_sync_api.py -k "test_connect_test_options_are_consumed_by_preflight_rejection or test_chromium_connect_over_cdp_wss_rejects_untrusted_private_ca or test_chromium_connect_over_cdp_wss_runs_complete_flow_without_http_discovery or test_chromium_connect_over_cdp_wss_rejects_incomplete_upgrade_response or test_browser_type_connect_unsupported_message_matrix or test_browser_type_connect_validation_precedes_unsupported_without_io or test_connect_boundary_counters_cover_public_http_discovery" +``` + For a broader local smoke before opening a PR: ```bash From d9408c16f029e9c2dc302feef67356c4396bfa90 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:15:25 +0000 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'Cargo.lo?= =?UTF-8?q?ck'=20with=20remote=20'Cargo.lock'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/224 --- Cargo.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index ef250e5..99fd39a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1000,12 +1000,15 @@ dependencies = [ "libc", "pyo3", "reqwest", + "rustls", + "rustls-pki-types", "serde", "serde_json", "tempfile", "thiserror", "tokio", "tokio-tungstenite", + "webpki-roots 0.26.11", ] [[package]] From c70b0ffd3632a1d6ee46acc435ceb8970ad3bac0 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:15:25 +0000 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'Cargo.to?= =?UTF-8?q?ml'=20with=20remote=20'Cargo.toml'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/224 --- Cargo.toml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fad379a..b5b6271 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,9 +18,15 @@ resolver = "2" [features] default = ["python"] python = ["dep:pyo3"] -# Internal runtime-state probes for explicit test-wheel builds only. +# Native connection probes, local CA injection, and internal runtime-state +# probes for explicit test wheels only. # Default and release wheel builds must not enable this feature. -test-support = ["python"] +test-support = [ + "python", + "dep:rustls", + "dep:rustls-pki-types", + "dep:webpki-roots", +] [lib] name = "_rustwright" @@ -36,8 +42,11 @@ serde_json = "1.0.127" libc = "0.2.186" tempfile = "3.8.1" thiserror = "2" +rustls = { version = "0.23.41", default-features = false, features = ["ring", "std"], optional = true } +rustls-pki-types = { version = "1.15.0", optional = true } tokio = { version = "1.52.3", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time"] } tokio-tungstenite = { version = "0.29.0", features = ["rustls-tls-webpki-roots"] } +webpki-roots = { version = "0.26.11", optional = true } [profile.release] lto = "fat" From e36356563b07991b088c390a01f158e7b6e31d9c Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:15:25 +0000 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'LIMITATI?= =?UTF-8?q?ONS.md'=20with=20remote=20'LIMITATIONS.md'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/224 --- LIMITATIONS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/LIMITATIONS.md b/LIMITATIONS.md index 03c77e9..b47d493 100644 --- a/LIMITATIONS.md +++ b/LIMITATIONS.md @@ -4,6 +4,12 @@ Rustwright is an alpha, not a complete Playwright replacement. - Chromium only. Firefox and WebKit entry points currently raise unsupported browser errors. +- Playwright wire endpoints from `playwright run-server` and + `BrowserType.launchServer()` are unsupported. Remote Chromium requires raw + CDP through `chromium.connect_over_cdp()`. HTTP discovery can identify a + Playwright endpoint definitively. Direct WebSocket detection is heuristic + and can only report that the first reply resembles Playwright wire. See + [`docs/REMOTE_BROWSERS.md`](docs/REMOTE_BROWSERS.md). - Behavioral parity is not fully proven. Rustwright exposes a broad Playwright-shaped API, but API-surface coverage is not the same as complete browser behavior parity. From 077b7c34d80becc6be267437aed4e3dd892417b4 Mon Sep 17 00:00:00 2001 From: suchintan <3853670+suchintan@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:15:25 +0000 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=94=84=20synced=20local=20'README.m?= =?UTF-8?q?d'=20with=20remote=20'README.md'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/Skyvern-AI/rustwright-cloud/pull/224 --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 5c8f93a..84d6e2f 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,12 @@ Rustwright drives browsers — but you still need somewhere to run them. Skyvern Each session returns a `browser_address` CDP endpoint that Rustwright connects to like any remote Chromium (sessions bill while open). +Use `chromium.connect_over_cdp()` for every remote Chromium connection. +`BrowserType.connect()` is not a CDP alias and does not support Playwright +`run-server` endpoints. See the [remote-browser guide](docs/REMOTE_BROWSERS.md) +for migration steps, endpoint diagnostics, security guidance, and the pinned +Open WebUI compose recipe. + **Get started:** 1. Make an account at [app.skyvern.com](https://app.skyvern.com)