commit 0af812490d114d96bb081dabcadbb702e9145394 Author: wehub-resource-sync Date: Mon Jul 13 12:12:36 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9460091 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +# Never let signing material enter a Docker build context / image. +# The test image (test-infra/Dockerfile.test) uses selective COPY today, but +# this is defense-in-depth against a future `COPY . .`. +test-infra/signing/ +*.pem diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..807d598 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ + +# Use bd merge for beads JSONL files +.beads/issues.jsonl merge=beads diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..9608fc2 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +ko_fi: cloakhq diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..e1ca06f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug Report +about: Report a bug or detection issue +labels: bug +--- + +Description: + +CloakBrowser version: + +Wrapper: + +Environment: + +Launch options: + + +Tested with a different IP or proxy? + +Works outside Docker / on host machine? + +Steps to reproduce: + + +Error output / screenshots: + +Dockerfile (if applicable): + +Additional notes: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b394280 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + actions: + patterns: + - "*" + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + groups: + python: + patterns: + - "*" + + - package-ecosystem: "npm" + directory: "/js" + schedule: + interval: "weekly" + groups: + javascript: + patterns: + - "*" diff --git a/.github/workflows/attest-release.yml b/.github/workflows/attest-release.yml new file mode 100644 index 0000000..28b172b --- /dev/null +++ b/.github/workflows/attest-release.yml @@ -0,0 +1,29 @@ +name: Attest Release Binary + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g. chromium-v145.0.7632.159.2)' + required: true + +jobs: + attest: + runs-on: ubuntu-latest + permissions: + id-token: write # Sigstore OIDC + attestations: write # GitHub attestation API + contents: write # Download release assets + steps: + - name: Download release binaries + run: gh release download "$RELEASE_TAG" --repo CloakHQ/cloakbrowser --pattern "cloakbrowser-*.tar.gz" --pattern "cloakbrowser-*.zip" + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.inputs.tag }} + + - name: Attest build provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: | + cloakbrowser-*.tar.gz + cloakbrowser-*.zip diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ed3a7af --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install -e ".[dev]" pytest pytest-asyncio + - name: Run tests + run: pytest tests/ -v -m "not slow" + + javascript: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 20 + - name: Install and build + run: cd js && npm install && npm run build + - name: Typecheck + run: cd js && npm run typecheck + - name: Run tests + run: cd js && npm test + + dotnet: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: '8.0.x' + - name: Build + run: dotnet build dotnet/CloakBrowser.sln -c Release + - name: Run tests + run: dotnet test dotnet/CloakBrowser.sln -c Release --no-build diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..abd69a7 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,163 @@ +name: Publish + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + job: + description: 'Job to run (leave empty to run all)' + required: false + type: choice + options: + - '' + - publish-pypi + - publish-npm + - publish-nuget + - publish-docker + +concurrency: + group: publish + cancel-in-progress: false + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.12" + - name: Python tests + run: | + pip install -e ".[dev]" pytest pytest-asyncio + pytest tests/ -v -m "not slow" + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + - name: JavaScript tests + run: cd js && npm ci && npm run build && npm test + - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: '8.0.x' + - name: .NET tests + run: dotnet test dotnet/CloakBrowser.sln -c Release + + validate-version: + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.12" + - name: Check tag matches package versions + run: | + TAG="${GITHUB_REF_NAME#v}" + PY=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))') + JS=$(python -c 'import json; print(json.load(open("js/package.json"))["version"])') + NET=$(python -c 'import re; print(re.search(r"([^<]+)", open("dotnet/src/CloakBrowser/CloakBrowser.csproj").read()).group(1))') + echo "Tag: $TAG | Python: $PY | npm: $JS | .NET: $NET" + [ "$TAG" = "$PY" ] || { echo "ERROR: tag v$TAG != _version.py $PY"; exit 1; } + [ "$TAG" = "$JS" ] || { echo "ERROR: tag v$TAG != package.json $JS"; exit 1; } + [ "$TAG" = "$NET" ] || { echo "ERROR: tag v$TAG != CloakBrowser.csproj $NET"; exit 1; } + + publish-pypi: + needs: [test, validate-version] + if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped') && (github.event.inputs.job == '' || github.event.inputs.job == 'publish-pypi') + runs-on: ubuntu-latest + permissions: + id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.12" + - name: Build + run: | + pip install build + python -m build + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1 + + publish-npm: + needs: [test, validate-version] + if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped') && (github.event.inputs.job == '' || github.event.inputs.job == 'publish-npm') + runs-on: ubuntu-latest + permissions: + id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 # npm 11.11.0 native — no upgrade needed (Node 22.22.2 has broken npm) + registry-url: 'https://registry.npmjs.org' + - name: Build + run: cd js && npm ci && npm run build + - name: Publish to npm + run: cd js && npm publish --provenance --access public + + publish-nuget: + needs: [test, validate-version] + if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped') && (github.event.inputs.job == '' || github.event.inputs.job == 'publish-nuget') + runs-on: ubuntu-latest + permissions: + id-token: write # OIDC trusted publishing — no NUGET_API_KEY secret needed + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: '8.0.x' + - name: NuGet login (OIDC trusted publishing) + id: nuget-login + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 + with: + user: CloakHQ + - name: Pack + run: dotnet pack dotnet/src/CloakBrowser/CloakBrowser.csproj -c Release -o out + - name: Publish to NuGet + run: dotnet nuget push out/*.nupkg --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + + publish-docker: + needs: [test, validate-version] + if: always() && needs.test.result == 'success' && (needs.validate-version.result == 'success' || needs.validate-version.result == 'skipped') && (github.event.inputs.job == '' || github.event.inputs.job == 'publish-docker') + runs-on: ubuntu-latest + permissions: + id-token: write # Cosign keyless signing + attestations + contents: read + attestations: write + packages: write + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Extract version + run: | + VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))') + echo "VERSION=$VERSION" >> $GITHUB_ENV + - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_PAT }} + - name: Build and push + id: build + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + cloakhq/cloakbrowser:${{ env.VERSION }} + cloakhq/cloakbrowser:latest + provenance: true + sbom: true + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - name: Sign image + run: cosign sign --yes cloakhq/cloakbrowser@${{ steps.build.outputs.digest }} + - name: Attest build provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: index.docker.io/cloakhq/cloakbrowser + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c53ecb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +dist/ +build/ +*.egg +.eggs/ + +# Virtual environment +.venv/ +venv/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Binary cache (downloaded chromium) +.cloakbrowser/ + +# Claude Code (private project context) +CLAUDE.md +.claude/ + +# JavaScript / Node.js +js/node_modules/ +js/dist/ + +# Distribution +*.tar.gz +*.whl +AGENTS.md +.beads +result + +# Private docs (launch posts, strategy) +docs/ + +# Internal test infrastructure (Docker, VPS-specific) +test-infra/ + +# Website (deployed separately) +site/ + +# Browser profile manager (deployed separately) +manager/ + +# Release scripts +publish.sh +deploy.sh +.env +debug +publish-docker.sh +captures +20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.txt + +# Beads / Dolt files (added by bd init) +.dolt/ +*.db +.beads-credential-key +.antigravitycli +.plans +.pi diff --git a/BINARY-LICENSE.md b/BINARY-LICENSE.md new file mode 100644 index 0000000..3a8c62d --- /dev/null +++ b/BINARY-LICENSE.md @@ -0,0 +1,172 @@ +# CloakBrowser Binary License + +**Version 1.2 — July 2026** + +Copyright (c) 2026 CloakHQ. All rights reserved. + +This license applies to the compiled CloakBrowser Chromium browser binary ("Binary") distributed by CloakHQ through official CloakHQ distribution channels, including GitHub Releases and cloakbrowser.dev. + +It does **not** apply to the wrapper source code in this repository, which is licensed under the [MIT License](LICENSE). + +By downloading, installing, or using the Binary, you agree to be bound by the terms of this license. + +## Intellectual Property + +The Binary is built on Chromium, which is open-source software by The Chromium Authors under the BSD 3-Clause License, and incorporates components from the open-source ungoogled-chromium project. + +CloakHQ's build configuration, patches, compiled releases, and the Binary as distributed by CloakHQ are the proprietary property of CloakHQ. This license governs only the Binary as distributed by CloakHQ and does not restrict any rights granted by upstream open-source licenses to their respective components. + +## Grant of Use + +You are granted a non-exclusive, non-transferable, non-sublicensable license to use the Binary for personal, commercial, and internal business purposes, subject to the Version-Specific Terms, Restrictions, and other limitations in this license. + +## Version-Specific Terms + +Starting with the version currently listed on cloakbrowser.dev, downloading and using the **latest major** Binary version requires an active paid subscription at the tier designated by CloakHQ for such access. + +Binary versions that CloakHQ makes available for use without an active paid subscription, or older Binary versions that you lawfully obtained from official CloakHQ distribution channels, remain subject to this License in full. + +All terms, restrictions, and limitations in this License apply equally to such versions, regardless of whether they require a paid subscription, the specific version number, or the amount of time that has passed since their release. + +Current subscription tiers, details, and pricing: [https://cloakbrowser.dev](https://cloakbrowser.dev) + +### Changes to Version-Specific Terms + +CloakHQ may update these Version-Specific Terms in future releases, including which major Chromium versions require an active paid subscription. Existing Binary License holders will receive at least thirty (30) days' notice of any change that materially affects their current access tier, provided through the channels described in the Modifications to Pricing and Terms section below. Continued use of the Binary after the effective date of such a change constitutes acceptance of the updated terms. + +## Modifications to Pricing and Terms + +CloakHQ reserves the right to modify, add, rename, or discontinue features, pricing, usage limits, or other terms of any subscription plan or tier offered by CloakHQ, whether now existing or introduced in the future, at any time, in its sole discretion. + +**Notice period.** CloakHQ will provide at least thirty (30) days' advance notice of any material price increase or reduction in features for active, paying subscribers, via email to the account's registered address and/or a notice displayed in the account dashboard. + +**Effective date.** Price and term changes take effect at the start of the next billing cycle following the notice period. Changes do not apply retroactively to periods already paid for. + +**Your options.** If you do not agree to a modified price or term, you may cancel your subscription before the change takes effect. Continued use of the Binary or subscription after the effective date constitutes acceptance of the new pricing and terms. + +**No refunds on cancellation.** If you cancel mid-billing-cycle, whether in response to a pricing change or otherwise, no refund will be issued for the remaining, unused portion of that cycle. + +**Grandfathering.** At CloakHQ's discretion, existing subscribers may retain current pricing for a stated period (a "grandfather period") before transitioning to new pricing. Any such grandfather period will be communicated in the notice referenced above and is not a guarantee of future pricing. + +**Non-material changes.** Minor changes that do not increase price or materially reduce functionality, including, without limitation, UI updates, non-breaking API changes, or documentation updates, may be made without advance notice. + +## License Keys and Subscription Access + +Any license key, account access, or subscription entitlement provided by CloakHQ may be used only by the person or organization to whom it was issued and only within the applicable subscription terms. + +You may not share, sell, lease, transfer, publish, or otherwise make available any license key, account access, or subscription entitlement to any third party, or use it to circumvent license validation, session limits, or subscription requirements, and CloakHQ may immediately suspend or revoke access for any violation. + +## Restrictions + +You may NOT: + +1. **Redistribute** the Binary, in whole or in part, whether modified or unmodified. +2. **Resell, sublicense, or repackage** the Binary, or include it in any product or service distributed to third parties, except under a separate license. +3. **Reverse engineer, decompile, or disassemble** the Binary, or attempt to derive source code from it, except to the extent permitted by applicable law. +4. **Modify** the Binary or create derivative works based on it. +5. **Remove or alter** any copyright notices, license files, or attribution included with the Binary. + +Normal use of the Binary with command-line flags, browser extensions, managed policies, custom profiles, or user data directories does not constitute modification or creation of derivative works. + +## Cloud, Container, OEM/SaaS License & Integration Use + +Using CloakBrowser for your own internal browsing, web scraping, data extraction, automation, research, or business workflows does not require an OEM/SaaS license, regardless of company size or revenue, as long as you control the browsing process and do not give third parties control over the browser itself. Version-specific subscription requirements still apply to the latest major version. + +**Internal use** — You may store and run the unmodified CloakBrowser Binary within your internal infrastructure, including, without limitation, Docker images, CI runners, and internal artifact repositories, solely for your organization's internal operational purposes. + +Internal operational purposes include, without limitation, web scraping, data extraction, research, and similar workflows performed for your own business. + +A third-party customer may request a business deliverable, including, without limitation, a report, dataset, analysis, or similar output, and the use may still be considered internal use if your organization controls the browsing process, execution environment, and method of performing the work. + +This internal use allowance applies to both the older version of the CloakBrowser Binary and any paid subscription tier, subject to the limitations and restrictions defined in this License. + +**Paid subscription** — An active paid subscription at the applicable tier grants access to the latest major CloakBrowser Binary version and eligible updates included with that subscription. No subscription tier, regardless of name, grants redistribution, resale, sublicensing, bundling, embedding, hosted-service, white-label, OEM, or SaaS rights unless expressly stated in a separate agreement. + +**Dependency listing** — Listing CloakBrowser as a dependency in your project, including, without limitation, in package files, build scripts, or documentation, does not constitute redistribution, as end users download the CloakBrowser Binary directly from official CloakHQ channels. No separate OEM/SaaS license is required for this. + +**OEM/SaaS license required** — Except for permitted internal use as described above, a separate OEM/SaaS license is required when the CloakBrowser Binary is bundled, embedded, exposed through an API, or used to provide browser functionality to third-party customers, including, without limitation, as part of a product, hosted service, browser-as-a-service offering, or customer-facing workflow. + +An OEM/SaaS license is also required if third-party customers are given technical access to, or control over, the browser capability itself. For purposes of this License, "control" means the ability to operate, configure, manage, or influence the CloakBrowser Binary, its browsing sessions, execution settings, or similar technical aspects. Requests for specific data, targets, reports, or other business outputs do not constitute control if the licensee retains full authority over how the browsing process is executed. + +Running the CloakBrowser Binary on your own infrastructure does not make a use internal if the Binary is being used to provide browser functionality, hosted browser execution, browser capacity, or customer-controlled browser workflows to third-party customers. + +Contact [info@cloakbrowser.dev](mailto:info@cloakbrowser.dev) for OEM/SaaS licensing. + +## Official Distribution + +The Binary must originally be obtained from official CloakHQ distribution channels, including GitHub Releases and cloakbrowser.dev. Internal organizational mirrors permitted under the Cloud, Container, OEM/SaaS License & Integration Use section are not considered unauthorized sources. + +## Trademark Notice + +This license does not grant you any right to use the CloakHQ or CloakBrowser name, logo, or trademarks, except for nominative use reasonably necessary to refer to CloakHQ or CloakBrowser. + +## Attribution + +Attribution is appreciated but not required. If you'd like to credit CloakBrowser, a "Powered by CloakBrowser" notice with a link to https://github.com/CloakHQ/CloakBrowser in your documentation, README, or about page is welcome. + +## Acceptable Use + +You are solely responsible for how you use the Binary. You agree NOT to use the Binary for any activity that violates applicable laws or regulations in your jurisdiction. CloakHQ does not endorse, encourage, or support any illegal use. + +Without limiting the above, the following uses are expressly prohibited: + +* Unauthorized access to financial, banking, healthcare, or government authentication systems +* Credential stuffing, brute-force login attempts, or automated account creation +* Circumventing authentication on systems you do not own or have authorization to test +* Any activity that constitutes fraud, identity theft, or unauthorized data collection + +## Indemnification + +You agree to indemnify and hold harmless CloakHQ, its contributors, officers, employees, and affiliates from any claims, damages, losses, liabilities, and expenses, including reasonable legal fees, arising from your use of the Binary, your violation of this License, or your violation of any applicable law or third-party rights. + +## Disclaimer + +THE BINARY 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 CLOAKHQ, ITS CONTRIBUTORS, OFFICERS, EMPLOYEES, AFFILIATES, LICENSORS, AUTHORS, OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, LOSSES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING OUT OF OR RELATING TO THE BINARY, ITS USE, OR ANY OTHER DEALINGS WITH THE BINARY. + +## Limitation of Liability + +IN NO EVENT SHALL CLOAKHQ, ITS CONTRIBUTORS, OFFICERS, EMPLOYEES, AFFILIATES, LICENSORS, AUTHORS, OR COPYRIGHT HOLDERS BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, BUSINESS OPPORTUNITIES, GOODWILL, OR REVENUE, ARISING OUT OF OR RELATING TO THE BINARY, ITS USE, OR ANY OTHER DEALINGS WITH THE BINARY, REGARDLESS OF THE THEORY OF LIABILITY. + +CLOAKHQ'S TOTAL AGGREGATE LIABILITY SHALL NOT EXCEED THE LESSER OF ONE HUNDRED FIFTY US DOLLARS (US $150) OR THE AMOUNT ACTUALLY PAID BY YOU TO CLOAKHQ FOR THE BINARY DURING THE THREE (3) MONTHS PRECEDING THE CLAIM. + +## Data Collection + +CloakHQ does not intentionally include telemetry, analytics, or tracking mechanisms in the Binary, except for operational communications required for license validation and monitoring the number of concurrently open sessions. + +The Binary is built on ungoogled-chromium, which removes Google-specific services and telemetry. Any network activity may result from normal browser operation, Chromium subsystems, user configuration, extensions, license validation, session monitoring, or the web pages and services you access, and not from any telemetry or analytics service operated by CloakHQ. + +## Updates + +CloakHQ may provide updates, patches, new versions, or support for the Binary at its discretion, subject to any applicable subscription terms. Any updates provided are subject to this License unless CloakHQ states otherwise. + +## Termination + +This License terminates automatically if you violate any of its terms. Upon termination, you must stop using the Binary and destroy all copies of the Binary in your possession or control. The Intellectual Property, Restrictions, Trademark Notice, Acceptable Use, Indemnification, Disclaimer, Limitation of Liability, Governing Law, Reservation of Rights, Entire Agreement, No Waiver, Assignment, and Severability sections survive termination. + +## Governing Law + +This License is governed by the laws of the jurisdiction in which CloakHQ, or its applicable legal entity or successor, is established. Any disputes arising under or relating to this License shall be subject to the exclusive jurisdiction of the courts in that jurisdiction. + +## Reservation of Rights + +All rights not expressly granted under this License are reserved by CloakHQ. + +## Entire Agreement + +This License constitutes the entire agreement between you and CloakHQ regarding the Binary and supersedes any prior or contemporaneous understandings relating to the Binary. + +## No Waiver + +Failure by CloakHQ to enforce any provision of this License does not constitute a waiver of that provision or any other provision. + +## Assignment + +You may not assign or transfer this License or any rights under it without prior written consent from CloakHQ. + +## Severability + +If any provision of this License is held to be unenforceable or invalid, that provision shall be modified to the minimum extent necessary to make it enforceable, and all remaining provisions shall continue in full force and effect. + +## Contact + +For licensing inquiries, including separate licenses for redistribution, OEM, or SaaS use, contact [info@cloakbrowser.dev](mailto:info@cloakbrowser.dev). diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c6cba44 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,461 @@ +# Changelog + +All notable changes to CloakBrowser — wrapper and binary — are documented here. + +Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromium patches. + +--- + +## [Unreleased] + +- **[wrapper]** Authenticated HTTP/HTTPS proxies now use the browser's native proxy authentication on every platform whose binary supports it — resolved per platform and binary version — and fall back to the standard proxy path on older binaries that don't. Fixes credentialed HTTP/HTTPS proxies on macOS and ARM, which previously could not use the native path. Python, JavaScript, Puppeteer, and .NET. + +## [0.4.10] — 2026-07-09 + +- **[wrapper]** Fix JS CLI silently exiting with no output when run via `npx`/`node_modules/.bin` (#427). The entry-point guard compared `import.meta.url` to an unresolved `process.argv[1]`; symlinked bin installs (npm/pnpm/npx) never matched, so no subcommand ran. Now realpath-resolves the invoked path before comparing. +- **[wrapper]** Fix `humanize=True` breaking interactions with elements **inside iframes** (#428). `frame.locator("#btn").click()` / `fill()` / `hover()` / etc. (and the frame-level `frame.click(...)` equivalents) now resolve the selector in the owning sub-frame's own document instead of misrouting to the top page, which previously raised `ElementNotAttachedError`. Humanized mouse motion is preserved inside iframes. Python only (the JS and .NET wrappers were already frame-correct). + +## [0.4.9] — 2026-07-09 + +- **[wrapper]** Free-tier launch banner and `cloakbrowser info` upgrade hint now surface the **7-day free Pro trial** (Chromium 148). Python, JS, and .NET. +- **[wrapper]** `info`/`update` never diverge from the Pro build that actually launches — `info` now shows both the cached build that will launch and the server's latest Pro version so the two can no longer silently disagree. Unpinned launches prefer the server's latest when it's newer than (or replaces a missing) cached build; `update` is license-aware (a valid Pro key updates the Pro binary, everyone else updates free); the background Pro update check is now a rate-limited foreground check (one call/hour, honors `CLOAKBROWSER_AUTO_UPDATE=false`); a valid Pro license never silently falls back to free; binary verification failures now surface verbatim instead of falling back to a cached build. Python, JS, and .NET. +- **[wrapper]** `info`/`doctor` Windows-font check now covers the full 8-font set (adds the two monospace fonts) and reports a strict N/8 count, with the launch-time warning firing on any incomplete set rather than only a fully-missing one. Adds a separate Office-font group (10 fonts) reported as informational N/10 with no install nudge. Python, JS, and .NET. + +## [0.4.8] — 2026-07-05 + +- **[binary]** Chromium **148.0.7778.215.5** (Pro, Windows + Linux; macOS follows) — the biggest fingerprint update yet. Pro license required; v146 stays free. + - **More common, more coherent hardware identities** — each `--fingerprint` seed is built from the most common real-world hardware values (screen, GPU, RAM, CPU cores, color depth, fonts, audio), chosen together so they form a popular, self-consistent device with no internal contradictions. + - Deeper, more consistent **Windows persona** — expanded and localized font populations, tighter graphics/display alignment, and seed-coherent screen size, color depth, and audio across browser- and OS-level signals. + - Expanded **NVIDIA GPU profiles**, each matched to a plausible CPU, RAM, and screen combination. + - **Window and screen geometry coherence** in both headed and headless modes (pairs with the wrapper's new maximized-window default). + - Greater **WebGL and WebGPU realism**, including consistent behavior under timing inspection. + - **Cleaner proxy behavior** — better proxy-auth handling and less proxy-specific reload and cache leakage. + - New **pass-through debug mode** (`--fingerprint=off`) and a **third-party-cookie compatibility flag** (`--fingerprint-allow-3p-cookies`). + - **Runtime Pro license sessions** — privacy-preserving groundwork for future session-limit enforcement (`--license-through-proxy` opts these calls onto your proxy, Linux only for now). +- **[wrapper]** `geoip=True` now works **without a proxy** — on a direct connection it resolves the machine's own public IP for timezone, locale, and the WebRTC exit IP (previously it no-oped without a proxy, leaving UTC + `en-US` in bare Docker/cloud launches). Locale coverage expanded from 50 to **132 countries**. Python, JS, and .NET. +- **[wrapper]** Headed and headless launches now open **maximized** on the newest Pro binaries (version-gated, same threshold as the headless no-viewport default), so the window fills the spoofed screen and window/screen geometry stays self-consistent. Suppressed when you set an explicit window size/position or viewport; older and free binaries are unchanged. Python, JS, and .NET. +- **[docs]** Documented the Chromium 148+ pass-through and compatibility flags you can pass via `args`: `--fingerprint=off` (native pass-through debug mode), `--fingerprint-allow-3p-cookies` (third-party-cookie compatibility for reCAPTCHA v3 / SSO / payment challenges), and `--license-through-proxy` (route license/session calls through your proxy, Linux only for now). + +## [0.4.7] — 2026-07-02 + +- **[docker]** Update the public `cloaktest` suite to use free-tier-stable bot-detection checks while Pro continues to verify reCAPTCHA v3 at 0.9. The Docker smoke test now runs Sannysoft, Incolumitas, Rebrowser, deviceandbrowserinfo, BrowserScan, and CreepJS lies/noise=false; FingerprintJS and reCAPTCHA v3 are no longer hard-pass checks for the free v146 image. + +## [0.4.6] — 2026-07-02 + +- **[wrapper]** The resolved Pro license key is now passed to the browser process at launch (via `CLOAKBROWSER_LICENSE_KEY`) so the Pro binary can authenticate itself, including when you supply a custom `env`. Fixes launch failures on the newest Pro binaries. Python, JS, and .NET. +- **[wrapper]** Headless launches on the newest Pro binaries now track the real screen surface instead of a fixed emulated viewport, keeping window geometry self-consistent (version-gated — older and free binaries keep the deterministic headless viewport). Passing an explicit `viewport=`/`no_viewport` (Python) or `viewport`/`defaultViewport` (JS) still overrides. Python, JS, and .NET. + +## [0.4.5] — 2026-06-29 + +- **[wrapper]** **`info` is now a full diagnostics command** — it reports the binary that will actually launch given your license, runs a launch test (`chrome --version`, plus a missing-shared-library probe on Linux), validates the license key to show the real tier (`free`/`solo`/`team`/`business`, or `invalid`), and checks Windows-font availability (Linux), the GeoIP database, and optional dependencies. `--quick` skips the launch test; `--json` emits machine-readable output. Python, JS, and .NET. +- **[wrapper]** One-time startup notice when spoofing the Windows platform on a Linux host without Windows fonts installed, with guidance to install them for best results. Best-effort and silent on error; suppress with `CLOAKBROWSER_SUPPRESS_FONT_WARNING=1`. Python, JS, and .NET. +- **[wrapper]** The first-launch banner now re-shows to free users every 3 days (Pro users still see it once). Python, JS, and .NET. + +## [0.4.4] — 2026-06-27 + +- **[wrapper]** **Exact version pinning / rollback** — pin a specific Chromium build with `browser_version=` (`browserVersion` in JS, `BrowserVersion` in .NET) or the `CLOAKBROWSER_VERSION` environment variable, e.g. `launch(browser_version="148.0.7778.215.2")`. Works for both Free and Pro binaries and lets you roll back if a new build regresses on your site. A pin never overwrites the cached "latest" marker, so a later unpinned launch returns to the newest build. Python, JS, and .NET. +- **[wrapper]** The Pro version check now sends the client platform so each OS resolves its own latest build independently — a new release for one platform no longer affects the others. Python, JS, and .NET. +- **[binary]** Chromium **148.0.7778.215.3** (Pro) — fingerprint fixes and alignment across Linux, Windows, and macOS (Apple Silicon + Intel). Pro license required; v146 stays free. + +## [0.4.3] — 2026-06-24 + +- **[wrapper]** **.NET 8 / C# client** — CloakBrowser now ships as a NuGet package (`CloakBrowser`), mirroring the Python and JS wrappers. Contributed by [@evelaa123](https://github.com/evelaa123) in PR #385. +- **[wrapper]** **macOS Pro is now available** — the latest binary (Chromium 148.0.7778.215.2) downloads on macOS (Apple Silicon + Intel) with a Pro license, the same as Linux and Windows. v146 stays free. +- **[wrapper]** A valid Pro license now hard-fails on a Pro download error on *every* platform instead of silently downgrading to the free binary. The temporary macOS-only free fallback added in 0.4.2 is removed now that the macOS Pro build ships. Python, JS, and .NET. + +## [0.4.2] — 2026-06-23 + +- **[wrapper]** macOS Pro licenses now fall back to the free binary (macOS Pro build coming) instead of failing to launch. Transient and signature-verification failures still hard-fail. Python and JS. + +## [0.4.1] — 2026-06-23 + +- **[wrapper]** Humanize: fix "Viewport size not available" crash on headed launches. Headed mode defaults to `no_viewport` (since 0.4.0), so `page.viewport_size` is `None` — human scroll now falls back to the live `window.innerWidth`/`window.innerHeight`. Covers Playwright (Python sync/async, JS) and Puppeteer. +- **[wrapper]** **[docker]** Widevine: opt-in CDM auto-fetch for persistent contexts. Set `CLOAKBROWSER_FETCH_WIDEVINE` and the Docker entrypoint pulls the Widevine CDM from Google's component server (arch-aware, SHA-256 verified, atomic, cached), so DRM playback works without a local Chrome to copy from. Off by default; bare-metal Linux users can run `bin/fetch-widevine.py` directly. +- **[meta]** First-launch banner now promotes the Pro tier (header badge dropped); refreshed README test-results stamp to Jun 2026 / Chromium 148. + +## [0.4.0] — 2026-06-22 + +- **[wrapper]** **CloakBrowser Pro**: all launch functions now accept a `license_key` parameter (`licenseKey` in JS); a key can also be supplied via the `CLOAKBROWSER_LICENSE_KEY` environment variable or a `~/.cloakbrowser/license.key` file. With a valid key the latest binary is downloaded from cloakbrowser.dev; without one, the free binary continues to download from GitHub Releases exactly as before. License validation is cached locally for 24h, and the Pro binary is authenticated with the same pinned Ed25519 signature as the free binary. A valid key whose Pro download or signature check fails surfaces a clear error rather than silently downgrading to the free binary. Adds `validate_license`/`LicenseInfo` exports and a `tier` field on `binary_info()`. Details: https://cloakbrowser.dev +- **[wrapper]** **Security**: downloaded binaries are now verified against a pinned Ed25519 signature on the published `SHA256SUMS` (a detached `SHA256SUMS.sig`), so a compromised download mirror can no longer certify a tampered binary — the previous same-origin checksum proved integrity but not authenticity (#308). The signed manifest also binds the release version, rejecting a forced downgrade to an older signed build. Verification is mandatory on the official download path; silent auto-update is preserved for everyone because only a constant public key is pinned, not per-version hashes. Older installed wrappers are unaffected. +- **[wrapper]** Headed launches no longer apply a fixed emulated viewport on top of the real browser window — the page now tracks the actual window so window-geometry stays self-consistent. Headless keeps a deterministic viewport (unchanged). Applies across `launch`, `launch_context`, `launch_persistent_context` (+ async) and the JS Playwright/Puppeteer wrappers. Passing an explicit `viewport=`/`no_viewport` (Python) or `viewport`/`defaultViewport` (JS) still works exactly as before. +- **[wrapper]** **Breaking**: removed the optional `patchright` backend. The `backend` parameter and `CLOAKBROWSER_BACKEND` environment variable no longer exist, and the `cloakbrowser[patchright]` extra is gone. Stock Playwright is now the only backend. The stealth binary handles automation-signal suppression at the C++ level — patchright added no measurable benefit on top of it (identical reCAPTCHA v3 score to plain Playwright) while breaking proxy auth and `add_init_script` (#27). Callers passing `backend=...` will get a `TypeError`; remove the argument. +- **[binary]** **CloakBrowser Pro — first Pro build**: Chromium `148.0.7778.215.2` for linux-x64, linux-arm64, and windows-x64 — 59 source-level fingerprint patches (up from 58 on 146). macOS builds to follow. Available to Pro subscribers at cloakbrowser.dev; v146 remains free on GitHub Releases. +- **[binary]** Rebased the full patch set across two Chromium major versions — 146 → 147 → 148 — re-applying and adapting every patch to current Chromium internals +- **[binary]** Cross-API fingerprint consistency improvements for Chromium 148 profiles +- **[binary]** WebRTC fingerprint hardening — network signals matched to real Chrome +- **[binary]** Font metric alignment for Windows profiles via the opt-in `--fingerprint-windows-font-metrics` flag — requires Windows fonts installed (see README "Font Setup on Linux") + +## [0.3.32] — 2026-06-20 + +- **[wrapper]** **Security**: Windows binary extraction — pass archive/destination paths to PowerShell via env vars instead of interpolating into the `-Command` string, closing a code-injection shape on paths containing single quotes (e.g. `C:\Users\O'Brien`) +- **[wrapper]** Widevine: auto-seed CDM hint file for persistent contexts on Linux, so DRM playback works without manual pre-seeding +- **[wrapper]** `cloakserve`: rewrite CDP WebSocket URLs so clients connect through the proxy correctly (thanks [@honor2030](https://github.com/honor2030), #234) +- **[wrapper]** `cloakserve`: add idle cleanup for seeded profiles (thanks [@Kumario1](https://github.com/Kumario1), #352) +- **[meta]** Fix `recaptcha_score.py` example — wait for the reCAPTCHA score to render before screenshot (thanks [@igo](https://github.com/igo) for the report, #374) +- **[meta]** Bump GitHub Actions in the actions group (#358) + +## [0.3.31] — 2026-05-26 + +- **[wrapper]** Route HTTP proxy credentials through `--proxy-server` flag, removing the need for Playwright's proxy auth handler on HTTP proxies +- **[wrapper]** JS: export `buildContextOptions` helper for custom context creation (thanks [@honor2030](https://github.com/honor2030), #262) +- **[wrapper]** Humanize: fix iframe coordinate offset in pointer-events check (thanks [@eofreternal](https://github.com/eofreternal), #303) +- **[wrapper]** Humanize: use shared deadline for timeout budget in frame and ElementHandle methods (#307) +- **[docker]** Clean up stale Xvfb lock so container survives restarts (thanks [@sparanoid](https://github.com/sparanoid), #284) +- **[meta]** Add pip and npm ecosystems to Dependabot, bump GitHub Actions (#309) + +## [0.3.30] — 2026-05-21 + +- **[binary]** New build 146.0.7680.177.5 for Linux x64 + Windows x64 — 58 source-level fingerprint patches (up from 57) +- **[binary]** Rendering consistency improvements across Linux and Windows — corrected GPU, display, and graphics parameters to match stock Chrome 146 profiles +- **[binary]** Windows: native GPU/rendering values now pass through directly instead of being spoofed, matching real hardware behavior +- **[binary]** Storage normalization fix for Windows +- **[binary]** HTTP proxy inline credential support at the network layer +- **[wrapper]** Update `PLATFORM_CHROMIUM_VERSIONS` for linux-x64 and windows-x64 to 146.0.7680.177.5 + +## [0.3.29] — 2026-05-20 + +- **[wrapper]** **Security**: `cloakserve` — guard WebSocket origins to prevent browser-origin CSRF via CDP proxy (thanks [@0xlally](https://github.com/0xlally) for the report, [@honor2030](https://github.com/honor2030) for the fix, #239, #240) +- **[wrapper]** **Security**: Lambda example — add URL scheme validation, SSRF protection, post-navigation re-validation, remove unsafe caller-controlled options (#233) +- **[wrapper]** **Security**: CI — isolate `workflow_dispatch` input to avoid shell injection in attest-release (thanks [@aaronjmars](https://github.com/aaronjmars), #223) +- **[wrapper]** **Security**: JS — bump tar + transitive deps via npm audit fix (thanks [@aaronjmars](https://github.com/aaronjmars), #222) +- **[wrapper]** Add `extension_paths` parameter for loading Chrome extensions in all launch functions (thanks [@zackycodes](https://github.com/zackycodes), #210) +- **[wrapper]** Humanize: add Playwright-style actionability checks — auto-wait for visible, enabled, stable elements before humanized actions (#228) +- **[wrapper]** JS: export composable launch helpers — `buildLaunchOptions()` and `humanizeBrowser()` for custom Playwright integrations (thanks [@honor2030](https://github.com/honor2030), #244) +- **[wrapper]** JS: add `launchPersistentContext()` to Puppeteer wrapper (#261) +- **[wrapper]** Add `flake.nix` for Nix/NixOS (thanks [@Seryiza](https://github.com/Seryiza), #220) +- **[meta]** JS: sync package-lock metadata (thanks [@245678000000](https://github.com/245678000000), #219) + +## [0.3.28] — 2026-05-11 + +- **[wrapper]** **Security**: `cloakserve` — sanitize fingerprint seed to prevent path traversal, bind to `127.0.0.1` on bare metal, detect Podman containers (#217) +- **[wrapper]** Fix GeoIP resolution hanging indefinitely — bounded with 10s timeout so `launch()` cannot stall (thanks [@manaskarra](https://github.com/manaskarra), #213) +- **[wrapper]** JS: preserve iframe scope in humanized frame actions — `check()`, `uncheck()`, `selectOption()` now execute in the correct frame (thanks [@manaskarra](https://github.com/manaskarra), #201) +- **[wrapper]** JS: add TypeScript types to humanized method options — `HumanActionOptions` type for `human_config` and `timeout` overrides (thanks [@eofreternal](https://github.com/eofreternal), #205) +- **[wrapper]** Log when SOCKS5 credential auto-encoding rewrites a proxy URL (thanks [@Youhai020616](https://github.com/Youhai020616), #209) +- **[wrapper]** JS: bump `playwright-core` peer dependency minimum to >=1.53.0 (#200) +- **[meta]** Bump sigstore/cosign-installer in CI (#214) + +## [0.3.27] — 2026-05-06 + +- **[wrapper]** Per-call `human_config` override — pass `human_config={...}` to individual humanized methods to override global HumanConfig on a per-action basis (#183) +- **[wrapper]** Humanized `scrollIntoViewIfNeeded` — auto-scrolls with human-like behavior when `humanize=True` (#183) +- **[wrapper]** Forward `timeout` parameter through humanized Playwright methods (#183) +- **[wrapper]** Fix humanize timeout default to align with Playwright's 30s auto-retry instead of custom 2s (#172) + +## [0.3.26] — 2026-04-28 + +- **[binary]** Windows x64 upgraded to Chromium 146.0.7680.177.4 — 57 source-level fingerprint patches (up from 33 on 145.0.7632.159.7), now matches Linux. Includes all binary improvements from 0.3.18–0.3.25: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3), WebRTC IP spoofing, proxy signal removal, CDP input stealth, storage quota normalization, WebAuthn/AAC/window position patches, WebGL and canvas consistency fixes, expanded GPU model database +- **[wrapper]** Auto URL-encode SOCKS5 credentials containing special characters in string URLs (#157) +- **[wrapper]** AWS Lambda integration example with cold-start hardening and handler-side retry orchestration (#177, thanks [@AlexTech314](https://github.com/AlexTech314)) +- **[docker]** Add emoji and extended font packages to resolve Kasada/Akamai canvas fingerprint blocks (#179) +- **[docs]** Add Font Setup on Linux section to README (#179) +- **[docs]** Add Deployment Integrations section to README (#177) +- **[meta]** Bump GitHub Actions dependencies (#178) + +## [0.3.25] — 2026-04-16 + +- **[wrapper]** Python: add `launch_context_async()` — async counterpart to `launch_context()`. Returns a BrowserContext with all kwargs forwarded to `browser.new_context()`, enabling `storage_state`, `permissions`, `extra_http_headers`, etc. without a persistent profile folder. Closes #141. +- **[wrapper]** JS: `launchContext()` and `launchPersistentContext()` silently dropped unknown options (including `storageState`). New `contextOptions` escape hatch forwards arbitrary options to Playwright's `newContext()`. +- **[wrapper]** Fix `humanConfig` TypeScript typing (#151). +- **[binary]** New build 146.0.7680.177.3 for Linux x64 + arm64 — 57 source-level fingerprint patches (up from 49): WebAuthn capabilities, AAC audio encoder, and window position spoofing; WebGL and canvas format consistency fixes; SOCKS5 warm connection pool auth fix for credentialed proxies. +- **[docs]** Add recommended anti-bot config and SOCKS5 tips to troubleshooting. + +## [0.3.24] — 2026-04-10 + +- **[wrapper]** Native SOCKS5 proxy support — pass `proxy="socks5://user:pass@host:port"` directly. Credentials handled natively by Chrome. Works across all launch functions, Python + JS. +- **[wrapper]** Add Playwright ElementHandle humanize support — `element_handle.click()`, `.fill()`, `.type()` now use human-like behavior when `humanize=True` (thanks [@evelaa123](https://github.com/evelaa123), #133) +- **[binary]** Upgrade Linux arm64 to Chromium 146.0.7680.177.2 (49 patches) — now matches Linux x64 +- **[binary]** New build 146.0.7680.177.2 for both Linux platforms: native SOCKS5 proxy with UDP ASSOCIATE (QUIC/HTTP3 over SOCKS5) +- **[docs]** Clarify humanize requires wrapper import over CDP (#126) + +## [0.3.23] — 2026-04-09 + +- **[wrapper]** Add full Puppeteer humanize support — human-like mouse, keyboard, and scroll behavior for `puppeteer-core` users (thanks [@evelaa123](https://github.com/evelaa123), #129) +- **[wrapper]** Fix Playwright humanize gaps — `pressSequentially`, `tap`, `clear` on pages and frames now use human-like behavior (#129) +- **[wrapper]** Expose humanize module for CDP-connected browsers — `import from 'cloakbrowser/human'` for manual patching of external Playwright instances (#126) +- **[docker]** Fix `cloakserve` locale/timezone mismatch — CLI args now route through `build_args()` so the companion `--lang` flag is added automatically (#130) +- **[meta]** Use Node 24 in CI publish workflow to work around broken npm in Node 22.22.2 + +## [0.3.22] — 2026-04-09 + +- **[binary]** Upgrade Linux x64 build to Chromium 146.0.7680.177.1 — 49 source-level C++ patches (up from 48), rebased from 145.0.7632.x + +## [0.3.21] — 2026-04-07 + +- **[wrapper]** Remove dead `--disable-blink-features=AutomationControlled` flag -- binary patch 009 already handles `navigator.webdriver` at source level +- **[wrapper]** Remove hardcoded GPU vendor/renderer flags -- binary auto-generates diverse, realistic GPU profiles from the fingerprint seed. Each seed gets a unique GPU instead of every user sharing the same one +- **[wrapper]** Allow `viewport=None` to disable viewport emulation in both Python and JS wrappers (thanks [@kitiho](https://github.com/kitiho), #107) +- **[wrapper]** Enable `geoip=True` in stealth test example to fix FingerprintJS detection +- **[meta]** Remove npm self-upgrade step in CI -- Node 22 ships with compatible npm +- **[docker]** Install `geoip2` in Docker image for GeoIP auto-detection support + +## [0.3.20] — 2026-04-06 + +- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.9 — 48 source-level C++ patches (up from 42) +- **[binary]** 6 new patches: WebRTC IP spoofing, proxy signal removal, network timing normalization, WebGL accuracy improvements +- **[binary]** New `--fingerprint-webrtc-ip` flag — spoof WebRTC ICE candidate IPs to match your proxy exit IP +- **[binary]** Proxy detection signals eliminated — timing, headers, and network metadata normalized when proxy is active +- **[binary]** WebGL rendering accuracy improvements for headed mode +- **[wrapper]** Auto-inject `--fingerprint-webrtc-ip` when `geoip=True` — uses resolved exit IP from GeoIP lookup +- **[wrapper]** Rewrite `cloakserve` as CDP multiplexer with per-connection fingerprint seeds and connection tracking +- **[wrapper]** Humanize keyboard improvements — better behavioral stealth for typing interactions (thanks [@evelaa123](https://github.com/evelaa123)) +- **[meta]** Bump GitHub Actions dependencies + +## [0.3.19] — 2026-03-30 + +- **[binary]** Upgrade Linux x64 build to 145.0.7632.159.8 — 42 source-level C++ patches (up from 33) +- **[binary]** 9 new fingerprint patches covering additional browser APIs and cross-platform consistency +- **[binary]** New `--fingerprint-noise` flag — disable noise injection while keeping deterministic fingerprint seed active +- **[binary]** Improved fingerprint noise reliability and determinism across all patched APIs +- **[binary]** Expanded platform-aware fingerprint spoofing for more realistic cross-platform profiles +- **[binary]** Font rendering and detection accuracy improvements for Windows profiles +- **[binary]** Removed experimental patches that caused compatibility issues with certain anti-bot systems +- **[binary]** Docker/VNC environment compatibility improvements +- **[wrapper]** Fix Playwright cleanup — `pw.stop()` now runs even if `browser.close()` raises or is cancelled (fixes #60, thanks [@dgtlmoon](https://github.com/dgtlmoon)) +- **[meta]** Pin GitHub Actions to commit SHAs, add Dependabot for automated dependency updates + +## [0.3.18] — 2026-03-15 + +- **[wrapper]** Fix welcome banner printing to stdout — now writes to stderr so it won't corrupt JSON output in programmatic usage (fixes #59) +- **[wrapper]** Fix `cloakserve` Docker WebGL by adding `--ignore-gpu-blocklist` flag +- **[docs]** Add Crawlee integration example +- **[meta]** Add GitHub issue template for bug reports + +## [0.3.17] — 2026-03-15 + +- **[binary]** Windows x64 build upgraded to 145.0.7632.159.7 — 33 source-level C++ patches, matching Linux +- **[wrapper]** Auto-inject GPU blocklist bypass for headed mode and Windows — fixes WebGL/WebGPU on software GPUs in Docker/VNC (fixes #56) +- **[wrapper]** Add 8 framework integration examples (Scrapy, Crawlee, BrowserBase, etc.) and README integrations section + +## [0.3.16] — 2026-03-14 + +- **[binary]** Linux arm64 build available — Raspberry Pi, AWS Graviton, Oracle Ampere now supported +- **[wrapper]** Add donate link to first-launch welcome banner + +## [0.3.15] — 2026-03-13 + +- **[binary]** Upgrade Linux build to 145.0.7632.159.7 — 33 source-level C++ patches +- **[binary]** StorageBuckets API quota normalization — closes the last storage-based incognito detection vector +- **[wrapper]** Fix non-ASCII character support in humanized typing — Cyrillic, CJK, and emoji now type correctly (thanks [@evelaa123](https://github.com/evelaa123)) + +## [0.3.14] — 2026-03-12 + +- **[binary]** Upgrade Linux build to 145.0.7632.159.6 — fix persistent context detection by FingerprintJS +- **[binary]** Storage quota normalization for persistent context profiles +- **[binary]** Fix outerHeight calculation for non-incognito contexts +- **[wrapper]** Add CLI for binary management — `python -m cloakbrowser install` / `npx cloakbrowser install` with visible download progress (closes #43) + +## [0.3.13] — 2026-03-10 + +- **[wrapper]** Suppress Playwright's `--enable-unsafe-swiftshader` default arg — eliminates SwiftShader software renderer detection signal, letting the binary's GPU spoofing work cleanly +- **[binary]** Upgrade Linux build to 145.0.7632.159.5 — fix WebGPU adapter limits and features for NVIDIA profiles + +## [0.3.12] — 2026-03-10 + +- **[binary]** Upgrade Linux build to 145.0.7632.159.4 +- **[binary]** Native locale spoofing — new C++ patch replaces detectable CDP-level locale emulation +- **[binary]** WebGPU fingerprint hardening — spoof adapter features, limits, device ID, and subgroup sizes for cross-API consistency +- **[binary]** Restore WebGPU blocklist bypass auto-injection (safe now with full adapter spoofing) +- **[binary]** Fix WebGL renderer suffix — remove driver version string flagged by BrowserLeaks +- **[wrapper]** Use binary flags for timezone/locale instead of CDP emulation — eliminates a detection vector +- **[wrapper]** Support bare proxy format (`user:pass@host:port`) without scheme prefix +- **[wrapper]** Use ANGLE-wrapped GPU strings in default stealth args for realistic WebGL fingerprint + +## [0.3.11] — 2026-03-08 + +- **[wrapper]** `humanize=True` — human-like mouse (Bézier curves, overshoot), keyboard (per-character timing, thinking pauses), scroll (accelerate/cruise/decelerate), and click behavior. Two presets: `default` and `careful`. Works in Python and JS. (thanks [@evelaa123](https://github.com/evelaa123)) +- **[binary]** CDP input stealth — 4 new source-level C++ patches removing automation signals from input events +- **[binary]** Support `--remote-debugging-address` flag for CDP bind address — eliminates the socat workaround in `cloakserve` Docker mode +- **[wrapper]** `cloakserve` updated to use `--remote-debugging-address=0.0.0.0` directly — socat dependency removed from Docker image +- **[binary]** GPU fingerprint accuracy improvements — renderer suffix strings now match real Chrome output across Windows and Linux profiles +- **[binary]** GPU capability accuracy fix for NVIDIA profiles — spoofed values now reflect actual hardware limits +- **[binary]** macOS GPU accuracy fix — GPU model database reference corrected for Apple Silicon profiles +- **[binary]** Fix CDP input synthesis — a guard condition prevented the patch from activating; now fires correctly on all input events +- **[binary]** Code quality hardening across patches — correctness and reliability fixes + +## [0.3.10] — 2026-03-07 + +- **[binary]** Upgrade Linux build to 145.0.7632.159.2 +- **[binary]** Fix detection regression caused by unnecessary browser flag (fixes #16) +- **[binary]** Fix fingerprint consistency in offline audio rendering +- **[wrapper]** Add `cloakserve` CDP server mode for Docker — exposes Chrome DevTools Protocol on `0.0.0.0:9222` for external tool integration +- **[wrapper]** Add wrapper regression tests: page.goto timing with stealth init (#9), add_init_script compatibility with proxy auth (#27) + +## [0.3.9] — 2026-03-05 + +- **[binary]** Upgrade Chromium base to 145.0.7632.159 (Linux x64). macOS and Windows remain on 145.0.7632.109.2 +- **[binary]** WebGPU adapter spoofing for headless/Docker, timezone multi-context fix, stealth audit phase 2 (6 detection vector fixes), font auto-hide for cross-platform fingerprints +- **[wrapper]** Default Playwright backend switched from `patchright` to stock `playwright`. Patchright broke proxy auth and `add_init_script` (#27) and is redundant since the binary handles stealth at C++ level. Opt in with `launch(backend="patchright")` or `CLOAKBROWSER_BACKEND=patchright` env var. Install: `pip install cloakbrowser[patchright]` +- **[wrapper]** Deduplicate CLI flags when user args overlap with stealth defaults — user values win cleanly instead of passing both to Chromium +- **[wrapper]** Extract shared `buildArgs` into `js/src/args.ts` (JS DRY fix), guard debug logging behind `DEBUG=cloakbrowser` env var + +## [0.3.7] — 2026-03-05 + +- **[wrapper]** Unify timezone parameter: rename `timezone_id` to `timezone` in `launch_context()`, `launch_persistent_context()`, and `launch_persistent_context_async()` (Python). Old `timezone_id` still works with a deprecation warning. JS: deprecate `timezoneId` on `LaunchContextOptions` — use `timezone` (inherited from `LaunchOptions`) +- **[wrapper]** Docker Hub image (`cloakhq/cloakbrowser`) — pre-built with Python + JS wrappers, Xvfb for headed mode, and `cloaktest` CLI shortcut. One-liner: `docker run --rm cloakhq/cloakbrowser cloaktest` +- **[wrapper]** Add "Launching stealth browser..." feedback to all examples for better UX in Docker/CI +- **[wrapper]** Comprehensive unit tests: 169 Python + 88 JS (up from 59 + 47) +- **[docs]** Streamline READMEs for launch — reorder for conversion, collapse fingerprint flags, update Docker section + +## [0.3.6] — 2026-03-04 + +- **[wrapper]** `proxy` parameter now accepts a Playwright proxy dict (`{server, bypass, username, password}`) in addition to URL strings — enables bypass lists and separate auth fields (PR #24). **TS note:** type changed from `string` to `string | object` — code that assumed `proxy` is always a string may need a `typeof` narrowing check + +## [0.3.5] — 2026-03-04 + +- **[wrapper]** Add `launch_persistent_context()` and `launch_persistent_context_async()` (Python) — persistent browser profiles with cookie/localStorage persistence across sessions, avoids incognito detection (thanks [@evelaa123](https://github.com/evelaa123), [@yahooguntu](https://github.com/yahooguntu) — PRs #22, #17) +- **[wrapper]** Add `launchPersistentContext()` (JS/TS) — same feature for JavaScript with full type support +- **[wrapper]** Fix Windows zip extraction failure when primary download server is down — file handle leak caused `ERROR_SHARING_VIOLATION` on fallback download (thanks [@evelaa123](https://github.com/evelaa123) — PR #23) + +## [0.3.4] — 2026-03-04 + +Binary v14: auto-spoof restored with seed, wrapper simplified to match. + +- **[binary]** Restore full auto-spoof when `--fingerprint=seed` is set — all randomized properties now derive from the seed consistently +- **[binary]** Auto-inject random fingerprint seed at startup if none provided. Binary is stealthy with zero flags +- **[binary]** 26 source-level C++ patches (up from 25) +- **[wrapper]** Simplify default stealth args — remove flags the binary now auto-generates. Wrapper still sets platform profile on Linux and `--no-sandbox` +- **[wrapper]** Fix timezone in `launch_context()` — use Playwright's per-context timezone instead of binary flag, fixing mismatch when creating new browser contexts with geoip +- **[wrapper]** Clarify README platform detection behavior + +## [0.3.3] — 2026-03-03 + +All platforms now run Chromium 145 v2 with 25 patches. Windows x64 added. + +- **[binary]** Auto-spoof by default — binary is stealthy with zero flags. Random fingerprint seed auto-generated at startup, no wrapper or configuration required +- **[binary]** Platform-aware auto-detection — GPU, screen dimensions, and User-Agent automatically match the real OS (macOS, Linux, Windows) without explicit flags +- **[binary]** Expanded GPU model database for realistic per-session diversity +- **[binary]** First macOS v145 builds (arm64 + x64) — 25 patches, up from 16 on v142 +- **[binary]** First Windows x64 v145 build — 25 patches +- **[wrapper]** Add Windows x64 platform support — auto-download, binary path resolution, and platform detection +- **[wrapper]** Upgrade macOS (arm64 + x64) from Chromium 142 to 145 — all platforms now ship the same 25-patch build +- **[wrapper]** Add explicit Mac GPU flags (`Apple M3 Metal` renderer) to default stealth args for consistent WebGL fingerprints +- **[wrapper]** Improve reCAPTCHA stealth test — wait for score element instead of blind sleep +- **[wrapper]** JS: add `win32-x64` platform mapping, Windows binary path (`chrome.exe`) + +## [0.3.1] — 2026-03-03 + +- **[wrapper]** Auto-check for wrapper updates on startup (PyPI/npm). Notifies users when a newer wrapper version is available. Runs once per process, respects `CLOAKBROWSER_AUTO_UPDATE=false`. + +--- + +## [0.3.0] — 2026-03-02 + +Chromium v145 upgrade. 25 fingerprint patches (up from 16). New download verification and fallback system. macOS v145 binary builds pending. + +### Breaking + +- **[wrapper]** Python dependency changed from `playwright` to `patchright` (CDP stealth fork). Patchright is API-compatible, but if you import `playwright` directly elsewhere, add it as a separate dependency. Replace `from playwright.sync_api` with `from patchright.sync_api` (or keep using `cloakbrowser.launch()` which handles this automatically). +- **[wrapper]** `launch_context()` / `launchContext()` now defaults viewport to 1920×947 (realistic maximized Chrome on 1080p Windows with 48px taskbar) instead of Playwright's default 1280×720. Pass `viewport={"width": 1280, "height": 720}` explicitly to restore old behavior. + +### 2026-03-02 + +- **[binary]** Full stealth audit — multiple detection vectors eliminated, improved cross-API consistency +- **[binary]** Platform-aware fingerprint defaults: screen dimensions, taskbar, and layout auto-adjust per spoofed platform +- **[binary]** Stability and performance improvements across fingerprint patches +- **[binary]** New optional flags: `--fingerprint-fonts-dir`, `--fingerprint-taskbar-height` +- **[wrapper]** Sync wrapper with latest binary changes: updated flag names, viewport, and defaults +- **[wrapper]** Per-platform Chromium versioning — Linux and macOS can track different binary versions independently +- **[wrapper]** Improved SHA-256 checksum verification and version marker migration + +### 2026-03-01 + +- **[wrapper]** Upgrade wrapper to Chromium v145.0.7632.109 +- **[wrapper]** Add GitHub Releases fallback when primary download mirror is unavailable +- **[wrapper]** Add SHA-256 checksum verification for binary downloads +- **[wrapper]** Wire timezone and locale params to Chromium binary flags +- **[wrapper]** Add device memory to default stealth args +- **[wrapper]** JS: add `colorScheme` support, guard download fallback against partial failures + +### 2026-02-28 + +- **[binary]** Enforce strict flag discipline — patches only activate when explicitly configured via command-line flags +- **[binary]** Improved fingerprint consistency across multiple browser APIs +- **[binary]** 3 new fingerprint patches + bug fixes in existing patches +- **[binary]** New command-line flag for device memory spoofing +- **[infra]** Automated test matrix: 8 groups, 41+ tests across core stealth, fingerprint noise, bot detection, reCAPTCHA, TLS, Turnstile, residential proxy, and enterprise reCAPTCHA +- **[infra]** Docker-based test runner with subprocess isolation per test group + +### 2026-02-25 + +- **[binary]** Reduced automation markers visible to detection scripts +- **[binary]** Added browser API support at build time +- **[binary]** Improved screen property consistency + +### 2026-02-24 + +- **[binary]** Comprehensive fingerprint audit and hardening pass +- **[binary]** Fixed font rendering edge case on cross-platform spoofing +- **[binary]** 4 new fingerprint patches + +### 2026-02-22 + +- **[binary]** Start Chromium v145 build (v145.0.7632.109) +- **[binary]** 24 fingerprint patches ported and adapted + +--- + +## [0.2.2] — 2026-03-01 + +### 2026-03-01 + +- **[wrapper]** Fix: replace `page.wait_for_timeout()` with `time.sleep()` to avoid timing leak +- **[wrapper]** Add auto-detect timezone and locale from proxy IP via GeoIP lookup +- **[binary]** CDP detection vector audit and hardening + +--- + +## [0.2.0] — 2026-02-27 + +macOS platform release. JavaScript/TypeScript wrapper. Self-hosted binary mirror. + +### 2026-02-27 + +- **[wrapper]** Add macOS support: Apple Silicon (arm64) and Intel (x64) binary downloads +- **[wrapper]** Add GPG-signed release workflow via GitHub Actions +- **[wrapper]** Fix macOS binary download: preserve `.app` symlinks, remove quarantine xattrs +- **[wrapper]** Add real bot detection assertions to stealth tests +- **[wrapper]** Bump version to 0.2.0 + +### 2026-02-26 + +- **[wrapper]** Switch binary downloads to self-hosted mirror (`cloakbrowser.dev`) as GitHub backup +- **[wrapper]** Set up GitLab mirror at `gitlab.com/CloakHQ/cloakbrowser` + +### 2026-02-25 + +- **[wrapper]** Move binary releases from separate repo to wrapper repo +- **[wrapper]** Add auto-update check on launch +- **[infra]** Initial Docker test infrastructure + matrix test runner + +### 2026-02-24 + +- **[wrapper]** Add JavaScript/TypeScript wrapper with Playwright + Puppeteer support (`npm install cloakbrowser`) +- **[wrapper]** Fix proxy authentication credentials support in URL (closes #4) + +--- + +## [0.1.4] — 2026-02-23 + +### 2026-02-23 + +- **[wrapper]** Stealth hardening: additional launch args and detection evasion improvements +- **[wrapper]** Full test suite rewrite with real detection site assertions +- **[wrapper]** Add Docker support with Dockerfile and compose config +- **[wrapper]** Add headed mode documentation + +--- + +## [0.1.0] — 2026-02-22 + +Initial release. Chromium v142 with 16 fingerprint patches. + +### 2026-02-22 + +- **[binary]** Chromium v142.0.7444.175 with 16 source-level fingerprint patches +- **[binary]** Fix browser brand string to match Chrome 142 format +- **[wrapper]** `launch()` and `launch_async()` — drop-in Playwright replacements +- **[wrapper]** Auto-download binary from GitHub Releases, cached in `~/.cloakbrowser/` +- **[wrapper]** Linux x64 platform support +- **[wrapper]** Passes 14/14 bot detection tests +- **[wrapper]** reCAPTCHA v3: 0.9 (server-verified), Cloudflare Turnstile: pass diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1de5fb5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +FROM python:3.12-slim + +# Chromium system deps + Node.js +RUN apt-get update && apt-get install -y --no-install-recommends \ + libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \ + libdbus-1-3 libdrm2 libxkbcommon0 libatspi2.0-0 libxcomposite1 \ + libxdamage1 libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 \ + libcairo2 libasound2 libx11-xcb1 libfontconfig1 libx11-6 \ + libxcb1 libxext6 libxshmfence1 \ + libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \ + libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation \ + fonts-noto-color-emoji fonts-unifont fonts-freefont-ttf \ + fonts-ipafont-gothic fonts-wqy-zenhei fonts-tlwg-loma-otf \ + xvfb xdotool openbox \ + curl ca-certificates \ + && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Python wrapper +COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./ +COPY cloakbrowser/ cloakbrowser/ +RUN pip install --no-cache-dir ".[serve,geoip]" + +# JS wrapper +COPY js/ js/ +RUN cd js && npm install && npm run build + +# Examples +COPY examples/ examples/ + +# Pre-download stealth Chromium binary during build (not at runtime) +# Remove welcome marker so users see it on first container run +RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()" \ + && rm -f ~/.cloakbrowser/.welcome_shown + +# CLI shortcuts +COPY bin/cloaktest /usr/local/bin/cloaktest +COPY bin/cloakserve /usr/local/bin/cloakserve +COPY bin/fetch-widevine.py /usr/local/bin/fetch-widevine.py +RUN chmod +x /usr/local/bin/cloaktest /usr/local/bin/cloakserve /usr/local/bin/fetch-widevine.py + +EXPOSE 9222 + +# Xvfb entrypoint for headed mode support +COPY bin/docker-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENV DISPLAY=:99 + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["python"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dda48a5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CloakHQ + +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/README.md b/README.md new file mode 100644 index 0000000..a1f18f5 --- /dev/null +++ b/README.md @@ -0,0 +1,1415 @@ +

+CloakBrowser +

+ +

+PyPI +npm +License +Last Commit +
+Stars +PyPI Downloads +npm Downloads +Docker Pulls +

+ +
+ +

Stealth Chromium that passes every bot detection test.

+ +
+Not a patched config. Not a JS injection. A real Chromium binary with fingerprints modified at the C++ source level. Antibot systems score it as a normal browser — because it is a normal browser. +
+ +
+ +

+Cloudflare Turnstile — 3 Tests Passing +
Cloudflare Turnstile — 3 live tests passing (headed mode, macOS) +

+ +
+ +

+Drop-in Playwright/Puppeteer replacement for Python and JavaScript.
+Same API, same code — just swap the import. 3 lines of code, 30 seconds to unblock. +

+ +- **66 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, WebRTC, network timing, automation signals, CDP input behavior +- **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes +- **Pro: 0.9 reCAPTCHA v3 score** — human-level, server-verified +- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites +- **Auto-downloads the right binary** — free or Pro based on your license +- **`pip install cloakbrowser`** or **`npm install cloakbrowser`** — binary auto-downloads, zero config +- **Open-source wrappers** — free v146 binary, Pro for latest builds + +**Try it now** — no install needed: + +```bash +docker run --rm cloakhq/cloakbrowser cloaktest +``` + +**Python:** + +```python +from cloakbrowser import launch + +browser = launch() +page = browser.new_page() +page.goto("https://example.com") +browser.close() +``` + +**JavaScript (Playwright):** + +```javascript +import { launch } from 'cloakbrowser'; + +const browser = await launch(); +const page = await browser.newPage(); +await page.goto('https://example.com'); +await browser.close(); +``` + +Also works with Puppeteer: `import { launch } from 'cloakbrowser/puppeteer'` ([details](#puppeteer)) + +**For sites with anti-bot protection**, add a residential proxy and these flags: + +```python +browser = launch( + proxy="http://user:pass@residential-proxy:port", # residential IP, not datacenter + geoip=True, # match timezone + locale to proxy IP + headless=False, # some sites detect headless even with C++ patches + humanize=True, # human-like mouse, keyboard, scroll +) +``` + +```javascript +const browser = await launch({ + proxy: 'http://user:pass@residential-proxy:port', + geoip: true, + headless: false, + humanize: true, +}); +``` + +See [Troubleshooting](#troubleshooting) for site-specific issues (FingerprintJS, Kasada, reCAPTCHA). + +## Install + +**Python:** + +```bash +pip install cloakbrowser +``` + +**JavaScript / Node.js:** + +```bash +# With Playwright +npm install cloakbrowser playwright-core + +# With Puppeteer +npm install cloakbrowser puppeteer-core +``` + +**.NET / C#:** + +```bash +dotnet add package CloakBrowser +``` + +> Community-maintained .NET client built on Microsoft.Playwright. See [`dotnet/README.md`](dotnet/README.md) for the full API. + +--- + +On first run, the stealth Chromium binary is automatically downloaded (~200MB, cached locally). + +**Optional:** Auto-detect timezone/locale from proxy IP: + +```bash +pip install cloakbrowser[geoip] +``` + +**Migrating from Playwright?** One-line change: + +```diff +- from playwright.sync_api import sync_playwright +- pw = sync_playwright().start() +- browser = pw.chromium.launch() ++ from cloakbrowser import launch ++ browser = launch() + +page = browser.new_page() +page.goto("https://example.com") +# ... rest of your code works unchanged +``` + +> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop. + +--- + +## Latest: v0.4.10 — 66 source-level stealth patches (Chromium 148.0.7778.215.5 — Windows + Linux; macOS follows) + +- **CloakBrowser Pro** — the latest binary (Chromium 148.0.7778.215.5, 66 source-level patches) is available to Pro subscribers on **Windows and Linux** (macOS is on 148.0.7778.215.3, next build follows). Set a `license_key` (`licenseKey` in JS) or the `CLOAKBROWSER_LICENSE_KEY` env var and the wrapper fetches the latest build automatically. See [CloakBrowser Pro](#cloakbrowser-pro) +- **.NET 8 / C# client** — CloakBrowser now ships as a NuGet package (`CloakBrowser`), mirroring the Python and JS wrappers. +- **66 fingerprint patches** — rendering consistency improvements across Linux and Windows, corrected GPU/display/graphics parameters to match stock Chrome profiles +- **Windows native GPU passthrough** — real hardware values pass through directly instead of being spoofed, matching real browser behavior +- **HTTP proxy inline credentials** — new network-layer support for proxies with inline authentication +- **`extension_paths`** — load Chrome extensions in all launch functions +- **Humanize actionability** — auto-wait for visible, enabled, stable elements before humanized actions +- **Per-call `human_config`** — override humanize settings on individual method calls +- **Composable JS helpers** — `buildLaunchOptions()` and `humanizeBrowser()` for custom Playwright integrations +- **Native SOCKS5 proxy** — `proxy="socks5://user:pass@host:port"` works directly in all launch functions, Python + JS. QUIC/HTTP3 tunnels through SOCKS5 via UDP ASSOCIATE +- **Proxy signal removal** — DNS/connect/SSL timing zeroed, proxy cache headers stripped, Proxy-Connection header leak removed +- **Chromium 146 upgrade** — rebased all patches from 145.0.7632.x to 146.0.7680.177 +- **WebRTC IP spoofing** — `--fingerprint-webrtc-ip=auto` resolves your proxy's exit IP and spoofs WebRTC ICE candidates. Auto-injected when using `geoip=True` (no extra network call) +- **`humanize=True`** — one flag makes all mouse, keyboard, and scroll interactions behave like a real user. Bézier curves, per-character typing, realistic scroll patterns +- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required +- **Timezone & locale from proxy IP** — `launch(proxy="...", geoip=True)` auto-detects timezone and locale +- **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection + +See the full [CHANGELOG.md](CHANGELOG.md) for details. + +## Why CloakBrowser? + +- **Config-level patches break** — `playwright-stealth`, `undetected-chromedriver`, and `puppeteer-extra` inject JavaScript or tweak flags. Every Chrome update breaks them. Antibot systems detect the patches themselves. +- **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser. +- **Source-level stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting) at the binary level. No JavaScript injection, no config-level hacks. Most stealth tools only patch at the surface. +- **Same behavior everywhere** — works identically local, in Docker, and on VPS. No environment-specific patches or config needed. +- **Works with AI agents and automation frameworks** — drop-in stealth for browser-use, Crawl4AI, Scrapling, Stagehand, LangChain, Selenium, and more. See [integrations](#framework-integrations). + +CloakBrowser doesn't solve CAPTCHAs — it prevents them from appearing. No CAPTCHA-solving services, no proxy rotation built in — bring your own proxies, use the Playwright API you already know. + +## CloakBrowser Pro + +The wrapper (Python + JS) is MIT, free forever. The binary uses a delayed +free-release model: + +- **Free (v146)** — the previous binary, on [GitHub Releases](https://github.com/CloakHQ/cloakbrowser/releases). Goes stale within weeks as detection evolves. +- **Pro (latest, Chromium 148.0.7778.215.5)** — the newest patches and Chromium upgrades first, so the [results below](#test-results) stay green as anti-bot systems change. Linux, Windows, and macOS (Apple Silicon + Intel). + +Anti-bot detection updates constantly, and an older binary degrades fast. +Pro keeps you on the build that's actively maintained against it. + +Use Pro if CloakBrowser is part of production scraping, QA, monitoring, or +automation where stale browser fingerprints cost you time or blocked runs. + +**New: try the latest Pro binary (Chromium 148) free for 7 days** — see how it +performs against your targets. Cancel anytime. + +Activate with your license key (env var, `license_key=` param, or `~/.cloakbrowser/license.key`): + +```bash +export CLOAKBROWSER_LICENSE_KEY=cb_xxxxxxxx +``` + +Pro plans & free trial → **[cloakbrowser.dev](https://cloakbrowser.dev)** + +## Test Results + +All tests verified against live detection services. Results below are for the latest Pro/current build unless noted. Last tested: Jul 2026 (Chromium 148). + +| Detection Service | Stock Playwright | CloakBrowser | Notes | +|---|---|---|---| +| **reCAPTCHA v3** | 0.1 (bot) | **0.9** (human) | Pro/current build; server-side verified | +| **Cloudflare Turnstile** (non-interactive) | FAIL | **PASS** | Auto-resolve | +| **Cloudflare Turnstile** (managed) | FAIL | **PASS** | Single click | +| **ShieldSquare** | BLOCKED | **PASS** | Production site | +| **FingerprintJS** bot detection | DETECTED | **PASS** | Pro/current build; demo.fingerprint.com | +| **BrowserScan** bot detection | DETECTED | **NORMAL** (4/4) | browserscan.net | +| **bot.incolumitas.com** | 13 fails | **1 fail** | WEBDRIVER spec only | +| **deviceandbrowserinfo.com** | 6 true flags | **0 true flags** | `isBot: false` | +| `navigator.webdriver` | `true` | **`false`** | Source-level patch | +| `navigator.plugins.length` | 0 | **5** | Real plugin list | +| `window.chrome` | `undefined` | **`object`** | Present like real Chrome | +| UA string | `HeadlessChrome` | **`Chrome/146.0.0.0`** | No headless leak | +| CDP detection | Detected | **Not detected** | `isAutomatedWithCDP: false` | +| TLS fingerprint | Mismatch | **Identical to Chrome** | ja3n/ja4/akamai match | +| | | **Tested against 30+ detection sites** | | + +### Proof + +

+reCAPTCHA v3 — Score 0.9 +
Pro/latest build: reCAPTCHA v3 score 0.9 — server-side verified (human-level) +

+ +

+Cloudflare Turnstile — Success +
Cloudflare Turnstile non-interactive challenge — auto-resolved +

+ +

+BrowserScan — Normal +
BrowserScan bot detection — NORMAL (4/4 checks passed) +

+ +

+FingerprintJS — Passed +
Pro/latest build: FingerprintJS web-scraping demo — data served, not blocked +

+ +

+deviceandbrowserinfo.com — You are human! +
deviceandbrowserinfo.com behavioral bot detection — "You are human!" with humanize=True (24/24 signals passed) +

+ +## Comparison + +| Feature | Playwright | playwright-stealth | undetected-chromedriver | Camoufox | CloakBrowser | +|---|---|---|---|---|---| +| reCAPTCHA v3 score (Pro/current) | 0.1 | 0.3-0.5 | 0.3-0.7 | 0.7-0.9 | **0.9** | +| Cloudflare Turnstile | Fail | Sometimes | Sometimes | Pass | **Pass** | +| Patch level | None | JS injection | Config patches | C++ (Firefox) | **C++ (Chromium)** | +| Survives Chrome updates | N/A | Breaks often | Breaks often | Yes | **Yes** | +| Maintained | Yes | Stale | Stale | Unstable | **Active** | +| Browser engine | Chromium | Chromium | Chrome | Firefox | **Chromium** | +| Playwright API | Native | Native | No (Selenium) | No | **Native** | + +## How It Works + +CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chromium binary: + +1. **You install** → `pip install cloakbrowser` or `npm install cloakbrowser` +2. **First launch** → binary auto-downloads for your platform (Chromium 146) +3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args +4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn + +The binary includes 66 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, WebRTC, network timing, hardware reporting, automation signal removal, and CDP input behavior mimicking. + +These are compiled into the Chromium binary — not injected via JavaScript, not set via flags. + +Binary downloads are verified against a pinned Ed25519 signature on the published checksums before extraction, so the download is confirmed authentic (genuinely ours) and not just intact. A compromised mirror cannot serve a tampered or downgraded binary. + +## API + +### `launch()` + +```python +from cloakbrowser import launch + +# Basic — headless, default stealth config +browser = launch() + +# Headed mode (see the browser window) +browser = launch(headless=False) + +# Pro — use the latest binary (or set CLOAKBROWSER_LICENSE_KEY env var) +browser = launch(license_key="cb_xxxxxxxx") + +# With proxy (HTTP or SOCKS5) +browser = launch(proxy="http://user:pass@proxy:8080") +browser = launch(proxy="socks5://user:pass@proxy:1080") + +# With proxy dict (bypass, separate auth fields) +browser = launch(proxy={"server": "http://proxy:8080", "bypass": ".google.com", "username": "user", "password": "pass"}) + +# With extra Chrome args +browser = launch(args=["--disable-gpu"]) + +# With timezone and locale (sets binary flags — no detectable CDP emulation) +browser = launch(timezone="America/New_York", locale="en-US") + +# Auto-detect timezone/locale from proxy IP (requires: pip install cloakbrowser[geoip]) +# Also auto-injects --fingerprint-webrtc-ip to prevent WebRTC IP leaks (no extra cost) +# Note: makes HTTP calls through your proxy to resolve exit IP (ipify.org, checkip.amazonaws.com) +browser = launch(proxy="http://proxy:8080", geoip=True) + +# Explicit timezone/locale always win over auto-detection +browser = launch(proxy="http://proxy:8080", geoip=True, timezone="Europe/London") + +# WebRTC IP spoofing only (no geoip dep needed — resolves exit IP via HTTP call through proxy) +browser = launch(proxy="http://proxy:8080", args=["--fingerprint-webrtc-ip=auto"]) + +# Explicit WebRTC IP (no network call) +browser = launch(proxy="http://proxy:8080", args=["--fingerprint-webrtc-ip=1.2.3.4"]) + +# Human-like mouse, keyboard, and scroll behavior +browser = launch(humanize=True) + +# With slower, more deliberate movements +browser = launch(humanize=True, human_preset="careful") + +# Without default stealth args (bring your own fingerprint flags) +browser = launch(stealth_args=False, args=["--fingerprint=12345"]) +``` + +Returns a standard Playwright `Browser` object. All Playwright methods work: `new_page()`, `new_context()`, `close()`, etc. + +### `launch_async()` + +```python +import asyncio +from cloakbrowser import launch_async + +async def main(): + browser = await launch_async() + page = await browser.new_page() + await page.goto("https://example.com") + print(await page.title()) + await browser.close() + +asyncio.run(main()) +``` + +### `launch_context()` + +Convenience function that creates browser + context in one call with user agent, viewport, locale, and timezone: + +```python +from cloakbrowser import launch_context + +context = launch_context( + user_agent="Custom UA", + viewport={"width": 1920, "height": 1080}, + locale="en-US", + timezone="America/New_York", +) +page = context.new_page() +page.goto("https://protected-site.com") +context.close() +``` + +Extra kwargs are forwarded to Playwright's `browser.new_context()` — use this for `storage_state`, `permissions`, `extra_http_headers`, etc. without needing a persistent profile folder: + +```python +from cloakbrowser import launch_context + +# Restore a saved session (cookies, localStorage) from a JSON file +context = launch_context(storage_state="state.json") +page = context.new_page() +page.goto("https://example.com") +# Save state back for next run +context.storage_state(path="state.json") +context.close() +``` + +### `launch_context_async()` + +Async counterpart to `launch_context()`. Same signature and kwargs forwarding: + +```python +import asyncio +from cloakbrowser import launch_context_async + +async def main(): + ctx = await launch_context_async(storage_state="state.json") + page = await ctx.new_page() + await page.goto("https://example.com") + await ctx.storage_state(path="state.json") + await ctx.close() + +asyncio.run(main()) +``` + +### `launch_persistent_context()` + +Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions. + +Use this when you need to: + +- **Stay logged in** across runs (cookies/sessions survive restarts) +- **Bypass incognito detection** (some sites flag empty, ephemeral profiles) +- **Load Chrome extensions** (extensions only work from a real user data dir) +- **Build natural browsing history** (cached fonts, service workers, IndexedDB accumulate over time, making the profile look more realistic) +- **Play DRM-protected video** (Widevine) — with a sideloaded CDM, the wrapper enables Widevine on the first launch (see [Widevine / DRM](#widevine--drm)) + +```python +from cloakbrowser import launch_persistent_context + +# First run — creates the profile +ctx = launch_persistent_context("./my-profile", headless=False) +page = ctx.new_page() +page.goto("https://protected-site.com") +ctx.close() # profile saved + +# Next run — cookies, localStorage restored automatically +ctx = launch_persistent_context("./my-profile", headless=False) + +# Load Chrome extensions +ctx = launch_persistent_context( + "./my-profile", + headless=False, + extension_paths=["./my-extension"], +) +``` + +Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `viewport`, `locale`, `timezone`, `color_scheme`, `geoip`, `extension_paths`. + +Async version: `launch_persistent_context_async()`. + +**Storage quota and incognito detection:** the binary normalizes storage quota by default (this also hides the real disk size). Detectors that infer private/incognito mode from quota — e.g. BrowserScan's incognito check (−10%) — read the default as incognito. Raise it to present as a regular profile: + +```python +ctx = launch_persistent_context("./my-profile", args=["--fingerprint-storage-quota=5000"]) +``` + +### Widevine / DRM + +The binary is built with Widevine support, but the Widevine CDM is a proprietary Google component we can't redistribute. Get it one of two ways (full background in [#96](https://github.com/CloakHQ/CloakBrowser/issues/96)): + +**Fetch it** — no Chrome install needed; pulls the CDM from Google's component server (Linux x86-64 only; SHA-256 + CRX3-signature verified). It lands at `~/.cloakbrowser/WidevineCdm`, which the wrapper auto-detects — no env var needed: + +```bash +python3 bin/fetch-widevine.py +``` + +**Or copy it** from an existing Chrome install, next to the binary: + +```bash +cp -r /opt/google/chrome/WidevineCdm ~/.cloakbrowser/chromium-/WidevineCdm +``` + +(In Docker, just pass `-e CLOAKBROWSER_FETCH_WIDEVINE=1` — the entrypoint runs the fetch automatically; see the Docker note below.) + +With the CDM in place, `launch_persistent_context()` enables Widevine **on the first launch** — the wrapper auto-writes the CDM hint file into the profile, so you don't need the manual two-launch workaround. This lets you play DRM-protected video (e.g. Netflix, Spotify Web). + +```python +from cloakbrowser import launch_persistent_context + +# WidevineCdm sideloaded next to the binary -> Widevine works on first launch +ctx = launch_persistent_context("./my-profile", headless=False) +``` + +- **Linux only.** Chromium's hint-file mechanism is Linux/ChromeOS-specific. On Windows the CDM can't initialise (DRM host verification) and macOS uses a different layout, so seeding is a no-op there. +- **Auto by presence.** No flag needed — a sideloaded CDM is the opt-in. Point at a CDM in a non-default location with `CLOAKBROWSER_WIDEVINE_CDM=/path/to/WidevineCdm`, or disable seeding entirely with `CLOAKBROWSER_WIDEVINE=0`. +- **Docker — auto-fetch (opt-in).** No Chrome to copy from inside the image, so the official image can fetch the CDM for you. Run with `-e CLOAKBROWSER_FETCH_WIDEVINE=1` and it pulls the CDM from Google's component server (the same source Chrome uses) on first launch, caches it at `~/.cloakbrowser/WidevineCdm` in the mounted volume, where the wrapper auto-detects it — for free or Pro binaries, and for `docker exec`'d scripts alike. **Off by default** — no network call unless you opt in — and best-effort, so a failed fetch never blocks launch. The download is signature- and checksum-verified before install. Bare-metal Linux users can run the same fetcher directly: `python3 bin/fetch-widevine.py` (pip-only installs can grab that one self-contained file from the repo). + +### CLI + +Pre-download the binary, diagnose your setup, or manage the cache from the command line: + +```bash +python -m cloakbrowser install # Download binary with progress output +python -m cloakbrowser info # Diagnostics: binary that will launch, license tier, env checks +python -m cloakbrowser update # Check for and download newer binary +python -m cloakbrowser clear-cache # Remove cached binaries +``` + +`info` reports the binary that will actually launch given your license, runs a quick launch test (and flags missing system libraries on Linux), shows your license tier, and checks fonts, GeoIP, and optional dependencies. Add `--quick` to skip the launch test or `--json` for machine-readable output. The same commands are available via `npx cloakbrowser ` (JS) and the `cloakbrowser` CLI (.NET). + +### Utility Functions + +```python +from cloakbrowser import binary_info, clear_cache, ensure_binary + +# Check binary installation status +print(binary_info()) +# {'version': '146.0.7680.177.5', 'platform': 'linux-x64', 'installed': True, ...} + +# Force re-download +clear_cache() + +# Pre-download binary (e.g., during Docker build) +ensure_binary() +``` + +## JavaScript / Node.js API + +CloakBrowser ships a TypeScript package with full type definitions. Choose Playwright or Puppeteer — same stealth binary underneath. + +### Playwright (default) + +```javascript +import { launch, launchContext, launchPersistentContext } from 'cloakbrowser'; + +// Basic +const browser = await launch(); + +// Pro — use the latest binary (or set CLOAKBROWSER_LICENSE_KEY env var) +const browser = await launch({ licenseKey: 'cb_xxxxxxxx' }); + +// With options +const browser = await launch({ + headless: false, + proxy: 'http://user:pass@proxy:8080', + args: ['--fingerprint=12345'], + timezone: 'America/New_York', + locale: 'en-US', + humanize: true, +}); + +// Convenience: browser + context in one call +const context = await launchContext({ + userAgent: 'Custom UA', + viewport: { width: 1920, height: 1080 }, + locale: 'en-US', + timezone: 'America/New_York', +}); +const page = await context.newPage(); + +// Persistent profile — cookies/localStorage survive restarts, avoids incognito detection +const ctx = await launchPersistentContext({ + userDataDir: './chrome-profile', + headless: false, + proxy: 'http://user:pass@proxy:8080', +}); +``` + +> **Note:** Each example above is standalone — not meant to run as one block. + +All Python options work in JS: `stealthArgs: false` to disable defaults, `geoip: true` to auto-detect timezone/locale from proxy IP. + +### Puppeteer + +> **Note:** The Playwright wrapper is recommended for sites with reCAPTCHA Enterprise. Puppeteer's CDP protocol leaks automation signals that reCAPTCHA Enterprise can detect, causing intermittent 403 errors. This is a known Puppeteer limitation, not specific to CloakBrowser. Use Playwright for best results. + +```javascript +import { launch } from 'cloakbrowser/puppeteer'; + +const browser = await launch({ headless: true }); +const page = await browser.newPage(); +await page.goto('https://example.com'); +await browser.close(); +``` + +### Utility Functions (JS) + +```javascript +import { ensureBinary, clearCache, binaryInfo } from 'cloakbrowser'; + +// Pre-download binary (e.g., during Docker build) +await ensureBinary(); + +// Check installation status +console.log(binaryInfo()); + +// Force re-download +clearCache(); +``` + +## Human Behavior + +Pass `humanize=True` to make all mouse, keyboard, and scroll interactions indistinguishable from real users. All Playwright calls (`page.click()`, `page.fill()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, Locator API) and Puppeteer calls (`page.click()`, `page.type()`, `page.mouse.*`, `page.keyboard.*`, ElementHandle API) are automatically replaced with human-like equivalents. No code changes needed. + +```python +browser = launch(humanize=True) +page = browser.new_page() +page.goto("https://example.com") +page.locator("#email").fill("user@example.com") # per-character timing, thinking pauses +page.locator("button[type=submit]").click() # Bézier curve, realistic aim point +``` + +```javascript +// Playwright +import { launch } from 'cloakbrowser'; +const browser = await launch({ humanize: true }); +``` + +```javascript +// Puppeteer +import { launch } from 'cloakbrowser/puppeteer'; +const browser = await launch({ humanize: true }); +``` + +**What changes:** + +| Interaction | Default | With `humanize=True` | +|---|---|---| +| Mouse movement | Instant teleport | Bézier curve with easing and slight overshoot | +| Clicks | Instant | Realistic aim point + hold duration | +| Keyboard | Instant fill | Per-character timing, thinking pauses, occasional typos with self-correction | +| Scroll | Jump | Accelerate → cruise → decelerate micro-steps | +| `fill()` | Instant value set | Clears existing content, types character by character | + +**Presets** — `default` (normal speed) or `careful` (slower, more deliberate, idle micro-movements between actions): + +```python +browser = launch(humanize=True, human_preset="careful") +``` + +```javascript +const browser = await launch({ humanize: true, humanPreset: 'careful' }); +``` + +**Custom config** — override any parameter: + +```python +browser = launch(humanize=True, human_config={ + "mistype_chance": 0.05, # 5% typo rate with self-correction + "typing_delay": 100, # slower typing (ms per character) + "idle_between_actions": True, # micro-movements between clicks + "idle_between_duration": [0.3, 0.8], # idle duration range (seconds) +}) +``` + +```javascript +const browser = await launch({ + humanize: true, + humanConfig: { + mistype_chance: 0.05, + typing_delay: 100, + idle_between_actions: true, + idle_between_duration: [0.3, 0.8], + } +}); +``` + +Access the original un-patched Playwright page at `page._original` if you need raw speed for a specific call. + +> **Note (Playwright):** Always use `page.click(selector)`, `page.type(selector, text)`, `page.hover(selector)`, or `page.locator(selector).*` — these go through the full humanize pipeline. Avoid `page.query_selector()` — `ElementHandle` objects bypass all patches, so mouse movement teleports, keyboard events fire without timing, and scroll has no human curve. +> +> **Note (Puppeteer):** Both selector-based methods (`page.click()`, `page.type()`) and ElementHandle methods (`el.click()`, `el.type()`) are fully humanized. `page.$()`, `page.$$()`, and `page.waitForSelector()` return patched handles automatically. + +> Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright and Puppeteer API coverage. + +## Configuration + +| Env Variable | Default | Description | +|---|---|---| +| `CLOAKBROWSER_BINARY_PATH` | — | Skip download, use a local Chromium binary | +| `CLOAKBROWSER_CACHE_DIR` | `~/.cloakbrowser` | Binary cache directory | +| `CLOAKBROWSER_DOWNLOAD_URL` | `cloakbrowser.dev` | Custom download URL for binary | +| `CLOAKBROWSER_AUTO_UPDATE` | `true` | Set to `false` to disable background update checks | +| `CLOAKBROWSER_SKIP_CHECKSUM` | `false` | Only applies to a custom `CLOAKBROWSER_DOWNLOAD_URL`: set to `true` to skip its checksum check. Signature verification on the official download path is mandatory and cannot be skipped. | +| `CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS` | `5` | Max seconds for GeoIP resolution before continuing without it | +| `CLOAKBROWSER_WIDEVINE_CDM` | — | Path to a sideloaded `WidevineCdm` directory (overrides auto-detection next to the binary). See [Widevine / DRM](#widevine--drm) | +| `CLOAKBROWSER_WIDEVINE` | `1` | Set to `0` to disable automatic Widevine hint-file seeding for persistent contexts | +| `CLOAKBROWSER_FETCH_WIDEVINE` | `0` | Docker only: set to `1` to auto-fetch the Widevine CDM on container start (Linux x86-64 only). See [Widevine / DRM](#widevine--drm) | +| `CLOAKBROWSER_VERSION` | — | Pin to an exact Chromium version for rollback (e.g. `148.0.7778.215.2`). Works with Free and Pro binaries | + +## Fingerprint Management + +The binary is **stealthy by default** — no flags needed. It auto-generates a random fingerprint seed at startup and spoofs all detectable values (GPU, hardware specs, screen dimensions, canvas, WebGL, audio, fonts). Every launch produces a fresh, coherent identity. + +**How fingerprinting works:** + +| Scenario | What happens | +|----------|-------------| +| **No flags** | Random seed auto-generated at startup. GPU, screen, hardware specs, and all noise patches are spoofed automatically. Fresh identity each launch. | +| **`--fingerprint=seed`** | Deterministic identity from the seed. Same seed = same fingerprint across launches. Use this for session persistence (returning visitor). | +| **`--fingerprint=seed` + explicit flags** | Explicit flags override individual auto-generated values. The seed fills in everything else. | + +The binary detects its platform at compile time — a macOS binary reports as macOS with Apple GPU, a Linux binary reports as Linux with NVIDIA GPU. The **wrapper** overrides this on Linux by passing `--fingerprint-platform=windows`, so sessions appear as Windows desktops (more common fingerprint, harder to cluster). Use `--fingerprint-platform` for cross-platform spoofing when running the binary directly. + +> **Tip: Use a fixed seed when revisiting the same site.** A random seed makes every session look like a different device — which can be suspicious when hitting the same site repeatedly from the same IP. For reCAPTCHA v3 Enterprise and similar scoring systems, a fixed seed produces a consistent fingerprint across sessions, making you look like a returning visitor: +> +> ```python +> browser = launch(args=["--fingerprint=12345"]) +> ``` +> +> ```javascript +> const browser = await launch({ args: ['--fingerprint=12345'] }); +> ``` + +### Default Fingerprint + +Every `launch()` call sets these automatically. The **wrapper** applies platform-aware defaults — on Linux it spoofs as Windows for a more common fingerprint, on macOS it runs as a native Mac browser: + +| Flag | Linux/Windows Default | macOS Default | Controls | +|------|--------------|---------------|----------| +| `--fingerprint` | Random (10000–99999) | Random (10000–99999) | Master seed for canvas, WebGL, audio, fonts, client rects | +| `--fingerprint-platform` | `windows` | `macos` | `navigator.platform`, User-Agent OS, GPU pool selection | + +The binary auto-generates everything else from the seed: GPU, hardware concurrency, device memory, and screen dimensions. Each seed produces a unique, consistent fingerprint. Override with explicit flags if needed. + +> **Using the binary directly?** It works out of the box with zero flags -- the binary auto-spoofs everything. Pass `--fingerprint=seed` for a persistent identity, or use explicit flags like `--fingerprint-gpu-renderer` to override any auto-generated value. + +### Additional Flags + +Supported by the binary but **not set by default** — pass via `args` to customize: + +| Flag | Controls | +|------|----------| +| `--fingerprint-gpu-vendor` | WebGL `UNMASKED_VENDOR_WEBGL` (auto-generated from seed + platform) | +| `--fingerprint-gpu-renderer` | WebGL `UNMASKED_RENDERER_WEBGL` (auto-generated from seed + platform) | +| `--fingerprint-hardware-concurrency` | `navigator.hardwareConcurrency` (auto-generated: `8`) | +| `--fingerprint-device-memory` | `navigator.deviceMemory` in GB (auto-generated: `8`) | +| `--fingerprint-screen-width` | Screen width (auto-generated: `1920` Win/Linux, `1440` macOS) | +| `--fingerprint-screen-height` | Screen height (auto-generated: `1080` Win/Linux, `900` macOS) | +| `--fingerprint-brand` | Browser brand: `Chrome`, `Edge`, `Opera`, `Vivaldi` | +| `--fingerprint-brand-version` | Brand version (UA + Client Hints) | +| `--fingerprint-platform-version` | Client Hints platform version | +| `--fingerprint-location` | Geolocation coordinates | +| `--fingerprint-timezone` | Timezone (e.g. `America/New_York`) | +| `--fingerprint-locale` | Locale (e.g. `en-US`) | +| `--fingerprint-storage-quota` | Override storage quota in MB — affects `storage.estimate()`, `storageBuckets`, and legacy webkit APIs. Auto-normalized when `--fingerprint` is set | +| `--fingerprint-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) | +| `--fingerprint-fonts-dir` | Path to directory containing target-platform fonts (see [Font Setup on Linux](#font-setup-on-linux)) | +| `--fingerprint-windows-font-metrics` | **Chromium 148+ binary only** (no-op on earlier builds). Align font metrics with the Windows platform when spoofing Windows on Linux — used in the [FingerprintJS config](#detected-by-fingerprintjs). Requires Windows fonts installed (see [Font Setup on Linux](#font-setup-on-linux)); no effect without them | +| `--fingerprint-webrtc-ip` | WebRTC ICE candidate IP replacement. Use `auto` to resolve from proxy exit IP (makes an HTTP call through the proxy), or pass an explicit IP. Auto-injected when `geoip=True` | +| `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active | +| `--fingerprint=off` | **Chromium 148+ binary only.** Pass-through debug mode — turns spoofing off and presents the machine's **real native fingerprint** (keeps only the baseline any Chrome needs). The binary strips the injected seed *and* `--fingerprint-platform`, so there's no mixed OS profile. Most useful on a genuine Windows machine to check whether an issue is our spoofing or the environment. Accepts `off`/`false`/`0`/`disable`/`disabled`. For a *pure* pass-through don't combine it with `geoip=True` / explicit timezone / locale — those stay applied. | +| `--fingerprint-allow-3p-cookies` | **Chromium 148+ binary only.** Re-enable third-party cookies for embedded flows that need them (reCAPTCHA v3, SSO, some payment challenges). Off by default; turn on only where a login/payment/embedded challenge loads but never finishes. | +| `--license-through-proxy` | **Chromium 148+ binary only, Linux only for now.** Route the Pro license/session calls through your `--proxy-server` instead of direct to cloakbrowser.dev. Off by default (these calls go direct, so they never spend proxy bandwidth or touch your scraping session). | +| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements | + +> **Note:** All stealth tests were verified with the default fingerprint config above. Changing these flags may affect detection results — test your configuration before using in production. + +### Font Setup on Linux + +**Required for aggressive anti-bot sites (Kasada, Akamai).** These systems render emoji on a hidden canvas and hash the pixel output. Minimal Linux environments (Docker, cloud VMs) often lack emoji and extended fonts, producing hashes that don't match any real browser. Install standard font packages to fix this: + +```bash +sudo apt install -y fonts-noto-color-emoji fonts-freefont-ttf fonts-unifont \ + fonts-ipafont-gothic fonts-wqy-zenhei fonts-tlwg-loma-otf +``` + +The Docker image (`cloakhq/cloakbrowser`) ships with these pre-installed. If you run the binary directly on a Linux server or in a custom Docker image, install them manually. + +**Optional: Windows fonts for CreepJS font enumeration.** The packages above fix anti-bot canvas checks but won't improve your CreepJS font score. For that, you need actual Windows fonts (Segoe UI, Calibri, Bahnschrift, etc.) from a Windows machine's `C:\Windows\Fonts\` directory — `ttf-mscorefonts-installer` only has old XP-era fonts and isn't enough. + +```bash +mkdir -p ~/.local/share/fonts/windows +cp /path/to/windows/fonts/*.ttf ~/.local/share/fonts/windows/ +cp /path/to/windows/fonts/*.TTF ~/.local/share/fonts/windows/ +fc-cache -f # mandatory for manually copied fonts +``` + +```python +browser = launch( + args=["--fingerprint-fonts-dir=/home/user/.local/share/fonts/windows"], +) +``` + +### Examples + +```python +# Pin a seed for a persistent identity +browser = launch(args=["--fingerprint=42069"]) + +# Full control — disable defaults, set everything yourself +browser = launch(stealth_args=False, args=[ + "--fingerprint=42069", + "--fingerprint-platform=windows", +]) + +# Override GPU to look like a specific machine +browser = launch(args=[ + "--fingerprint-gpu-vendor=Intel Inc.", + "--fingerprint-gpu-renderer=Intel Iris OpenGL Engine", +]) +``` + +## Examples + +**Python** — see [`examples/`](examples/): + +- [`basic.py`](examples/basic.py) — Launch and load a page +- [`persistent_context.py`](examples/persistent_context.py) — Persistent profile with cookie/localStorage persistence +- [`recaptcha_score.py`](examples/recaptcha_score.py) — Check your reCAPTCHA v3 score +- [`stealth_test.py`](examples/stealth_test.py) — Run against 6 detection sites +- [`fingerprint_scan_test.py`](examples/fingerprint_scan_test.py) — Test against fingerprint-scan.com and CreepJS + +**JavaScript** — see [`js/examples/`](js/examples/): + +- [`basic-playwright.ts`](js/examples/basic-playwright.ts) — Playwright launch and load +- [`basic-puppeteer.ts`](js/examples/basic-puppeteer.ts) — Puppeteer launch and load +- [`stealth-test.ts`](js/examples/stealth-test.ts) — Run against 6 detection sites + +### Framework Integrations + +CloakBrowser works with any framework that uses Playwright or Chromium: + +```python +# Option 1: Framework launches our binary directly (Selenium, Stagehand, UC) +from cloakbrowser.download import ensure_binary +from cloakbrowser.config import get_default_stealth_args +binary_path = ensure_binary() # auto-downloads if needed +stealth_args = get_default_stealth_args() # all fingerprint flags + +# Option 2: CloakBrowser launches first, framework connects via CDP (browser-use, Crawl4AI, Scrapling) +from cloakbrowser import launch_async +browser = await launch_async(args=["--remote-debugging-port=9242"]) +# Connect your framework to http://127.0.0.1:9242 — all stealth flags are set +# Note: humanize requires the wrapper (see below) +``` + +> **Humanize over CDP**: Stealth fingerprint patches work automatically over CDP, but `humanize=True` is a wrapper-level feature. If you connect to CloakBrowser via CDP from a separate script, import the patching functions to add humanization: +> +> ```js +> import { patchBrowser, resolveConfig } from 'cloakbrowser/human'; +> patchBrowser(browser, resolveConfig('default')); +> ``` + +| Framework | Stars | Language | Example | +|-----------|-------|----------|---------| +| [browser-use](https://github.com/browser-use/browser-use) | 70K | Python | [`browser_use_example.py`](examples/integrations/browser_use_example.py) | +| [Crawl4AI](https://github.com/unclecode/crawl4ai) | 58K | Python | [`crawl4ai_example.py`](examples/integrations/crawl4ai_example.py) | +| [Crawlee](https://github.com/apify/crawlee-python) | 8.6K | Python | [`crawlee_example.py`](examples/integrations/crawlee_example.py) | +| [Scrapling](https://github.com/D4Vinci/Scrapling) | 21K | Python | [`scrapling_example.py`](examples/integrations/scrapling_example.py) | +| [Stagehand](https://github.com/browserbase/stagehand) | 21K | TypeScript | [`stagehand.ts`](js/examples/stagehand.ts) | +| [LangChain](https://github.com/langchain-ai/langchain) | 100K+ | Python | [`langchain_loader.py`](examples/integrations/langchain_loader.py) | +| [Selenium](https://github.com/SeleniumHQ/selenium) | — | Python | [`selenium_example.py`](examples/integrations/selenium_example.py) | +| [undetected-chromedriver](https://github.com/ultrafunkamsterdam/undetected-chromedriver) | 12K | Python | [`undetected_chromedriver.py`](examples/integrations/undetected_chromedriver.py) | +| [agent-browser](https://github.com/nichochar/agent-browser) | — | Shell | [`agent_browser.sh`](examples/integrations/agent_browser.sh) | + +### Deployment Integrations + +| Platform | Example | +|----------|---------| +| [AWS Lambda](https://aws.amazon.com/lambda/) | [`aws_lambda/`](examples/integrations/aws_lambda/) — One-shot scrapes in Lambda (container image) | + +## Platforms + +| Platform | Free | Pro | Status | +|---|---|---|---| +| Linux x86_64 | Chromium 146 (58 patches) | Chromium 148 (66 patches) | ✅ | +| Linux arm64 (RPi, Graviton) | Chromium 146 (58 patches) | Chromium 148 (66 patches) | ✅ | +| macOS arm64 (Apple Silicon) | Chromium 145 (26 patches) | Chromium 148 (66 patches) | ✅ | +| macOS x86_64 (Intel) | Chromium 145 (26 patches) | Chromium 148 (66 patches) | ✅ | +| Windows x86_64 | Chromium 146 (58 patches) | Chromium 148 (66 patches) | ✅ | + +The wrapper auto-downloads the correct binary for your platform. + +**macOS first launch:** The binary is ad-hoc signed. On first run, macOS Gatekeeper will block it. Right-click the app → **Open** → click **Open** in the dialog. This is only needed once. + +## Docker + +Pre-built image on Docker Hub — no install, no setup. + +> **Pro:** the image ships with the free binary. Set `CLOAKBROWSER_LICENSE_KEY` (e.g. `-e CLOAKBROWSER_LICENSE_KEY=cb_xxx`, or in Compose) and the latest binary downloads at runtime. + +### Quick test + +```bash +docker run --rm cloakhq/cloakbrowser cloaktest +``` + +### Run a script + +```bash +# Inline script +docker run --rm cloakhq/cloakbrowser python -c " +from cloakbrowser import launch +browser = launch() +page = browser.new_page() +page.goto('https://example.com') +print(page.title()) +browser.close() +" + +# Mount your own script +docker run --rm -v ./my_script.py:/app/my_script.py cloakhq/cloakbrowser python my_script.py + +# With a proxy +docker run --rm cloakhq/cloakbrowser python -c " +from cloakbrowser import launch +browser = launch(proxy='http://user:pass@proxy:8080') +page = browser.new_page() +page.goto('https://example.com') +print(page.title()) +browser.close() +" +``` + +### CDP server mode + +Start a persistent stealth browser and connect to it remotely via Chrome DevTools Protocol: + +```bash +docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser cloakserve +``` + +Then connect from your host machine: + +```python +from playwright.sync_api import sync_playwright + +pw = sync_playwright().start() +browser = pw.chromium.connect_over_cdp("http://localhost:9222") +page = browser.new_page() +page.goto("https://example.com") +print(page.title()) +browser.close() +``` + +If your framework needs a direct WebSocket endpoint, fetch Chrome's discovery document and use the rewritten `webSocketDebuggerUrl`. The URL points back through `cloakserve` so the CDP proxy can keep per-seed routing intact: + +```bash +curl http://localhost:9222/json/version | jq -r .webSocketDebuggerUrl +# ws://localhost:9222/devtools/browser/ + +curl 'http://localhost:9222/json/version?fingerprint=11111' | jq -r .webSocketDebuggerUrl +# ws://localhost:9222/fingerprint/11111/devtools/browser/ +``` + +When `cloakserve` runs behind a reverse proxy or TLS terminator, forward the public host/protocol headers so generated WebSocket URLs use the address clients can actually reach: + +```nginx +proxy_set_header Host $host; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Forwarded-Proto $scheme; +``` + +With those headers, `/json/version` returns public endpoints such as `wss://cdp.example.com/fingerprint/11111/devtools/browser/` instead of an internal container host. + +Pass extra flags to the browser: + +```bash +# With proxy +docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \ + cloakserve --proxy-server=http://proxy:8080 + +# Headed mode (renders to Xvfb inside container) +docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \ + cloakserve --headless=false + +# Reap disconnected per-seed browser processes after 5 minutes +docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \ + cloakserve --idle-timeout=300 +``` + +Stop the server: + +```bash +docker stop cloak && docker rm cloak +``` + +> **Security:** CDP gives full control over the browser (execute JS, read pages, access files). +> The examples bind to `127.0.0.1` so only your machine can connect. Never expose port 9222 +> to the public internet without additional authentication. + +### Docker Compose + +```yaml +services: + cloakbrowser: + image: cloakhq/cloakbrowser + command: cloakserve + restart: unless-stopped + ports: + - "127.0.0.1:9222:9222" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9222/json/version"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s +``` + +**Per-connection fingerprint seeds** — run multiple browser identities from a single container. Each unique seed spawns a separate Chrome process with its own fingerprint: + +```python +# Each seed gets unique canvas noise, client rects, and other browser signals +b1 = pw.chromium.connect_over_cdp("http://localhost:9222?fingerprint=11111") +b2 = pw.chromium.connect_over_cdp("http://localhost:9222?fingerprint=22222") + +# Full identity control via query params +b3 = pw.chromium.connect_over_cdp( + "http://localhost:9222?fingerprint=33333" + "&timezone=Asia/Tokyo&locale=ja-JP&platform=macos" + "&hardware-concurrency=4&device-memory=8" +) + +# Auto-detect timezone/locale from proxy exit IP +b4 = pw.chromium.connect_over_cdp( + "http://localhost:9222?fingerprint=44444" + "&proxy=http://proxy:8080&geoip=true" +) +``` + +Supported query params: `fingerprint`, `timezone`, `locale`, `platform`, `platform-version`, `brand`, `brand-version`, `gpu-vendor`, `gpu-renderer`, `hardware-concurrency`, `device-memory`, `screen-width`, `screen-height`, `proxy`, `geoip`. Same seed reuses the same process (first connection's params win). No seed = shared default process (backward compatible). + +By default, per-seed processes stay alive until `cloakserve` exits. If clients create many unique seeds, set `--idle-timeout=SECONDS` or `CLOAKSERVE_IDLE_TIMEOUT=SECONDS` to automatically terminate a seed's Chrome process after its last CDP WebSocket disconnects. `0`, `off`, `false`, `none`, or `disabled` disable idle cleanup. When cleanup runs, the seed's temporary profile directory under `--data-dir` is removed too. Check active processes at `GET /` (returns JSON with PIDs, ports, connection counts, idle timeout, and pending cleanup status). + +**Persistent profiles** — mount a volume to keep cookies and sessions across container restarts: + +```bash +docker run --rm -v ./my-profile:/profile cloakhq/cloakbrowser python -c " +from cloakbrowser import launch_persistent_context +ctx = launch_persistent_context('/profile') +page = ctx.new_page() +page.goto('https://example.com') +ctx.close() +" +``` + +Run again with the same volume — cookies, localStorage, and cache are restored automatically. + +To enable Widevine DRM (Netflix, Spotify Web, etc.) in a persistent profile, add `-e CLOAKBROWSER_FETCH_WIDEVINE=1` to auto-fetch the CDM on first launch (see [Widevine / DRM](#widevine--drm)); it caches in the mounted volume. + +**Resource usage:** ~190MB RAM idle, ~280MB with 3 tabs. ~30MB per additional tab. + +### Extend with your own image + +```dockerfile +FROM cloakhq/cloakbrowser +COPY your_script.py /app/ +CMD ["python", "your_script.py"] +``` + +**Building your own image from pip** — use `python -m cloakbrowser install` to download the binary during build with visible progress: + +```dockerfile +FROM python:3.12-slim +RUN pip install cloakbrowser && python -m cloakbrowser install +COPY your_script.py /app/ +CMD ["python", "/app/your_script.py"] +``` + +**Building from source** — a [`Dockerfile`](Dockerfile) is also included if you prefer to build your own image: + +```bash +docker build -t cloakbrowser . +``` + +CloakBrowser works identically local, in Docker, and on VPS. No environment-specific config needed. + +**Note:** If you run CloakBrowser inside a web server with uvloop (e.g., `uvicorn[standard]`), use `--loop asyncio` to avoid subprocess pipe hangs. + +## Troubleshooting + +--- + +### Still getting blocked on aggressive sites (DataDome, Turnstile)? + +Some sites detect headless mode even with our C++ patches. Run in **headed mode** with a virtual display: + +```bash +# Install Xvfb (virtual framebuffer) +sudo apt install xvfb + +# Start virtual display +Xvfb :99 -screen 0 1920x1080x24 & +export DISPLAY=:99 +``` + +```python +from cloakbrowser import launch + +# Headed mode + residential proxy for maximum stealth +browser = launch(headless=False, proxy="http://your-residential-proxy:port") +page = browser.new_page() +page.goto("https://heavily-protected-site.com") # passes DataDome, etc. +browser.close() +``` + +This runs a real headed browser rendered on a virtual display — no physical monitor needed. Combine with the recommended config below for maximum stealth. + +--- + +### Recommended config for anti-bot sites + +Most blocks come from missing one of these three things, not from browser fingerprint detection: + +```python +browser = launch( + proxy="http://your-residential-proxy:port", # residential IP — datacenter IPs get blocked by reputation alone + geoip=True, # matches timezone + locale to proxy exit IP (without this: UTC + en-US = bot signal) + headless=False, # headed mode — some sites detect headless even with C++ patches + humanize=True, # human-like mouse, keyboard, scroll behavior +) +``` + +```javascript +const browser = await launch({ + proxy: 'http://your-residential-proxy:port', + geoip: true, + headless: false, + humanize: true, +}); +``` + +If your proxy supports SOCKS5, use it for better compatibility — SOCKS5 tunnels raw TCP, avoiding HTTP CONNECT issues that some proxies have with HTTP/2: + +```python +browser = launch(proxy="socks5://user:pass@proxy:1080", geoip=True, headless=False, humanize=True) +``` + +If you're still blocked after this, check the font setup below. + +--- + +### Detected by FingerprintJS? + +FingerprintJS (`demo.fingerprint.com/playground`) checks multiple signals. Each detection has a specific cause: + +| Detection | Cause | Fix | +|-----------|-------|-----| +| **`nodriver` / bad bot** | Stale binary/wrapper, missing current FPJS patches, or poor proxy IP reputation | Upgrade to the latest Pro binary (`148.0.7778.215.5+`), use a residential proxy with `geoip=True`, and use the config below. | +| **Browser tampering** | Noise injection detected by ML | `--fingerprint-noise=false` | +| **Browser tampering** (fonts) | Font metrics don't match the spoofed Windows platform | `--fingerprint-windows-font-metrics` (Chromium 148+ binary; requires [Windows fonts installed](#font-setup-on-linux)) | +| **Virtual machine** | Screen dimensions don't match viewport | `--fingerprint-screen-width/height` matching viewport | + +Config that passes FPJS on the latest binary (Linux, residential proxy): + +```python +browser = launch( + headless=False, + proxy="http://user:pass@residential-proxy:port", + geoip=True, + args=[ + "--fingerprint-noise=false", # prevents tampering detection + "--fingerprint-windows-font-metrics", # align font metrics — 148+ binary, needs Windows fonts + ], +) +``` + +```javascript +const browser = await launch({ + headless: false, + proxy: 'http://user:pass@residential-proxy:port', + geoip: true, + args: [ + '--fingerprint-noise=false', + '--fingerprint-windows-font-metrics', // align font metrics — 148+ binary, needs Windows fonts + ], +}); +``` + +Requires a **Chromium 148+ binary** and **Windows fonts** installed (see [Font Setup on Linux](#font-setup-on-linux)); run with a **residential proxy** and `geoip=True`. + +**Persistent contexts** (`launch_persistent_context` / `launchPersistentContext`) use the same FPJS config on the latest Pro binary. Use a real `userDataDir`. Storage-quota tuning is unrelated to FingerprintJS here; it only affects detectors that infer incognito from quota, such as BrowserScan (see [storage quota](#launch_persistent_context)). For DRM/media playback, see [Widevine / DRM](#widevine--drm). + +--- + +### Blocked on Kasada / Akamai sites despite correct config? + +On minimal Linux environments, missing font packages cause canvas emoji rendering to produce hashes that anti-bot systems don't recognize. This is the most common cause of blocks on aggressive sites after proxy, geoip, and headed mode are already set up correctly. + +Install the font packages listed in [Font Setup on Linux](#font-setup-on-linux) above. + +--- + +### Sites challenge fresh sessions but work after first visit + +Some sites challenge first-time visitors with no cookies over HTTP/2. This affects all Chromium browsers, not just CloakBrowser. Use a persistent profile to warm up cookies once, then reuse across sessions: + +```python +from cloakbrowser import launch_persistent_context + +# First run: warm up with --disable-http2 +ctx = launch_persistent_context("./profile", args=["--disable-http2"]) +page = ctx.new_page() +page.goto("https://example.com") # warms up cookies +ctx.close() + +# Future runs — no --disable-http2 needed +ctx = launch_persistent_context("./profile") +page = ctx.new_page() +page.goto("https://example.com") # passes with saved cookies +``` + +```javascript +import { launchPersistentContext } from 'cloakbrowser'; + +// First run: warm up with --disable-http2 +let ctx = await launchPersistentContext({ userDataDir: './profile', args: ['--disable-http2'] }); +let page = await ctx.newPage(); +await page.goto('https://example.com'); +await ctx.close(); + +// Future runs — no --disable-http2 needed +ctx = await launchPersistentContext({ userDataDir: './profile' }); +``` + +For stateless/ephemeral use cases, `launch(args=["--disable-http2"])` forces HTTP/1.1 which bypasses the check. Only use this flag for sites that require it — most work fine with HTTP/2. If your proxy supports SOCKS5, use `proxy="socks5://user:pass@host:port"` instead — SOCKS5 bypasses HTTP CONNECT entirely. + +--- + +### Something not working? Make sure you're on the latest version + +Older versions may use outdated stealth args or download an older binary: + +```bash +pip install -U cloakbrowser # Python +npm install cloakbrowser@latest # JavaScript +docker pull cloakhq/cloakbrowser:latest # Docker +``` + +### New update broke something? Roll back + +Two ways to go back to a working version: + +**Pin the binary** (keep current wrapper, just use an older Chromium) — works for Free and Pro: + +```python +# Free — pin a public release +browser = launch(browser_version="146.0.7680.177.5") + +# Pro — pin a previous Pro version +browser = launch(license_key="cb_xxxxxxxx", browser_version="148.0.7778.215.2") +``` + +```bash +export CLOAKBROWSER_VERSION=146.0.7680.177.5 # env var for all launches +``` + +```javascript +// Free — pin a public release +const browser = await launch({ browserVersion: '146.0.7680.177.5' }); +``` + +The pin is never sticky — unpinned launches always use the latest available version. + +**Or downgrade the wrapper** (each wrapper release hardcodes which binary version it downloads): + +```bash +pip install cloakbrowser==0.3.21 # Python +npm install cloakbrowser@0.3.21 # JavaScript +docker pull cloakhq/cloakbrowser:0.3.21 # Docker +``` + +--- + +Set a custom download URL or use a local binary: + +```bash +export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome +``` + +### macOS: "App is damaged" or Gatekeeper blocks launch + +The binary is ad-hoc signed. macOS quarantines downloaded files. Run once to clear it: + +```bash +xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app +``` + +--- + +### "playwright install" vs CloakBrowser binary + +You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps: + +```bash +playwright install-deps chromium +``` + +--- + +### Site detects incognito / private browsing mode + +By default, `launch()` opens an incognito context. Some sites penalize this. Use `launch_persistent_context()` to get a real profile with cookie persistence: + +```python +from cloakbrowser import launch_persistent_context + +ctx = launch_persistent_context("./my-profile", headless=False) +``` + +If the site still flags incognito, raise the storage quota to appear as a regular browsing session. See the [storage quota tradeoff](#launch_persistent_context) for details on how this affects different detection services. + +--- + +### reCAPTCHA v3 scores are low (0.1–0.3) + +Avoid `page.wait_for_timeout()` — it sends CDP protocol commands that reCAPTCHA detects. Use native sleep instead: + +```python +# Bad — sends CDP commands, reCAPTCHA detects this +page.wait_for_timeout(3000) + +# Good — invisible to the browser +import time +time.sleep(3) +``` + +```javascript +// Bad — sends CDP commands +await page.waitForTimeout(3000); + +// Good — invisible to the browser +await new Promise(r => setTimeout(r, 3000)); +``` + +Other tips for maximizing reCAPTCHA scores: + +- **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer)) +- **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint +- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower +- **Space out requests** — back-to-back `grecaptcha.execute()` calls from the same session get penalized. Wait 30+ seconds between pages with reCAPTCHA +- **Use a fixed fingerprint seed** for consistent device identity across sessions (see [Fingerprint Management](#fingerprint-management)) +- **Use `page.type()` instead of `page.fill()`** for form filling — `fill()` sets values directly without keyboard events, which reCAPTCHA's behavioral analysis flags. `type()` with a delay simulates real keystrokes: + + ```python + page.type("#email", "user@example.com", delay=50) + ``` + +- **Minimize `page.evaluate()` calls** before the reCAPTCHA check fires — each one sends CDP traffic + +## FAQ + +**Q: Is this legal?** +A: CloakBrowser is a browser built on open-source Chromium. We do not condone illegal use. Automating systems without authorization, credential stuffing, and account creation abuse are expressly prohibited. See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md) for full terms. + +**Q: Is CloakBrowser free?** +A: The wrapper (Python + JS) is MIT and free forever. The binary uses a delayed free-release model: the previous Chromium major version (currently v146) is free on GitHub Releases with unlimited sessions; the latest major version is for [Pro subscribers](https://cloakbrowser.dev). Each new major release rolls the prior major version down to free. + +**Q: Do I need a license key for the free version?** +A: No. The free binary downloads automatically with no key. A license key only unlocks the latest (Pro) binary. + +**Q: What happens if I cancel Pro?** +A: Your subscription stays active until the end of the current billing period — cancelling doesn't cut you off immediately. After it ends, the wrapper stops pulling new Pro versions and falls back to the free binary on its next license check (cached ~24h). You just stop getting new versions. + +**Q: How is this different from Camoufox?** +A: Camoufox patches Firefox. We patch Chromium. Chromium means native Playwright support, larger ecosystem, and TLS fingerprints that match real Chrome. Camoufox returned in early 2026 but is in unstable beta — CloakBrowser is production-ready. + +**Q: Will detection sites eventually catch this?** +A: Possibly. Bot detection is an arms race. Source-level patches are harder to detect than config-level patches, but not impossible. We actively monitor and update when detection evolves. + +**Q: Can I use my own proxy?** +A: Yes. Pass `proxy="http://user:pass@host:port"` or `proxy="socks5://user:pass@host:port"` to `launch()`. Both HTTP and SOCKS5 proxies are supported natively. + +## Links + +- 📋 **Changelog** — [CHANGELOG.md](CHANGELOG.md) +- 🌐 **Website** — [cloakbrowser.dev](https://cloakbrowser.dev) +- 🐛 **Bug reports & feature requests** — [GitHub Issues](https://github.com/CloakHQ/CloakBrowser/issues) +- 📦 **PyPI** — [pypi.org/project/cloakbrowser](https://pypi.org/project/cloakbrowser/) +- 📦 **npm** — [npmjs.com/package/cloakbrowser](https://www.npmjs.com/package/cloakbrowser) +- ☕ **Support** — [ko-fi.com/cloakhq](https://ko-fi.com/cloakhq) +- 📧 **Contact** — + +## Security + +The wrapper automatically verifies every binary download against a pinned Ed25519 signature on the published checksums before extraction — a compromised mirror cannot serve a tampered or downgraded binary. Releases are additionally signed for manual supply chain verification: + +```bash +# Verify GPG signature (binary release tag) +gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD +git verify-tag chromium-v146.0.7680.177.5 + +# Verify GitHub binary attestation (Sigstore) +gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser + +# Verify Docker image signature (Cosign/Sigstore) +cosign verify \ + --certificate-identity-regexp "https://github.com/CloakHQ/CloakBrowser/" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + cloakhq/cloakbrowser:latest +``` + +## License + +- **Wrapper code** (this repository) — MIT. See [LICENSE](https://github.com/CloakHQ/CloakBrowser/blob/main/LICENSE). +- **CloakBrowser binary** (compiled Chromium): + - **v146 and earlier** — free for personal and commercial use, no redistribution (OEM/SaaS license required to serve third parties). + - **v148+ (latest)** — requires an active [CloakBrowser Pro](https://cloakbrowser.dev) subscription to download. + - See [BINARY-LICENSE.md](https://github.com/CloakHQ/CloakBrowser/blob/main/BINARY-LICENSE.md) for full terms. + +## Contributing + +Issues and PRs welcome. If something isn't working, [open an issue](https://github.com/CloakHQ/CloakBrowser/issues) — we respond fast. + +## Contributors + +- [@evelaa123](https://github.com/evelaa123) — humanize behavior, persistent contexts, Windows fix, .NET client +- [@yahooguntu](https://github.com/yahooguntu) — persistent contexts +- [@kitiho](https://github.com/kitiho) — null viewport fix +- [@eofreternal](https://github.com/eofreternal) — humanConfig type fix, humanized method option types, iframe pointer-events fix +- [@manaskarra](https://github.com/manaskarra) — iframe scope fix for humanized frame actions, GeoIP timeout guard +- [@Youhai020616](https://github.com/Youhai020616) — SOCKS5 credential encoding logging +- [@AlexTech314](https://github.com/AlexTech314) — AWS Lambda integration, cold-start hardening +- [@dgtlmoon](https://github.com/dgtlmoon) — graceful pw.stop() cleanup +- [@zackycodes](https://github.com/zackycodes) — Chrome extension loading +- [@aaronjmars](https://github.com/aaronjmars) — security fixes (shell injection, dep bumps) +- [@Seryiza](https://github.com/Seryiza) — Nix/NixOS flake +- [@245678000000](https://github.com/245678000000) — package-lock sync +- [@honor2030](https://github.com/honor2030) — cloakserve WebSocket origin guard, CDP WebSocket URL rewrite, composable JS launch helpers +- [@sparanoid](https://github.com/sparanoid) — Docker Xvfb lock cleanup +- [@Kumario1](https://github.com/Kumario1) — cloakserve idle cleanup for seeded profiles +- [@0xlally](https://github.com/0xlally) — security reports (cloakserve path traversal, WebSocket origin bypass) + +## Star History + + + + + + Star History Chart + + diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..bfcdd7c --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`CloakHQ/CloakBrowser` +- 原始仓库:https://github.com/CloakHQ/CloakBrowser +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/bin/cloakserve b/bin/cloakserve new file mode 100755 index 0000000..d1719e3 --- /dev/null +++ b/bin/cloakserve @@ -0,0 +1,883 @@ +#!/usr/bin/env python3 +"""CDP multiplexer — per-connection fingerprint seeds for stealth Chromium. + +Spawns a separate Chrome process per unique fingerprint seed, routing CDP +connections through a single port. Each seed gets its own browser identity. + +Usage: + cloakserve # default, backward compat + cloakserve --port=9222 # custom port + +Client: + browser = pw.chromium.connect_over_cdp("http://host:9222?fingerprint=12345") + browser = pw.chromium.connect_over_cdp( + "http://host:9222?fingerprint=12345&timezone=America/New_York&locale=en-US" + ) +""" + +from __future__ import annotations + +import asyncio +import ipaddress +import json +import logging +import os +import random +import re +import shutil +import socket +import subprocess +import sys +import time +from dataclasses import dataclass +from urllib.parse import parse_qs, urlparse + +from pathlib import Path + +import aiohttp +import websockets +from aiohttp import web + +from cloakbrowser.browser import build_args, maybe_resolve_geoip, _resolve_webrtc_args, _normalize_socks_string_url +from cloakbrowser.download import ensure_binary + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger("cloakserve") + +# Args for running Chrome directly (outside Playwright). +# Playwright normally adds its own version of these. +BASE_CHROME_ARGS = [ + "--no-first-run", + "--no-default-browser-check", + "--disable-dev-shm-usage", + "--disable-extensions", + "--disable-popup-blocking", + "--disable-background-networking", + "--metrics-recording-only", + "--ignore-gpu-blocklist", +] + +BASE_CDP_PORT = 5100 + +SAFE_SEED_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$") +RESERVED_SEEDS = {"__default__"} +TRUSTED_WS_ORIGINS = {"devtools://devtools", "chrome-devtools://devtools"} + + +def _host_port_from_netloc(netloc: str, default_port: int) -> tuple[str, int] | None: + """Return a normalized (host, port) pair for an Origin/Host netloc.""" + if "," in netloc: + return None + try: + parsed = urlparse(f"//{netloc.strip()}") + authority = parsed.netloc.rsplit("@", 1)[-1] + if ( + not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or authority.endswith(":") + or parsed.path + or parsed.params + or parsed.query + or parsed.fragment + ): + return None + return (parsed.hostname.lower(), parsed.port if parsed.port is not None else default_port) + except ValueError: + return None + + +def _is_loopback_host(hostname: str) -> bool: + """Return True for localhost and loopback IP literals.""" + hostname = hostname.strip("[]").rstrip(".").lower() + if hostname == "localhost": + return True + try: + return ipaddress.ip_address(hostname).is_loopback + except ValueError: + return False + + +def _origin_is_allowed( + origin: str | None, + host: str | None, + request_scheme: str = "http", +) -> bool: + """Return True when a WebSocket Origin is safe to proxy to local CDP.""" + if origin is None: + # Playwright/Puppeteer and other non-browser CDP clients commonly omit + # Origin. Keep those clients working while rejecting browser-origin CSRF. + return True + + origin = origin.strip() + if not origin or origin.lower() == "null": + return False + if origin in TRUSTED_WS_ORIGINS: + return True + + try: + parsed = urlparse(origin) + except ValueError: + return False + + if parsed.scheme not in ("http", "https"): + return False + if parsed.path or parsed.params or parsed.query or parsed.fragment: + return False + + origin_default_port = 443 if parsed.scheme == "https" else 80 + request_scheme = request_scheme.split(",", 1)[0].strip().lower() + request_default_port = 443 if request_scheme in ("https", "wss") else 80 + origin_host = _host_port_from_netloc(parsed.netloc, origin_default_port) + request_host = _host_port_from_netloc(host or "", request_default_port) + if origin_host is None or request_host is None: + return False + if not _is_loopback_host(request_host[0]): + return False + return origin_host == request_host + + +def _reject_untrusted_origin(request: web.Request) -> web.Response | None: + """Reject browser-origin WebSocket upgrades that would expose local CDP.""" + origin = request.headers.get("Origin") + host = request.headers.get("Host") + scheme = request.headers.get("X-Forwarded-Proto", getattr(request, "scheme", "http")) + if _origin_is_allowed(origin, host, request_scheme=scheme): + return None + logger.warning("Rejected CDP WebSocket from untrusted Origin %r for Host %r", origin, host) + return web.Response(status=403, text="Forbidden: untrusted WebSocket origin\n") + + +# --------------------------------------------------------------------------- +# ChromeProcess — one running Chrome instance +# --------------------------------------------------------------------------- + +@dataclass +class ChromeProcess: + seed: str + process: subprocess.Popen + cdp_port: int + user_data_dir: str + timezone: str | None = None + locale: str | None = None + proxy: str | None = None + + +# --------------------------------------------------------------------------- +# ChromePool — manages multiple Chrome processes keyed by seed +# --------------------------------------------------------------------------- + +class ChromePool: + def __init__( + self, + binary: str, + global_args: list[str], + headless: bool, + data_dir: str = "/tmp/cloakserve", + default_seed: str | None = None, + default_locale: str | None = None, + default_timezone: str | None = None, + idle_timeout: float = 0.0, + ): + self._binary = binary + self._global_args = global_args + self._headless = headless + self._data_dir = data_dir + self._default_seed = default_seed + self._default_locale = default_locale + self._default_timezone = default_timezone + self._idle_timeout = idle_timeout + self._processes: dict[str, ChromeProcess] = {} + self._default: ChromeProcess | None = None + self._locks: dict[str, asyncio.Lock] = {} + self._next_port = BASE_CDP_PORT + # Connection refcounting for status reporting + self._connections: dict[str, int] = {} + self._idle_tasks: dict[str, asyncio.Task] = {} + + def _get_lock(self, seed: str) -> asyncio.Lock: + if seed not in self._locks: + self._locks[seed] = asyncio.Lock() + return self._locks[seed] + + def _safe_rmtree(self, path: str) -> None: + resolved = Path(path).resolve() + data_resolved = Path(self._data_dir).resolve() + if resolved == data_resolved or not resolved.is_relative_to(data_resolved): + logger.error("Refusing to delete path outside data_dir: %s", resolved) + return + shutil.rmtree(path, True) + + def _allocate_port(self) -> int: + """Find a free port starting from _next_port.""" + for _ in range(100): + port = self._next_port + self._next_port += 1 + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", port)) + return port + except OSError: + continue + raise RuntimeError("No free ports available for Chrome CDP") + + def connect(self, seed_key: str) -> None: + """Increment connection refcount for a seed.""" + self._cancel_idle_cleanup(seed_key) + self._connections[seed_key] = self._connections.get(seed_key, 0) + 1 + + def disconnect(self, seed_key: str) -> None: + """Decrement connection refcount for a seed.""" + count = self._connections.get(seed_key, 0) - 1 + if count <= 0: + self._connections.pop(seed_key, None) + self._schedule_idle_cleanup(seed_key) + else: + self._connections[seed_key] = count + + def _cancel_idle_cleanup(self, seed_key: str) -> None: + task = self._idle_tasks.pop(seed_key, None) + if task is None or task.done(): + return + try: + current_task = asyncio.current_task() + except RuntimeError: + current_task = None + if task is not current_task: + task.cancel() + + def _discard_idle_task(self, seed_key: str, task: asyncio.Task) -> None: + if self._idle_tasks.get(seed_key) is task: + self._idle_tasks.pop(seed_key, None) + + def _schedule_idle_cleanup(self, seed_key: str) -> None: + if self._idle_timeout <= 0 or seed_key not in self._processes: + return + + self._cancel_idle_cleanup(seed_key) + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + + task = loop.create_task( + self._cleanup_after_idle(seed_key, self._idle_timeout), + name=f"cloakserve-idle-cleanup-{seed_key}", + ) + self._idle_tasks[seed_key] = task + task.add_done_callback(lambda done_task: self._discard_idle_task(seed_key, done_task)) + + async def _cleanup_after_idle(self, seed_key: str, timeout: float) -> None: + try: + await asyncio.sleep(timeout) + if self._connections.get(seed_key, 0) > 0 or seed_key not in self._processes: + return + logger.info("Cleaning up idle Chrome process (seed=%s)", seed_key) + await self._cleanup_process(seed_key) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Idle cleanup failed for seed=%s", seed_key) + + async def get_or_launch( + self, + seed: str | None, + extra_args: list[str] | None = None, + timezone: str | None = None, + locale: str | None = None, + proxy: str | None = None, + geoip: bool = False, + ) -> ChromeProcess: + """Get existing or launch new Chrome process for a seed.""" + # Apply CLI defaults when query params don't provide values + if seed is None and self._default_seed: + seed = self._default_seed + if locale is None: + locale = self._default_locale + if timezone is None: + timezone = self._default_timezone + + # No seed = default shared process + if seed is None: + seed_key = "__default__" + actual_seed = str(random.randint(10000, 99999)) + else: + if not SAFE_SEED_RE.match(seed) or seed in RESERVED_SEEDS: + raise web.HTTPBadRequest( + text=json.dumps({"error": "Invalid fingerprint seed"}), + content_type="application/json", + ) + seed_key = seed + actual_seed = seed + + lock = self._get_lock(seed_key) + async with lock: + # Check if already running (including default fast-path) + if seed_key in self._processes: + proc = self._processes[seed_key] + if proc.process.poll() is None: + if seed_key in self._idle_tasks: + self._schedule_idle_cleanup(seed_key) + if any([extra_args, timezone, locale, proxy, geoip]): + logger.warning( + "Seed %s already running (port %d, tz=%s, locale=%s, proxy=%s) — " + "ignoring new params (first-launch wins)", + seed_key, proc.cdp_port, + proc.timezone, proc.locale, proc.proxy, + ) + return proc + # Dead — clean up + await self._cleanup_process(seed_key) + + # Resolve geoip if requested + exit_ip = None + if geoip and proxy: + timezone, locale, exit_ip = maybe_resolve_geoip(True, proxy, timezone, locale) + + # Build Chrome args via shared logic + fp_extra = [f"--fingerprint={actual_seed}"] + if extra_args: + fp_extra.extend(extra_args) + if proxy: + fp_extra.append(f"--proxy-server={_normalize_socks_string_url(proxy)}") + + # WebRTC IP spoofing: resolve auto, inject geoip exit IP + fp_extra = _resolve_webrtc_args(fp_extra, proxy) + if exit_ip and not any(a.startswith("--fingerprint-webrtc-ip") for a in (fp_extra or [])): + fp_extra = list(fp_extra or []) + fp_extra.append(f"--fingerprint-webrtc-ip={exit_ip}") + + chrome_args = build_args( + stealth_args=True, + extra_args=fp_extra, + timezone=timezone, + locale=locale, + headless=self._headless, + ) + + # Allocate port and user data dir + port = self._allocate_port() + user_data_dir = os.path.join(self._data_dir, seed_key) + os.makedirs(user_data_dir, exist_ok=True) + + full_args = ( + [self._binary] + + BASE_CHROME_ARGS + + chrome_args + + self._global_args + + [ + f"--remote-debugging-port={port}", + "--remote-debugging-address=127.0.0.1", + f"--user-data-dir={user_data_dir}", + ] + ) + + logger.info("Launching Chrome (seed=%s, port=%d)", actual_seed, port) + process = subprocess.Popen( + full_args, + stdout=subprocess.DEVNULL, + ) + + # Wait for CDP to be ready + if not await self._wait_for_cdp(port): + process.kill() + await asyncio.to_thread(process.wait, timeout=5) + await asyncio.to_thread(self._safe_rmtree, user_data_dir) + raise web.HTTPBadGateway( + text=json.dumps({"error": "Chrome failed to start"}), + content_type="application/json", + ) + + cp = ChromeProcess( + seed=actual_seed, + process=process, + cdp_port=port, + user_data_dir=user_data_dir, + timezone=timezone, + locale=locale, + proxy=proxy, + ) + self._processes[seed_key] = cp + + if seed is None: + self._default = cp + + logger.info("Chrome ready (seed=%s, port=%d, pid=%d)", actual_seed, port, process.pid) + return cp + + async def _cleanup_process(self, key: str) -> None: + """Terminate a Chrome process and clean up.""" + self._cancel_idle_cleanup(key) + proc = self._processes.pop(key, None) + if not proc: + return + if proc.process.poll() is None: + proc.process.terminate() + try: + await asyncio.to_thread(proc.process.wait, timeout=5) + except subprocess.TimeoutExpired: + proc.process.kill() + await asyncio.to_thread(self._safe_rmtree, proc.user_data_dir) + if self._default is proc: + self._default = None + self._locks.pop(key, None) + self._connections.pop(key, None) + + async def shutdown(self) -> None: + """Terminate all Chrome processes.""" + idle_tasks = list(self._idle_tasks.values()) + self._idle_tasks.clear() + for task in idle_tasks: + if not task.done(): + task.cancel() + if idle_tasks: + await asyncio.gather(*idle_tasks, return_exceptions=True) + for key in list(self._processes.keys()): + await self._cleanup_process(key) + logger.info("All Chrome processes terminated") + + @staticmethod + async def _wait_for_cdp(port: int, timeout: float = 10.0) -> bool: + """Poll Chrome's /json/version until ready.""" + deadline = time.monotonic() + timeout + delay = 0.1 + session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=1) + ) + try: + while time.monotonic() < deadline: + try: + async with session.get( + f"http://127.0.0.1:{port}/json/version" + ) as resp: + if resp.status == 200: + return True + except Exception: + pass + await asyncio.sleep(delay) + delay = min(delay * 2, 1.0) + return False + finally: + await session.close() + + +# --------------------------------------------------------------------------- +# Query param parsing +# --------------------------------------------------------------------------- + +# Params that need special handling (not simple --fingerprint-{name}= mapping) +SPECIAL_PARAMS = {"fingerprint", "proxy", "geoip", "locale", "timezone"} + + +def parse_connection_params(query_string: str) -> dict: + """Parse query params into connection config.""" + qs = parse_qs(query_string, keep_blank_values=False) + + result: dict = { + "seed": None, + "timezone": None, + "locale": None, + "proxy": None, + "geoip": False, + "extra_args": [], + } + + for key, values in qs.items(): + val = values[0] + if key == "fingerprint": + result["seed"] = val + elif key == "timezone": + result["timezone"] = val + elif key == "locale": + result["locale"] = val + elif key == "proxy": + result["proxy"] = val + elif key == "geoip": + result["geoip"] = val.lower() in ("true", "1", "yes") + elif key not in SPECIAL_PARAMS: + # Generic fingerprint param: map to --fingerprint-{key}={val} + result["extra_args"].append(f"--fingerprint-{key}={val}") + + return result + + +# --------------------------------------------------------------------------- +# HTTP handlers +# --------------------------------------------------------------------------- + +def _ws_scheme(request: web.Request) -> str: + """Return 'wss' if client connected via HTTPS (e.g. TLS-terminating proxy), else 'ws'.""" + proto = request.headers.get("X-Forwarded-Proto", request.scheme) + proto = proto.split(",", 1)[0].strip().lower() + return "wss" if proto == "https" else "ws" + + +def _external_host(request: web.Request) -> str: + """Return the public host to use in rewritten CDP WebSocket URLs.""" + fallback_host = request.headers.get("Host") or f"localhost:{request.app['port']}" + forwarded_host = request.headers.get("X-Forwarded-Host") + if forwarded_host: + public_host = forwarded_host.split(",", 1)[0].strip() + if public_host: + return public_host + return fallback_host + + +async def handle_root(request: web.Request) -> web.Response: + """Health check / process status.""" + pool: ChromePool = request.app["pool"] + processes = {} + for key, proc in pool._processes.items(): + if proc.process.poll() is None: + processes[key] = { + "pid": proc.process.pid, + "port": proc.cdp_port, + "seed": proc.seed, + "connections": pool._connections.get(key, 0), + "idle_cleanup_pending": key in pool._idle_tasks, + "timezone": proc.timezone, + "locale": proc.locale, + "proxy": proc.proxy, + } + return web.json_response({ + "status": "ok", + "active": len(processes), + "idle_timeout": pool._idle_timeout, + "processes": processes, + }) + + +async def handle_json_version(request: web.Request) -> web.Response: + """Proxy /json/version with optional per-seed routing.""" + pool: ChromePool = request.app["pool"] + params = parse_connection_params(request.query_string) + + cp = await pool.get_or_launch( + seed=params["seed"], + extra_args=params["extra_args"] or None, + timezone=params["timezone"], + locale=params["locale"], + proxy=params["proxy"], + geoip=params["geoip"], + ) + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"http://127.0.0.1:{cp.cdp_port}/json/version", + timeout=aiohttp.ClientTimeout(total=5), + ) as resp: + data = await resp.json() + except Exception as exc: + logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc) + return web.json_response({"error": "CDP endpoint unreachable"}, status=502) + + # Rewrite webSocketDebuggerUrl to route through our multiplexer + host = _external_host(request) + seed_key = params["seed"] + if seed_key: + ws_path = f"fingerprint/{seed_key}/devtools/browser" + else: + ws_path = "devtools/browser" + + # Extract the browser GUID from Chrome's original URL + orig_ws = data.get("webSocketDebuggerUrl", "") + guid = orig_ws.rsplit("/", 1)[-1] if "/devtools/" in orig_ws else "" + + scheme = _ws_scheme(request) + data["webSocketDebuggerUrl"] = f"{scheme}://{host}/{ws_path}/{guid}" + return web.json_response(data) + + +async def handle_json_list(request: web.Request) -> web.Response: + """Proxy /json/list with per-seed routing. Rewrites all entries.""" + pool: ChromePool = request.app["pool"] + params = parse_connection_params(request.query_string) + + cp = await pool.get_or_launch( + seed=params["seed"], + extra_args=params["extra_args"] or None, + timezone=params["timezone"], + locale=params["locale"], + proxy=params["proxy"], + geoip=params["geoip"], + ) + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"http://127.0.0.1:{cp.cdp_port}/json/list", + timeout=aiohttp.ClientTimeout(total=5), + ) as resp: + data = await resp.json() + except Exception as exc: + logger.error("Failed to reach Chrome CDP (port %d): %s", cp.cdp_port, exc) + return web.json_response({"error": "CDP endpoint unreachable"}, status=502) + + host = _external_host(request) + scheme = _ws_scheme(request) + seed_key = params["seed"] + + for entry in data: + if "webSocketDebuggerUrl" in entry: + ws_tail = entry["webSocketDebuggerUrl"].split("/devtools/")[-1] + if seed_key: + entry["webSocketDebuggerUrl"] = ( + f"{scheme}://{host}/fingerprint/{seed_key}/devtools/{ws_tail}" + ) + else: + entry["webSocketDebuggerUrl"] = f"{scheme}://{host}/devtools/{ws_tail}" + + return web.json_response(data) + + +# --------------------------------------------------------------------------- +# WebSocket proxy +# --------------------------------------------------------------------------- + +async def proxy_cdp_websocket( + client_ws: web.WebSocketResponse, + target_url: str, + label: str, +) -> None: + """Bidirectional WebSocket proxy between client and Chrome CDP.""" + try: + async with websockets.connect( + target_url, max_size=None, ping_interval=None, ping_timeout=None, + ) as cdp_ws: + logger.info("%s: connected to %s", label, target_url) + + async def client_to_cdp(): + try: + async for msg in client_ws: + if msg.type == aiohttp.WSMsgType.TEXT: + await cdp_ws.send(msg.data) + elif msg.type == aiohttp.WSMsgType.BINARY: + await cdp_ws.send(msg.data) + elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED): + break + except Exception as exc: + logger.debug("%s [c->cdp]: %s", label, exc) + + async def cdp_to_client(): + try: + async for msg in cdp_ws: + if isinstance(msg, str): + await client_ws.send_str(msg) + else: + await client_ws.send_bytes(msg) + except Exception as exc: + logger.debug("%s [cdp->c]: %s", label, exc) + + c2d = asyncio.create_task(client_to_cdp(), name="c2d") + d2c = asyncio.create_task(cdp_to_client(), name="d2c") + done, pending = await asyncio.wait( + [c2d, d2c], return_when=asyncio.FIRST_COMPLETED, + ) + for task in pending: + task.cancel() + logger.info("%s: disconnected", label) + + except Exception as exc: + logger.error("%s error: %s", label, exc) + + +async def handle_ws_default(request: web.Request) -> web.StreamResponse: + """WebSocket proxy for default (no-seed) Chrome: /devtools/{type}/{guid}""" + rejected = _reject_untrusted_origin(request) + if rejected is not None: + return rejected + + pool: ChromePool = request.app["pool"] + path = request.match_info.get("path", "") + + cp = await pool.get_or_launch(seed=None) + + ws = web.WebSocketResponse() + await ws.prepare(request) + + pool.connect("__default__") + try: + target_url = f"ws://127.0.0.1:{cp.cdp_port}/devtools/{path}" + await proxy_cdp_websocket(ws, target_url, f"CDP default [{path}]") + finally: + pool.disconnect("__default__") + return ws + + +async def handle_ws_seed(request: web.Request) -> web.StreamResponse: + """WebSocket proxy for seed-specific Chrome: /fingerprint/{seed}/devtools/{type}/{guid}""" + rejected = _reject_untrusted_origin(request) + if rejected is not None: + return rejected + + pool: ChromePool = request.app["pool"] + seed = request.match_info["seed"] + path = request.match_info.get("path", "") + + cp = await pool.get_or_launch(seed=seed) + + ws = web.WebSocketResponse() + await ws.prepare(request) + + pool.connect(seed) + try: + target_url = f"ws://127.0.0.1:{cp.cdp_port}/devtools/{path}" + await proxy_cdp_websocket(ws, target_url, f"CDP seed={seed} [{path}]") + finally: + pool.disconnect(seed) + return ws + + +async def on_shutdown(app: web.Application) -> None: + await app["pool"].shutdown() + + +# --------------------------------------------------------------------------- +# CLI arg parsing +# --------------------------------------------------------------------------- + +def _default_data_dir() -> str: + """Smart default: container → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve.""" + if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv"): + return "/tmp/cloakserve" + return str(Path.home() / ".cloakbrowser" / "cloakserve") + + +def _parse_idle_timeout(value: str) -> float: + value = value.strip() + if value.lower() in {"0", "false", "off", "none", "disabled"}: + return 0.0 + timeout = float(value) + if timeout < 0: + raise ValueError("--idle-timeout must be greater than or equal to 0") + return timeout + + +def _default_idle_timeout() -> float: + value = os.environ.get("CLOAKSERVE_IDLE_TIMEOUT") + if value is None: + return 0.0 + return _parse_idle_timeout(value) + + +def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]: + """Parse cloakserve-specific args, return (config, passthrough_args). + + --fingerprint, --fingerprint-locale, and --fingerprint-timezone are + extracted into config defaults so they route through build_args() + (e.g. locale needs both --lang and --fingerprint-locale). + Query-string params override these defaults per-connection. + """ + config: dict = { + "port": 9222, + "headless": True, + "data_dir": None, + "default_seed": None, + "default_locale": None, + "default_timezone": None, + "idle_timeout": _default_idle_timeout(), + } + passthrough = [] + # Flags consumed by cloakserve (not passed to Chrome) + consumed_prefixes = ( + "--port=", + "--data-dir=", + "--idle-timeout=", + "--remote-debugging-port=", + "--remote-debugging-address=", + ) + + for arg in argv: + if arg.startswith("--port="): + config["port"] = int(arg.split("=", 1)[1]) + elif arg.startswith("--data-dir="): + config["data_dir"] = arg.split("=", 1)[1] + elif arg.startswith("--idle-timeout="): + config["idle_timeout"] = _parse_idle_timeout(arg.split("=", 1)[1]) + elif arg == "--headless=false" or arg == "--headless=False": + config["headless"] = False + passthrough.append(arg) + elif arg.startswith(consumed_prefixes): + pass # Strip these silently + # Route through build_args() so companion flags are set correctly + elif arg.startswith("--fingerprint-locale="): + config["default_locale"] = arg.split("=", 1)[1] + elif arg.startswith("--fingerprint-timezone="): + config["default_timezone"] = arg.split("=", 1)[1] + elif arg.startswith("--fingerprint="): + config["default_seed"] = arg.split("=", 1)[1] + else: + passthrough.append(arg) + + if config["data_dir"] is None: + config["data_dir"] = _default_data_dir() + + return config, passthrough + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + binary = ensure_binary() + config, global_args = parse_cli_args(sys.argv[1:]) + + if config["default_seed"] and ( + not SAFE_SEED_RE.match(config["default_seed"]) + or config["default_seed"] in RESERVED_SEEDS + ): + logger.error("Invalid --fingerprint seed: %s", config["default_seed"]) + sys.exit(1) + + pool = ChromePool( + binary=binary, + global_args=global_args, + headless=config["headless"], + data_dir=config["data_dir"], + default_seed=config["default_seed"], + default_locale=config["default_locale"], + default_timezone=config["default_timezone"], + idle_timeout=config["idle_timeout"], + ) + + app = web.Application() + app["pool"] = pool + app["port"] = config["port"] + + # Routes + app.router.add_get("/", handle_root) + app.router.add_get("/json/version", handle_json_version) + app.router.add_get("/json/version/", handle_json_version) + app.router.add_get("/json/list", handle_json_list) + app.router.add_get("/json/list/", handle_json_list) + app.router.add_get("/json", handle_json_list) + app.router.add_get("/json/", handle_json_list) + + # WebSocket routes — seed-specific (must be before default to match first) + app.router.add_get("/fingerprint/{seed}/devtools/{path:.+}", handle_ws_seed) + # WebSocket routes — default (no seed) + app.router.add_get("/devtools/{path:.+}", handle_ws_default) + + app.on_shutdown.append(on_shutdown) + + port = config["port"] + logger.info("CloakBrowser CDP multiplexer starting on port %d", port) + logger.info( + "Connect: playwright.chromium.connect_over_cdp(" + "\"http://localhost:%d?fingerprint=\")", + port, + ) + + in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv") + host = "0.0.0.0" if in_container else "127.0.0.1" + web.run_app(app, host=host, port=port, print=None) + + +if __name__ == "__main__": + main() diff --git a/bin/cloaktest b/bin/cloaktest new file mode 100755 index 0000000..7140643 --- /dev/null +++ b/bin/cloaktest @@ -0,0 +1,3 @@ +#!/bin/bash +# Run CloakBrowser stealth test suite +exec python -u /app/examples/stealth_test.py --no-screenshots "$@" diff --git a/bin/docker-entrypoint.sh b/bin/docker-entrypoint.sh new file mode 100644 index 0000000..feb1b55 --- /dev/null +++ b/bin/docker-entrypoint.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Clean up any stale Xvfb lock left behind by a previous container instance. +# `/tmp` is not a tmpfs in this image, so on `docker restart` the previous +# container's `/tmp/.X99-lock` survives, and Xvfb refuses to start with an +# existing lock — leaving the container with no X server, every Chrome +# launch dying with "Missing X server or $DISPLAY", and `cloakserve` +# returning 502 forever. See CloakHQ/CloakBrowser#283. +rm -f /tmp/.X99-lock /tmp/.X11-unix/X99 + +# Start Xvfb for headed mode (Turnstile, CAPTCHAs), then run user command +Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp & + +# Wait for the X server to actually accept connections before starting the WM. +# A blind `sleep 1` races under a CPU-starved start: openbox can come up before +# X is ready, fail to connect, and never retry — leaving --start-maximized a +# silent no-op for the container's whole life. Poll instead (xdotool is already +# installed and needs a live X server to answer). Bounded to ~10s. +for _ in $(seq 1 50); do + DISPLAY=:99 xdotool getdisplaygeometry >/dev/null 2>&1 && break + sleep 0.2 +done + +# Window manager so headed --start-maximized is honored (bare Xvfb has no WM; +# without one the flag is a silent no-op and the window stays un-maximized). +DISPLAY=:99 openbox & + +# Opt-in: fetch the Widevine CDM so persistent contexts present as a real +# Chrome (a DRM/EME probe is used by some bot detectors). Off by default — only +# runs when CLOAKBROWSER_FETCH_WIDEVINE is set, and never if the user already +# pointed at a CDM or disabled seeding. The CDM is fetched per-container from +# Google's component server (the same source Chrome uses), cached in the +# ~/.cloakbrowser volume, and is best-effort: a failure must never block launch. +_fetch_widevine="${CLOAKBROWSER_FETCH_WIDEVINE:-}" +case "${_fetch_widevine,,}" in + 1|true|yes|on) + # printf (not echo) so a value like `-n` isn't swallowed as a flag. + if [ -z "${CLOAKBROWSER_WIDEVINE_CDM:-}" ] && \ + ! printf '%s' "${CLOAKBROWSER_WIDEVINE:-}" | grep -qiE '^(0|false|off|no)$'; then + # Fetch to the default location (the version-independent cache root, + # ~/.cloakbrowser/WidevineCdm). The wrapper's auto-detection + # (cloakbrowser/widevine.py, js/src/widevine.ts) falls back to this path + # after the per-binary dir, so the CDM is discoverable by ANY process (CMD + # or `docker exec`) and ANY binary (free or Pro, any version) with no env + # var. Best-effort: a failure must never block launch. + python /usr/local/bin/fetch-widevine.py --quiet \ + || echo "[cloakbrowser] Widevine fetch failed; continuing without it" >&2 + fi + ;; +esac + +exec "$@" diff --git a/bin/fetch-widevine.py b/bin/fetch-widevine.py new file mode 100644 index 0000000..9352352 --- /dev/null +++ b/bin/fetch-widevine.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +"""Fetch the Widevine CDM from Google's component-update server (Linux). + +The CloakBrowser binary is built with Widevine support, but the CDM itself is a +proprietary Google component we don't redistribute. This pulls it at runtime from +the same component server Chrome uses, then drops it where the wrapper's +``CLOAKBROWSER_WIDEVINE_CDM`` resolution (cloakbrowser/widevine.py) expects it: + + /manifest.json + /_platform_specific/linux_/libwidevinecdm.so + +No curl/jq/unzip needed. Linux x86-64 only (Google doesn't publish the CDM for +linux arm64). The Docker entrypoint runs this when CLOAKBROWSER_FETCH_WIDEVINE is +set; bare-metal Linux users can run it directly. + +Integrity: the download is checked against the server-provided SHA-256 (over TLS). +When `cryptography` is importable (it is in any pip/Docker install of cloakbrowser), +the CRX3 publisher signature is additionally verified and bound to the expected +Widevine app id — same trust root Chrome's component updater uses. Standalone runs +without `cryptography` fall back to TLS + SHA-256. +""" + +import argparse +import hashlib +import io +import json +import os +import platform +import shutil +import struct +import sys +import tempfile +import urllib.request +import zipfile + +# Widevine CDM component id in Chromium's component updater. +APP_ID = "oimompecagnajdejgnnjijobebaeigek" +UPDATE_URL = "https://update.googleapis.com/service/update2/json" +# Deliberately-low installed version so the server always reports an update. +INSTALLED_VERSION = "1.4.9.1088" +XSSI_PREFIX = ")]}'" + + +def _arch(): + """Map the host machine to the Widevine platform suffix (x86-64 only). + + Google's component server publishes the Linux Widevine CDM for x86-64 only — + arm64/aarch64 return no update (verified: the server either reports noupdate + or hands back the x86-64 binary), so reject them with a clear message rather + than letting the request reach the misleading "no update available" path. + """ + m = platform.machine().lower() + if m in ("x86_64", "amd64", "x64"): + return "x64" + if m in ("aarch64", "arm64", "arm"): + raise SystemExit("the Widevine CDM is not published for linux arm64 (x86-64 only)") + raise SystemExit(f"unsupported architecture for Widevine: {platform.machine()!r}") + + +def _read_varint(b, i): + shift = result = 0 + while True: + if i >= len(b): + raise ValueError("truncated varint") + byte = b[i]; i += 1 + result |= (byte & 0x7F) << shift + if not byte & 0x80: + return result, i + shift += 7 + if shift > 63: + raise ValueError("varint too long") + + +def _parse_pb(b): + """Minimal protobuf reader → {field_num: [length-delimited bytes, ...]}.""" + out, i, n = {}, 0, len(b) + while i < n: + tag, i = _read_varint(b, i) + field, wire = tag >> 3, tag & 7 + if wire == 2: + ln, i = _read_varint(b, i) + out.setdefault(field, []).append(b[i:i + ln]); i += ln + elif wire == 0: + _, i = _read_varint(b, i) + elif wire == 1: + i += 8 + elif wire == 5: + i += 4 + else: + raise ValueError(f"unsupported protobuf wire type {wire}") + return out + + +def _crx_appid(pubkey_der): + """CRX app id = first 16 bytes of SHA-256(pubkey), each nibble mapped a–p.""" + digest = hashlib.sha256(pubkey_der).digest()[:16] + return "".join(chr(0x61 + (byte >> 4)) + chr(0x61 + (byte & 0xF)) for byte in digest), digest + + +def _verify_crx3(crx_bytes): + """Verify the CRX3 RSA publisher signature and bind it to APP_ID. + + Returns True if verified, False if `cryptography` is unavailable (caller then + relies on TLS + the server SHA-256). Raises SystemExit on a real failure. + We verify the RSASSA-PKCS1-v1_5 / SHA-256 proof (CRX3 field 2), which is what + Google signs the Widevine component with; ECDSA proofs (field 3) are not + relied on. The app id is derived from the signing key — the same trust root + Chrome verifies — so a non-Widevine publisher key can't satisfy the check. + """ + try: + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import padding + from cryptography.exceptions import InvalidSignature + except ImportError: + return False + + if len(crx_bytes) < 12: + raise SystemExit("not a CRX3 file (too short)") + if crx_bytes[:4] != b"Cr24": + raise SystemExit("not a CRX3 file (bad magic)") + version = struct.unpack(" None: + """Route cloakbrowser logger to stderr with clean output.""" + logging.basicConfig( + level=logging.INFO, + format="%(message)s", + stream=sys.stderr, + force=True, + ) + # Suppress noisy HTTP request logs from httpx + logging.getLogger("httpx").setLevel(logging.WARNING) + + +def cmd_install(args: argparse.Namespace) -> None: + from .download import ensure_binary + + path = ensure_binary() + print(path) + + +def _module_available(module: str) -> bool: + try: + return importlib.util.find_spec(module) is not None + except (ImportError, ValueError): + return False + + +def _binary_version(binary_path: str) -> tuple[bool, str, str]: + """Launch ` --version` to prove it runs. Returns (ok, version, err).""" + try: + result = subprocess.run( + [binary_path, "--version"], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError) as exc: + return False, "", str(exc) + if result.returncode != 0: + return False, "", (result.stderr or result.stdout).strip() + return True, result.stdout.strip(), "" + + +def _missing_shared_libs(binary_path: str) -> list[str]: + """Linux-only: ldd the binary and return missing .so names (empty otherwise).""" + if platform.system() != "Linux": + return [] + try: + result = subprocess.run( + ["ldd", "--", binary_path], # -- so a path starting with - isn't read as a flag + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return [] + missing = [] + for line in result.stdout.splitlines(): + if "=> not found" in line: + missing.append(line.split("=>")[0].strip()) + return missing + + +def _resolve_license() -> tuple[dict, bool]: + """Resolve + validate the license the way ensure_binary does. + + Returns (license_section, entitled_to_pro). Network call (validate) only + happens when a key is actually present. + """ + from .license import resolve_license_key, validate_license + + key = resolve_license_key(None) + # ensure_binary disables Pro routing when a custom download URL is set, so the + # diagnostic must report free too (matches download.py). + if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"): + key = None + if not key: + return {"tier": "free"}, False + try: + lic = validate_license(key) + except Exception as exc: + return {"tier": "unknown", "error": str(exc)}, False + if lic is None: + return {"tier": "unknown", "error": "could not validate"}, False + if lic.valid: + return {"tier": lic.plan, "valid": True, "expires": lic.expires}, True + return {"tier": "invalid", "valid": False}, False + + +def _effective_binary(entitled_pro: bool, quick: bool = False) -> dict: + """Describe the binary ensure_binary would actually launch (no download). + + Mirrors ensure_binary's resolution (override > version pin > license tier). + Unlike binary_info(), a Pro binary on disk is only reported when the license + entitles Pro — so a keyless run correctly shows the free binary even if a + Pro binary is cached. + """ + from .config import ( + CHROMIUM_VERSION, + get_binary_dir, + get_binary_path, + get_effective_version, + get_local_binary_override, + normalize_requested_version, + ) + + override = get_local_binary_override() + if override: + return { + "version": None, + "latest_version": None, + "pinned": False, + "tier": "override", + "bundled_version": CHROMIUM_VERSION, + "path": override, + "installed": os.path.isfile(override), + "cache_dir": None, + "override": override, + } + + requested = normalize_requested_version(None) + + # For a Pro license, surface the server's latest separately from the version + # that will actually launch, so `info` can never silently diverge from launch + # (the divergence a customer hit: info showed latest, launch ran a stale cache). + # --quick keeps `info` fully network-free (skip the server latest lookup). + latest_version = None + if entitled_pro and not quick: + from .license import get_pro_latest_version + + latest_version = get_pro_latest_version() + + if requested: + version = requested + elif entitled_pro: + # "Will launch now" is the cached Pro build; if none is cached, the next + # launch downloads latest_version. get_effective_version(pro=True) returns + # None (never the free base) when nothing is cached. + version = get_effective_version(pro=True) or latest_version + else: + version = get_effective_version() + + path = get_binary_path(version, pro=entitled_pro) if version else None + return { + "version": version, + "latest_version": latest_version, + "pinned": bool(requested), + "tier": "pro" if entitled_pro else "free", + "bundled_version": CHROMIUM_VERSION, + "path": str(path) if path else None, + "installed": bool(path) and path.exists(), + "cache_dir": str(get_binary_dir(version, pro=entitled_pro)) if version else None, + "override": None, + } + + +def _collect_diagnostics(quick: bool) -> dict: + """Gather environment + binary diagnostics without triggering a download.""" + diag: dict = {} + + diag["environment"] = { + "python": sys.version.split()[0], + "os": platform.system(), + "arch": platform.machine(), + } + + # Resolve the license up front — it decides which binary actually launches + # (ensure_binary only uses the Pro binary when a key validates). Computed + # before the binary section, displayed after it. + license_info, entitled_pro = _resolve_license() + + from .config import get_platform_tag + + try: + diag["environment"]["platform_tag"] = get_platform_tag() + except Exception as exc: + diag["environment"]["platform_tag"] = f"unavailable ({exc})" + + try: + diag["binary"] = _effective_binary(entitled_pro, quick=quick) + except Exception as exc: # platform unsupported, etc. + diag["binary"] = {"error": str(exc)} + + # Launch test — prove the binary actually executes (skipped by --quick). + binary = diag["binary"].get("path") + installed = diag["binary"].get("installed") + if quick: + diag["launch"] = {"tested": False, "reason": "skipped (--quick)"} + elif not binary or not (installed or (binary and os.path.isfile(binary))): + diag["launch"] = {"tested": False, "reason": "binary not installed"} + else: + ok, version, err = _binary_version(binary) + diag["launch"] = {"tested": True, "ok": ok, "version": version, "error": err} + if not ok: + diag["launch"]["missing_libs"] = _missing_shared_libs(binary) + + # Windows-font probe — only meaningful on a Linux host spoofing Windows. + # Omitted entirely off Linux, where it carries no signal. + if platform.system() == "Linux": + from .browser import ( + _OFFICE_FONT_TELLS, + _WINDOWS_FONT_TELLS, + _count_fonts_present, + ) + + # Strict count, not "any one present" — real font installs are atomic + # (you have the whole pack or none), so report how complete the set is. + win_n = _count_fonts_present(_WINDOWS_FONT_TELLS) + office_n = _count_fonts_present(_OFFICE_FONT_TELLS) + diag["fonts"] = { + "windows": None if win_n is None else [win_n, len(_WINDOWS_FONT_TELLS)], + "office": None if office_n is None else [office_n, len(_OFFICE_FONT_TELLS)], + } + + diag["license"] = license_info + + # GeoIP DB — presence only, never downloads. + from .geoip import GEOIP_DB_FILENAME, _get_geoip_dir + + db_path = _get_geoip_dir() / GEOIP_DB_FILENAME + diag["geoip"] = {"db_present": db_path.exists(), "path": str(db_path)} + + # Optional Python modules. + diag["modules"] = { + label: _module_available(module) + for label, module in { + "playwright": "playwright.sync_api", + "geoip2": "geoip2.database", + "aiohttp": "aiohttp", + "websockets": "websockets", + }.items() + } + + return diag + + +def _print_diagnostics(diag: dict) -> None: + """Render the diagnostics dict as a human-readable report.""" + env = diag["environment"] + print("CloakBrowser diagnostics") + print(f"Python: {env['python']}") + print(f"OS: {env['os']} {env['arch']}") + print(f"Platform: {env.get('platform_tag', 'unknown')}") + + binary = diag["binary"] + if "error" in binary: + print(f"Binary: unavailable ({binary['error']})") + else: + if binary["tier"] == "override": + print("Version: set via CLOAKBROWSER_BINARY_PATH (see Launch line)") + else: + latest = binary.get("latest_version") + if latest: + # Pro: show what launches now AND the server's latest, so the two + # can never silently diverge. + print(f"Version: {binary['version']} ({binary['tier']}) — will launch") + if latest == binary["version"]: + print(f"Latest: {latest} (up to date)") + elif binary.get("pinned"): + print( + f"Latest: {latest} (available — pinned; unset " + "CLOAKBROWSER_VERSION to upgrade)" + ) + else: + print(f"Latest: {latest} (available — next launch upgrades)") + elif binary["version"] is None: + # Pro with no cached build and no server answer (e.g. offline). + print( + f"Version: not downloaded yet ({binary['tier']}) " + "— next launch downloads the latest" + ) + else: + print(f"Version: {binary['version']} ({binary['tier']})") + print(f"Binary: {binary['path']}") + print(f"Installed: {binary['installed']}") + if binary.get("cache_dir"): + print(f"Cache: {binary['cache_dir']}") + if binary.get("override"): + print(f"Override: {binary['override']} (CLOAKBROWSER_BINARY_PATH)") + + launch = diag["launch"] + if not launch.get("tested"): + print(f"Launch: {launch['reason']}") + elif launch["ok"]: + print(f"Launch: ✓ {launch['version']}") + else: + print(f"Launch: ✗ failed — {launch['error']}") + for lib in launch.get("missing_libs", []): + print(f" missing: {lib}") + if launch.get("missing_libs"): + print(" → install the missing system libraries (e.g. apt-get install)") + + if "fonts" in diag: + win = diag["fonts"]["windows"] + if win is None: + print("Win fonts: unknown (fc-list unavailable)") + else: + n, total = win + verdict = "ok" if n == total else "missing" if n == 0 else "partial" + print(f"Win fonts: {verdict} ({n}/{total})") + if n < total: + print(" → incomplete Windows font set; copy real Windows fonts (Segoe UI, Calibri, Consolas)") + office = diag["fonts"].get("office") + if office is not None: + n, total = office + # Office is informational only — no Office pack is a normal Windows + # persona (~53% of real machines have none), so no install nudge. + verdict = "ok" if n == total else "absent" if n == 0 else "partial" + print(f"Office fonts: {verdict} ({n}/{total})") + + lic = diag["license"] + tier = lic["tier"] + if tier == "free": + print("License: Free") + print(f" {UPGRADE_HINT}") + elif "error" in lic: + print(f"License: {tier} ({lic['error']})") + else: + print(f"License: {tier}") + + geoip = diag["geoip"] + print(f"GeoIP DB: {'present' if geoip['db_present'] else 'not downloaded (optional)'}") + + print("Modules:") + for label, available in diag["modules"].items(): + print(f" {label}: {'ok' if available else 'missing'}") + + +def cmd_info(args: argparse.Namespace) -> None: + quick = getattr(args, "quick", False) + diag = _collect_diagnostics(quick=quick) + if getattr(args, "json", False): + import json + + print(json.dumps(diag, indent=2)) + else: + _print_diagnostics(diag) + + +def cmd_update(args: argparse.Namespace) -> None: + from .download import check_for_pro_update, check_for_update + + logger = logging.getLogger("cloakbrowser") + logger.info("Checking for updates...") + + # A valid Pro license updates the Pro binary; everyone else updates free. + _, entitled_pro = _resolve_license() + if entitled_pro: + from .license import resolve_license_key + + new_version = check_for_pro_update(resolve_license_key(None)) + label = "Pro Chromium" + else: + new_version = check_for_update() + label = "Chromium" + + if new_version: + print(f"Updated to {label} {new_version}") + else: + print("Already up to date.") + + +def cmd_clear_cache(args: argparse.Namespace) -> None: + from .config import get_cache_dir + from .download import clear_cache + + if not get_cache_dir().exists(): + print("No cache to clear.") + return + clear_cache() + print("Cache cleared.") + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="cloakbrowser", + description="Manage the CloakBrowser stealth Chromium binary.", + ) + sub = parser.add_subparsers(dest="command") + + sub.add_parser("install", help="Download the Chromium binary") + + def _add_info_flags(p: argparse.ArgumentParser) -> None: + p.add_argument( + "--quick", + "--no-launch", + action="store_true", + dest="quick", + help="Skip the binary launch test (faster; the license is still validated)", + ) + p.add_argument("--json", action="store_true", help="Emit diagnostics as JSON") + + _add_info_flags(sub.add_parser("info", help="Environment + binary diagnostics")) + _add_info_flags(sub.add_parser("doctor", help="Alias for info")) + sub.add_parser("update", help="Check for and download a newer binary") + sub.add_parser("clear-cache", help="Remove all cached binaries") + + args = parser.parse_args() + if not args.command: + parser.print_help() + sys.exit(2) + + _setup_logging() + + commands = { + "install": cmd_install, + "info": cmd_info, + "doctor": cmd_info, + "update": cmd_update, + "clear-cache": cmd_clear_cache, + } + + try: + commands[args.command](args) + except KeyboardInterrupt: + sys.exit(130) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/cloakbrowser/_version.py b/cloakbrowser/_version.py new file mode 100644 index 0000000..805e7c4 --- /dev/null +++ b/cloakbrowser/_version.py @@ -0,0 +1 @@ +__version__ = "0.4.10" diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py new file mode 100644 index 0000000..4f9d92d --- /dev/null +++ b/cloakbrowser/browser.py @@ -0,0 +1,1440 @@ +"""Core browser launch functions for cloakbrowser. + +Provides launch() and launch_async() — thin wrappers around Playwright +that use our patched stealth Chromium binary instead of stock Chromium. + +Usage: + from cloakbrowser import launch + + browser = launch() + page = browser.new_page() + page.goto("https://protected-site.com") + browser.close() +""" + +from __future__ import annotations + +import logging +import os +import sys +from typing import Any, Literal, TypedDict +from urllib.parse import quote, unquote, urlparse, urlunparse + +from .config import ( + DEFAULT_VIEWPORT, + IGNORE_DEFAULT_ARGS, + binary_supports_headless_no_viewport, + binary_supports_http_proxy_inline_auth, + binary_supports_maximized_window, + get_default_stealth_args, +) +from .download import ensure_binary +from .license import build_launch_env +from .human.config import HumanConfigOverrides, HumanPreset +from .widevine import seed_widevine_hint + +logger = logging.getLogger("cloakbrowser") + + +# Sentinel to distinguish "viewport not provided" from "viewport=None" (disable emulation) +_VIEWPORT_UNSET = object() + + +def _default_no_viewport(browser: Any) -> None: + """Default ``new_page()``/``new_context()`` to ``no_viewport=True``. + + ``launch()`` returns a raw Playwright ``Browser``; a bare ``browser.new_page()`` + would otherwise inherit Playwright's emulated 1280x720 viewport, producing + ``outerWidth < innerWidth`` — a physically impossible window (bot tell). We wrap + the two factory methods so pages track the real OS window instead. ``setdefault`` + only: an explicit ``viewport`` or ``no_viewport`` from the caller is never + overridden (Playwright rejects passing both). Applied for headed launches only. + Composes under humanize's ``patch_browser`` (apply this first). + """ + orig_new_context = browser.new_context + orig_new_page = browser.new_page + + def _patched_new_context(**kwargs: Any) -> Any: + if "viewport" not in kwargs: + kwargs.setdefault("no_viewport", True) + return orig_new_context(**kwargs) + + def _patched_new_page(**kwargs: Any) -> Any: + if "viewport" not in kwargs: + kwargs.setdefault("no_viewport", True) + return orig_new_page(**kwargs) + + browser.new_context = _patched_new_context + browser.new_page = _patched_new_page + + +def _default_no_viewport_async(browser: Any) -> None: + """Async variant of :func:`_default_no_viewport`.""" + orig_new_context = browser.new_context + orig_new_page = browser.new_page + + async def _patched_new_context(**kwargs: Any) -> Any: + if "viewport" not in kwargs: + kwargs.setdefault("no_viewport", True) + return await orig_new_context(**kwargs) + + async def _patched_new_page(**kwargs: Any) -> Any: + if "viewport" not in kwargs: + kwargs.setdefault("no_viewport", True) + return await orig_new_page(**kwargs) + + browser.new_context = _patched_new_context + browser.new_page = _patched_new_page + + +def _resolve_context_viewport( + viewport: Any, headless: bool, headless_no_viewport: bool = False +) -> dict[str, Any]: + """Return the viewport kwarg for a context. + + Headed: no emulated viewport so the page tracks the real window. Headless on a + newer binary (``headless_no_viewport``): also ``no_viewport``, since it reports + coherent dimensions without emulation. Headless on an older binary: a fixed + ``DEFAULT_VIEWPORT`` keeps dimensions coherent and deterministic. Explicit + ``viewport`` / ``None`` honored. + """ + if viewport is _VIEWPORT_UNSET: + if headless and not headless_no_viewport: + return {"viewport": DEFAULT_VIEWPORT} + return {"no_viewport": True} + if viewport is None: + return {"no_viewport": True} + return {"viewport": viewport} + + +def _drop_conflicting_viewport(context_kwargs: dict[str, Any], kwargs: dict[str, Any]) -> None: + """Playwright rejects passing both ``viewport`` and ``no_viewport``. ``viewport`` is a + named parameter (never in ``**kwargs``), so the only conflict is a caller passing + ``no_viewport`` via ``**kwargs`` alongside an explicit ``viewport`` — the explicit + ``no_viewport`` wins; drop the viewport so Playwright doesn't error. + """ + if "no_viewport" in kwargs and "viewport" in context_kwargs: + logger.debug("Both viewport and no_viewport requested; no_viewport (kwargs) wins") + context_kwargs.pop("viewport", None) + + +def _resolve_timezone(timezone: str | None, kwargs: dict[str, Any]) -> str | None: + """Accept both timezone and timezone_id — either works, no warning.""" + if "timezone_id" in kwargs: + if timezone is None: + timezone = kwargs.pop("timezone_id") + else: + kwargs.pop("timezone_id") + return timezone + + +def _check_removed_kwargs(kwargs: dict[str, Any]) -> None: + """Raise a clear error for removed parameters that now fall into **kwargs.""" + if "backend" in kwargs: + raise TypeError( + "The 'backend' parameter has been removed — patchright is no longer " + "supported and stock Playwright is the only backend. Remove the argument." + ) + + +class _ProxySettingsRequired(TypedDict): + server: str + + +class ProxySettings(_ProxySettingsRequired, total=False): + """Playwright-compatible proxy configuration.""" + + bypass: str + username: str + password: str + + +def launch( + headless: bool = True, + proxy: str | ProxySettings | None = None, + args: list[str] | None = None, + stealth_args: bool = True, + timezone: str | None = None, + locale: str | None = None, + geoip: bool = False, + humanize: bool = False, + human_preset: HumanPreset = "default", + human_config: HumanConfigOverrides | None = None, + extension_paths: list[str] | None = None, + license_key: str | None = None, + browser_version: str | None = None, + _suppress_maximize: bool = False, + **kwargs: Any, +) -> Any: + """Launch stealth Chromium browser. Returns a Playwright Browser object. + + Args: + headless: Run in headless mode (default True). + proxy: Proxy URL string or Playwright proxy dict. + String: 'http://user:pass@proxy:8080' (credentials auto-extracted). + Dict: {"server": "http://proxy:8080", "bypass": ".google.com", ...} + — passed directly to Playwright. + args: Additional Chromium CLI arguments to pass. + extension_paths: List of Chrome extension paths to load. + stealth_args: Include default stealth fingerprint args (default True). + Set to False if you want to pass your own --fingerprint flags. + timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag. + locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag. + geoip: Auto-detect timezone/locale from proxy IP (default False). + Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB + GeoLite2-City database on first use. Explicit timezone/locale + always override geoip results. + humanize: Enable human-like mouse, keyboard, scroll behavior (default False). + human_preset: Humanize preset — 'default' or 'careful' (default 'default'). + human_config: Custom humanize config mapping to override preset values. + **kwargs: Passed directly to playwright.chromium.launch(). + + Returns: + Playwright Browser object — use same API as playwright.chromium.launch(). + + Example: + >>> from cloakbrowser import launch + >>> browser = launch() + >>> page = browser.new_page() + >>> page.goto("https://bot.incolumitas.com") + >>> print(page.title()) + >>> browser.close() + """ + _check_removed_kwargs(kwargs) + + from playwright.sync_api import sync_playwright + + binary_path = ensure_binary(license_key=license_key, browser_version=browser_version) + timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) + proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key) + args = _resolve_webrtc_args(args, proxy) + args = _append_webrtc_exit_ip(args, exit_ip) + + chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and not _suppress_maximize) + _maybe_warn_windows_fonts(chrome_args) + + logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args)) + + launch_env = build_launch_env(license_key, user_env=kwargs.pop("env", None)) + env_kwargs = {} if launch_env is None else {"env": launch_env} + + pw = sync_playwright().start() + browser = pw.chromium.launch( + executable_path=binary_path, + headless=headless, + args=chrome_args, + ignore_default_args=IGNORE_DEFAULT_ARGS, + **env_kwargs, + **proxy_kwargs, + **kwargs, + ) + + # Patch close() to also stop the Playwright instance + _original_close = browser.close + + def _close_with_cleanup() -> None: + try: + _original_close() + finally: + pw.stop() + + browser.close = _close_with_cleanup + + # Default new_page()/new_context() to no_viewport for headed (page tracks the + # real window) and for headless on binaries that report coherent dimensions + # natively; older headless binaries keep Playwright's default viewport. Apply + # before humanize so the wraps compose. + if not headless or binary_supports_headless_no_viewport(license_key, browser_version): + _default_no_viewport(browser) + + # Human-like behavioral patching + if humanize: + from .human import patch_browser + from .human.config import resolve_config + cfg = resolve_config(human_preset, human_config) + patch_browser(browser, cfg) + + return browser + + +async def launch_async( # noqa: C901 + headless: bool = True, + proxy: str | ProxySettings | None = None, + args: list[str] | None = None, + stealth_args: bool = True, + timezone: str | None = None, + locale: str | None = None, + geoip: bool = False, + humanize: bool = False, + human_preset: HumanPreset = "default", + human_config: HumanConfigOverrides | None = None, + extension_paths: list[str] | None = None, + license_key: str | None = None, + browser_version: str | None = None, + _suppress_maximize: bool = False, + **kwargs: Any, +) -> Any: + """Async version of launch(). Returns a Playwright Browser object. + + Args: + headless: Run in headless mode (default True). + proxy: Proxy URL string or Playwright proxy dict (see launch() for details). + args: Additional Chromium CLI arguments to pass. + extension_paths: List of Chrome extension paths to load. + stealth_args: Include default stealth fingerprint args (default True). + timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag. + locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag. + geoip: Auto-detect timezone/locale from proxy IP (default False). + humanize: Enable human-like mouse, keyboard, scroll behavior (default False). + human_preset: Humanize preset — 'default' or 'careful' (default 'default'). + human_config: Custom humanize config mapping to override preset values. + **kwargs: Passed directly to playwright.chromium.launch(). + + Returns: + Playwright Browser object (async API). + + Example: + >>> import asyncio + >>> from cloakbrowser import launch_async + >>> + >>> async def main(): + ... browser = await launch_async() + ... page = await browser.new_page() + ... await page.goto("https://bot.incolumitas.com") + ... print(await page.title()) + ... await browser.close() + >>> + >>> asyncio.run(main()) + """ + _check_removed_kwargs(kwargs) + + from playwright.async_api import async_playwright + + binary_path = ensure_binary(license_key=license_key, browser_version=browser_version) + timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) + proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key) + args = _resolve_webrtc_args(args, proxy) + args = _append_webrtc_exit_ip(args, exit_ip) + chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and not _suppress_maximize) + _maybe_warn_windows_fonts(chrome_args) + + logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args)) + + launch_env = build_launch_env(license_key, user_env=kwargs.pop("env", None)) + env_kwargs = {} if launch_env is None else {"env": launch_env} + + pw = await async_playwright().start() + browser = await pw.chromium.launch( + executable_path=binary_path, + headless=headless, + args=chrome_args, + ignore_default_args=IGNORE_DEFAULT_ARGS, + **env_kwargs, + **proxy_kwargs, + **kwargs, + ) + + # Patch close() to also stop the Playwright instance + _original_close = browser.close + + async def _close_with_cleanup() -> None: + try: + await _original_close() + finally: + await pw.stop() + + browser.close = _close_with_cleanup + + # Default new_page()/new_context() to no_viewport for headed and qualifying + # headless binaries (see launch()). + if not headless or binary_supports_headless_no_viewport(license_key, browser_version): + _default_no_viewport_async(browser) + + # Human-like behavioral patching (async variant) + if humanize: + from .human import patch_browser_async + from .human.config import resolve_config + cfg = resolve_config(human_preset, human_config) + patch_browser_async(browser, cfg) + + return browser + + +def launch_persistent_context( + user_data_dir: str | os.PathLike, + headless: bool = True, + proxy: str | ProxySettings | None = None, + args: list[str] | None = None, + stealth_args: bool = True, + user_agent: str | None = None, + viewport: dict | None = _VIEWPORT_UNSET, + locale: str | None = None, + timezone: str | None = None, + color_scheme: Literal["light", "dark", "no-preference"] | None = None, + geoip: bool = False, + humanize: bool = False, + human_preset: HumanPreset = "default", + human_config: HumanConfigOverrides | None = None, + extension_paths: list[str] | None = None, + license_key: str | None = None, + browser_version: str | None = None, + **kwargs: Any, +) -> Any: + """Launch stealth browser with a persistent profile and return a BrowserContext. + + This persists cookies, localStorage, cache, and other browser state across + sessions by storing them in ``user_data_dir``. Also avoids incognito detection + by services like BrowserScan (-10% penalty). + + Args: + user_data_dir: Path to the directory where browser profile data is stored. + Created automatically if it doesn't exist. Reuse the same path across + sessions to restore cookies, localStorage, cached credentials, etc. + headless: Run in headless mode (default True). + proxy: Proxy URL string or Playwright proxy dict (see launch() for details). + args: Additional Chromium CLI arguments. + extension_paths: List of Chrome extension paths to load. + stealth_args: Include default stealth fingerprint args (default True). + user_agent: Custom user agent string. + viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}. + Pass None to disable viewport emulation (use OS window size). + locale: Browser locale, e.g. "en-US". + timezone: IANA timezone (e.g. 'America/New_York'). + color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'. + Default: None (uses Chromium default, which is 'light'). + geoip: Auto-detect timezone/locale from proxy IP (default False). + Requires ``pip install cloakbrowser[geoip]``. + humanize: Enable human-like mouse, keyboard, scroll behavior (default False). + human_preset: Humanize preset — 'default' or 'careful' (default 'default'). + human_config: Custom humanize config mapping to override preset values. + **kwargs: Passed directly to playwright.chromium.launch_persistent_context(). + + Returns: + Playwright BrowserContext object backed by a persistent profile. + Call ``.close()`` when done — this also stops the Playwright instance. + + Example: + >>> from cloakbrowser import launch_persistent_context + >>> ctx = launch_persistent_context("./my-profile", headless=False) + >>> page = ctx.new_page() + >>> page.goto("https://protected-site.com") + >>> ctx.close() # Profile is saved; re-use path next run to restore state. + """ + _check_removed_kwargs(kwargs) + + from playwright.sync_api import sync_playwright + + timezone = _resolve_timezone(timezone, kwargs) + + binary_path = ensure_binary(license_key=license_key, browser_version=browser_version) + timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) + proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key) + args = _resolve_webrtc_args(args, proxy) + args = _append_webrtc_exit_ip(args, exit_ip) + chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs) + _maybe_warn_windows_fonts(chrome_args) + + logger.debug( + "Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)", + headless, + user_data_dir, + ) + + # locale and timezone are set via binary flags (--lang, --fingerprint-timezone) + # — NOT via Playwright context kwargs which use detectable CDP emulation. + context_kwargs: dict[str, Any] = {} + if user_agent: + context_kwargs["user_agent"] = user_agent + context_kwargs.update( + _resolve_context_viewport( + viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version) + ) + ) + if color_scheme: + context_kwargs["color_scheme"] = color_scheme + context_kwargs.update(kwargs) + _drop_conflicting_viewport(context_kwargs, kwargs) + + # Resolve env for the browser process (license key injection, if needed) + user_env = context_kwargs.pop("env", None) + launch_env = build_launch_env(license_key, user_env=user_env) + if launch_env is not None: + context_kwargs["env"] = launch_env + + seed_widevine_hint(user_data_dir, binary_path) + + pw = sync_playwright().start() + context = pw.chromium.launch_persistent_context( + user_data_dir=os.fspath(user_data_dir), + executable_path=binary_path, + headless=headless, + args=chrome_args, + ignore_default_args=IGNORE_DEFAULT_ARGS, + **proxy_kwargs, + **context_kwargs, + ) + + # Patch close() to also stop the Playwright instance + _original_close = context.close + + def _close_with_cleanup() -> None: + try: + _original_close() + finally: + pw.stop() + + context.close = _close_with_cleanup + + # Human-like behavioral patching + if humanize: + from .human import patch_context + from .human.config import resolve_config + cfg = resolve_config(human_preset, human_config) + patch_context(context, cfg) + + return context + + +async def launch_persistent_context_async( + user_data_dir: str | os.PathLike, + headless: bool = True, + proxy: str | ProxySettings | None = None, + args: list[str] | None = None, + stealth_args: bool = True, + user_agent: str | None = None, + viewport: dict | None = _VIEWPORT_UNSET, + locale: str | None = None, + timezone: str | None = None, + color_scheme: Literal["light", "dark", "no-preference"] | None = None, + geoip: bool = False, + humanize: bool = False, + human_preset: HumanPreset = "default", + human_config: HumanConfigOverrides | None = None, + extension_paths: list[str] | None = None, + license_key: str | None = None, + browser_version: str | None = None, + **kwargs: Any, +) -> Any: + """Async version of launch_persistent_context(). + + Launch stealth browser with a persistent profile and return a BrowserContext. + This persists cookies, localStorage, cache, and other browser state across + sessions by storing them in ``user_data_dir``. + + Args: + user_data_dir: Path to the directory where browser profile data is stored. + Created automatically if it doesn't exist. + headless: Run in headless mode (default True). + proxy: Proxy URL string or Playwright proxy dict (see launch() for details). + args: Additional Chromium CLI arguments. + extension_paths: List of Chrome extension paths to load. + stealth_args: Include default stealth fingerprint args (default True). + user_agent: Custom user agent string. + viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}. + Pass None to disable viewport emulation (use OS window size). + locale: Browser locale, e.g. "en-US". + timezone: IANA timezone (e.g. 'America/New_York'). + color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'. + geoip: Auto-detect timezone/locale from proxy IP (default False). + humanize: Enable human-like mouse, keyboard, scroll behavior (default False). + human_preset: Humanize preset — 'default' or 'careful' (default 'default'). + human_config: Custom humanize config mapping to override preset values. + **kwargs: Passed directly to playwright.chromium.launch_persistent_context(). + + Returns: + Playwright BrowserContext object backed by a persistent profile (async API). + Call ``await .close()`` when done. + + Example: + >>> import asyncio + >>> from cloakbrowser import launch_persistent_context_async + >>> + >>> async def main(): + ... ctx = await launch_persistent_context_async("./my-profile", headless=False) + ... page = await ctx.new_page() + ... await page.goto("https://protected-site.com") + ... await ctx.close() + >>> + >>> asyncio.run(main()) + """ + _check_removed_kwargs(kwargs) + + from playwright.async_api import async_playwright + + timezone = _resolve_timezone(timezone, kwargs) + + binary_path = ensure_binary(license_key=license_key, browser_version=browser_version) + timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) + proxy_kwargs, proxy_extra_args = _resolve_proxy_config(proxy, browser_version, license_key) + args = _resolve_webrtc_args(args, proxy) + args = _append_webrtc_exit_ip(args, exit_ip) + chrome_args = build_args(stealth_args, (args or []) + proxy_extra_args, timezone=timezone, locale=locale, headless=headless, extension_paths=extension_paths, start_maximized=binary_supports_maximized_window(license_key, browser_version) and viewport is _VIEWPORT_UNSET and "viewport" not in kwargs and "no_viewport" not in kwargs) + _maybe_warn_windows_fonts(chrome_args) + + logger.debug( + "Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)", + headless, + user_data_dir, + ) + + # locale and timezone are set via binary flags (--lang, --fingerprint-timezone) + # — NOT via Playwright context kwargs which use detectable CDP emulation. + context_kwargs: dict[str, Any] = {} + if user_agent: + context_kwargs["user_agent"] = user_agent + context_kwargs.update( + _resolve_context_viewport( + viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version) + ) + ) + if color_scheme: + context_kwargs["color_scheme"] = color_scheme + context_kwargs.update(kwargs) + _drop_conflicting_viewport(context_kwargs, kwargs) + + # Resolve env for the browser process (license key injection, if needed) + user_env = context_kwargs.pop("env", None) + launch_env = build_launch_env(license_key, user_env=user_env) + if launch_env is not None: + context_kwargs["env"] = launch_env + + seed_widevine_hint(user_data_dir, binary_path) + + pw = await async_playwright().start() + context = await pw.chromium.launch_persistent_context( + user_data_dir=os.fspath(user_data_dir), + executable_path=binary_path, + headless=headless, + args=chrome_args, + ignore_default_args=IGNORE_DEFAULT_ARGS, + **proxy_kwargs, + **context_kwargs, + ) + + # Patch close() to also stop the Playwright instance + _original_close = context.close + + async def _close_with_cleanup() -> None: + try: + await _original_close() + finally: + await pw.stop() + + context.close = _close_with_cleanup + + # Human-like behavioral patching (async variant) + if humanize: + from .human import patch_context_async + from .human.config import resolve_config + cfg = resolve_config(human_preset, human_config) + patch_context_async(context, cfg) + + return context + + +def launch_context( + headless: bool = True, + proxy: str | ProxySettings | None = None, + args: list[str] | None = None, + stealth_args: bool = True, + user_agent: str | None = None, + viewport: dict | None = _VIEWPORT_UNSET, + locale: str | None = None, + timezone: str | None = None, + color_scheme: Literal["light", "dark", "no-preference"] | None = None, + geoip: bool = False, + humanize: bool = False, + human_preset: HumanPreset = "default", + human_config: HumanConfigOverrides | None = None, + extension_paths: list[str] | None = None, + license_key: str | None = None, + browser_version: str | None = None, + **kwargs: Any, +) -> Any: + """Launch stealth browser and return a BrowserContext with common options pre-set. + + Convenience function that creates a browser + context in one call. + Useful for setting user agent, viewport, locale, etc. + + Args: + headless: Run in headless mode (default True). + proxy: Proxy URL string or Playwright proxy dict (see launch() for details). + args: Additional Chromium CLI arguments. + extension_paths: List of Chrome extension paths to load. + stealth_args: Include default stealth fingerprint args (default True). + user_agent: Custom user agent string. + viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}. + Pass None to disable viewport emulation (use OS window size). + locale: Browser locale, e.g. "en-US". + timezone: IANA timezone (e.g. 'America/New_York'). + color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'. + Default: None (uses Chromium default, which is 'light'). + geoip: Auto-detect timezone/locale from proxy IP (default False). + humanize: Enable human-like mouse, keyboard, scroll behavior (default False). + human_preset: Humanize preset — 'default' or 'careful' (default 'default'). + human_config: Custom humanize config mapping to override preset values. + **kwargs: Passed to browser.new_context(). + + Returns: + Playwright BrowserContext object. + """ + _check_removed_kwargs(kwargs) + + timezone = _resolve_timezone(timezone, kwargs) + + # Resolve geoip BEFORE launch() to avoid double-resolution and ensure + # resolved values flow to binary flags + timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) + # Inject geoip exit IP for WebRTC spoofing (free — no extra HTTP call) + args = _append_webrtc_exit_ip(args, exit_ip) + # --fingerprint-timezone is process-wide (reads CommandLine in renderer), + # so it applies to ALL contexts, not just the default one. + # locale and timezone are set via binary flags only — no CDP emulation. + browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args, + timezone=timezone, locale=locale, extension_paths=extension_paths, + license_key=license_key, browser_version=browser_version, + # Caller chose a viewport geometry → don't also auto-maximize + # the window (mirrors the persistent-context path + JS). + _suppress_maximize=(viewport is not _VIEWPORT_UNSET or "no_viewport" in kwargs)) + + context_kwargs: dict[str, Any] = {} + if user_agent: + context_kwargs["user_agent"] = user_agent + context_kwargs.update( + _resolve_context_viewport( + viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version) + ) + ) + if color_scheme: + context_kwargs["color_scheme"] = color_scheme + context_kwargs.update(kwargs) + _drop_conflicting_viewport(context_kwargs, kwargs) + + try: + context = browser.new_context(**context_kwargs) + except Exception: + browser.close() + raise + + # Patch close() to also close the browser (and its Playwright instance) + _original_ctx_close = context.close + + def _close_context_with_cleanup() -> None: + try: + _original_ctx_close() + finally: + browser.close() + + context.close = _close_context_with_cleanup + + # Human-like behavioral patching + if humanize: + from .human import patch_context + from .human.config import resolve_config + cfg = resolve_config(human_preset, human_config) + patch_context(context, cfg) + + return context + + +async def launch_context_async( + headless: bool = True, + proxy: str | ProxySettings | None = None, + args: list[str] | None = None, + stealth_args: bool = True, + user_agent: str | None = None, + viewport: dict | None = _VIEWPORT_UNSET, + locale: str | None = None, + timezone: str | None = None, + color_scheme: Literal["light", "dark", "no-preference"] | None = None, + geoip: bool = False, + humanize: bool = False, + human_preset: HumanPreset = "default", + human_config: HumanConfigOverrides | None = None, + extension_paths: list[str] | None = None, + license_key: str | None = None, + browser_version: str | None = None, + **kwargs: Any, +) -> Any: + """Async version of launch_context(). + + Launch stealth browser and return a BrowserContext with common options pre-set. + All extra kwargs are forwarded to ``browser.new_context()`` — use this for + ``storage_state``, ``permissions``, ``extra_http_headers``, etc. without needing + a persistent profile folder. + + Args: + headless: Run in headless mode (default True). + proxy: Proxy URL string or Playwright proxy dict (see launch() for details). + args: Additional Chromium CLI arguments. + extension_paths: List of Chrome extension paths to load. + stealth_args: Include default stealth fingerprint args (default True). + user_agent: Custom user agent string. + viewport: Viewport size dict, e.g. {"width": 1920, "height": 1080}. + Pass None to disable viewport emulation (use OS window size). + locale: Browser locale, e.g. "en-US". + timezone: IANA timezone (e.g. 'America/New_York'). + color_scheme: Color scheme preference — 'light', 'dark', or 'no-preference'. + geoip: Auto-detect timezone/locale from proxy IP (default False). + humanize: Enable human-like mouse, keyboard, scroll behavior (default False). + human_preset: Humanize preset — 'default' or 'careful' (default 'default'). + human_config: Custom humanize config mapping to override preset values. + **kwargs: Passed to browser.new_context() — e.g. storage_state, permissions. + + Returns: + Playwright BrowserContext object (async API). + Call ``await .close()`` when done — this also closes the underlying browser. + + Example: + >>> import asyncio + >>> from cloakbrowser import launch_context_async + >>> + >>> async def main(): + ... # Load saved session (cookies, localStorage) + ... ctx = await launch_context_async( + ... headless=True, + ... storage_state="state.json", + ... ) + ... page = await ctx.new_page() + ... await page.goto("https://example.com") + ... # Save state back + ... await ctx.storage_state(path="state.json") + ... await ctx.close() + >>> + >>> asyncio.run(main()) + """ + _check_removed_kwargs(kwargs) + + timezone = _resolve_timezone(timezone, kwargs) + + # Resolve geoip BEFORE launch_async() to avoid double-resolution and ensure + # resolved values flow to binary flags + timezone, locale, exit_ip = maybe_resolve_geoip(geoip, proxy, timezone, locale) + args = _append_webrtc_exit_ip(args, exit_ip) + # --fingerprint-timezone is process-wide (reads CommandLine in renderer), + # so it applies to ALL contexts, not just the default one. + # locale and timezone are set via binary flags only — no CDP emulation. + browser = await launch_async(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args, + timezone=timezone, locale=locale, extension_paths=extension_paths, + license_key=license_key, browser_version=browser_version, + # Caller chose a viewport geometry → don't also auto-maximize + # the window (mirrors the persistent-context path + JS). + _suppress_maximize=(viewport is not _VIEWPORT_UNSET or "no_viewport" in kwargs)) + + context_kwargs: dict[str, Any] = {} + if user_agent: + context_kwargs["user_agent"] = user_agent + context_kwargs.update( + _resolve_context_viewport( + viewport, headless, binary_supports_headless_no_viewport(license_key, browser_version) + ) + ) + if color_scheme: + context_kwargs["color_scheme"] = color_scheme + context_kwargs.update(kwargs) + _drop_conflicting_viewport(context_kwargs, kwargs) + + # Catch BaseException (not just Exception) so that asyncio.CancelledError + # triggers browser cleanup — otherwise the underlying Chromium process + # leaks when the awaiting task is cancelled. + try: + context = await browser.new_context(**context_kwargs) + except BaseException: + try: + await browser.close() + except BaseException: + pass + raise + + # Patch close() to also close the browser (and its Playwright instance) + _original_ctx_close = context.close + + async def _close_context_with_cleanup() -> None: + try: + await _original_ctx_close() + finally: + await browser.close() + + context.close = _close_context_with_cleanup + + # Human-like behavioral patching (async variant) + if humanize: + from .human import patch_context_async + from .human.config import resolve_config + cfg = resolve_config(human_preset, human_config) + patch_context_async(context, cfg) + + return context + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _ensure_proxy_scheme(proxy_url: str) -> str: + """Prepend http:// to schemeless proxy URLs so parsers can extract hostname.""" + return proxy_url if "://" in proxy_url else f"http://{proxy_url}" + + +def _assemble_proxy_url( + scheme: str, + host: str, + port: int | None, + enc_user: str, + enc_pass: str | None, + path: str = "", + params: str = "", + query: str = "", + fragment: str = "", +) -> str: + """Build a proxy URL from already-percent-encoded credentials and host parts. + + ``enc_pass is None`` means no password (no colon in userinfo). Empty string + means present-but-empty (colon preserved). This mirrors the distinction + urlparse makes between ``user@host`` and ``user:@host``. + """ + if ":" in host: # IPv6 literal — re-add brackets + host = f"[{host}]" + if enc_pass is not None: + userinfo = f"{enc_user}:{enc_pass}@" + elif enc_user: + userinfo = f"{enc_user}@" + else: + userinfo = "" + netloc = f"{userinfo}{host}" + if port is not None: + netloc += f":{port}" + return urlunparse((scheme, netloc, path, params, query, fragment)) + + +def _reconstruct_socks_url(proxy: ProxySettings) -> str: + """Reconstruct a SOCKS5 URL with inline credentials from a Playwright proxy dict.""" + server = proxy.get("server", "") + username = proxy.get("username", "") + password = proxy.get("password", "") + if not username: + return server + parsed = urlparse(server) + enc_user = quote(username, safe="") + # Dict convention: empty/missing password → no colon. + enc_pass = quote(password, safe="") if password else None + return _assemble_proxy_url( + parsed.scheme, parsed.hostname or "", parsed.port, + enc_user, enc_pass, parsed.path, + ) + + +def _normalize_socks_string_url(url: str) -> str: + """Re-encode credentials in a SOCKS5 URL string so Chromium's parser doesn't + truncate them at special chars like '='. Idempotent: pre-encoded input stays + the same (decoded then re-encoded). + + Emits an INFO log when re-encoding actually changes the URL, so users who + previously hit silent SOCKS5 fallback (#157) can see what the wrapper did. + Silent on already-encoded inputs (no false-positive noise). + + On unparseable input (invalid port, broken IPv6 literal, etc.) logs a + warning and returns the original string — preserves pre-fix pass-through + behavior so Chromium's own error handling kicks in. + """ + try: + parsed = urlparse(url) + # Accessing .port raises ValueError on invalid port strings. + _ = parsed.port + except ValueError as e: + logger.warning("Malformed SOCKS5 proxy URL, passing through unchanged: %s", e) + return url + # Skip only if no credentials at all (username AND password both absent). + # urlparse returns None for absent components, "" for present-but-empty. + if parsed.username is None and parsed.password is None: + return url + raw_user = parsed.username or "" + enc_user = quote(unquote(raw_user), safe="") if raw_user else "" + # Preserve the colon separator when password component is present, even if + # empty, so `user:@host` stays `user:@host`. + if parsed.password is not None: + raw_pass = parsed.password + enc_pass = quote(unquote(raw_pass), safe="") if raw_pass else "" + else: + raw_pass = None + enc_pass = None + normalized = _assemble_proxy_url( + parsed.scheme, parsed.hostname or "", parsed.port, + enc_user, enc_pass, + parsed.path, parsed.params, parsed.query, parsed.fragment, + ) + # Compare credentials, not the full URL: urlparse cosmetically lowercases + # scheme and hostname, so a full-string compare would falsely fire on + # `socks5://USER:pass@HOST.com:1080` even when no encoding work happened. + if enc_user != raw_user or enc_pass != raw_pass: + logger.info( + "Auto URL-encoded SOCKS5 proxy credentials (special characters " + "detected). Pre-encode the URL to suppress this notice." + ) + return normalized + + +def _extract_proxy_url(proxy: str | ProxySettings | None) -> str | None: + """Extract and normalize proxy URL string from proxy param. + + For SOCKS5 dicts with separate username/password fields, reconstructs + the full URL with inline credentials so SOCKS5 auth works. + """ + if proxy is None: + return None + if isinstance(proxy, dict): + server = proxy.get("server", "") + if not server: + return None + if _is_socks_proxy(proxy): + return _reconstruct_socks_url(proxy) + return _ensure_proxy_scheme(server) + return _ensure_proxy_scheme(proxy) + + +def maybe_resolve_geoip( + geoip: bool, + proxy: str | ProxySettings | None, + timezone: str | None, + locale: str | None, +) -> tuple[str | None, str | None, str | None]: + """Auto-fill timezone/locale from the egress IP when geoip is enabled. + + Returns ``(timezone, locale, exit_ip)``. *exit_ip* is a free bonus + from the geoip lookup (no extra HTTP call) — used for WebRTC spoofing. + + With a proxy the egress IP is the proxy's exit IP; with no proxy it is + the machine's own public IP, so geoip works proxy-free too. + """ + if not geoip: + return timezone, locale, None + + from .geoip import resolve_proxy_exit_ip, resolve_proxy_geo_with_ip + + # None when no proxy → echo services resolve the machine's own public IP + proxy_url = _extract_proxy_url(proxy) if proxy else None + + # When both tz/locale are explicit, resolve the exit IP for WebRTC — but only + # with a proxy. With no proxy the WebRTC IP would just be the real connection + # IP the site already sees (a no-op), so skip the third-party echo call. + if timezone is not None and locale is not None: + exit_ip = resolve_proxy_exit_ip(proxy_url) if proxy_url else None + return timezone, locale, exit_ip + + geo_tz, geo_locale, exit_ip = resolve_proxy_geo_with_ip(proxy_url) + if timezone is None: + timezone = geo_tz + if locale is None: + locale = geo_locale + return timezone, locale, exit_ip + + +def _resolve_webrtc_args( + args: list[str] | None, + proxy: str | ProxySettings | None, +) -> list[str] | None: + """Replace --fingerprint-webrtc-ip=auto with the resolved proxy exit IP. + + Returns args unchanged if no ``auto`` value is present. + """ + if not args: + return args + idx = None + for i, a in enumerate(args): + if a == "--fingerprint-webrtc-ip=auto": + idx = i + break + if idx is None: + return args + proxy_url = _extract_proxy_url(proxy) + if not proxy_url: + logger.warning("--fingerprint-webrtc-ip=auto requires a proxy; removing flag") + args = list(args) + del args[idx] + return args + try: + from .geoip import resolve_proxy_exit_ip + exit_ip = resolve_proxy_exit_ip(proxy_url) + except Exception: + logger.warning("Failed to resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto") + args = list(args) + del args[idx] + return args + if exit_ip: + args = list(args) + args[idx] = f"--fingerprint-webrtc-ip={exit_ip}" + else: + logger.warning("Could not resolve proxy exit IP for WebRTC spoofing; removing --fingerprint-webrtc-ip=auto") + args = list(args) + del args[idx] + return args + + +def _append_webrtc_exit_ip( + args: list[str] | None, exit_ip: str | None +) -> list[str] | None: + """Append ``--fingerprint-webrtc-ip=`` unless the user already set it. + + *exit_ip* comes free from the geoip lookup; it spoofs the WebRTC IP to the + egress IP. No-op when there is no exit IP or the flag is already present. This + rule must stay identical across every launch path, so it lives in one place. + """ + if exit_ip and not (args and any(a.startswith("--fingerprint-webrtc-ip") for a in args)): + args = list(args or []) + args.append(f"--fingerprint-webrtc-ip={exit_ip}") + return args + + +def build_args( + stealth_args: bool, + extra_args: list[str] | None, + timezone: str | None = None, + locale: str | None = None, + headless: bool = True, + extension_paths: list[str] | None = None, + start_maximized: bool = False, +) -> list[str]: + """Combine stealth args with user-provided args and locale flags. + + Deduplicates by flag key (everything before '='). + Priority: stealth defaults < user args < dedicated params (timezone/locale). + """ + seen: dict[str, str] = {} + + if stealth_args: + for arg in get_default_stealth_args(): + seen[arg.split("=", 1)[0]] = arg + + # GPU blocklist bypass: + # - Headed mode (all platforms): Chromium blocks WebGL on software GPUs + # in Docker/Xvfb. Flag lets SwiftShader serve WebGL. See issue #56. + # - Windows (all modes): Chromium's GPU blocklist blocks WebGPU for the + # Microsoft Basic Render Driver. Dawn's adapter_blocklist bypass alone + # isn't enough — need this flag too. Linux doesn't need it. + import platform as _platform + if not headless or _platform.system() == "Windows": + seen["--ignore-gpu-blocklist"] = "--ignore-gpu-blocklist" + + if extra_args: + for arg in extra_args: + key = arg.split("=", 1)[0] + if key in seen: + logger.debug("Arg override: %s -> %s", seen[key], arg) + seen[key] = arg + + # Timezone/locale flags are independent of stealth_args — always inject when set + if timezone: + key = "--fingerprint-timezone" + flag = f"{key}={timezone}" + if key in seen: + logger.debug("Arg override: %s -> %s", seen[key], flag) + seen[key] = flag + if locale: + for key in ("--lang", "--fingerprint-locale"): + flag = f"{key}={locale}" + if key in seen: + logger.debug("Arg override: %s -> %s", seen[key], flag) + seen[key] = flag + + if extension_paths: + abs_paths = [os.path.abspath(p) for p in extension_paths] + ext_val = ",".join(abs_paths) + + seen["--load-extension"] = f"--load-extension={ext_val}" + seen["--disable-extensions-except"] = ( + f"--disable-extensions-except={ext_val}" + ) + + # Open maximized (real Windows Chrome overwhelmingly runs maximized) so the + # window fills the spoofed screen. Skipped if the caller already chose a + # window geometry. Gated to binaries where this stays coherent (see + # binary_supports_maximized_window) — below the gate it would create + # outerWidth < innerWidth. + if start_maximized and not any( + k in seen for k in ("--start-maximized", "--window-size", "--window-position") + ): + seen["--start-maximized"] = "--start-maximized" + + return list(seen.values()) + + +# --------------------------------------------------------------------------- +# Windows-font mismatch warning (Linux only) +# +# On Linux the binary spoofs the Windows platform by default, but fonts come +# from the host OS. A font-less Linux box contradicts the Windows claim and +# font-fingerprinting anti-bot systems flag the mismatch. Warn once per +# environment. See docs/chrome40-fpjs-font-minimum-set-investigation.md. +# --------------------------------------------------------------------------- + +# Microsoft-proprietary fonts that signal a real Windows install (absent from +# ttf-mscorefonts-installer). Keep in sync with issue #395 and +# docs/chrome40-fpjs-font-minimum-set-investigation.md. +# Windows OS fonts — ship with Windows itself, so their absence on a +# Windows-spoofing Linux host degrades results. The two monospace fonts +# (Consolas + Courier New) are part of the recommended set so the generic +# `monospace` family resolves to a Windows font. See issue #395. +_WINDOWS_FONT_TELLS = ( + "Segoe UI", + "Segoe UI Light", + "Calibri", + "Marlett", + "MS UI Gothic", + "Franklin Gothic", + "Consolas", + "Courier New", +) + +# MS Office supplemental fonts, installed as one atomic block by every Office +# install. Roughly half of real Windows machines have this pack and half do +# not, so its absence is a perfectly normal Windows setup, NOT a problem — +# reported as an informational signal only, never a warning. +_OFFICE_FONT_TELLS = ( + "MT Extra", + "Century", + "Century Gothic", + "MS Reference Specialty", + "Wingdings 2", + "Wingdings 3", + "Book Antiqua", + "Bookshelf Symbol 7", + "Monotype Corsiva", + "Bookman Old Style", +) + +_font_warning_checked = False + + +def _count_fonts_present(tells: tuple[str, ...]) -> int | None: + """Count how many tell-tale fonts are installed, via fc-list. + + Returns the number present (0..len(tells)), or None if it can't be + determined (fc-list missing or errored). Callers must NOT treat None as + zero — None means "unknown", 0 means "genuinely none installed". + """ + import subprocess + try: + result = subprocess.run( + ["fc-list"], capture_output=True, text=True, timeout=5 + ) + except (OSError, subprocess.SubprocessError): + return None + if result.returncode != 0: + return None + listing = result.stdout.lower() + return sum(1 for font in tells if font.lower() in listing) + + +def _windows_fonts_present() -> bool | None: + """True if ALL Windows OS fonts are installed, False if any are missing, + None if unknown. Strict: a partial set is treated as incomplete, since the + font install is atomic and a missing font degrades the Windows persona. + """ + n = _count_fonts_present(_WINDOWS_FONT_TELLS) + return None if n is None else n == len(_WINDOWS_FONT_TELLS) + + +def _maybe_warn_windows_fonts(chrome_args: list[str]) -> None: + """Warn once when spoofing Windows on a Linux host without the full Windows + font set. + + Best-effort and silent on error — never raises. Gated by an in-process flag + plus a cache-dir marker so it fires at most once per environment. Suppress + entirely with CLOAKBROWSER_SUPPRESS_FONT_WARNING. + """ + global _font_warning_checked + if _font_warning_checked: + return + _font_warning_checked = True + try: + import platform + if os.environ.get("CLOAKBROWSER_SUPPRESS_FONT_WARNING"): + return + if platform.system() != "Linux": + return + # Effective platform = the last --fingerprint-platform in the final argv + # (build_args dedups, so there is at most one). None => no Windows spoof. + effective_platform = None + for arg in chrome_args: + if arg.startswith("--fingerprint-platform="): + effective_platform = arg.split("=", 1)[1].strip().lower() + if effective_platform != "windows": + return + from .config import get_cache_dir + marker = get_cache_dir() / ".font_warning_shown" + if marker.exists(): + return + present = _windows_fonts_present() + if present is None or present is True: + return # full set present, or can't determine — don't warn + # Write straight to stderr (like the welcome banner and the JS/.NET + # wrappers) so an app's logging config can't silence it. + sys.stderr.write( + "[cloakbrowser] Incomplete Windows font set — installing the full " + "set is strongly advised for best results when spoofing Windows on " + "Linux. https://github.com/CloakHQ/cloakbrowser#font-setup-on-linux " + "(silence: CLOAKBROWSER_SUPPRESS_FONT_WARNING=1)\n" + ) + try: + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("") + except OSError: + pass + except Exception: + pass + + +def _parse_proxy_url(proxy: str) -> dict[str, Any]: + """Parse HTTP(S) proxy URL, extracting credentials into separate Playwright fields. + + Handles: http://user:pass@host:port -> {server: "http://host:port", username: "user", password: "pass"} + Also handles: no credentials, URL-encoded special chars, missing port, + and bare proxy strings without a scheme (e.g. 'user:pass@host:port' -> treated as http). + + SOCKS5 URLs are NOT handled here — they take a dedicated path via + ``_normalize_socks_string_url`` in ``_resolve_proxy_config``. + """ + # Bare format: "user:pass@host:port" — urlparse needs a scheme to extract credentials. + normalized = proxy + if "@" in proxy and "://" not in proxy: + normalized = f"http://{proxy}" + + parsed = urlparse(normalized) + + if not parsed.username: + return {"server": proxy} # no creds — return original unchanged + + # Rebuild server URL without credentials + netloc = parsed.hostname or "" + if parsed.port: + netloc += f":{parsed.port}" + + server = urlunparse((parsed.scheme, netloc, parsed.path, "", "", "")) + + result: dict[str, Any] = {"server": server} + result["username"] = unquote(parsed.username) + if parsed.password: + result["password"] = unquote(parsed.password) + + return result + + +def _has_credentials(proxy: str | ProxySettings) -> bool: + """Check if the proxy has inline or dict-level credentials.""" + if isinstance(proxy, dict): + return bool(proxy.get("username")) + return "@" in proxy + + +def _reconstruct_http_url(proxy: ProxySettings) -> str: + """Reconstruct an HTTP(S) proxy URL with inline credentials from a Playwright proxy dict.""" + server = proxy.get("server", "") + username = proxy.get("username", "") + password = proxy.get("password", "") + if not username: + return server + parsed = urlparse(_ensure_proxy_scheme(server)) + enc_user = quote(username, safe="") + enc_pass = quote(password, safe="") if password else None + return _assemble_proxy_url( + parsed.scheme, parsed.hostname or "", parsed.port, + enc_user, enc_pass, parsed.path, + ) + + +def _normalize_http_string_url(url: str) -> str: + """Re-encode credentials in an HTTP(S) proxy URL string for --proxy-server. + + Same pattern as ``_normalize_socks_string_url`` — decode then re-encode to + ensure Chromium's proxy URL parser handles special chars correctly. + """ + normalized = url if "://" in url else f"http://{url}" + try: + parsed = urlparse(normalized) + _ = parsed.port + except ValueError as e: + logger.warning("Malformed HTTP proxy URL, passing through unchanged: %s", e) + return normalized + if parsed.username is None and parsed.password is None: + return normalized + raw_user = parsed.username or "" + enc_user = quote(unquote(raw_user), safe="") if raw_user else "" + if parsed.password is not None: + raw_pass = parsed.password + enc_pass = quote(unquote(raw_pass), safe="") if raw_pass else "" + else: + raw_pass = None + enc_pass = None + result = _assemble_proxy_url( + parsed.scheme, parsed.hostname or "", parsed.port, + enc_user, enc_pass, + parsed.path, parsed.params, parsed.query, parsed.fragment, + ) + if enc_user != raw_user or enc_pass != raw_pass: + logger.info( + "Auto URL-encoded HTTP proxy credentials (special characters " + "detected). Pre-encode the URL to suppress this notice." + ) + return result + + +def _is_socks_proxy(proxy: str | ProxySettings | None) -> bool: + """Check if the proxy uses SOCKS5 protocol.""" + if proxy is None: + return False + url = proxy.get("server", "") if isinstance(proxy, dict) else proxy + return url.lower().startswith(("socks5://", "socks5h://")) + + +def _resolve_proxy_config( + proxy: str | ProxySettings | None, + browser_version: str | None = None, + license_key: str | None = None, +) -> tuple[dict[str, Any], list[str]]: + """Resolve proxy into Playwright kwargs and Chrome args. + + Proxies with credentials (SOCKS5 always; HTTP/HTTPS only on binaries that + support inline proxy auth) are passed via Chrome's --proxy-server flag with + inline credentials, bypassing Playwright's CDP auth interceptor which breaks + on some proxies and Google domains (#182). HTTP/HTTPS creds on older binaries + fall back to Playwright's proxy dict. + + Returns: + (proxy_kwargs, extra_chrome_args) — one or both will be empty. + """ + if proxy is None: + return {}, [] + + if _is_socks_proxy(proxy): + # SOCKS5: bypass Playwright, pass directly to Chrome via --proxy-server. + # Chrome handles SOCKS5 auth natively from the URL. + if isinstance(proxy, dict): + url = _reconstruct_socks_url(proxy) + extra_args = [f"--proxy-server={url}"] + if proxy.get("bypass"): + extra_args.append(f"--proxy-bypass-list={proxy['bypass']}") + return {}, extra_args + # String URL — re-encode creds to work around Chromium parser truncating + # passwords at '=' and other special chars (#157). + return {}, [f"--proxy-server={_normalize_socks_string_url(proxy)}"] + + # HTTP/HTTPS with credentials, only on binaries that ship inline proxy auth: + # use Chrome's native proxy authentication path instead of Playwright's CDP + # auth interceptor (#182). Older binaries (free macOS/linux-arm64) can't parse + # inline credentials, so they fall through to the Playwright proxy dict below. + if _has_credentials(proxy) and binary_supports_http_proxy_inline_auth( + license_key, browser_version + ): + if isinstance(proxy, dict): + url = _reconstruct_http_url(proxy) + extra_args = [f"--proxy-server={url}"] + if proxy.get("bypass"): + extra_args.append(f"--proxy-bypass-list={proxy['bypass']}") + return {}, extra_args + return {}, [f"--proxy-server={_normalize_http_string_url(proxy)}"] + + # HTTP/HTTPS without credentials: use Playwright's proxy dict + if isinstance(proxy, dict): + return {"proxy": proxy}, [] + return {"proxy": _parse_proxy_url(proxy)}, [] diff --git a/cloakbrowser/config.py b/cloakbrowser/config.py new file mode 100644 index 0000000..497b4f7 --- /dev/null +++ b/cloakbrowser/config.py @@ -0,0 +1,424 @@ +"""Stealth configuration and platform detection for cloakbrowser.""" + +from __future__ import annotations + +import os +import platform +import random +import re +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Chromium version shipped with this release. +# Different platforms may ship different versions during transition periods. +# CHROMIUM_VERSION is the latest across all platforms (for display/reference). +# Use get_chromium_version() for the current platform's actual version. +# --------------------------------------------------------------------------- +CHROMIUM_VERSION = "146.0.7680.177.5" + +PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = { + "linux-x64": "146.0.7680.177.5", + "linux-arm64": "146.0.7680.177.3", + "darwin-arm64": "145.0.7632.109.2", + "darwin-x64": "145.0.7632.109.2", + "windows-x64": "146.0.7680.177.5", +} + +# --------------------------------------------------------------------------- +# Ed25519 public keys for verifying downloaded binaries. +# +# Each release publishes SHA256SUMS and a detached signature SHA256SUMS.sig. +# The wrapper verifies that signature against the keys below before trusting +# any hash in the manifest, so the download origin alone cannot certify a +# tampered binary. Values are base64 of the 32-byte raw public key. Multiple +# entries are accepted to allow key rotation. +# --------------------------------------------------------------------------- +BINARY_SIGNING_PUBKEYS: list[str] = [ + "MKFKwIhUcKWq5xTuNA0Ovg99njcDEcEJvmWYYhApvaU=", +] + +# --------------------------------------------------------------------------- +# Playwright default args to suppress — these leak automation signals. +# --enable-automation: exposes navigator.webdriver = true +# --enable-unsafe-swiftshader: forces software WebGL rendering via SwiftShader, +# producing a distinctive renderer string that no real user browser has +# --------------------------------------------------------------------------- +IGNORE_DEFAULT_ARGS = ["--enable-automation", "--enable-unsafe-swiftshader"] + + +# --------------------------------------------------------------------------- +# Default stealth arguments passed to the patched Chromium binary. +# These activate source-level fingerprint patches compiled into the binary. +# --------------------------------------------------------------------------- +def get_default_stealth_args() -> list[str]: + """Build stealth args with a random fingerprint seed per launch. + + On macOS, skips platform/GPU spoofing — runs as a native Mac browser. + Spoofing Windows on Mac creates detectable mismatches (fonts, GPU, etc.). + """ + seed = random.randint(10000, 99999) + system = platform.system() + + base = [ + "--no-sandbox", + f"--fingerprint={seed}", + ] + + if system == "Darwin": + # Tell the fingerprint patches we're on macOS so GPU/UA match natively + return base + ["--fingerprint-platform=macos"] + + # Linux/Windows: Windows fingerprint profile. + # Screen and window size come from the real display, not this flag (verified: + # identical across seeds), so the wrapper must not emulate a viewport on top in + # headed mode — that would break outerWidth >= innerWidth coherence. + return base + ["--fingerprint-platform=windows"] + + +# --------------------------------------------------------------------------- +# Default viewport — used for HEADLESS only (headed launches use no_viewport so +# the page tracks the real window). Headless has no window chrome, so a fixed +# viewport stays coherent (outer == inner) and gives deterministic dimensions. +# Models a maximized Chrome on 1080p Windows: screen=1920x1080, +# innerHeight=947 (minus ~85px Chrome UI: tabs + address bar + bookmarks). +# --------------------------------------------------------------------------- +DEFAULT_VIEWPORT = {"width": 1920, "height": 947} + +# --------------------------------------------------------------------------- +# Platform detection +# --------------------------------------------------------------------------- +SUPPORTED_PLATFORMS: dict[tuple[str, str], str] = { + ("Linux", "x86_64"): "linux-x64", + ("Linux", "aarch64"): "linux-arm64", + ("Darwin", "arm64"): "darwin-arm64", + ("Darwin", "x86_64"): "darwin-x64", + ("Windows", "AMD64"): "windows-x64", + ("Windows", "x86_64"): "windows-x64", +} + +# Platforms with pre-built binaries available for download (derived from version map). +AVAILABLE_PLATFORMS: set[str] = set(PLATFORM_CHROMIUM_VERSIONS.keys()) + + +_VERSION_PIN_RE = re.compile(r"^[0-9]+(?:\.[0-9]+){3,4}$") + + +def normalize_requested_version(version: str | None = None) -> str | None: + """Return an explicit Chromium version pin from arg/env, or None. + + The explicit argument wins over CLOAKBROWSER_VERSION. Only numeric dotted + versions are accepted because the value is interpolated into cache paths and + download URLs. + """ + raw = version if version is not None else os.environ.get("CLOAKBROWSER_VERSION") + if raw is None: + return None + normalized = raw.strip() + if not normalized: + return None + if not _VERSION_PIN_RE.fullmatch(normalized): + raise ValueError( + "Invalid browser version pin. Use a full numeric Chromium version, " + "e.g. '148.0.7778.215.2'." + ) + return normalized + + +def get_chromium_version() -> str: + """Return the Chromium version for the current platform.""" + tag = get_platform_tag() + return PLATFORM_CHROMIUM_VERSIONS.get(tag, CHROMIUM_VERSION) + + +def get_platform_tag() -> str: + """Return the platform tag for binary download (e.g. 'linux-x64', 'darwin-arm64').""" + system = platform.system() + machine = platform.machine() + tag = SUPPORTED_PLATFORMS.get((system, machine)) + if tag is None: + raise RuntimeError( + f"Unsupported platform: {system} {machine}. " + f"Supported: {', '.join(f'{s}-{m}' for (s, m) in SUPPORTED_PLATFORMS)}" + ) + return tag + + +# --------------------------------------------------------------------------- +# Binary cache paths +# --------------------------------------------------------------------------- +def get_cache_dir() -> Path: + """Return the cache directory for downloaded binaries. + + Override with CLOAKBROWSER_CACHE_DIR env var. + Default: ~/.cloakbrowser/ + """ + custom = os.environ.get("CLOAKBROWSER_CACHE_DIR") + if custom: + return Path(custom) + return Path.home() / ".cloakbrowser" + + +def get_binary_dir(version: str | None = None, pro: bool = False) -> Path: + """Return the directory for a Chromium version binary.""" + v = version or get_chromium_version() + suffix = "-pro" if pro else "" + return get_cache_dir() / f"chromium-{v}{suffix}" + + +def get_binary_path(version: str | None = None, pro: bool = False) -> Path: + """Return the expected path to the chrome executable.""" + binary_dir = get_binary_dir(version, pro=pro) + + if platform.system() == "Darwin": + # macOS: Chromium.app bundle + return binary_dir / "Chromium.app" / "Contents" / "MacOS" / "Chromium" + elif platform.system() == "Windows": + return binary_dir / "chrome.exe" + else: + # Linux: flat binary + return binary_dir / "chrome" + + +def check_platform_available() -> None: + """Raise a clear error if no pre-built binary exists for this platform. + + Skipped when CLOAKBROWSER_BINARY_PATH is set (user has their own build). + """ + if get_local_binary_override(): + return + + tag = get_platform_tag() # raises if platform unsupported entirely + if tag not in AVAILABLE_PLATFORMS: + available = ", ".join(sorted(AVAILABLE_PLATFORMS)) + import sys + + sys.exit( + f"\n\033[1mCloakBrowser\033[0m — Pre-built binaries are currently only available for: {available}.\n\n" + f"To use CloakBrowser now, set CLOAKBROWSER_BINARY_PATH to a local Chromium binary." + ) + + +def get_effective_version(pro: bool = False) -> str | None: + """Return the best available version: auto-updated if available, else platform default. + + Reads a platform-scoped marker file from the cache directory. + Returns the platform's hardcoded version if no update has been downloaded. + + When ``pro=True``, reads from the Pro-specific marker and returns ``None`` when + no cached Pro binary matches it. A valid Pro license must NEVER fall back to the + free binary, so there is deliberately no free-version fallback here — callers + treat ``None`` as "resolve the latest Pro version from the server" instead. + """ + base = get_chromium_version() + cache = get_cache_dir() + + if pro: + marker = cache / f"latest_pro_version_{get_platform_tag()}" + if marker.exists(): + try: + version = marker.read_text().strip() + if version: + binary = get_binary_path(version, pro=True) + # Match launch's _pro_binary_ready (exists AND executable) so + # `info` never reports a build that launch would reject. + if binary.exists() and os.access(binary, os.X_OK): + return version + except (ValueError, OSError): + pass + return None + + # Free tier: try platform-scoped marker first, fall back to legacy marker + for name in (f"latest_version_{get_platform_tag()}", "latest_version"): + marker = cache / name + if marker.exists(): + try: + version = marker.read_text().strip() + if version and _version_newer(version, base): + binary = get_binary_path(version) + if binary.exists(): + return version + except (ValueError, OSError): + pass + return base + + +def _version_tuple(v: str) -> tuple[int, ...]: + """Parse '145.0.7718.0' into (145, 0, 7718, 0) for comparison.""" + return tuple(int(x) for x in v.split(".")) + + +def _version_newer(a: str, b: str) -> bool: + """Return True if version a is strictly newer than version b.""" + return _version_tuple(a) > _version_tuple(b) + + +# --------------------------------------------------------------------------- +# Download URL +# --------------------------------------------------------------------------- +DOWNLOAD_BASE_URL = os.environ.get( + "CLOAKBROWSER_DOWNLOAD_URL", + "https://cloakbrowser.dev", +) + +GITHUB_API_URL = "https://api.github.com/repos/CloakHQ/cloakbrowser/releases" + +GITHUB_DOWNLOAD_BASE_URL = "https://github.com/CloakHQ/cloakbrowser/releases/download" + + +def get_archive_ext() -> str: + """Return the archive extension for the current platform (.zip for Windows, .tar.gz otherwise).""" + return ".zip" if platform.system() == "Windows" else ".tar.gz" + + +def get_archive_name(tag: str | None = None) -> str: + """Return the archive filename for a platform tag (e.g. 'cloakbrowser-linux-x64.tar.gz').""" + t = tag or get_platform_tag() + return f"cloakbrowser-{t}{get_archive_ext()}" + + +def get_download_url(version: str | None = None) -> str: + """Return the full download URL for the current platform's binary archive.""" + v = version or get_chromium_version() + return f"{DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}" + + +def get_fallback_download_url(version: str | None = None) -> str: + """Return the GitHub Releases fallback URL for the binary archive.""" + v = version or get_chromium_version() + return f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/{get_archive_name()}" + + +# --------------------------------------------------------------------------- +# Local binary override (skip download, use your own build) +# --------------------------------------------------------------------------- +def get_local_binary_override() -> str | None: + """Check if user has set a local binary path via env var. + + Set CLOAKBROWSER_BINARY_PATH to use a locally built Chromium instead of downloading. + """ + return os.environ.get("CLOAKBROWSER_BINARY_PATH") + + +# --------------------------------------------------------------------------- +# Headless viewport handling by binary version +# --------------------------------------------------------------------------- +# First Chromium build that reports coherent headless dimensions without an +# emulated viewport. On these binaries the wrapper launches headless with +# no_viewport; older binaries need a fixed DEFAULT_VIEWPORT to stay coherent. +# None => not shipped yet; feature off, behavior byte-identical to today. +# TODO: set to the chromium version string that first ships it. +HEADLESS_NO_VIEWPORT_MIN_VERSION: str | None = "148.0.7778.215.4" + + +def binary_supports_headless_no_viewport( + license_key: str | None = None, browser_version: str | None = None +) -> bool: + """Whether headless can launch with ``no_viewport`` on the resolved binary. + + Only binaries at or above ``HEADLESS_NO_VIEWPORT_MIN_VERSION`` qualify; older + ones keep ``DEFAULT_VIEWPORT``. A local override binary + (``CLOAKBROWSER_BINARY_PATH``) is unknown-version, so stay on the safe path. + """ + if HEADLESS_NO_VIEWPORT_MIN_VERSION is None: + return False + # A declared version (browser_version arg OR CLOAKBROWSER_VERSION env) wins even + # under a local override — the caller is asserting the version (also how internal + # builds opt in). Only an override with no declared version stays on the safe path. + try: + declared = normalize_requested_version(browser_version) + except ValueError: + declared = None + if declared: + version = declared + elif get_local_binary_override(): + return False + else: + from .license import resolve_license_key + + pro = bool(resolve_license_key(license_key)) + version = get_effective_version(pro=pro) + try: + return not _version_newer(HEADLESS_NO_VIEWPORT_MIN_VERSION, version) + except (ValueError, AttributeError): + return False + + +# --------------------------------------------------------------------------- +# Inline HTTP proxy authentication by binary version +# --------------------------------------------------------------------------- +# First Chromium build, per platform, whose binary can take inline HTTP proxy +# credentials (Chromium-native ``--proxy-server=http://user:pass@host``). Below +# the floor the wrapper MUST route credentialed HTTP/HTTPS proxies through +# Playwright's proxy dict instead — an older binary treats the ``user:pass@`` +# authority as an invalid proxy host and drops proxy auth (#182). +# +# A single global threshold (like HEADLESS_NO_VIEWPORT_MIN_VERSION) can't model +# this: the capability landed in different build lineages at different points — +# linux-x64/windows-x64 have it at 146.0.7680.177.5, but the free macOS (145.x) +# and linux-arm64 (146.0.7680.177.3) floors predate it, so those only qualify +# once they resolve to a Pro/newer build (148+). Platforms absent from this map +# are treated as never-inline (unknown capability => safe fallback). +HTTP_PROXY_INLINE_AUTH_MIN_VERSION: dict[str, str] = { + "linux-x64": "146.0.7680.177.5", + "windows-x64": "146.0.7680.177.5", + "linux-arm64": "148.0.7778.215.3", + "darwin-arm64": "148.0.7778.215.3", + "darwin-x64": "148.0.7778.215.3", +} + + +def binary_supports_http_proxy_inline_auth( + license_key: str | None = None, browser_version: str | None = None +) -> bool: + """Whether the resolved binary accepts inline HTTP proxy credentials. + + The capability is a function of the binary version; ``license_key`` only + resolves *which* version launches (Pro build vs free default), exactly like + ``binary_supports_headless_no_viewport``. A declared pin wins, else a valid + Pro license resolves to the Pro build (which ships the patch), else the free + per-platform default — then it compares to this platform's floor. Below the + floor, credentialed HTTP proxies fall back to Playwright's proxy dict. A + local override with no declared version is unknown-version, so stay on the + safe fallback. Python, JS and .NET mirror this gate. + """ + floor = HTTP_PROXY_INLINE_AUTH_MIN_VERSION.get(get_platform_tag()) + if floor is None: + return False + try: + declared = normalize_requested_version(browser_version) + except ValueError: + declared = None + if declared: + version = declared + elif get_local_binary_override(): + return False + else: + from .license import resolve_license_key + + pro = bool(resolve_license_key(license_key)) + version = get_effective_version(pro=pro) + if version is None: + # Pro with no cached marker => resolve latest Pro from the server, which + # is >= the floor by construction and always ships the patch. + return True + try: + return not _version_newer(floor, version) + except (ValueError, AttributeError): + return False + + +def binary_supports_maximized_window( + license_key: str | None = None, browser_version: str | None = None +) -> bool: + """Whether the wrapper may auto-add ``--start-maximized``. + + Gated on the same threshold as the no_viewport shim: only binaries whose + headless surface-fix + headed screen-clamp make a maximized window coherent + (``outer == screen``). Below it, maximizing headless while the CDP viewport + stays at 1280x720 yields ``outerWidth < innerWidth`` — an impossible-window + bot tell — so the flag must NOT be added. Shares + ``HEADLESS_NO_VIEWPORT_MIN_VERSION``; kept as its own name so the two can + diverge later. Python, JS and .NET mirror this gate. + """ + return binary_supports_headless_no_viewport(license_key, browser_version) diff --git a/cloakbrowser/download.py b/cloakbrowser/download.py new file mode 100644 index 0000000..23072b6 --- /dev/null +++ b/cloakbrowser/download.py @@ -0,0 +1,1161 @@ +"""Binary download and cache management for cloakbrowser. + +Downloads the patched Chromium binary on first use, caches it locally. +Similar to how Playwright downloads its own bundled Chromium. +""" + +from __future__ import annotations + +import hashlib +import logging +import os +import shutil +import platform +import stat +import subprocess +import sys +import tarfile +import tempfile +import threading +import time +from pathlib import Path + +import httpx + +from ._version import __version__ as _wrapper_version +from .config import ( + BINARY_SIGNING_PUBKEYS, + CHROMIUM_VERSION, + DOWNLOAD_BASE_URL, + GITHUB_API_URL, + GITHUB_DOWNLOAD_BASE_URL, + _version_newer, + check_platform_available, + get_archive_ext, + get_archive_name, + get_binary_dir, + get_binary_path, + get_cache_dir, + get_chromium_version, + get_download_url, + get_effective_version, + get_fallback_download_url, + get_local_binary_override, + get_platform_tag, + normalize_requested_version, +) + +logger = logging.getLogger("cloakbrowser") + + +class BinaryVerificationError(RuntimeError): + """A downloaded binary could not be authenticated (bad/missing signature, + version mismatch, or checksum failure). + + Distinct from transient download/network errors: a verification failure is + a tampering signal and MUST surface, never silently fall back to another + binary. The Pro routing in ensure_binary re-raises this rather than + downgrading to the free tier. + """ + + +# Timeout for download (large binary, allow 10 min) +DOWNLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0) + +# Auto-update check interval (1 hour) +UPDATE_CHECK_INTERVAL = 3600 + +# Free-tier welcome banner re-show interval (3 days). Free users see the Pro +# upsell again after this gap; Pro users see it only once (see _show_welcome). +WELCOME_FREE_INTERVAL = 3 * 24 * 3600 + +# Pro Chromium major shown in the free-tier welcome banner. Bump at each Pro +# major release (there is no local constant to derive it from — the live Pro +# version comes from the network, which we don't call just to print a banner). +PRO_MAJOR = "148" + + +def _welcome_due(marker: Path, pro: bool) -> bool: + """Whether the welcome banner should be shown now. + + Pro: once ever (only when the marker is absent). Free: re-show when the + marker is absent or its timestamp is older than WELCOME_FREE_INTERVAL. + Unreadable or legacy empty markers count as stale (due). + """ + if not marker.exists(): + return True + if pro: + return False + try: + last = int(marker.read_text().strip()) + except (OSError, ValueError): + return True + return (time.time() - last) >= WELCOME_FREE_INTERVAL + + +def _show_welcome(pro: bool = False) -> None: + """Show welcome message on launch. A marker file gates the cadence: + Pro shows once ever; free re-shows every WELCOME_FREE_INTERVAL. + + The Pro-upsell line is shown to free-tier users only; Pro users get a plain + banner (no "running free tier" message, which would be false for them). + """ + marker = get_cache_dir() / ".welcome_shown" + if not _welcome_due(marker, pro): + return + sys.stderr.write("\n") + sys.stderr.write(" CloakBrowser — stealth Chromium for automation\n") + sys.stderr.write(" https://github.com/CloakHQ/CloakBrowser\n") + sys.stderr.write("\n") + if pro: + sys.stderr.write( + f" CloakBrowser Pro active (v{PRO_MAJOR}) — latest binary, newest patches.\n" + ) + sys.stderr.write(" Pro support → support@cloakbrowser.dev\n") + else: + free_major = CHROMIUM_VERSION.split(".")[0] + sys.stderr.write( + f" Running free tier (v{free_major}). " + f"Pro = latest binary (v{PRO_MAJOR}) + newest anti-bot patches.\n" + ) + sys.stderr.write(" Try Pro free for 7 days → https://cloakbrowser.dev\n") + sys.stderr.write(" Star us if CloakBrowser helps your project!\n") + sys.stderr.write("\n") + try: + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text(str(int(time.time()))) + except OSError: + pass + + +def ensure_binary( + license_key: str | None = None, + browser_version: str | None = None, +) -> str: + """Ensure the stealth Chromium binary is available. Download if needed. + + Returns the path to the chrome executable as a string. + + Args: + license_key: Pro license key. Also reads from CLOAKBROWSER_LICENSE_KEY env var. + browser_version: Exact Chromium version pin. Also reads from CLOAKBROWSER_VERSION. + + Set CLOAKBROWSER_BINARY_PATH to skip download and use a local build. + """ + # Check for local override first + local_override = get_local_binary_override() + if local_override: + path = Path(local_override) + if not path.exists(): + raise FileNotFoundError( + f"CLOAKBROWSER_BINARY_PATH set to '{local_override}' but file does not exist" + ) + logger.info("Using local binary override: %s", local_override) + return str(path) + + requested_version = normalize_requested_version(browser_version) + + # Pro license key check (custom download URL overrides Pro path) + from .license import resolve_license_key, validate_license + + key = resolve_license_key(license_key) + if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"): + key = None + + if key: + info = validate_license(key) + if info and info.valid: + # A valid license is entitled to Pro, so Pro failures surface loudly + # rather than silently substituting the older free binary. (A blip + # during a routine update never reaches here: _ensure_pro_binary + # returns the cached Pro binary and updates in the background.) + try: + return _ensure_pro_binary(key, requested_version=requested_version) + except BinaryVerificationError: + # Authenticity could not be confirmed — surface verbatim. + raise + except Exception as e: + # Transient failure with no cached Pro binary to use — surface a + # clear error rather than silently downloading the free binary. + raise RuntimeError( + f"Pro binary unavailable: {e}. Your license is valid but the " + f"Pro binary could not be downloaded right now. Retry in a " + f"moment. To use the free binary instead, unset " + f"CLOAKBROWSER_LICENSE_KEY." + ) from e + elif info: + logger.warning( + "License validation failed (plan=%s), using free tier", info.plan + ) + else: + logger.warning("License validation unavailable, using free tier") + + # Fail fast if no binary available for this platform + check_platform_available() + + if requested_version: + binary_path = get_binary_path(requested_version) + if binary_path.exists() and _is_executable(binary_path): + logger.debug( + "Pinned binary found in cache: %s (version %s)", + binary_path, + requested_version, + ) + _show_welcome() + return str(binary_path) + + logger.info( + "Stealth Chromium %s not found. Downloading for %s...", + requested_version, + get_platform_tag(), + ) + _download_and_extract(requested_version) + + if not (binary_path.exists() and _is_executable(binary_path)): + raise RuntimeError( + f"Pinned download completed but binary not found at expected path: {binary_path}. " + f"This may indicate a packaging issue. Please report at " + f"https://github.com/CloakHQ/cloakbrowser/issues" + ) + _show_welcome() + return str(binary_path) + + # Check for auto-updated version first, then fall back to hardcoded + effective = get_effective_version() + binary_path = get_binary_path(effective) + + if binary_path.exists() and _is_executable(binary_path): + logger.debug("Binary found in cache: %s (version %s)", binary_path, effective) + _show_welcome() + _maybe_trigger_update_check() + return str(binary_path) + + # Fall back to platform's hardcoded version if effective version binary doesn't exist + platform_version = get_chromium_version() + if effective != platform_version: + fallback_path = get_binary_path() + if fallback_path.exists() and _is_executable(fallback_path): + logger.debug("Binary found in cache: %s", fallback_path) + _maybe_trigger_update_check() + return str(fallback_path) + + # Download platform's hardcoded version + logger.info( + "Stealth Chromium %s not found. Downloading for %s...", + platform_version, + get_platform_tag(), + ) + _download_and_extract() + + binary_path = get_binary_path() + if not binary_path.exists(): + raise RuntimeError( + f"Download completed but binary not found at expected path: {binary_path}. " + f"This may indicate a packaging issue. Please report at " + f"https://github.com/CloakHQ/cloakbrowser/issues" + ) + + _maybe_trigger_update_check() + return str(binary_path) + + +def _download_and_extract(version: str | None = None) -> None: + """Download the binary archive and extract to cache directory. + + Tries the primary server (cloakbrowser.dev) first, falls back to + GitHub Releases if the primary is unreachable or returns an error. + Verifies SHA-256 checksum before extraction when available. + """ + primary_url = get_download_url(version) + fallback_url = get_fallback_download_url(version) + binary_dir = get_binary_dir(version) + binary_path = get_binary_path(version) + + # Create cache dir + binary_dir.parent.mkdir(parents=True, exist_ok=True) + + # Download to temp file first (atomic — no partial downloads in cache) + with tempfile.NamedTemporaryFile(suffix=get_archive_ext(), delete=False) as tmp: + tmp_path = Path(tmp.name) + + try: + # Try primary, fall back to GitHub Releases (skip fallback if custom URL) + try: + _download_file(primary_url, tmp_path) + except Exception as primary_err: + if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"): + raise + logger.warning( + "Primary download failed (%s), trying GitHub Releases...", + primary_err, + ) + _download_file(fallback_url, tmp_path) + + # Verify the download before extraction. On the official path this is a + # mandatory, non-bypassable Ed25519 signature check (see + # _verify_download_checksum); the skip flag only applies to custom + # self-hosted CLOAKBROWSER_DOWNLOAD_URL setups. + _verify_download_checksum(tmp_path, version) + + _extract_archive(tmp_path, binary_dir, binary_path) + _show_welcome() + finally: + # Clean up temp file + tmp_path.unlink(missing_ok=True) + + +def _pro_binary_ready(version: str | None) -> bool: + """True when a cached, executable Pro binary exists for ``version``.""" + if not version: + return False + path = get_binary_path(version, pro=True) + return path.exists() and _is_executable(path) + + +def _ensure_pro_binary( + license_key: str, + requested_version: str | None = None, +) -> str: + """Ensure the Pro binary is downloaded and cached. Returns the binary path. + + A valid Pro license NEVER falls back to the free binary. If the latest Pro + build cannot be resolved or downloaded and no cached Pro binary exists, the + error is raised rather than silently launching the free tier. + """ + from .license import get_pro_latest_version + + # --- Pinned: launch the exact requested version, no server cross-check, no + # marker write (a rollback pin must not stick future unpinned launches). --- + if requested_version: + if _pro_binary_ready(requested_version): + binary_path = get_binary_path(requested_version, pro=True) + logger.debug( + "Pinned Pro binary found in cache: %s (version %s)", + binary_path, + requested_version, + ) + _show_welcome(pro=True) + return str(binary_path) + logger.info( + "Downloading Pro Chromium %s for %s...", requested_version, get_platform_tag() + ) + _download_pro_binary(requested_version, license_key) + binary_path = get_binary_path(requested_version, pro=True) + if not binary_path.exists(): + raise RuntimeError( + f"Pro download completed but binary not found at: {binary_path}" + ) + _show_welcome(pro=True) + return str(binary_path) + + # --- Unpinned: track the server's latest stable. --- + effective = get_effective_version(pro=True) + + # Honor CLOAKBROWSER_AUTO_UPDATE=false the way the free path does: if the user + # froze updates AND a Pro build is already cached, keep it and skip the server + # check. With no cached build we must still fetch one — a valid Pro license can + # never launch the free binary. (The `update` CLI ignores this and always acts.) + frozen = os.environ.get("CLOAKBROWSER_AUTO_UPDATE", "").lower() == "false" + if frozen and _pro_binary_ready(effective): + logger.debug("Pro auto-update disabled; using cached %s", effective) + _show_welcome(pro=True) + return str(get_binary_path(effective, pro=True)) + + # get_pro_latest_version() is rate-limited to one network call per hour and + # returns a cached string in between, so this foreground check stays cheap on + # steady-state launches while still landing new stable after a version gap. + latest = get_pro_latest_version() + + # Prefer the server's latest when it is newer than — or replaces a missing — + # the cached build. Otherwise stay on the cached Pro binary (fast, offline-ok). + if latest and ( + not _pro_binary_ready(effective) # also covers effective is None + or _version_newer(latest, effective) + ): + version: str | None = latest + else: + version = effective + + if version is None: + # Valid Pro license but nothing resolvable (server unreachable AND no + # cached Pro build). Never downgrade to the free binary — fail loudly. + raise RuntimeError("Could not determine latest Pro version from server") + + if _pro_binary_ready(version): + binary_path = get_binary_path(version, pro=True) + # Advance the marker if this cached build is newer than what the marker names, + # so `info` (and a later server-outage launch) reflect the build we actually + # launch — never a stale marker. + if version != effective: + try: + _write_pro_version_marker(version) + except OSError: + pass + logger.debug("Pro binary found in cache: %s (version %s)", binary_path, version) + _show_welcome(pro=True) + return str(binary_path) + + # `version` (the server latest) needs downloading. On failure, fall back to a + # cached Pro build if we have one — never the free binary. + try: + logger.info( + "Downloading Pro Chromium %s for %s...", version, get_platform_tag() + ) + _download_pro_binary(version, license_key) + except BinaryVerificationError: + # A tampering signal must surface verbatim — never mask it behind the + # cached-Pro fallback, which is only for transient download failures. + raise + except Exception: + if _pro_binary_ready(effective): + logger.warning( + "Pro update to %s failed; launching cached Pro binary %s", + version, + effective, + ) + _show_welcome(pro=True) + return str(get_binary_path(effective, pro=True)) + raise + + binary_path = get_binary_path(version, pro=True) + if not binary_path.exists(): + raise RuntimeError( + f"Pro download completed but binary not found at: {binary_path}" + ) + + # Advance the marker so future unpinned launches use this build. + try: + _write_pro_version_marker(version) + except OSError: + pass + + _show_welcome(pro=True) + return str(binary_path) + + +def _download_pro_binary(version: str, license_key: str) -> None: + """Download a Pro binary from cloakbrowser.dev with license key auth. + + Requests the explicit version so the served archive matches the signed + manifest verified in _verify_pro_download. + """ + download_url = f"{DOWNLOAD_BASE_URL}/api/download/{version}" + binary_dir = get_binary_dir(version, pro=True) + binary_path = get_binary_path(version, pro=True) + platform_tag = get_platform_tag() + + binary_dir.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.NamedTemporaryFile(suffix=get_archive_ext(), delete=False) as tmp: + tmp_path = Path(tmp.name) + + try: + _download_file( + download_url, + tmp_path, + headers={ + "Authorization": f"Bearer {license_key}", + "X-Platform": platform_tag, + }, + ) + + # Pro binaries come from cloakbrowser.dev — the same origin as free + # downloads — so the M1 attack the Ed25519 signature defends against + # applies equally. Verify with the same non-bypassable signature check; + # CLOAKBROWSER_SKIP_CHECKSUM does NOT bypass it (parity with the + # official free path). + _verify_pro_download(tmp_path, version) + + _extract_archive(tmp_path, binary_dir, binary_path) + finally: + tmp_path.unlink(missing_ok=True) + + +def _verify_pro_download(file_path: Path, version: str) -> None: + """Verify a Pro archive with the same non-bypassable Ed25519 signature check + as official free downloads. + + Pro binaries are served from cloakbrowser.dev (same origin as the free + tier), so a tampered same-origin SHA256SUMS could otherwise certify a + tampered binary (M1, #308). Fetch the Pro SHA256SUMS + detached + SHA256SUMS.sig, verify the signature against the pinned keys FIRST, bind the + manifest to the requested version, then verify the archive's SHA-256. + + An invalid signature, checksum, or version mismatch raises + BinaryVerificationError (a tampering signal the router surfaces verbatim); + CLOAKBROWSER_SKIP_CHECKSUM cannot bypass it. A failed manifest FETCH is + transient — nothing was validated — and raises a plain RuntimeError. A + valid-license user is never silently downgraded to the free binary. + """ + base = f"{DOWNLOAD_BASE_URL}/releases/pro/chromium-v{version}" + try: + manifest_resp = httpx.get( + f"{base}/SHA256SUMS", follow_redirects=True, timeout=10.0 + ) + manifest_resp.raise_for_status() + sig_resp = httpx.get( + f"{base}/SHA256SUMS.sig", follow_redirects=True, timeout=10.0 + ) + sig_resp.raise_for_status() + except Exception as exc: + # Fetch failure is transient, not tampering — raise a plain RuntimeError + # (the router reports it as "unavailable, retry") rather than a + # BinaryVerificationError (which it surfaces as a tampering signal). + raise RuntimeError( + f"Could not fetch the signed SHA256SUMS for Pro {version} ({exc})" + ) from exc + + manifest_bytes = manifest_resp.content + # _verify_signature / _verify_checksum raise plain RuntimeError; convert to + # BinaryVerificationError so the Pro router treats them as tampering signals + # (re-raise) rather than transient failures (fall back to free). + try: + _verify_signature(manifest_bytes, sig_resp.content) + except RuntimeError as exc: + raise BinaryVerificationError(str(exc)) from exc + manifest_text = manifest_bytes.decode("utf-8") + + # Version binding: same forced-downgrade defense as the official path. + declared = _parse_manifest_version(manifest_text) + if declared != version: + raise BinaryVerificationError( + f"Version mismatch in signed Pro SHA256SUMS: requested {version}, " + f"manifest declares {declared or 'none'}. Refusing (possible downgrade)." + ) + + tarball_name = get_archive_name() + expected = _parse_checksums(manifest_text).get(tarball_name) + if expected is None: + raise BinaryVerificationError( + f"Signature-verified Pro SHA256SUMS has no entry for {tarball_name} — " + f"cannot confirm binary integrity." + ) + try: + _verify_checksum(file_path, expected) + except RuntimeError as exc: + raise BinaryVerificationError(str(exc)) from exc + + +def _verify_download_checksum(file_path: Path, version: str | None = None) -> None: + """Verify the downloaded archive's integrity and authenticity. + + Official path (cloakbrowser.dev / GitHub Releases): fetch SHA256SUMS plus + its detached Ed25519 signature SHA256SUMS.sig, verify the signature against + the pinned public keys FIRST, then verify the archive's SHA-256 against the + now-authenticated manifest. Mandatory and non-bypassable — a same-origin + manifest can no longer certify a tampered binary (#308). + + Custom self-hosted path (CLOAKBROWSER_DOWNLOAD_URL set): the pinned keys do + not apply to a third-party server, so fall back to the plain same-origin + SHA256SUMS check, which CLOAKBROWSER_SKIP_CHECKSUM may bypass. + """ + tarball_name = get_archive_name() + + if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"): + # Self-hosted mirror: signature scheme does not apply. Preserve the + # legacy same-origin checksum behavior, skippable as before. + if os.environ.get("CLOAKBROWSER_SKIP_CHECKSUM", "").lower() == "true": + logger.warning( + "CLOAKBROWSER_SKIP_CHECKSUM set — skipping verification for custom download URL" + ) + return + checksums = _fetch_checksums(version) + if checksums is None: + logger.warning( + "SHA256SUMS not available from custom URL — skipping checksum verification" + ) + return + expected = checksums.get(tarball_name) + if expected is None: + logger.warning( + "SHA256SUMS found but no entry for %s — skipping verification", + tarball_name, + ) + return + _verify_checksum(file_path, expected) + return + + # Official path: signature is the trust root and is non-bypassable. + manifest = _fetch_signed_manifest(version) + if manifest is None: + raise RuntimeError( + "Could not fetch a signed SHA256SUMS (SHA256SUMS + SHA256SUMS.sig) " + "for this release — refusing to use an unverified binary. " + "Retry, or report at https://github.com/CloakHQ/cloakbrowser/issues" + ) + manifest_bytes, sig_bytes = manifest + _verify_signature(manifest_bytes, sig_bytes) + manifest_text = manifest_bytes.decode("utf-8") + + # Version binding: the signed manifest must declare the version we asked for. + # The signature proves "we made this manifest", not "this is the version you + # requested" — without this check a mirror could serve a genuinely-signed + # older release in place of the requested one (forced downgrade). + requested = version or get_chromium_version() + declared = _parse_manifest_version(manifest_text) + if declared != requested: + raise RuntimeError( + f"Version mismatch in signed SHA256SUMS: requested {requested}, " + f"manifest declares {declared or 'none'}. Refusing (possible downgrade)." + ) + + checksums = _parse_checksums(manifest_text) + expected = checksums.get(tarball_name) + if expected is None: + raise RuntimeError( + f"Signature-verified SHA256SUMS has no entry for {tarball_name} — " + f"cannot confirm binary integrity." + ) + _verify_checksum(file_path, expected) + + +def _parse_manifest_version(text: str) -> str | None: + """Read the 'version=' line from a signed manifest. None if absent. + + The line has no internal whitespace so older wrappers' SHA256SUMS parsers + ignore it (they only accept ' ' lines). + """ + for line in text.splitlines(): + line = line.strip() + if line.startswith("version="): + return line[len("version=") :].strip() + return None + + +def _fetch_signed_manifest(version: str | None = None) -> tuple[bytes, bytes] | None: + """Fetch (SHA256SUMS, SHA256SUMS.sig) raw bytes for a version, or None. + + Both files are fetched from the SAME origin so the signature always matches + the exact manifest bytes it certifies. The primary origin is tried first, + then the GitHub Releases mirror. follow_redirects mirrors _fetch_checksums: + cloakbrowser.dev 301-redirects /chromium-v* to GitHub Releases. + """ + v = version or get_chromium_version() + bases = [ + f"{DOWNLOAD_BASE_URL}/chromium-v{v}", + f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}", + ] + for base in bases: + try: + manifest_resp = httpx.get( + f"{base}/SHA256SUMS", follow_redirects=True, timeout=10.0 + ) + manifest_resp.raise_for_status() + sig_resp = httpx.get( + f"{base}/SHA256SUMS.sig", follow_redirects=True, timeout=10.0 + ) + sig_resp.raise_for_status() + return manifest_resp.content, sig_resp.content + except Exception: + continue + return None + + +def _verify_signature(manifest_bytes: bytes, sig_b64: bytes) -> None: + """Verify a detached Ed25519 signature over the raw manifest bytes. + + sig_b64 is the base64 of the 64-byte raw signature. Tries each pinned key + in BINARY_SIGNING_PUBKEYS; succeeds if any validates. Raises RuntimeError + if the signature is malformed or no pinned key validates it. + """ + import base64 + + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + try: + signature = base64.b64decode(sig_b64.strip(), validate=True) + except Exception as exc: + raise RuntimeError( + f"Malformed SHA256SUMS.sig (not valid base64): {exc}" + ) from exc + + for pubkey_b64 in BINARY_SIGNING_PUBKEYS: + try: + pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(pubkey_b64)) + except Exception: + # Skip an unparsable pinned key (e.g. the placeholder) rather than + # aborting — another pinned key may still validate. + continue + try: + pub.verify(signature, manifest_bytes) + logger.info("SHA256SUMS signature verified: Ed25519 OK") + return + except Exception: + # InvalidSignature, or a malformed/wrong-length signature that makes + # verify raise something else — either way this key didn't match, + # so try the next pinned key (and ultimately fail closed below). + continue + + raise RuntimeError( + "SHA256SUMS signature verification failed — no pinned key validated the " + "manifest. The binary's authenticity could not be confirmed. " + "Report at https://github.com/CloakHQ/cloakbrowser/issues" + ) + + +def _fetch_checksums(version: str | None = None) -> dict[str, str] | None: + """Fetch SHA256SUMS file for a version. Returns {filename: hash} or None.""" + v = version or get_chromium_version() + has_custom_url = os.environ.get("CLOAKBROWSER_DOWNLOAD_URL") + + # Build URL list — respect custom URL contract (no GitHub fallback) + urls = [f"{DOWNLOAD_BASE_URL}/chromium-v{v}/SHA256SUMS"] + if not has_custom_url: + urls.append(f"{GITHUB_DOWNLOAD_BASE_URL}/chromium-v{v}/SHA256SUMS") + + for url in urls: + try: + resp = httpx.get(url, follow_redirects=True, timeout=10.0) + resp.raise_for_status() + return _parse_checksums(resp.text) + except Exception: + continue + return None + + +def _parse_checksums(text: str) -> dict[str, str]: + """Parse SHA256SUMS format: '<64-hex sha256> filename' per line. + + Only lines whose first token is a 64-character hex digest are accepted + (matches the JS parser); blank lines, the version= line, and any other + junk are ignored. + """ + result = {} + for line in text.strip().splitlines(): + parts = line.strip().split(None, 1) + if len(parts) != 2: + continue + hash_val, filename = parts + hash_val = hash_val.lower() + if len(hash_val) != 64 or any(c not in "0123456789abcdef" for c in hash_val): + continue + result[filename.lstrip("*")] = hash_val + return result + + +def _verify_checksum(file_path: Path, expected_hash: str) -> None: + """Verify SHA-256 of a file. Raises RuntimeError on mismatch.""" + sha256 = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256.update(chunk) + actual = sha256.hexdigest().lower() + if actual != expected_hash: + raise RuntimeError( + f"Checksum verification failed!\n" + f" Expected: {expected_hash}\n" + f" Got: {actual}\n" + f" File may be corrupted or tampered with. " + f"Please retry or report at https://github.com/CloakHQ/cloakbrowser/issues" + ) + logger.info("Checksum verified: SHA-256 OK") + + +def _download_file(url: str, dest: Path, headers: dict[str, str] | None = None) -> None: + """Download a file with progress logging.""" + logger.info("Downloading from %s", url) + + with httpx.stream( + "GET", + url, + follow_redirects=True, + timeout=DOWNLOAD_TIMEOUT, + headers=headers or {}, + ) as response: + response.raise_for_status() + + total = int(response.headers.get("content-length", 0)) + downloaded = 0 + last_logged_pct = -1 + + with open(dest, "wb") as f: + for chunk in response.iter_bytes(chunk_size=8192): + f.write(chunk) + downloaded += len(chunk) + + if total > 0: + pct = int(downloaded / total * 100) + # Log every 10% + if pct >= last_logged_pct + 10: + last_logged_pct = pct + logger.info( + "Download progress: %d%% (%d/%d MB)", + pct, + downloaded // (1024 * 1024), + total // (1024 * 1024), + ) + + logger.info("Download complete: %d MB", dest.stat().st_size // (1024 * 1024)) + + +def _extract_archive( + archive_path: Path, dest_dir: Path, binary_path: Path | None = None +) -> None: + """Extract tar.gz or zip archive to destination directory.""" + logger.info("Extracting to %s", dest_dir) + + # Clean existing dir if partial download existed + if dest_dir.exists(): + shutil.rmtree(dest_dir) + + dest_dir.mkdir(parents=True, exist_ok=True) + + if str(archive_path).endswith(".zip"): + _extract_zip(archive_path, dest_dir) + else: + _extract_tar(archive_path, dest_dir) + + # If extracted into a single subdirectory, flatten it + # (e.g. fingerprint-chromium-142-custom-v2/chrome → chrome) + # But never flatten .app bundles — macOS needs the bundle structure intact + _flatten_single_subdir(dest_dir) + + # Make binary executable + bp = binary_path or get_binary_path() + if bp.exists(): + _make_executable(bp) + + # macOS: remove quarantine/provenance xattrs to prevent Gatekeeper prompts + if platform.system() == "Darwin": + _remove_quarantine(dest_dir) + + if bp.exists(): + logger.info("Binary ready: %s", bp) + + +def _extract_tar(archive_path: Path, dest_dir: Path) -> None: + """Extract tar.gz archive with path traversal protection.""" + with tarfile.open(archive_path, "r:gz") as tar: + safe_members = [] + for member in tar.getmembers(): + # Allow symlinks — macOS .app bundles require them (Framework layout) + if member.issym() or member.islnk(): + link_target = member.linkname + if os.path.isabs(link_target) or ".." in link_target.split("/"): + logger.warning( + "Skipping suspicious symlink: %s -> %s", + member.name, + link_target, + ) + continue + else: + member_path = (dest_dir / member.name).resolve() + if not str(member_path).startswith(str(dest_dir.resolve())): + raise RuntimeError( + f"Archive contains path traversal: {member.name}" + ) + safe_members.append(member) + + tar.extractall(dest_dir, members=safe_members) + + +def _extract_zip(archive_path: Path, dest_dir: Path) -> None: + """Extract zip archive with path traversal protection.""" + import zipfile + + with zipfile.ZipFile(archive_path, "r") as zf: + for info in zf.infolist(): + member_path = (dest_dir / info.filename).resolve() + if not str(member_path).startswith(str(dest_dir.resolve())): + raise RuntimeError(f"Archive contains path traversal: {info.filename}") + zf.extractall(dest_dir) + + +def _flatten_single_subdir(dest_dir: Path) -> None: + """If extraction created a single subdirectory, move its contents up. + + Many tar archives wrap files in a top-level directory (e.g. + fingerprint-chromium-142-custom-v2/chrome). We want chrome at dest_dir/chrome. + """ + entries = list(dest_dir.iterdir()) + if len(entries) == 1 and entries[0].is_dir(): + subdir = entries[0] + # Never flatten .app bundles — macOS needs the bundle structure + if subdir.name.endswith(".app"): + logger.debug("Keeping .app bundle intact: %s", subdir.name) + return + logger.debug("Flattening single subdirectory: %s", subdir.name) + for item in subdir.iterdir(): + shutil.move(str(item), str(dest_dir / item.name)) + subdir.rmdir() + + +def _is_executable(path: Path) -> bool: + """Check if a file is executable.""" + return os.access(path, os.X_OK) + + +def _make_executable(path: Path) -> None: + """Make a file executable (chmod +x). Skipped on Windows (no-op / AV lock risk).""" + if platform.system() == "Windows": + return + current = path.stat().st_mode + path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def _remove_quarantine(path: Path) -> None: + """Remove macOS quarantine/provenance xattrs so Gatekeeper doesn't block the binary.""" + try: + subprocess.run( + ["xattr", "-cr", str(path)], + capture_output=True, + timeout=30, + ) + logger.debug("Removed quarantine attributes from %s", path) + except Exception: + logger.debug("Failed to remove quarantine attributes", exc_info=True) + + +def clear_cache() -> None: + """Remove all cached binaries. Forces re-download on next launch.""" + from .config import get_cache_dir + + cache_dir = get_cache_dir() + if cache_dir.exists(): + shutil.rmtree(cache_dir) + logger.info("Cache cleared: %s", cache_dir) + + +def binary_info(browser_version: str | None = None) -> dict: + """Return info about the current binary installation. + + tier reflects what is actually installed on disk, not merely whether a + license is cached — a cached license with no Pro binary downloaded yet is + still effectively running the free binary, and the active key may differ + from the cached one. + + browser_version (or CLOAKBROWSER_VERSION) pins the reported version so the + info matches what a pinned launch actually runs, instead of latest. + """ + requested = normalize_requested_version(browser_version) + # Prefer Pro only if a Pro binary actually exists on disk. get_effective_version + # returns None for Pro when nothing is cached (it never falls back to free). + pro_version = requested or get_effective_version(pro=True) + pro = _pro_binary_ready(pro_version) # already false for a None version + + if pro: + effective = pro_version + binary_path = get_binary_path(pro_version, pro=True) + else: + effective = requested or get_effective_version() + binary_path = get_binary_path(effective) + download_url = ( + f"{DOWNLOAD_BASE_URL}/api/download/latest" + if pro + else get_download_url(effective) + ) + return { + "version": effective, + "tier": "pro" if pro else "free", + "bundled_version": CHROMIUM_VERSION, + "platform": get_platform_tag(), + "binary_path": str(binary_path), + "installed": binary_path.exists(), + "cache_dir": str(get_binary_dir(effective, pro=pro)), + "download_url": download_url, + } + + +# --------------------------------------------------------------------------- +# Auto-update +# --------------------------------------------------------------------------- + + +def check_for_update() -> str | None: + """Manually check for a newer Chromium version. Returns new version or None. + + This is the public API for triggering an update check. Unlike the + background check in ensure_binary(), this blocks until complete. + """ + latest = _get_latest_chromium_version() + if latest is None: + return None + if not _version_newer(latest, get_chromium_version()): + return None + + binary_dir = get_binary_dir(latest) + if binary_dir.exists(): + # Already downloaded + _write_version_marker(latest) + return latest + + logger.info("Downloading Chromium %s...", latest) + _download_and_extract(version=latest) + _write_version_marker(latest) + return latest + + +def check_for_pro_update(license_key: str) -> str | None: + """Move a Pro install to the server's latest stable. Blocks until complete. + + Returns the new version when a newer Pro build is downloaded or an + already-cached newer build is activated, else None (already up to date or the + server could not be reached). Requires a valid Pro license key. + """ + from .license import get_pro_latest_version + + latest = get_pro_latest_version() + if not latest: + return None + + effective = get_effective_version(pro=True) + if effective and not _version_newer(latest, effective) and _pro_binary_ready( + effective + ): + # Already on the latest cached Pro build. + return None + + if not _pro_binary_ready(latest): + logger.info("Downloading Pro Chromium %s...", latest) + _download_pro_binary(latest, license_key) + binary_path = get_binary_path(latest, pro=True) + if not binary_path.exists(): + raise RuntimeError( + f"Pro download completed but binary not found at: {binary_path}" + ) + + _write_pro_version_marker(latest) + return latest + + +def _should_check_for_update() -> bool: + """Check if auto-update is enabled and rate limit hasn't been hit.""" + if os.environ.get("CLOAKBROWSER_AUTO_UPDATE", "").lower() == "false": + return False + if get_local_binary_override(): + return False + if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"): + return False + + check_file = get_cache_dir() / ".last_update_check" + if check_file.exists(): + try: + last_check = float(check_file.read_text().strip()) + if time.time() - last_check < UPDATE_CHECK_INTERVAL: + return False + except (ValueError, OSError): + pass + return True + + +def _get_latest_chromium_version() -> str | None: + """Hit GitHub Releases API, return latest chromium-v* version for this platform. + + Checks that the release has a binary asset for the current platform, + so Linux-only releases won't be offered to macOS users. + """ + try: + resp = httpx.get(GITHUB_API_URL, params={"per_page": 10}, timeout=10.0) + resp.raise_for_status() + platform_tarball = get_archive_name() + for release in resp.json(): + tag = release.get("tag_name", "") + if tag.startswith("chromium-v") and not release.get("draft"): + asset_names = {a["name"] for a in release.get("assets", [])} + if platform_tarball in asset_names: + return tag.removeprefix("chromium-v") + return None + except Exception: + logger.debug("Auto-update check failed", exc_info=True) + return None + + +def _write_version_marker(version: str) -> None: + """Write the latest version marker for this platform to cache dir.""" + cache_dir = get_cache_dir() + cache_dir.mkdir(parents=True, exist_ok=True) + marker = cache_dir / f"latest_version_{get_platform_tag()}" + # Write to temp file then rename for atomicity + tmp = marker.with_suffix(".tmp") + tmp.write_text(version) + tmp.rename(marker) + + +def _write_pro_version_marker(version: str) -> None: + """Atomically write the latest Pro version marker for this platform.""" + cache_dir = get_cache_dir() + cache_dir.mkdir(parents=True, exist_ok=True) + marker = cache_dir / f"latest_pro_version_{get_platform_tag()}" + tmp = marker.with_suffix(".tmp") + tmp.write_text(version) + os.replace(str(tmp), str(marker)) + + +_wrapper_update_checked = False + + +def _check_wrapper_update() -> None: + """Check PyPI for a newer wrapper version. Runs once per process.""" + global _wrapper_update_checked + if _wrapper_update_checked: + return + _wrapper_update_checked = True + if os.environ.get("CLOAKBROWSER_AUTO_UPDATE", "").lower() == "false": + return + if os.environ.get("CLOAKBROWSER_DOWNLOAD_URL"): + return + try: + resp = httpx.get( + "https://pypi.org/pypi/cloakbrowser/json", + timeout=5.0, + ) + resp.raise_for_status() + latest = resp.json()["info"]["version"] + if _version_newer(latest, _wrapper_version): + logger.warning( + "Update available: cloakbrowser %s → %s. " + "Run: pip install --upgrade cloakbrowser", + _wrapper_version, + latest, + ) + except Exception: + logger.debug("Wrapper update check failed", exc_info=True) + + +def _check_and_download_update() -> None: + """Background task: check for newer binary, download if available.""" + try: + # Record check timestamp first (rate limiting) + check_file = get_cache_dir() / ".last_update_check" + check_file.parent.mkdir(parents=True, exist_ok=True) + check_file.write_text(str(time.time())) + + platform_version = get_chromium_version() + latest = _get_latest_chromium_version() + if latest is None: + return + if not _version_newer(latest, platform_version): + return + + # Already downloaded? + if get_binary_dir(latest).exists(): + _write_version_marker(latest) + return + + logger.info( + "Newer Chromium available: %s (current: %s). Downloading in background...", + latest, + platform_version, + ) + _download_and_extract(version=latest) + _write_version_marker(latest) + logger.info( + "Background update complete: Chromium %s ready. Will use on next launch.", + latest, + ) + except Exception: + logger.debug("Background update failed", exc_info=True) + + +def _maybe_trigger_update_check() -> None: + """Fire-and-forget update check in a daemon thread.""" + # Wrapper update: once per process, not rate-limited + if not _wrapper_update_checked: + t = threading.Thread(target=_check_wrapper_update, daemon=True) + t.start() + + # Binary update: rate-limited to once per hour + if not _should_check_for_update(): + return + t = threading.Thread(target=_check_and_download_update, daemon=True) + t.start() diff --git a/cloakbrowser/geoip.py b/cloakbrowser/geoip.py new file mode 100644 index 0000000..454a11e --- /dev/null +++ b/cloakbrowser/geoip.py @@ -0,0 +1,360 @@ +"""GeoIP-based timezone and locale detection from proxy IP. + +Optional feature — requires ``geoip2`` package:: + + pip install cloakbrowser[geoip] + +Downloads GeoLite2-City.mmdb (~70 MB) on first use, caches in +``~/.cloakbrowser/geoip/``. Background re-download after 30 days. +""" + +from __future__ import annotations + +import ipaddress +import logging +import math +import os +import socket +import tempfile +import threading +import time +from pathlib import Path +from urllib.parse import urlparse + +logger = logging.getLogger("cloakbrowser") + +# P3TERX mirror of MaxMind GeoLite2-City — no license key needed +GEOIP_DB_URL = ( + "https://github.com/P3TERX/GeoLite.mmdb/raw/download/GeoLite2-City.mmdb" +) +GEOIP_DB_FILENAME = "GeoLite2-City.mmdb" +GEOIP_UPDATE_INTERVAL = 30 * 86_400 # 30 days +DEFAULT_GEOIP_TIMEOUT_SECONDS = 5.0 +GEOIP_TIMEOUT_ENV = "CLOAKBROWSER_GEOIP_TIMEOUT_SECONDS" + +# Country ISO code → BCP 47 locale (covers ~90 % of proxy traffic) +COUNTRY_LOCALE_MAP: dict[str, str] = { + "US": "en-US", "GB": "en-GB", "AU": "en-AU", "CA": "en-CA", "NZ": "en-NZ", + "IE": "en-IE", "ZA": "en-ZA", "SG": "en-SG", + "DE": "de-DE", "AT": "de-AT", "CH": "de-CH", + "FR": "fr-FR", "BE": "fr-BE", + "ES": "es-ES", "MX": "es-MX", "AR": "es-AR", "CO": "es-CO", "CL": "es-CL", + "BR": "pt-BR", "PT": "pt-PT", + "IT": "it-IT", "NL": "nl-NL", + "JP": "ja-JP", "KR": "ko-KR", "CN": "zh-CN", "TW": "zh-TW", "HK": "zh-HK", + "RU": "ru-RU", "UA": "uk-UA", "PL": "pl-PL", "CZ": "cs-CZ", "RO": "ro-RO", + "IL": "he-IL", "TR": "tr-TR", "SA": "ar-SA", "AE": "ar-AE", "EG": "ar-EG", + "IN": "hi-IN", "ID": "id-ID", "PH": "en-PH", + "TH": "th-TH", "VN": "vi-VN", "MY": "ms-MY", + "SE": "sv-SE", "NO": "nb-NO", "DK": "da-DK", "FI": "fi-FI", + "GR": "el-GR", "HU": "hu-HU", "BG": "bg-BG", + # Extended coverage — common residential/mobile proxy exits + "SI": "sl-SI", "SK": "sk-SK", "HR": "hr-HR", "RS": "sr-RS", "LT": "lt-LT", + "LV": "lv-LV", "EE": "et-EE", "IS": "is-IS", "LU": "fr-LU", "MT": "en-MT", + "CY": "el-CY", "MD": "ro-MD", "BY": "ru-BY", "GE": "ka-GE", "AL": "sq-AL", + "MK": "mk-MK", "BA": "bs-BA", + "PE": "es-PE", "VE": "es-VE", "EC": "es-EC", "UY": "es-UY", "CR": "es-CR", + "DO": "es-DO", "GT": "es-GT", "BO": "es-BO", "PY": "es-PY", + "PK": "en-PK", "BD": "bn-BD", "LK": "si-LK", "KZ": "ru-KZ", "IR": "fa-IR", + "IQ": "ar-IQ", "JO": "ar-JO", "LB": "ar-LB", "KW": "ar-KW", "QA": "ar-QA", + "OM": "ar-OM", "BH": "ar-BH", + "NG": "en-NG", "KE": "en-KE", "MA": "fr-MA", "DZ": "ar-DZ", "TN": "ar-TN", + "GH": "en-GH", + "AM": "hy-AM", "AZ": "az-AZ", "UZ": "uz-UZ", "KG": "ky-KG", "TJ": "tg-TJ", + "TM": "tk-TM", + "ME": "sr-ME", "XK": "sq-XK", "LI": "de-LI", "MC": "fr-MC", "AD": "ca-AD", + "MM": "my-MM", "KH": "km-KH", "LA": "lo-LA", "MN": "mn-MN", "BN": "ms-BN", + "MO": "zh-MO", + "YE": "ar-YE", "SY": "ar-SY", "PS": "ar-PS", "LY": "ar-LY", + "ET": "am-ET", "TZ": "sw-TZ", "UG": "en-UG", "SN": "fr-SN", "CI": "fr-CI", + "CM": "fr-CM", "AO": "pt-AO", "MZ": "pt-MZ", "ZM": "en-ZM", "ZW": "en-ZW", + "HN": "es-HN", "NI": "es-NI", "SV": "es-SV", "PA": "es-PA", "JM": "en-JM", + "TT": "en-TT", "PR": "es-PR", +} + + +def resolve_proxy_geo(proxy_url: str | None) -> tuple[str | None, str | None]: + """Resolve timezone and locale from a proxy's IP address. + + Returns ``(timezone, locale)`` — either or both may be ``None`` on + failure (missing dep, DB download error, lookup miss). Never raises. + + When *proxy_url* is falsy, the machine's own public IP is used instead + (direct HTTP to the echo services, no proxy). + """ + tz, locale, _ip = resolve_proxy_geo_with_ip(proxy_url) + return tz, locale + + +def resolve_proxy_geo_with_ip( + proxy_url: str | None, +) -> tuple[str | None, str | None, str | None]: + """Resolve timezone, locale, and exit IP from a proxy. + + Returns ``(timezone, locale, exit_ip)``. The exit IP is a free bonus + from the lookup — reused for WebRTC spoofing without an extra HTTP call. + + When *proxy_url* is falsy, the egress IP is the machine's own public IP + (echo services queried directly, no proxy), so geoip works proxy-free. + """ + try: + import geoip2.database # noqa: F811 + except ImportError: + raise ImportError( + "geoip2 is required for geoip=True. Install it with:\n" + " pip install cloakbrowser[geoip]" + ) from None + + # Ensure the DB first — the download must NOT be bounded by the resolution + # timeout (a first-use ~70MB fetch legitimately outlasts it). + db_path = _ensure_geoip_db() + + timeout = _get_geoip_timeout_seconds() + deadline = _deadline_from_timeout(timeout) + + # Exit IP (through proxy, or the machine's own public IP when proxy_url is + # falsy) is most accurate — gateway DNS may differ from exit. Resolved even + # when the DB is unavailable: the IP does not need the DB, and dropping it on + # a DB hiccup would let WebRTC fall back to the real IP behind a proxy while + # the connection shows the proxy IP — a real deanonymization. + ip = _resolve_exit_ip(proxy_url, timeout=_remaining_seconds(deadline)) + # Hostname fallback only applies to a proxy; no proxy → echo services only + if ip is None and proxy_url and not _deadline_expired(deadline): + ip = _resolve_proxy_ip(proxy_url) + if ip is None or _deadline_expired(deadline): + if deadline is not None and _deadline_expired(deadline): + logger.warning("GeoIP resolution timed out after %.1fs; continuing without GeoIP", timeout) + return None, None, None + + # DB only drives tz/locale; a missing/failed DB still returns the exit IP. + if db_path is None: + return None, None, ip + + try: + with geoip2.database.Reader(str(db_path)) as reader: + resp = reader.city(ip) + timezone = resp.location.time_zone + country = resp.country.iso_code + locale = COUNTRY_LOCALE_MAP.get(country) if country else None + logger.debug( + "GeoIP: %s → tz=%s, country=%s, locale=%s", + ip, timezone, country, locale, + ) + return timezone, locale, ip + except Exception as exc: + logger.warning("GeoIP lookup failed for %s: %s", ip, exc) + return None, None, ip + + +# --------------------------------------------------------------------------- +# Proxy IP resolution +# --------------------------------------------------------------------------- + + +def _resolve_proxy_ip(proxy_url: str) -> str | None: + """Extract proxy hostname from URL and resolve to an IP address.""" + try: + hostname = urlparse(proxy_url).hostname + if not hostname: + return None + + # Already a literal IP? + try: + socket.inet_pton(socket.AF_INET, hostname) + return hostname + except OSError: + pass + try: + socket.inet_pton(socket.AF_INET6, hostname) + return hostname + except OSError: + pass + + # DNS resolve (returns first result, handles both v4/v6) + results = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM) + if results: + ip = results[0][4][0] + logger.debug("Resolved proxy %s → %s", hostname, ip) + return ip + return None + except Exception as exc: + logger.warning("Failed to resolve proxy hostname: %s", exc) + return None + + +def _is_private_ip(ip: str) -> bool: + """Check if an IP address is private/internal (not routable on the internet).""" + try: + return ipaddress.ip_address(ip).is_private + except ValueError: + return False + + +# IP echo services — fast, no auth, return just the IP +_IP_ECHO_URLS = [ + "https://api.ipify.org", + "https://checkip.amazonaws.com", + "https://ifconfig.me/ip", +] + + +def _get_geoip_timeout_seconds() -> float: + raw = os.getenv(GEOIP_TIMEOUT_ENV) + if not raw: + return DEFAULT_GEOIP_TIMEOUT_SECONDS + try: + timeout = float(raw) + except ValueError: + timeout = float("nan") + if not math.isfinite(timeout): + logger.warning( + "Invalid %s=%r; using %.1fs", + GEOIP_TIMEOUT_ENV, + raw, + DEFAULT_GEOIP_TIMEOUT_SECONDS, + ) + return DEFAULT_GEOIP_TIMEOUT_SECONDS + return max(timeout, 0.0) + + +def _deadline_from_timeout(timeout: float) -> float | None: + if timeout <= 0: + return None + return time.monotonic() + timeout + + +def _remaining_seconds(deadline: float | None) -> float | None: + if deadline is None: + return None + return max(deadline - time.monotonic(), 0.0) + + +def _deadline_expired(deadline: float | None) -> bool: + return deadline is not None and time.monotonic() >= deadline + + +def resolve_proxy_exit_ip(proxy_url: str | None) -> str | None: + """Resolve the egress IP, bounded by the GeoIP timeout. + + With a proxy this is the proxy's exit IP; with no proxy it is the + machine's own public IP (echo services queried directly). + """ + timeout = _get_geoip_timeout_seconds() + deadline = _deadline_from_timeout(timeout) + ip = _resolve_exit_ip(proxy_url, timeout=timeout) + if ip is None and _deadline_expired(deadline): + logger.warning("GeoIP resolution timed out after %.1fs; continuing without GeoIP", timeout) + return ip + + +def _resolve_exit_ip(proxy_url: str | None, timeout: float | None = None) -> str | None: + """Discover the egress IP via the echo services. + + Through *proxy_url* when given (the proxy's exit IP); directly (no proxy) + when *proxy_url* is falsy — that returns the machine's own public IP. + """ + import httpx + + deadline = _deadline_from_timeout(timeout or 0) + + for url in _IP_ECHO_URLS: + try: + remaining = _remaining_seconds(deadline) + if remaining is not None and remaining <= 0: + return None + request_timeout = min(10.0, remaining) if remaining is not None else 10.0 + resp = httpx.get(url, proxy=proxy_url or None, timeout=request_timeout) + resp.raise_for_status() + ip = resp.text.strip() + # Validate it looks like an IP + ipaddress.ip_address(ip) + logger.debug("Exit IP via %s: %s", url, ip) + return ip + except httpx.UnsupportedProtocol: + logger.warning( + "SOCKS5 proxy requires socksio: pip install cloakbrowser[geoip]" + ) + return None + except Exception: + continue + logger.warning("Failed to discover exit IP through proxy") + return None + + +# --------------------------------------------------------------------------- +# GeoIP database management +# --------------------------------------------------------------------------- + + +def _get_geoip_dir() -> Path: + from .config import get_cache_dir + + return get_cache_dir() / "geoip" + + +def _ensure_geoip_db() -> Path | None: + """Return path to GeoLite2-City.mmdb, downloading on first use.""" + db_path = _get_geoip_dir() / GEOIP_DB_FILENAME + + if db_path.exists(): + _maybe_trigger_update(db_path) + return db_path + + try: + _download_geoip_db(db_path) + return db_path + except Exception as exc: + logger.warning("Failed to download GeoIP database: %s", exc) + return None + + +def _download_geoip_db(dest: Path) -> None: + """Atomic download of GeoLite2-City.mmdb via httpx.""" + import httpx + + dest.parent.mkdir(parents=True, exist_ok=True) + logger.info("Downloading GeoIP database (~70 MB) …") + + tmp_fd, tmp_name = tempfile.mkstemp(dir=dest.parent, suffix=".tmp") + tmp_path = Path(tmp_name) + try: + with httpx.stream( + "GET", GEOIP_DB_URL, follow_redirects=True, timeout=300.0 + ) as resp: + resp.raise_for_status() + total = int(resp.headers.get("content-length", 0)) + downloaded = 0 + last_pct = -1 + with open(tmp_fd, "wb") as f: + for chunk in resp.iter_bytes(chunk_size=65_536): + f.write(chunk) + downloaded += len(chunk) + if total: + pct = downloaded * 100 // total + if pct >= last_pct + 10: + last_pct = pct + logger.info("GeoIP download: %d %%", pct) + + tmp_path.rename(dest) + logger.info("GeoIP database ready: %s", dest) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + + +def _maybe_trigger_update(db_path: Path) -> None: + """Re-download in background if DB is older than 30 days.""" + try: + age = time.time() - db_path.stat().st_mtime + if age < GEOIP_UPDATE_INTERVAL: + return + except OSError: + return + + def _bg() -> None: + try: + _download_geoip_db(db_path) + except Exception: + logger.debug("Background GeoIP update failed", exc_info=True) + + threading.Thread(target=_bg, daemon=True).start() diff --git a/cloakbrowser/human/__init__.py b/cloakbrowser/human/__init__.py new file mode 100644 index 0000000..c94d578 --- /dev/null +++ b/cloakbrowser/human/__init__.py @@ -0,0 +1,3050 @@ +"""Human-like behavioral layer for cloakbrowser. + +Activated via humanize=True in launch() / launch_async(). +Patches page methods to use Bezier mouse curves, realistic typing, and smooth scrolling. + +Stealth-aware (fixes #110): + - isInputElement / isSelectorFocused use CDP Isolated Worlds instead of page.evaluate + - Shift symbol typing uses CDP Input.dispatchKeyEvent for isTrusted=true events + - Falls back to page.evaluate only when CDP session is unavailable + +Supports both sync and async Playwright APIs. +""" + +from __future__ import annotations + +import json +import logging +import sys +import time +from typing import Any, Optional + +from .config import HumanConfig, HumanPreset, resolve_config, merge_config +from .config import rand, rand_range, sleep_ms, async_sleep_ms +from .mouse import RawMouse, human_move, human_click, click_target, human_idle +from .keyboard import RawKeyboard, human_type +from .scroll import scroll_to_element, human_scroll_into_view +from .mouse_async import AsyncRawMouse, async_human_move, async_human_click, async_human_idle +from .keyboard_async import AsyncRawKeyboard, async_human_type +from .scroll_async import async_scroll_to_element, async_human_scroll_into_view +from .actionability import ( + ensure_actionable, ensure_stable, check_pointer_events, + ensure_actionable_handle, check_pointer_events_handle, + ActionabilityError, ElementNotAttachedError, ElementNotVisibleError, + ElementNotStableError, ElementNotEnabledError, ElementNotEditableError, + ElementNotReceivingEventsError, + CHECKS_CLICK, CHECKS_HOVER, CHECKS_INPUT, CHECKS_FOCUS, CHECKS_CHECK, +) +from .actionability_async import ( + async_ensure_actionable, async_ensure_stable, async_check_pointer_events, + async_ensure_actionable_handle, async_check_pointer_events_handle, +) + +_SELECT_ALL = "Meta+a" if sys.platform == "darwin" else "Control+a" + +__all__ = [ + "patch_browser", "patch_context", "patch_page", + "patch_browser_async", "patch_context_async", "patch_page_async", + "HumanConfig", "resolve_config", "merge_config", + "human_move", "human_click", "click_target", "human_idle", + "human_type", "scroll_to_element", "human_scroll_into_view", +] + +logger = logging.getLogger("cloakbrowser.human") + + +# ============================================================================ +# CDP Isolated World — stealth DOM evaluation +# ============================================================================ + +class _SyncIsolatedWorld: + """Manages a CDP isolated execution context for DOM reads (sync). + + Produces clean Error.stack traces (no 'eval at evaluate :302:') + and is invisible to querySelector monkey-patches in the main world. + Context ID is invalidated on navigation and auto-recreated on next call. + """ + + __slots__ = ("_page", "_cdp", "_context_id") + + def __init__(self, page: Any): + self._page = page + self._cdp: Any = None + self._context_id: Optional[int] = None + + def _ensure_cdp(self) -> Any: + if self._cdp is None: + self._cdp = self._page.context.new_cdp_session(self._page) + return self._cdp + + def _create_world(self) -> int: + cdp = self._ensure_cdp() + tree = cdp.send("Page.getFrameTree") + frame_id = tree["frameTree"]["frame"]["id"] + result = cdp.send("Page.createIsolatedWorld", { + "frameId": frame_id, + "worldName": "", + "grantUniveralAccess": True, + }) + self._context_id = result["executionContextId"] + return self._context_id + + def evaluate(self, expression: str) -> Any: + """Evaluate JS in isolated world. Auto-recreates on stale context.""" + if self._context_id is None: + self._create_world() + + for attempt in range(2): + try: + result = self._cdp.send("Runtime.evaluate", { + "expression": expression, + "contextId": self._context_id, + "returnByValue": True, + }) + if "exceptionDetails" in result: + if attempt == 0: + self._create_world() + continue + return None + return result.get("result", {}).get("value") + except Exception: + if attempt == 0: + self._context_id = None + try: + self._create_world() + except Exception: + return None + continue + return None + return None + + def invalidate(self) -> None: + """Mark context as stale — call after navigation.""" + self._context_id = None + + def get_cdp_session(self) -> Any: + """Get the underlying CDP session (reused for Input.dispatchKeyEvent).""" + return self._ensure_cdp() + + +class _AsyncIsolatedWorld: + """Manages a CDP isolated execution context for DOM reads (async). + + Same as _SyncIsolatedWorld but uses await for all CDP calls. + """ + + __slots__ = ("_page", "_cdp", "_context_id") + + def __init__(self, page: Any): + self._page = page + self._cdp: Any = None + self._context_id: Optional[int] = None + + async def _ensure_cdp(self) -> Any: + if self._cdp is None: + self._cdp = await self._page.context.new_cdp_session(self._page) + return self._cdp + + async def _create_world(self) -> int: + cdp = await self._ensure_cdp() + tree = await cdp.send("Page.getFrameTree") + frame_id = tree["frameTree"]["frame"]["id"] + result = await cdp.send("Page.createIsolatedWorld", { + "frameId": frame_id, + "worldName": "", + "grantUniveralAccess": True, + }) + self._context_id = result["executionContextId"] + return self._context_id + + async def evaluate(self, expression: str) -> Any: + """Evaluate JS in isolated world. Auto-recreates on stale context.""" + if self._context_id is None: + await self._create_world() + + for attempt in range(2): + try: + result = await self._cdp.send("Runtime.evaluate", { + "expression": expression, + "contextId": self._context_id, + "returnByValue": True, + }) + if "exceptionDetails" in result: + if attempt == 0: + await self._create_world() + continue + return None + return result.get("result", {}).get("value") + except Exception: + if attempt == 0: + self._context_id = None + try: + await self._create_world() + except Exception: + return None + continue + return None + return None + + def invalidate(self) -> None: + """Mark context as stale — call after navigation.""" + self._context_id = None + + async def get_cdp_session(self) -> Any: + """Get the underlying CDP session (reused for Input.dispatchKeyEvent).""" + return await self._ensure_cdp() + + +# ============================================================================ +# Cursor state +# ============================================================================ + +class _CursorState: + __slots__ = ("x", "y", "initialized") + + def __init__(self) -> None: + self.x: float = 0 + self.y: float = 0 + self.initialized: bool = False + + +# ============================================================================ +# Stealth DOM queries — isolated world with evaluate fallback +# ============================================================================ + +def _is_input_element(page: Any, selector: str) -> bool: + """Check if selector is an input element. Uses CDP isolated world when available.""" + world: Optional[_SyncIsolatedWorld] = getattr(page, '_stealth_world', None) + if world is not None: + try: + escaped = json.dumps(selector) + result = world.evaluate( + f"(() => {{" + f" const el = document.querySelector({escaped});" + f" if (!el) return false;" + f" const tag = el.tagName.toLowerCase();" + f" return tag === 'input' || tag === 'textarea'" + f" || el.getAttribute('contenteditable') === 'true';" + f"}})()" + ) + return bool(result) + except Exception: + pass + + # Fallback: page.evaluate (detectable — should only happen if CDP fails) + try: + return page.evaluate( + """(sel) => { + const el = document.querySelector(sel); + if (!el) return false; + const tag = el.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' + || el.getAttribute('contenteditable') === 'true'; + }""", + selector, + ) + except Exception: + return False + + +async def _async_is_input_element(page: Any, selector: str) -> bool: + """Check if selector is an input element (async). Uses CDP isolated world when available.""" + world: Optional[_AsyncIsolatedWorld] = getattr(page, '_stealth_world', None) + if world is not None: + try: + escaped = json.dumps(selector) + result = await world.evaluate( + f"(() => {{" + f" const el = document.querySelector({escaped});" + f" if (!el) return false;" + f" const tag = el.tagName.toLowerCase();" + f" return tag === 'input' || tag === 'textarea'" + f" || el.getAttribute('contenteditable') === 'true';" + f"}})()" + ) + return bool(result) + except Exception: + pass + + try: + return await page.evaluate( + """(sel) => { + const el = document.querySelector(sel); + if (!el) return false; + const tag = el.tagName.toLowerCase(); + return tag === 'input' || tag === 'textarea' + || el.getAttribute('contenteditable') === 'true'; + }""", + selector, + ) + except Exception: + return False + + +def _is_selector_focused(page: Any, selector: str) -> bool: + """Check if the element matching selector is currently focused. + Uses CDP isolated world when available.""" + world: Optional[_SyncIsolatedWorld] = getattr(page, '_stealth_world', None) + if world is not None: + try: + escaped = json.dumps(selector) + result = world.evaluate( + f"(() => {{" + f" const el = document.querySelector({escaped});" + f" return el === document.activeElement;" + f"}})()" + ) + return bool(result) + except Exception: + pass + + try: + return page.evaluate( + """(sel) => { + const el = document.querySelector(sel); + return el === document.activeElement; + }""", + selector, + ) + except Exception: + return False + + +async def _async_is_selector_focused(page: Any, selector: str) -> bool: + """Check if the element matching selector is currently focused (async). + Uses CDP isolated world when available.""" + world: Optional[_AsyncIsolatedWorld] = getattr(page, '_stealth_world', None) + if world is not None: + try: + escaped = json.dumps(selector) + result = await world.evaluate( + f"(() => {{" + f" const el = document.querySelector({escaped});" + f" return el === document.activeElement;" + f"}})()" + ) + return bool(result) + except Exception: + pass + + try: + return await page.evaluate( + """(sel) => { + const el = document.querySelector(sel); + return el === document.activeElement; + }""", + selector, + ) + except Exception: + return False + + +# ============================================================================ +# Locator class-level patching (sync) +# ============================================================================ + +_locator_sync_patched = False + + +def _patch_locator_class_sync(): + """Patch all Locator interaction methods to go through humanized page methods.""" + global _locator_sync_patched + if _locator_sync_patched: + return + _locator_sync_patched = True + + from playwright.sync_api._generated import Locator + + _orig_fill = Locator.fill + _orig_click = Locator.click + _orig_type = Locator.type + _orig_dblclick = Locator.dblclick + _orig_hover = Locator.hover + _orig_check = Locator.check + _orig_uncheck = Locator.uncheck + _orig_set_checked = Locator.set_checked + _orig_select_option = Locator.select_option + _orig_press = Locator.press + _orig_press_sequentially = Locator.press_sequentially + _orig_tap = Locator.tap + _orig_drag_to = Locator.drag_to + _orig_clear = Locator.clear + _orig_scroll_into_view = getattr(Locator, 'scroll_into_view_if_needed', None) + + def _get_selector(self): + return self._impl_obj._selector + + def _is_humanized(self): + return hasattr(self.page, '_original') + + def _get_cfg(self): + return getattr(self.page, '_human_cfg', None) + + def _route_target(self): + """Resolution target for a humanized locator action (#428). + + Main-frame locators return the Page (unchanged behavior). A locator that + belongs to a *sub-frame* returns that frame's humanized wrapper so the + selector resolves in the frame's own document. Returns ``None`` when the + owning sub-frame is known but not yet humanized, so the caller falls back + to the native Playwright locator method (frame-correct, un-humanized). + """ + page = self.page + impl_frame = getattr(getattr(self, "_impl_obj", None), "_frame", None) + if impl_frame is None: + return page + try: + if impl_frame is page.main_frame._impl_obj: + return page + for f in page.frames: + if getattr(f, "_impl_obj", None) is impl_frame: + return f if getattr(f, "_human_patched", False) else None + # Frame not among page.frames (unknown/detached) -> legacy Page path. + return page + except Exception: + return page + + def _forward_kwargs(kwargs): + out = {} + if "timeout" in kwargs: + out["timeout"] = kwargs["timeout"] + if "human_config" in kwargs: + out["human_config"] = kwargs["human_config"] + if "force" in kwargs: + out["force"] = kwargs["force"] + return out + + def _humanized_fill(self, value, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + _orig_fill(self, value, **kwargs) + else: + tgt.fill(_get_selector(self), value, **_forward_kwargs(kwargs)) + else: + _orig_fill(self, value, **kwargs) + + def _humanized_click(self, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + _orig_click(self, **kwargs) + else: + tgt.click(_get_selector(self), **_forward_kwargs(kwargs)) + else: + _orig_click(self, **kwargs) + + def _humanized_type(self, text, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + _orig_type(self, text, **kwargs) + else: + tgt.type(_get_selector(self), text, **_forward_kwargs(kwargs)) + else: + _orig_type(self, text, **kwargs) + + def _humanized_dblclick(self, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + _orig_dblclick(self, **kwargs) + else: + tgt.dblclick(_get_selector(self), **_forward_kwargs(kwargs)) + else: + _orig_dblclick(self, **kwargs) + + def _humanized_hover(self, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + _orig_hover(self, **kwargs) + else: + tgt.hover(_get_selector(self), **_forward_kwargs(kwargs)) + else: + _orig_hover(self, **kwargs) + + def _humanized_scroll_into_view_if_needed(self, **kwargs): + if _is_humanized(self): + page = self.page + cfg = _get_cfg(self) + cursor = getattr(page, '_human_cursor', None) + raw = getattr(page, '_human_raw_mouse', None) + call_cfg = merge_config(cfg, kwargs.get("human_config")) if cfg else None + if call_cfg is None or cursor is None or raw is None: + if _orig_scroll_into_view is not None: + native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"} + return _orig_scroll_into_view(self, **native_kwargs) + return + timeout = kwargs.get("timeout", 30000) + try: + _, nx, ny, _ = human_scroll_into_view( + page, raw, + lambda: self.bounding_box(timeout=timeout), + cursor.x, cursor.y, call_cfg, + ) + cursor.x = nx + cursor.y = ny + except Exception: + if _orig_scroll_into_view is not None: + native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"} + _orig_scroll_into_view(self, **native_kwargs) + elif _orig_scroll_into_view is not None: + _orig_scroll_into_view(self, **kwargs) + + def _humanized_check(self, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + _orig_check(self, **kwargs) + return + fwd = _forward_kwargs(kwargs) + cfg = _get_cfg(self) + if cfg and cfg.idle_between_actions: + raw = type("_R", (), {"move": self.page._original.mouse_move})() + human_idle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), 0, 0, cfg) + checked = self.is_checked() + if not checked: + tgt.click(_get_selector(self), **fwd) + else: + _orig_check(self, **kwargs) + + def _humanized_uncheck(self, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + _orig_uncheck(self, **kwargs) + return + fwd = _forward_kwargs(kwargs) + cfg = _get_cfg(self) + if cfg and cfg.idle_between_actions: + raw = type("_R", (), {"move": self.page._original.mouse_move})() + human_idle(raw, rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), 0, 0, cfg) + checked = self.is_checked() + if checked: + tgt.click(_get_selector(self), **fwd) + else: + _orig_uncheck(self, **kwargs) + + def _humanized_set_checked(self, checked, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + _orig_set_checked(self, checked, **kwargs) + return + fwd = _forward_kwargs(kwargs) + current = self.is_checked() + if current != checked: + tgt.click(_get_selector(self), **fwd) + else: + _orig_set_checked(self, checked, **kwargs) + + def _humanized_select_option(self, value=None, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + _orig_select_option(self, value, **kwargs) + return + fwd = _forward_kwargs(kwargs) + selector = _get_selector(self) + tgt.hover(selector, **fwd) + sleep_ms(rand(100, 300)) + _orig_select_option(self, value, **kwargs) + else: + _orig_select_option(self, value, **kwargs) + + def _humanized_press(self, key, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + _orig_press(self, key, **kwargs) + return + fwd = _forward_kwargs(kwargs) + selector = _get_selector(self) + if not _is_selector_focused(tgt, selector): + tgt.click(selector, **fwd) + sleep_ms(rand(50, 150)) + self.page.keyboard.press(key) + else: + _orig_press(self, key, **kwargs) + + def _humanized_press_sequentially(self, text, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + _orig_press_sequentially(self, text, **kwargs) + return + fwd = _forward_kwargs(kwargs) + selector = _get_selector(self) + if not _is_selector_focused(tgt, selector): + tgt.click(selector, **fwd) + sleep_ms(rand(50, 150)) + self.page.keyboard.type(text) + else: + _orig_press_sequentially(self, text, **kwargs) + + def _humanized_tap(self, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + _orig_tap(self, **kwargs) + else: + tgt.click(_get_selector(self), **_forward_kwargs(kwargs)) + else: + _orig_tap(self, **kwargs) + + def _humanized_drag_to(self, target, **kwargs): + if _is_humanized(self): + page = self.page + originals = getattr(page, '_original', None) + src_box = self.bounding_box() + tgt_box = target.bounding_box() + if src_box and tgt_box and originals: + sx = src_box['x'] + src_box['width'] / 2 + sy = src_box['y'] + src_box['height'] / 2 + tx = tgt_box['x'] + tgt_box['width'] / 2 + ty = tgt_box['y'] + tgt_box['height'] / 2 + page.mouse.move(sx, sy) + sleep_ms(rand(100, 200)) + originals.mouse_down() + sleep_ms(rand(80, 150)) + page.mouse.move(tx, ty) + sleep_ms(rand(80, 150)) + originals.mouse_up() + else: + _orig_drag_to(self, target, **kwargs) + else: + _orig_drag_to(self, target, **kwargs) + + def _humanized_clear(self, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + _orig_clear(self, **kwargs) + return + fwd = _forward_kwargs(kwargs) + selector = _get_selector(self) + if not _is_selector_focused(tgt, selector): + tgt.click(selector, **fwd) + sleep_ms(rand(50, 100)) + self.page.keyboard.press(_SELECT_ALL) + sleep_ms(rand(30, 80)) + self.page.keyboard.press("Backspace") + else: + _orig_clear(self, **kwargs) + + Locator.fill = _humanized_fill + Locator.click = _humanized_click + Locator.type = _humanized_type + Locator.dblclick = _humanized_dblclick + Locator.hover = _humanized_hover + Locator.check = _humanized_check + Locator.uncheck = _humanized_uncheck + Locator.set_checked = _humanized_set_checked + Locator.select_option = _humanized_select_option + Locator.press = _humanized_press + Locator.press_sequentially = _humanized_press_sequentially + Locator.tap = _humanized_tap + Locator.drag_to = _humanized_drag_to + Locator.clear = _humanized_clear + if _orig_scroll_into_view is not None: + Locator.scroll_into_view_if_needed = _humanized_scroll_into_view_if_needed + + +# ============================================================================ +# Locator class-level patching (async) +# ============================================================================ + +_locator_async_patched = False + + +def _patch_locator_class_async(): + """Patch all async Locator interaction methods to go through humanized page methods.""" + global _locator_async_patched + if _locator_async_patched: + return + _locator_async_patched = True + + from playwright.async_api._generated import Locator as AsyncLocator + + _orig_fill = AsyncLocator.fill + _orig_click = AsyncLocator.click + _orig_type = AsyncLocator.type + _orig_dblclick = AsyncLocator.dblclick + _orig_hover = AsyncLocator.hover + _orig_check = AsyncLocator.check + _orig_uncheck = AsyncLocator.uncheck + _orig_set_checked = AsyncLocator.set_checked + _orig_select_option = AsyncLocator.select_option + _orig_press = AsyncLocator.press + _orig_press_sequentially = AsyncLocator.press_sequentially + _orig_tap = AsyncLocator.tap + _orig_drag_to = AsyncLocator.drag_to + _orig_clear = AsyncLocator.clear + _orig_scroll_into_view = getattr(AsyncLocator, 'scroll_into_view_if_needed', None) + + def _get_selector(self): + return self._impl_obj._selector + + def _is_humanized(self): + return hasattr(self.page, '_original') + + def _get_cfg(self): + return getattr(self.page, '_human_cfg', None) + + def _route_target(self): + """Resolution target for a humanized locator action (#428). + + Main-frame locators return the Page (unchanged behavior). A locator that + belongs to a *sub-frame* returns that frame's humanized wrapper so the + selector resolves in the frame's own document. Returns ``None`` when the + owning sub-frame is known but not yet humanized, so the caller falls back + to the native Playwright locator method (frame-correct, un-humanized). + """ + page = self.page + impl_frame = getattr(getattr(self, "_impl_obj", None), "_frame", None) + if impl_frame is None: + return page + try: + if impl_frame is page.main_frame._impl_obj: + return page + for f in page.frames: + if getattr(f, "_impl_obj", None) is impl_frame: + return f if getattr(f, "_human_patched", False) else None + # Frame not among page.frames (unknown/detached) -> legacy Page path. + return page + except Exception: + return page + + def _forward_kwargs(kwargs): + out = {} + if "timeout" in kwargs: + out["timeout"] = kwargs["timeout"] + if "human_config" in kwargs: + out["human_config"] = kwargs["human_config"] + if "force" in kwargs: + out["force"] = kwargs["force"] + return out + + async def _humanized_fill(self, value, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + await _orig_fill(self, value, **kwargs) + else: + await tgt.fill(_get_selector(self), value, **_forward_kwargs(kwargs)) + else: + await _orig_fill(self, value, **kwargs) + + async def _humanized_click(self, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + await _orig_click(self, **kwargs) + else: + await tgt.click(_get_selector(self), **_forward_kwargs(kwargs)) + else: + await _orig_click(self, **kwargs) + + async def _humanized_type(self, text, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + await _orig_type(self, text, **kwargs) + else: + await tgt.type(_get_selector(self), text, **_forward_kwargs(kwargs)) + else: + await _orig_type(self, text, **kwargs) + + async def _humanized_dblclick(self, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + await _orig_dblclick(self, **kwargs) + else: + await tgt.dblclick(_get_selector(self), **_forward_kwargs(kwargs)) + else: + await _orig_dblclick(self, **kwargs) + + async def _humanized_hover(self, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + await _orig_hover(self, **kwargs) + else: + await tgt.hover(_get_selector(self), **_forward_kwargs(kwargs)) + else: + await _orig_hover(self, **kwargs) + + async def _humanized_scroll_into_view_if_needed(self, **kwargs): + if _is_humanized(self): + page = self.page + cfg = _get_cfg(self) + cursor = getattr(page, '_human_cursor', None) + raw = getattr(page, '_human_raw_mouse', None) + call_cfg = merge_config(cfg, kwargs.get("human_config")) if cfg else None + if call_cfg is None or cursor is None or raw is None: + if _orig_scroll_into_view is not None: + native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"} + await _orig_scroll_into_view(self, **native_kwargs) + return + timeout = kwargs.get("timeout", 30000) + + async def _get_box(): + return await self.bounding_box(timeout=timeout) + try: + _, nx, ny, _ = await async_human_scroll_into_view( + page, raw, _get_box, + cursor.x, cursor.y, call_cfg, + ) + cursor.x = nx + cursor.y = ny + except Exception: + if _orig_scroll_into_view is not None: + native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"} + await _orig_scroll_into_view(self, **native_kwargs) + elif _orig_scroll_into_view is not None: + await _orig_scroll_into_view(self, **kwargs) + + async def _humanized_check(self, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + await _orig_check(self, **kwargs) + return + fwd = _forward_kwargs(kwargs) + cfg = _get_cfg(self) + if cfg and cfg.idle_between_actions: + raw = type("_R", (), {"move": self.page._original.mouse_move})() + await async_human_idle( + raw, + rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), + 0, 0, cfg, + ) + checked = await self.is_checked() + if not checked: + await tgt.click(_get_selector(self), **fwd) + else: + await _orig_check(self, **kwargs) + + async def _humanized_uncheck(self, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + await _orig_uncheck(self, **kwargs) + return + fwd = _forward_kwargs(kwargs) + cfg = _get_cfg(self) + if cfg and cfg.idle_between_actions: + raw = type("_R", (), {"move": self.page._original.mouse_move})() + await async_human_idle( + raw, + rand(cfg.idle_between_duration[0], cfg.idle_between_duration[1]), + 0, 0, cfg, + ) + checked = await self.is_checked() + if checked: + await tgt.click(_get_selector(self), **fwd) + else: + await _orig_uncheck(self, **kwargs) + + async def _humanized_set_checked(self, checked, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + await _orig_set_checked(self, checked, **kwargs) + return + fwd = _forward_kwargs(kwargs) + current = await self.is_checked() + if current != checked: + await tgt.click(_get_selector(self), **fwd) + else: + await _orig_set_checked(self, checked, **kwargs) + + async def _humanized_select_option(self, value=None, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + await _orig_select_option(self, value, **kwargs) + return + fwd = _forward_kwargs(kwargs) + selector = _get_selector(self) + await tgt.hover(selector, **fwd) + await async_sleep_ms(rand(100, 300)) + await _orig_select_option(self, value, **kwargs) + else: + await _orig_select_option(self, value, **kwargs) + + async def _humanized_press(self, key, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + await _orig_press(self, key, **kwargs) + return + fwd = _forward_kwargs(kwargs) + selector = _get_selector(self) + if not await _async_is_selector_focused(tgt, selector): + await tgt.click(selector, **fwd) + await async_sleep_ms(rand(50, 150)) + await self.page.keyboard.press(key) + else: + await _orig_press(self, key, **kwargs) + + async def _humanized_press_sequentially(self, text, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + await _orig_press_sequentially(self, text, **kwargs) + return + fwd = _forward_kwargs(kwargs) + selector = _get_selector(self) + if not await _async_is_selector_focused(tgt, selector): + await tgt.click(selector, **fwd) + await async_sleep_ms(rand(50, 150)) + await self.page.keyboard.type(text) + else: + await _orig_press_sequentially(self, text, **kwargs) + + async def _humanized_tap(self, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + await _orig_tap(self, **kwargs) + else: + await tgt.click(_get_selector(self), **_forward_kwargs(kwargs)) + else: + await _orig_tap(self, **kwargs) + + async def _humanized_drag_to(self, target, **kwargs): + if _is_humanized(self): + page = self.page + originals = getattr(page, '_original', None) + src_box = await self.bounding_box() + tgt_box = await target.bounding_box() + if src_box and tgt_box and originals: + sx = src_box['x'] + src_box['width'] / 2 + sy = src_box['y'] + src_box['height'] / 2 + tx = tgt_box['x'] + tgt_box['width'] / 2 + ty = tgt_box['y'] + tgt_box['height'] / 2 + await page.mouse.move(sx, sy) + await async_sleep_ms(rand(100, 200)) + await originals.mouse_down() + await async_sleep_ms(rand(80, 150)) + await page.mouse.move(tx, ty) + await async_sleep_ms(rand(80, 150)) + await originals.mouse_up() + else: + await _orig_drag_to(self, target, **kwargs) + else: + await _orig_drag_to(self, target, **kwargs) + + async def _humanized_clear(self, **kwargs): + if _is_humanized(self): + tgt = _route_target(self) + if tgt is None: + await _orig_clear(self, **kwargs) + return + fwd = _forward_kwargs(kwargs) + selector = _get_selector(self) + if not await _async_is_selector_focused(tgt, selector): + await tgt.click(selector, **fwd) + await async_sleep_ms(rand(50, 100)) + await self.page.keyboard.press(_SELECT_ALL) + await async_sleep_ms(rand(30, 80)) + await self.page.keyboard.press("Backspace") + else: + await _orig_clear(self, **kwargs) + + AsyncLocator.fill = _humanized_fill + AsyncLocator.click = _humanized_click + AsyncLocator.type = _humanized_type + AsyncLocator.dblclick = _humanized_dblclick + AsyncLocator.hover = _humanized_hover + AsyncLocator.check = _humanized_check + AsyncLocator.uncheck = _humanized_uncheck + AsyncLocator.set_checked = _humanized_set_checked + AsyncLocator.select_option = _humanized_select_option + AsyncLocator.press = _humanized_press + AsyncLocator.press_sequentially = _humanized_press_sequentially + AsyncLocator.tap = _humanized_tap + AsyncLocator.drag_to = _humanized_drag_to + AsyncLocator.clear = _humanized_clear + if _orig_scroll_into_view is not None: + AsyncLocator.scroll_into_view_if_needed = _humanized_scroll_into_view_if_needed + + +# ============================================================================ +# SYNC patching +# ============================================================================ + + +def patch_page(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None: + """Replace page methods with human-like implementations (sync).""" + originals = type("Originals", (), { + "click": page.click, + "type": page.type, + "fill": page.fill, + "goto": page.goto, + "hover": page.hover, + "dblclick": page.dblclick, + "select_option": page.select_option, + "mouse_move": page.mouse.move, + "mouse_click": page.mouse.click, + "mouse_wheel": page.mouse.wheel, + "mouse_down": page.mouse.down, + "mouse_up": page.mouse.up, + "keyboard_type": page.keyboard.type, + "keyboard_down": page.keyboard.down, + "keyboard_up": page.keyboard.up, + "keyboard_press": page.keyboard.press, + "keyboard_insert_text": page.keyboard.insert_text, + })() + + page._original = originals + page._human_cfg = cfg + page._human_cursor = cursor + + # --- Stealth infrastructure --- + try: + stealth = _SyncIsolatedWorld(page) + page._stealth_world = stealth + cdp_session = stealth.get_cdp_session() + except Exception: + stealth = None + page._stealth_world = None + cdp_session = None + logger.debug("Could not create CDP session — stealth features disabled") + + raw_mouse: RawMouse = type("_RawMouse", (), { + "move": originals.mouse_move, + "down": originals.mouse_down, + "up": originals.mouse_up, + "wheel": originals.mouse_wheel, + })() + + raw_keyboard: RawKeyboard = type("_RawKeyboard", (), { + "down": originals.keyboard_down, + "up": originals.keyboard_up, + "type": originals.keyboard_type, + "insert_text": originals.keyboard_insert_text, + })() + + page._human_raw_mouse = raw_mouse + + def _ensure_cursor_init() -> None: + if not cursor.initialized: + cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]) + cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]) + originals.mouse_move(cursor.x, cursor.y) + cursor.initialized = True + + def _human_goto(url: str, **kwargs: Any) -> Any: + response = originals.goto(url, **kwargs) + # Invalidate isolated world after navigation (context ID becomes stale) + if stealth is not None: + stealth.invalidate() + return response + + def _human_click(selector: str, **kwargs: Any) -> None: + _ensure_cursor_init() + call_cfg = merge_config(cfg, kwargs.get("human_config")) + timeout = kwargs.get("timeout", 30000) + force = kwargs.get("force", False) + skip_checks = kwargs.pop("_skip_checks", False) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force and not skip_checks: + ensure_actionable(page, selector, CHECKS_CLICK, timeout=_remaining_ms(), force=force) + if call_cfg.idle_between_actions: + human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg) + box, cx, cy, did_scroll = scroll_to_element( + page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(), + ) + cursor.x = cx + cursor.y = cy + is_input = _is_input_element(page, selector) + if not force and did_scroll: + ensure_stable(page, selector, timeout=_remaining_ms()) + box = page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box + target = click_target(box, is_input, call_cfg) + if not force: + check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms()) + human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg) + cursor.x = target.x + cursor.y = target.y + human_click(raw_mouse, is_input, call_cfg) + + def _human_dblclick(selector: str, **kwargs: Any) -> None: + _ensure_cursor_init() + call_cfg = merge_config(cfg, kwargs.get("human_config")) + timeout = kwargs.get("timeout", 30000) + force = kwargs.get("force", False) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force: + ensure_actionable(page, selector, CHECKS_CLICK, timeout=_remaining_ms(), force=force) + if call_cfg.idle_between_actions: + human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg) + box, cx, cy, did_scroll = scroll_to_element( + page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(), + ) + cursor.x = cx + cursor.y = cy + is_input = _is_input_element(page, selector) + if not force and did_scroll: + ensure_stable(page, selector, timeout=_remaining_ms()) + box = page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box + target = click_target(box, is_input, call_cfg) + if not force: + check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms()) + human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg) + cursor.x = target.x + cursor.y = target.y + raw_mouse.down(click_count=2) + sleep_ms(rand(30, 60)) + raw_mouse.up(click_count=2) + + def _human_hover(selector: str, **kwargs: Any) -> None: + _ensure_cursor_init() + call_cfg = merge_config(cfg, kwargs.get("human_config")) + timeout = kwargs.get("timeout", 30000) + force = kwargs.get("force", False) + skip_checks = kwargs.pop("_skip_checks", False) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force and not skip_checks: + ensure_actionable(page, selector, CHECKS_HOVER, timeout=_remaining_ms(), force=force) + if call_cfg.idle_between_actions: + human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg) + box, cx, cy, did_scroll = scroll_to_element( + page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(), + ) + cursor.x = cx + cursor.y = cy + if not force and did_scroll: + ensure_stable(page, selector, timeout=_remaining_ms()) + box = page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box + target = click_target(box, False, call_cfg) + if not force: + check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms()) + human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg) + cursor.x = target.x + cursor.y = target.y + + def _human_type(selector: str, text: str, **kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + timeout = kwargs.get("timeout", 30000) + force = kwargs.get("force", False) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force: + ensure_actionable(page, selector, CHECKS_INPUT, timeout=_remaining_ms(), force=force) + sleep_ms(rand_range(call_cfg.field_switch_delay)) + _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config")) + sleep_ms(rand(100, 250)) + human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session) + + def _human_fill(selector: str, value: str, **kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + timeout = kwargs.get("timeout", 30000) + force = kwargs.get("force", False) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force: + ensure_actionable(page, selector, CHECKS_INPUT, timeout=_remaining_ms(), force=force) + sleep_ms(rand_range(call_cfg.field_switch_delay)) + _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config")) + sleep_ms(rand(100, 250)) + originals.keyboard_press(_SELECT_ALL) + sleep_ms(rand(30, 80)) + originals.keyboard_press("Backspace") + sleep_ms(rand(50, 150)) + human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp_session) + + def _human_check(selector: str, **kwargs: Any) -> None: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force: + ensure_actionable(page, selector, CHECKS_CHECK, timeout=_remaining_ms(), force=force) + try: + checked = page.is_checked(selector) + except Exception: + checked = False + if not checked: + _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config")) + + def _human_uncheck(selector: str, **kwargs: Any) -> None: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force: + ensure_actionable(page, selector, CHECKS_CHECK, timeout=_remaining_ms(), force=force) + try: + checked = page.is_checked(selector) + except Exception: + checked = True + if checked: + _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config")) + + def _human_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force: + ensure_actionable(page, selector, CHECKS_FOCUS, timeout=_remaining_ms(), force=force) + _human_hover(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config")) + sleep_ms(rand(100, 300)) + pw_kwargs = {k: v for k, v in kwargs.items() if k not in ("human_config", "force")} + return originals.select_option(selector, value, **pw_kwargs) + + def _human_press(selector: str, key: str, **kwargs: Any) -> None: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force: + ensure_actionable(page, selector, CHECKS_FOCUS, timeout=_remaining_ms(), force=force) + if not _is_selector_focused(page, selector): + _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config")) + sleep_ms(rand(50, 150)) + originals.keyboard_press(key) + + def _human_mouse_move(x: float, y: float, **kwargs: Any) -> None: + _ensure_cursor_init() + human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg) + cursor.x = x + cursor.y = y + + def _human_mouse_click(x: float, y: float, **kwargs: Any) -> None: + _ensure_cursor_init() + human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg) + cursor.x = x + cursor.y = y + human_click(raw_mouse, False, cfg) + + def _human_keyboard_type(text: str, **kwargs: Any) -> None: + human_type(page, raw_keyboard, text, cfg, cdp_session=cdp_session) + + page.goto = _human_goto + page.click = _human_click + page.dblclick = _human_dblclick + page.hover = _human_hover + page.type = _human_type + page.fill = _human_fill + page.check = _human_check + page.uncheck = _human_uncheck + page.select_option = _human_select_option + page.press = _human_press + page.mouse.move = _human_mouse_move + page.mouse.click = _human_mouse_click + page.keyboard.type = _human_keyboard_type + # --- Patch Frame-level methods (for sub-frames) --- + _patch_frames_sync(page, cfg, cursor, raw_mouse, raw_keyboard, originals) + + # --- Patch ElementHandle selectors (query_selector, query_selector_all, wait_for_selector) --- + _patch_page_element_handles_sync(page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session) + + # Initialize cursor immediately so it doesn't visibly jump from (0,0) + cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]) + cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]) + try: + originals.mouse_move(cursor.x, cursor.y) + cursor.initialized = True + except Exception: + pass + + # --- Patch Locator class (class-level, runs once) --- + _patch_locator_class_sync() + + +# ============================================================================ +# SYNC ElementHandle patching +# ============================================================================ + + +def _is_input_element_handle_sync(el: Any) -> bool: + """Check if an ElementHandle is an input/textarea/contenteditable (sync).""" + try: + return el.evaluate( + """(node) => { + const tag = node.tagName ? node.tagName.toLowerCase() : ''; + return tag === 'input' || tag === 'textarea' + || node.getAttribute && node.getAttribute('contenteditable') === 'true'; + }""" + ) + except Exception: + return False + + +def _patch_single_element_handle_sync( + el: Any, page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any, + stealth: Any, cdp_session: Any, +) -> None: + """Patch all interaction methods on a sync Playwright ElementHandle.""" + if getattr(el, '_human_patched', False): + return + el._human_patched = True + + # Save originals + _orig_click = el.click + _orig_dblclick = el.dblclick + _orig_hover = el.hover + _orig_type = el.type + _orig_fill = el.fill + _orig_press = el.press + _orig_select_option = el.select_option + _orig_check = el.check + _orig_uncheck = el.uncheck + _orig_set_checked = getattr(el, 'set_checked', None) + _orig_tap = el.tap + _orig_focus = el.focus + _orig_scroll_into_view = getattr(el, 'scroll_into_view_if_needed', None) + + # Nested selectors + _orig_qs = el.query_selector + _orig_qsa = el.query_selector_all + _orig_wfs = el.wait_for_selector + + def _patched_qs(selector: str, **kwargs: Any) -> Any: + child = _orig_qs(selector, **kwargs) + if child is not None: + _patch_single_element_handle_sync( + child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return child + + def _patched_qsa(selector: str, **kwargs: Any) -> Any: + children = _orig_qsa(selector, **kwargs) + for child in children: + _patch_single_element_handle_sync( + child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return children + + def _patched_wfs(selector: str, **kwargs: Any) -> Any: + child = _orig_wfs(selector, **kwargs) + if child is not None: + _patch_single_element_handle_sync( + child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return child + + el.query_selector = _patched_qs + el.query_selector_all = _patched_qsa + el.wait_for_selector = _patched_wfs + + # Helper: move cursor to element. Accepts optional ``call_cfg`` so per-call + # ``human_config`` overrides on type/fill carry through to mouse timing. + # Also scrolls into view first so off-screen elements don't silently fall + # back to the unpatched native method (#129, #172 follow-up). + def _move_to_element(call_cfg: HumanConfig = cfg): + if not cursor.initialized: + cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1]) + cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1]) + originals.mouse_move(cursor.x, cursor.y) + cursor.initialized = True + + # Scroll into view first — best-effort. If the element can't be located + # we fall through to bounding_box() below which returns None and lets + # the caller fall back to the original Playwright method. + try: + _, nx, ny, _ = human_scroll_into_view( + page, raw_mouse, lambda: el.bounding_box(), + cursor.x, cursor.y, call_cfg, + ) + cursor.x = nx + cursor.y = ny + except Exception: + pass + + box = el.bounding_box() + if not box: + return None + + is_inp = _is_input_element_handle_sync(el) + target = click_target(box, is_inp, call_cfg) + + if call_cfg.idle_between_actions: + human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg) + + human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg) + cursor.x = target.x + cursor.y = target.y + return {'box': box, 'is_inp': is_inp} + + # --- el.click() --- + def _human_el_click(**kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force) + info = _move_to_element(call_cfg) + if info is None: + return _orig_click(**kwargs) + if not force: + check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000)) + human_click(raw_mouse, info['is_inp'], call_cfg) + + # --- el.dblclick() --- + def _human_el_dblclick(**kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force) + info = _move_to_element(call_cfg) + if info is None: + return _orig_dblclick(**kwargs) + if not force: + check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000)) + raw_mouse.down(click_count=2) + sleep_ms(rand(30, 60)) + raw_mouse.up(click_count=2) + + # --- el.hover() --- + def _human_el_hover(**kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force) + info = _move_to_element(call_cfg) + if info is None: + return _orig_hover(**kwargs) + + # --- el.type() --- + def _human_el_type(text: str, **kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force) + info = _move_to_element(call_cfg) + if info is None: + return _orig_type(text, **kwargs) + if not force: + check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000)) + human_click(raw_mouse, info['is_inp'], call_cfg) + sleep_ms(rand(100, 250)) + human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session) + + # --- el.fill() --- + def _human_el_fill(value: str, **kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force) + info = _move_to_element(call_cfg) + if info is None: + return _orig_fill(value, **kwargs) + if not force: + check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000)) + human_click(raw_mouse, info['is_inp'], call_cfg) + sleep_ms(rand(100, 250)) + originals.keyboard_press(_SELECT_ALL) + sleep_ms(rand(30, 80)) + originals.keyboard_press("Backspace") + sleep_ms(rand(50, 150)) + human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp_session) + + # --- el.scroll_into_view_if_needed() --- + # Playwright's native version snaps the page — a strong bot signal. + # Replace with the same accelerate → cruise → decelerate → overshoot wheel + # sequence used by page.click(). Falls back to the native method if the + # element is detached or scrolling fails. + def _human_el_scroll_into_view_if_needed(**kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + if not cursor.initialized: + cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1]) + cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1]) + try: + originals.mouse_move(cursor.x, cursor.y) + cursor.initialized = True + except Exception: + pass + try: + _, nx, ny, _ = human_scroll_into_view( + page, raw_mouse, lambda: el.bounding_box(), + cursor.x, cursor.y, call_cfg, + ) + cursor.x = nx + cursor.y = ny + except Exception: + if _orig_scroll_into_view is not None: + native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"} + _orig_scroll_into_view(**native_kwargs) + + # --- el.press() --- + def _human_el_press(key: str, **kwargs: Any) -> None: + sleep_ms(rand(20, 60)) + originals.keyboard_down(key) + sleep_ms(rand_range(cfg.key_hold)) + originals.keyboard_up(key) + + # --- el.select_option() --- + def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force) + info = _move_to_element() + if info is None: + return _orig_select_option(value, **kwargs) + human_click(raw_mouse, False, cfg) + sleep_ms(rand(100, 300)) + return _orig_select_option(value, **kwargs) + + # --- el.check() --- + def _human_el_check(**kwargs: Any) -> None: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force) + try: + if el.is_checked(): + return + except Exception: + pass + info = _move_to_element() + if info is None: + return _orig_check(**kwargs) + if not force: + check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000)) + human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.uncheck() --- + def _human_el_uncheck(**kwargs: Any) -> None: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force) + try: + if not el.is_checked(): + return + except Exception: + pass + info = _move_to_element() + if info is None: + return _orig_uncheck(**kwargs) + if not force: + check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000)) + human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.set_checked() --- + def _human_el_set_checked(checked: bool, **kwargs: Any) -> None: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force) + try: + current = el.is_checked() + if current == checked: + return + except Exception: + pass + info = _move_to_element() + if info is None and _orig_set_checked: + return _orig_set_checked(checked, **kwargs) + if info: + if not force: + check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000)) + human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.tap() --- + def _human_el_tap(**kwargs: Any) -> None: + info = _move_to_element() + if info is None: + return _orig_tap(**kwargs) + human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.focus() --- + # FIX: move cursor humanly but use programmatic focus (no click side-effects). + # Stock Playwright el.focus() never clicks — it just calls element.focus() in JS. + # Clicking would trigger onclick, submit forms, navigate links, etc. + def _human_el_focus() -> None: + _move_to_element() # human-like cursor movement (Bézier) + _orig_focus() # programmatic focus, no click side-effects + + el.click = _human_el_click + el.dblclick = _human_el_dblclick + el.hover = _human_el_hover + el.type = _human_el_type + el.fill = _human_el_fill + el.press = _human_el_press + el.select_option = _human_el_select_option + el.check = _human_el_check + el.uncheck = _human_el_uncheck + if _orig_set_checked is not None: + el.set_checked = _human_el_set_checked + el.tap = _human_el_tap + el.focus = _human_el_focus + if _orig_scroll_into_view is not None: + el.scroll_into_view_if_needed = _human_el_scroll_into_view_if_needed + + +def _patch_page_element_handles_sync( + page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any, + stealth: Any, cdp_session: Any, +) -> None: + """Patch page.query_selector, query_selector_all, wait_for_selector to return humanized ElementHandles (sync).""" + _orig_qs = page.query_selector + _orig_qsa = page.query_selector_all + _orig_wfs = page.wait_for_selector + + def _patched_qs(selector: str, **kwargs: Any) -> Any: + el = _orig_qs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return el + + def _patched_qsa(selector: str, **kwargs: Any) -> Any: + els = _orig_qsa(selector, **kwargs) + for el in els: + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return els + + def _patched_wfs(selector: str, **kwargs: Any) -> Any: + el = _orig_wfs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return el + + page.query_selector = _patched_qs + page.query_selector_all = _patched_qsa + page.wait_for_selector = _patched_wfs + + +def _patch_frames_sync( + page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any, +) -> None: + for frame in _iter_frames(page): + _patch_single_frame_sync(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals) + + orig_main_frame = getattr(page, "_original_main_frame", None) + if orig_main_frame is None: + try: + _orig_goto = originals.goto + + def _frame_aware_goto(url: str, **kwargs: Any) -> Any: + response = _orig_goto(url, **kwargs) + # Invalidate isolated world after navigation + stealth_world = getattr(page, '_stealth_world', None) + if stealth_world is not None: + stealth_world.invalidate() + for frame in _iter_frames(page): + if not getattr(frame, "_human_patched", False): + _patch_single_frame_sync(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals) + return response + + page.goto = _frame_aware_goto + page._original_main_frame = True + except Exception: + pass + + +def _patch_single_frame_sync( + frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any, +) -> None: + if getattr(frame, "_human_patched", False): + return + frame._human_patched = True + + # Frame-scoped resolution: humanize via ``frame.locator(selector)`` so the + # selector resolves in the sub-frame's own document, while the mouse stays + # page-level (bounding_box() is already page/viewport-space). Mirrors the JS + # wrapper's patchSingleFrame/moveToFrameSelector. #428. + _orig_frame_click = frame.click + _orig_frame_dblclick = frame.dblclick + _orig_frame_hover = frame.hover + _orig_frame_type = frame.type + _orig_frame_fill = frame.fill + _orig_frame_check = frame.check + _orig_frame_uncheck = frame.uncheck + _orig_frame_select_option = frame.select_option + _orig_frame_press = frame.press + _orig_frame_drag_and_drop = getattr(frame, 'drag_and_drop', None) + + stealth_world = getattr(page, '_stealth_world', None) + cdp_session = None + if stealth_world is not None: + try: + cdp_session = stealth_world.get_cdp_session() + except Exception: + pass + + def _native_kwargs(kwargs: dict) -> dict: + # Native Playwright frame methods don't accept our ``human_config`` kwarg. + return {k: v for k, v in kwargs.items() if k != "human_config"} + + def _deadline_remaining(kwargs: dict): + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + return lambda: max(0.0, (deadline - time.monotonic()) * 1000) + + def _frame_is_input(selector: str) -> bool: + try: + return bool(frame.locator(selector).first.evaluate( + "el => { const t = el.tagName.toLowerCase();" + " return t === 'input' || t === 'textarea'" + " || el.getAttribute('contenteditable') === 'true'; }" + )) + except Exception: + return False + + def _frame_is_focused(selector: str) -> bool: + try: + return bool(frame.locator(selector).first.evaluate( + "el => el === document.activeElement" + )) + except Exception: + return False + + def _move_to_frame_selector(selector: str, kwargs: dict, input_bias: bool, remaining_ms): + call_cfg = merge_config(cfg, kwargs.get("human_config")) + if call_cfg.idle_between_actions: + human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg) + loc = frame.locator(selector).first + try: + loc.scroll_into_view_if_needed(timeout=max(1, remaining_ms())) + except Exception: + pass + try: + box = loc.bounding_box(timeout=max(1, remaining_ms())) + except Exception: + box = None + if not box: + return None + is_input = input_bias or _frame_is_input(selector) + target = click_target(box, is_input, call_cfg) + human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg) + cursor.x = target.x + cursor.y = target.y + return call_cfg, is_input + + def _frame_click(selector: str, **kwargs: Any) -> None: + remaining_ms = _deadline_remaining(kwargs) + moved = _move_to_frame_selector(selector, kwargs, False, remaining_ms) + if moved is None: + _orig_frame_click(selector, **_native_kwargs(kwargs)) + return + human_click(raw_mouse, moved[1], moved[0]) + + def _frame_dblclick(selector: str, **kwargs: Any) -> None: + remaining_ms = _deadline_remaining(kwargs) + moved = _move_to_frame_selector(selector, kwargs, False, remaining_ms) + if moved is None: + _orig_frame_dblclick(selector, **_native_kwargs(kwargs)) + return + raw_mouse.down(click_count=2) + sleep_ms(rand(30, 60)) + raw_mouse.up(click_count=2) + + def _frame_hover(selector: str, **kwargs: Any) -> None: + remaining_ms = _deadline_remaining(kwargs) + moved = _move_to_frame_selector(selector, kwargs, False, remaining_ms) + if moved is None: + _orig_frame_hover(selector, **_native_kwargs(kwargs)) + + def _frame_type(selector: str, text: str, **kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + sleep_ms(rand_range(call_cfg.field_switch_delay)) + _frame_click(selector, **kwargs) + sleep_ms(rand(100, 250)) + try: + human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp_session) + except Exception: + _orig_frame_type(selector, text, **_native_kwargs(kwargs)) + + def _frame_fill(selector: str, value: str, **kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + sleep_ms(rand_range(call_cfg.field_switch_delay)) + _frame_click(selector, **kwargs) + sleep_ms(rand(100, 250)) + originals.keyboard_press(_SELECT_ALL) + sleep_ms(rand(30, 80)) + originals.keyboard_press("Backspace") + sleep_ms(rand(50, 150)) + try: + human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp_session) + except Exception: + _orig_frame_fill(selector, value, **_native_kwargs(kwargs)) + + def _frame_check(selector: str, **kwargs: Any) -> None: + try: + checked = frame.locator(selector).first.is_checked() + except Exception: + _orig_frame_check(selector, **_native_kwargs(kwargs)) + return + if not checked: + try: + _frame_click(selector, **kwargs) + except Exception: + _orig_frame_check(selector, **_native_kwargs(kwargs)) + + def _frame_uncheck(selector: str, **kwargs: Any) -> None: + try: + checked = frame.locator(selector).first.is_checked() + except Exception: + _orig_frame_uncheck(selector, **_native_kwargs(kwargs)) + return + if checked: + try: + _frame_click(selector, **kwargs) + except Exception: + _orig_frame_uncheck(selector, **_native_kwargs(kwargs)) + + def _frame_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any: + _frame_hover(selector, **kwargs) + sleep_ms(rand(100, 300)) + return _orig_frame_select_option(selector, value, **_native_kwargs(kwargs)) + + def _frame_press(selector: str, key: str, **kwargs: Any) -> None: + if not _frame_is_focused(selector): + _frame_click(selector, **kwargs) + sleep_ms(rand(50, 150)) + originals.keyboard_press(key) + + def _frame_clear(selector: str, **kwargs: Any) -> None: + if not _frame_is_focused(selector): + _frame_click(selector, **kwargs) + sleep_ms(rand(50, 100)) + originals.keyboard_press(_SELECT_ALL) + sleep_ms(rand(30, 80)) + originals.keyboard_press("Backspace") + + def _frame_drag_and_drop(source: str, target: str, **kwargs: Any) -> None: + try: + src_box = frame.locator(source).bounding_box() + tgt_box = frame.locator(target).bounding_box() + except Exception: + src_box = tgt_box = None + if src_box and tgt_box: + sx = src_box['x'] + src_box['width'] / 2 + sy = src_box['y'] + src_box['height'] / 2 + tx = tgt_box['x'] + tgt_box['width'] / 2 + ty = tgt_box['y'] + tgt_box['height'] / 2 + page.mouse.move(sx, sy) + sleep_ms(rand(100, 200)) + originals.mouse_down() + sleep_ms(rand(80, 150)) + page.mouse.move(tx, ty) + sleep_ms(rand(80, 150)) + originals.mouse_up() + elif _orig_frame_drag_and_drop: + _orig_frame_drag_and_drop(source, target, **kwargs) + + frame.click = _frame_click + frame.dblclick = _frame_dblclick + frame.hover = _frame_hover + frame.type = _frame_type + frame.fill = _frame_fill + frame.check = _frame_check + frame.uncheck = _frame_uncheck + frame.select_option = _frame_select_option + frame.press = _frame_press + frame.clear = _frame_clear + frame.drag_and_drop = _frame_drag_and_drop + + # --- Patch frame-level ElementHandle selectors --- + _patch_frame_element_handles_sync( + frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth_world, cdp_session + ) + + +def _patch_frame_element_handles_sync( + frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: RawMouse, raw_keyboard: RawKeyboard, originals: Any, + stealth: Any, cdp_session: Any, +) -> None: + """Patch frame.query_selector, query_selector_all, wait_for_selector (sync).""" + _orig_qs = frame.query_selector + _orig_qsa = frame.query_selector_all + _orig_wfs = frame.wait_for_selector + + def _patched_qs(selector: str, **kwargs: Any) -> Any: + el = _orig_qs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return el + + def _patched_qsa(selector: str, **kwargs: Any) -> Any: + els = _orig_qsa(selector, **kwargs) + for el in els: + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return els + + def _patched_wfs(selector: str, **kwargs: Any) -> Any: + el = _orig_wfs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session + ) + return el + + frame.query_selector = _patched_qs + frame.query_selector_all = _patched_qsa + frame.wait_for_selector = _patched_wfs + + + +def _iter_frames(page: Any): + try: + main = page.main_frame + yield main + for child in main.child_frames: + yield child + except Exception: + pass + + +def patch_context(context: Any, cfg: HumanConfig) -> None: + cursor = _CursorState() + for page in context.pages: + patch_page(page, cfg, cursor) + context.on("page", lambda p: patch_page(p, cfg, _CursorState()) if not hasattr(p, '_original') else None) + + orig_new_page = context.new_page + + def _patched_new_page(**kwargs: Any) -> Any: + page = orig_new_page(**kwargs) + if not hasattr(page, '_original'): + patch_page(page, cfg, _CursorState()) + return page + + context.new_page = _patched_new_page + + +def patch_browser(browser: Any, cfg: HumanConfig) -> None: + for context in browser.contexts: + patch_context(context, cfg) + + orig_new_context = browser.new_context + + def _patched_new_context(**kwargs: Any) -> Any: + context = orig_new_context(**kwargs) + patch_context(context, cfg) + return context + + browser.new_context = _patched_new_context + + orig_new_page = browser.new_page + + def _patched_new_page(**kwargs: Any) -> Any: + page = orig_new_page(**kwargs) + if not hasattr(page, '_original'): + patch_page(page, cfg, _CursorState()) + return page + + browser.new_page = _patched_new_page + + +# ============================================================================ +# ASYNC patching +# ============================================================================ + + +def patch_page_async(page: Any, cfg: HumanConfig, cursor: _CursorState) -> None: + """Replace page methods with human-like implementations (async).""" + originals = type("Originals", (), { + "click": page.click, + "type": page.type, + "fill": page.fill, + "goto": page.goto, + "hover": page.hover, + "dblclick": page.dblclick, + "select_option": page.select_option, + "mouse_move": page.mouse.move, + "mouse_click": page.mouse.click, + "mouse_wheel": page.mouse.wheel, + "mouse_down": page.mouse.down, + "mouse_up": page.mouse.up, + "keyboard_type": page.keyboard.type, + "keyboard_down": page.keyboard.down, + "keyboard_up": page.keyboard.up, + "keyboard_press": page.keyboard.press, + "keyboard_insert_text": page.keyboard.insert_text, + })() + + page._original = originals + page._human_cfg = cfg + page._human_cursor = cursor + + # --- Stealth infrastructure (lazy-initialized, async) --- + stealth = _AsyncIsolatedWorld(page) + page._stealth_world = stealth + cdp_session_holder: list[Any] = [None] # mutable container for closure + page._cdp_session_holder = cdp_session_holder # expose for frame-level patching + + async def _ensure_cdp() -> Any: + if cdp_session_holder[0] is None: + try: + cdp_session_holder[0] = await stealth.get_cdp_session() + except Exception: + logger.debug("Could not create async CDP session") + return cdp_session_holder[0] + + raw_mouse: AsyncRawMouse = type("_AsyncRawMouse", (), { + "move": originals.mouse_move, + "down": originals.mouse_down, + "up": originals.mouse_up, + "wheel": originals.mouse_wheel, + })() + + raw_keyboard: AsyncRawKeyboard = type("_AsyncRawKeyboard", (), { + "down": originals.keyboard_down, + "up": originals.keyboard_up, + "type": originals.keyboard_type, + "insert_text": originals.keyboard_insert_text, + })() + + page._human_raw_mouse = raw_mouse + + async def _ensure_cursor_init() -> None: + if not cursor.initialized: + cursor.x = rand(cfg.initial_cursor_x[0], cfg.initial_cursor_x[1]) + cursor.y = rand(cfg.initial_cursor_y[0], cfg.initial_cursor_y[1]) + await originals.mouse_move(cursor.x, cursor.y) + cursor.initialized = True + + async def _human_goto(url: str, **kwargs: Any) -> Any: + response = await originals.goto(url, **kwargs) + # Invalidate isolated world after navigation + stealth.invalidate() + return response + + async def _human_click(selector: str, **kwargs: Any) -> None: + await _ensure_cursor_init() + call_cfg = merge_config(cfg, kwargs.get("human_config")) + timeout = kwargs.get("timeout", 30000) + force = kwargs.get("force", False) + skip_checks = kwargs.pop("_skip_checks", False) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force and not skip_checks: + await async_ensure_actionable(page, selector, CHECKS_CLICK, timeout=_remaining_ms(), force=force) + if call_cfg.idle_between_actions: + await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg) + box, cx, cy, did_scroll = await async_scroll_to_element( + page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(), + ) + cursor.x = cx + cursor.y = cy + is_input = await _async_is_input_element(page, selector) + if not force and did_scroll: + await async_ensure_stable(page, selector, timeout=_remaining_ms()) + box = await page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box + target = click_target(box, is_input, call_cfg) + if not force: + await async_check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms()) + await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg) + cursor.x = target.x + cursor.y = target.y + await async_human_click(raw_mouse, is_input, call_cfg) + + async def _human_dblclick(selector: str, **kwargs: Any) -> None: + await _ensure_cursor_init() + call_cfg = merge_config(cfg, kwargs.get("human_config")) + timeout = kwargs.get("timeout", 30000) + force = kwargs.get("force", False) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force: + await async_ensure_actionable(page, selector, CHECKS_CLICK, timeout=_remaining_ms(), force=force) + if call_cfg.idle_between_actions: + await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg) + box, cx, cy, did_scroll = await async_scroll_to_element( + page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(), + ) + cursor.x = cx + cursor.y = cy + is_input = await _async_is_input_element(page, selector) + if not force and did_scroll: + await async_ensure_stable(page, selector, timeout=_remaining_ms()) + box = await page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box + target = click_target(box, is_input, call_cfg) + if not force: + await async_check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms()) + await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg) + cursor.x = target.x + cursor.y = target.y + await raw_mouse.down(click_count=2) + await async_sleep_ms(rand(30, 60)) + await raw_mouse.up(click_count=2) + + async def _human_hover(selector: str, **kwargs: Any) -> None: + await _ensure_cursor_init() + call_cfg = merge_config(cfg, kwargs.get("human_config")) + timeout = kwargs.get("timeout", 30000) + force = kwargs.get("force", False) + skip_checks = kwargs.pop("_skip_checks", False) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force and not skip_checks: + await async_ensure_actionable(page, selector, CHECKS_HOVER, timeout=_remaining_ms(), force=force) + if call_cfg.idle_between_actions: + await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg) + box, cx, cy, did_scroll = await async_scroll_to_element( + page, raw_mouse, selector, cursor.x, cursor.y, call_cfg, timeout=_remaining_ms(), + ) + cursor.x = cx + cursor.y = cy + if not force and did_scroll: + await async_ensure_stable(page, selector, timeout=_remaining_ms()) + box = await page.locator(selector).first.bounding_box(timeout=max(1, _remaining_ms())) or box + target = click_target(box, False, call_cfg) + if not force: + await async_check_pointer_events(page, selector, target.x, target.y, stealth, timeout=_remaining_ms()) + await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg) + cursor.x = target.x + cursor.y = target.y + + async def _human_type(selector: str, text: str, **kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + timeout = kwargs.get("timeout", 30000) + force = kwargs.get("force", False) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force: + await async_ensure_actionable(page, selector, CHECKS_INPUT, timeout=_remaining_ms(), force=force) + await async_sleep_ms(rand_range(call_cfg.field_switch_delay)) + await _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config")) + await async_sleep_ms(rand(100, 250)) + cdp = await _ensure_cdp() + await async_human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp) + + async def _human_fill(selector: str, value: str, **kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + timeout = kwargs.get("timeout", 30000) + force = kwargs.get("force", False) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force: + await async_ensure_actionable(page, selector, CHECKS_INPUT, timeout=_remaining_ms(), force=force) + await async_sleep_ms(rand_range(call_cfg.field_switch_delay)) + await _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config")) + await async_sleep_ms(rand(100, 250)) + await originals.keyboard_press(_SELECT_ALL) + await async_sleep_ms(rand(30, 80)) + await originals.keyboard_press("Backspace") + await async_sleep_ms(rand(50, 150)) + cdp = await _ensure_cdp() + await async_human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp) + + async def _human_check(selector: str, **kwargs: Any) -> None: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force: + await async_ensure_actionable(page, selector, CHECKS_CHECK, timeout=_remaining_ms(), force=force) + try: + checked = await page.is_checked(selector) + except Exception: + checked = False + if not checked: + await _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config")) + + async def _human_uncheck(selector: str, **kwargs: Any) -> None: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force: + await async_ensure_actionable(page, selector, CHECKS_CHECK, timeout=_remaining_ms(), force=force) + try: + checked = await page.is_checked(selector) + except Exception: + checked = True + if checked: + await _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config")) + + async def _human_press(selector: str, key: str, **kwargs: Any) -> None: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force: + await async_ensure_actionable(page, selector, CHECKS_FOCUS, timeout=_remaining_ms(), force=force) + if not await _async_is_selector_focused(page, selector): + await _human_click(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config")) + await async_sleep_ms(rand(50, 150)) + await originals.keyboard_press(key) + + async def _human_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + + if not force: + await async_ensure_actionable(page, selector, CHECKS_FOCUS, timeout=_remaining_ms(), force=force) + await _human_hover(selector, _skip_checks=True, timeout=_remaining_ms(), force=force, human_config=kwargs.get("human_config")) + await async_sleep_ms(rand(100, 300)) + pw_kwargs = {k: v for k, v in kwargs.items() if k not in ("human_config", "force")} + return await originals.select_option(selector, value, **pw_kwargs) + + async def _human_mouse_move(x: float, y: float, **kwargs: Any) -> None: + await _ensure_cursor_init() + await async_human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg) + cursor.x = x + cursor.y = y + + async def _human_mouse_click(x: float, y: float, **kwargs: Any) -> None: + await _ensure_cursor_init() + await async_human_move(raw_mouse, cursor.x, cursor.y, x, y, cfg) + cursor.x = x + cursor.y = y + await async_human_click(raw_mouse, False, cfg) + + async def _human_keyboard_type(text: str, **kwargs: Any) -> None: + cdp = await _ensure_cdp() + await async_human_type(page, raw_keyboard, text, cfg, cdp_session=cdp) + + page.goto = _human_goto + page.click = _human_click + page.dblclick = _human_dblclick + page.hover = _human_hover + page.type = _human_type + page.fill = _human_fill + page.check = _human_check + page.uncheck = _human_uncheck + page.select_option = _human_select_option + page.press = _human_press + page.mouse.move = _human_mouse_move + page.mouse.click = _human_mouse_click + page.keyboard.type = _human_keyboard_type + + # --- Patch Frame-level methods (for sub-frames) --- + _patch_frames_async(page, cfg, cursor, raw_mouse, raw_keyboard, originals) + + # --- Patch ElementHandle selectors (query_selector, query_selector_all, wait_for_selector) --- + _patch_page_element_handles_async(page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder) + + # --- Patch async Locator class (class-level, runs once) --- + _patch_locator_class_async() + + +# ============================================================================ +# ASYNC ElementHandle patching +# ============================================================================ + + +async def _async_is_input_element_handle(el: Any) -> bool: + """Check if an ElementHandle is an input/textarea/contenteditable (async).""" + try: + return await el.evaluate( + """(node) => { + const tag = node.tagName ? node.tagName.toLowerCase() : ''; + return tag === 'input' || tag === 'textarea' + || node.getAttribute && node.getAttribute('contenteditable') === 'true'; + }""" + ) + except Exception: + return False + + +def _patch_single_element_handle_async( + el: Any, page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any, + stealth: Any, cdp_session_holder: Any, +) -> None: + """Patch all interaction methods on an async Playwright ElementHandle.""" + if getattr(el, '_human_patched', False): + return + el._human_patched = True + + # Save originals + _orig_click = el.click + _orig_dblclick = el.dblclick + _orig_hover = el.hover + _orig_type = el.type + _orig_fill = el.fill + _orig_press = el.press + _orig_select_option = el.select_option + _orig_check = el.check + _orig_uncheck = el.uncheck + _orig_set_checked = getattr(el, 'set_checked', None) + _orig_tap = el.tap + _orig_focus = el.focus + _orig_scroll_into_view = getattr(el, 'scroll_into_view_if_needed', None) + + # Nested selectors + _orig_qs = el.query_selector + _orig_qsa = el.query_selector_all + _orig_wfs = el.wait_for_selector + + async def _patched_qs(selector: str, **kwargs: Any) -> Any: + child = await _orig_qs(selector, **kwargs) + if child is not None: + _patch_single_element_handle_async( + child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return child + + async def _patched_qsa(selector: str, **kwargs: Any) -> Any: + children = await _orig_qsa(selector, **kwargs) + for child in children: + _patch_single_element_handle_async( + child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return children + + async def _patched_wfs(selector: str, **kwargs: Any) -> Any: + child = await _orig_wfs(selector, **kwargs) + if child is not None: + _patch_single_element_handle_async( + child, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return child + + el.query_selector = _patched_qs + el.query_selector_all = _patched_qsa + el.wait_for_selector = _patched_wfs + + # Helper: move cursor to element (async). Accepts optional ``call_cfg`` so + # per-call ``human_config`` overrides on type/fill carry through to mouse + # timing. Also scrolls into view first so off-screen elements work + # (#129, #172 follow-up). + async def _move_to_element(call_cfg: HumanConfig = cfg): + if not cursor.initialized: + cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1]) + cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1]) + await originals.mouse_move(cursor.x, cursor.y) + cursor.initialized = True + + # Scroll into view first — best-effort. + async def _get_box(): + return await el.bounding_box() + try: + _, nx, ny, _ = await async_human_scroll_into_view( + page, raw_mouse, _get_box, + cursor.x, cursor.y, call_cfg, + ) + cursor.x = nx + cursor.y = ny + except Exception: + pass + + box = await el.bounding_box() + if not box: + return None + + is_inp = await _async_is_input_element_handle(el) + target = click_target(box, is_inp, call_cfg) + + if call_cfg.idle_between_actions: + await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg) + + await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg) + cursor.x = target.x + cursor.y = target.y + return {'box': box, 'is_inp': is_inp} + + async def _get_cdp(): + if cdp_session_holder[0] is None: + try: + cdp_session_holder[0] = await stealth.get_cdp_session() + except Exception: + pass + return cdp_session_holder[0] + + # --- el.click() --- + async def _human_el_click(**kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force) + info = await _move_to_element(call_cfg) + if info is None: + return await _orig_click(**kwargs) + if not force: + await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000)) + await async_human_click(raw_mouse, info['is_inp'], call_cfg) + + # --- el.dblclick() --- + async def _human_el_dblclick(**kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + await async_ensure_actionable_handle(page, el, CHECKS_CLICK, timeout=_remaining_ms(), force=force) + info = await _move_to_element(call_cfg) + if info is None: + return await _orig_dblclick(**kwargs) + if not force: + await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000)) + await raw_mouse.down(click_count=2) + await async_sleep_ms(rand(30, 60)) + await raw_mouse.up(click_count=2) + + # --- el.hover() --- + async def _human_el_hover(**kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + await async_ensure_actionable_handle(page, el, CHECKS_HOVER, timeout=_remaining_ms(), force=force) + info = await _move_to_element(call_cfg) + if info is None: + return await _orig_hover(**kwargs) + + # --- el.type() --- + async def _human_el_type(text: str, **kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force) + info = await _move_to_element(call_cfg) + if info is None: + return await _orig_type(text, **kwargs) + if not force: + await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000)) + await async_human_click(raw_mouse, info['is_inp'], call_cfg) + await async_sleep_ms(rand(100, 250)) + cdp = await _get_cdp() + await async_human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp) + + # --- el.fill() --- + async def _human_el_fill(value: str, **kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + await async_ensure_actionable_handle(page, el, CHECKS_INPUT, timeout=_remaining_ms(), force=force) + info = await _move_to_element(call_cfg) + if info is None: + return await _orig_fill(value, **kwargs) + if not force: + await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000)) + await async_human_click(raw_mouse, info['is_inp'], call_cfg) + await async_sleep_ms(rand(100, 250)) + await originals.keyboard_press(_SELECT_ALL) + await async_sleep_ms(rand(30, 80)) + await originals.keyboard_press("Backspace") + await async_sleep_ms(rand(50, 150)) + cdp = await _get_cdp() + await async_human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp) + + # --- el.scroll_into_view_if_needed() --- + async def _human_el_scroll_into_view_if_needed(**kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + if not cursor.initialized: + cursor.x = rand(call_cfg.initial_cursor_x[0], call_cfg.initial_cursor_x[1]) + cursor.y = rand(call_cfg.initial_cursor_y[0], call_cfg.initial_cursor_y[1]) + try: + await originals.mouse_move(cursor.x, cursor.y) + cursor.initialized = True + except Exception: + pass + + async def _get_box(): + return await el.bounding_box() + try: + _, nx, ny, _ = await async_human_scroll_into_view( + page, raw_mouse, _get_box, + cursor.x, cursor.y, call_cfg, + ) + cursor.x = nx + cursor.y = ny + except Exception: + if _orig_scroll_into_view is not None: + native_kwargs = {k: v for k, v in kwargs.items() if k != "human_config"} + await _orig_scroll_into_view(**native_kwargs) + + # --- el.press() --- + async def _human_el_press(key: str, **kwargs: Any) -> None: + await async_sleep_ms(rand(20, 60)) + await originals.keyboard_down(key) + await async_sleep_ms(rand_range(cfg.key_hold)) + await originals.keyboard_up(key) + + # --- el.select_option() --- + async def _human_el_select_option(value: Any = None, **kwargs: Any) -> Any: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + await async_ensure_actionable_handle(page, el, CHECKS_FOCUS, timeout=_remaining_ms(), force=force) + info = await _move_to_element() + if info is None: + return await _orig_select_option(value, **kwargs) + await async_human_click(raw_mouse, False, cfg) + await async_sleep_ms(rand(100, 300)) + return await _orig_select_option(value, **kwargs) + + # --- el.check() --- + async def _human_el_check(**kwargs: Any) -> None: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force) + try: + if await el.is_checked(): + return + except Exception: + pass + info = await _move_to_element() + if info is None: + return await _orig_check(**kwargs) + if not force: + await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000)) + await async_human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.uncheck() --- + async def _human_el_uncheck(**kwargs: Any) -> None: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force) + try: + if not await el.is_checked(): + return + except Exception: + pass + info = await _move_to_element() + if info is None: + return await _orig_uncheck(**kwargs) + if not force: + await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000)) + await async_human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.set_checked() --- + async def _human_el_set_checked(checked: bool, **kwargs: Any) -> None: + force = kwargs.get("force", False) + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + def _remaining_ms(): + return max(0, (deadline - time.monotonic()) * 1000) + if not force: + await async_ensure_actionable_handle(page, el, CHECKS_CHECK, timeout=_remaining_ms(), force=force) + try: + current = await el.is_checked() + if current == checked: + return + except Exception: + pass + info = await _move_to_element() + if info is None and _orig_set_checked: + return await _orig_set_checked(checked, **kwargs) + if info: + if not force: + await async_check_pointer_events_handle(page, el, cursor.x, cursor.y, timeout=min(_remaining_ms(), 5000)) + await async_human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.tap() --- + async def _human_el_tap(**kwargs: Any) -> None: + info = await _move_to_element() + if info is None: + return await _orig_tap(**kwargs) + await async_human_click(raw_mouse, info['is_inp'], cfg) + + # --- el.focus() --- + # FIX: move cursor humanly but use programmatic focus (no click side-effects). + # Stock Playwright el.focus() never clicks — it just calls element.focus() in JS. + # Clicking would trigger onclick, submit forms, navigate links, etc. + async def _human_el_focus() -> None: + await _move_to_element() # human-like cursor movement (Bézier) + await _orig_focus() # programmatic focus, no click side-effects + + el.click = _human_el_click + el.dblclick = _human_el_dblclick + el.hover = _human_el_hover + el.type = _human_el_type + el.fill = _human_el_fill + el.press = _human_el_press + el.select_option = _human_el_select_option + el.check = _human_el_check + el.uncheck = _human_el_uncheck + if _orig_set_checked is not None: + el.set_checked = _human_el_set_checked + el.tap = _human_el_tap + el.focus = _human_el_focus + if _orig_scroll_into_view is not None: + el.scroll_into_view_if_needed = _human_el_scroll_into_view_if_needed + + +def _patch_page_element_handles_async( + page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any, + stealth: Any, cdp_session_holder: Any, +) -> None: + """Patch page.query_selector, query_selector_all, wait_for_selector to return humanized ElementHandles (async).""" + _orig_qs = page.query_selector + _orig_qsa = page.query_selector_all + _orig_wfs = page.wait_for_selector + + async def _patched_qs(selector: str, **kwargs: Any) -> Any: + el = await _orig_qs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_async( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return el + + async def _patched_qsa(selector: str, **kwargs: Any) -> Any: + els = await _orig_qsa(selector, **kwargs) + for el in els: + _patch_single_element_handle_async( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return els + + async def _patched_wfs(selector: str, **kwargs: Any) -> Any: + el = await _orig_wfs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_async( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return el + + page.query_selector = _patched_qs + page.query_selector_all = _patched_qsa + page.wait_for_selector = _patched_wfs + + +def _patch_frames_async( + page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any, +) -> None: + for frame in _iter_frames(page): + _patch_single_frame_async(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals) + + orig_main_frame = getattr(page, "_original_main_frame", None) + if orig_main_frame is None: + try: + _orig_goto = originals.goto + + async def _frame_aware_goto(url: str, **kwargs: Any) -> Any: + response = await _orig_goto(url, **kwargs) + # Invalidate isolated world after navigation + stealth_world = getattr(page, '_stealth_world', None) + if stealth_world is not None: + stealth_world.invalidate() + for frame in _iter_frames(page): + if not getattr(frame, "_human_patched", False): + _patch_single_frame_async(frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals) + return response + + page.goto = _frame_aware_goto + page._original_main_frame = True + except Exception: + pass + + +def _patch_single_frame_async( + frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any, +) -> None: + if getattr(frame, "_human_patched", False): + return + frame._human_patched = True + + # Frame-scoped resolution (async): humanize via ``frame.locator(selector)`` so + # the selector resolves in the sub-frame's own document, while the mouse stays + # page-level. Mirrors the sync patcher / JS moveToFrameSelector. #428. + _orig_frame_click = frame.click + _orig_frame_dblclick = frame.dblclick + _orig_frame_hover = frame.hover + _orig_frame_type = frame.type + _orig_frame_fill = frame.fill + _orig_frame_check = frame.check + _orig_frame_uncheck = frame.uncheck + _orig_frame_select_option = frame.select_option + _orig_frame_press = frame.press + _orig_frame_drag_and_drop = getattr(frame, 'drag_and_drop', None) + + stealth_world = getattr(page, '_stealth_world', None) + cdp_session_holder = getattr(page, '_cdp_session_holder', [None]) + + async def _get_cdp(): + if cdp_session_holder[0] is None and stealth_world is not None: + try: + cdp_session_holder[0] = await stealth_world.get_cdp_session() + except Exception: + cdp_session_holder[0] = None + return cdp_session_holder[0] + + def _native_kwargs(kwargs: dict) -> dict: + return {k: v for k, v in kwargs.items() if k != "human_config"} + + def _deadline_remaining(kwargs: dict): + timeout = kwargs.get("timeout", 30000) + deadline = time.monotonic() + timeout / 1000.0 + return lambda: max(0.0, (deadline - time.monotonic()) * 1000) + + async def _frame_is_input(selector: str) -> bool: + try: + return bool(await frame.locator(selector).first.evaluate( + "el => { const t = el.tagName.toLowerCase();" + " return t === 'input' || t === 'textarea'" + " || el.getAttribute('contenteditable') === 'true'; }" + )) + except Exception: + return False + + async def _frame_is_focused(selector: str) -> bool: + try: + return bool(await frame.locator(selector).first.evaluate( + "el => el === document.activeElement" + )) + except Exception: + return False + + async def _move_to_frame_selector(selector: str, kwargs: dict, input_bias: bool, remaining_ms): + call_cfg = merge_config(cfg, kwargs.get("human_config")) + if call_cfg.idle_between_actions: + await async_human_idle(raw_mouse, rand(call_cfg.idle_between_duration[0], call_cfg.idle_between_duration[1]), cursor.x, cursor.y, call_cfg) + loc = frame.locator(selector).first + try: + await loc.scroll_into_view_if_needed(timeout=max(1, remaining_ms())) + except Exception: + pass + try: + box = await loc.bounding_box(timeout=max(1, remaining_ms())) + except Exception: + box = None + if not box: + return None + is_input = input_bias or await _frame_is_input(selector) + target = click_target(box, is_input, call_cfg) + await async_human_move(raw_mouse, cursor.x, cursor.y, target.x, target.y, call_cfg) + cursor.x = target.x + cursor.y = target.y + return call_cfg, is_input + + async def _frame_click(selector: str, **kwargs: Any) -> None: + remaining_ms = _deadline_remaining(kwargs) + moved = await _move_to_frame_selector(selector, kwargs, False, remaining_ms) + if moved is None: + await _orig_frame_click(selector, **_native_kwargs(kwargs)) + return + await async_human_click(raw_mouse, moved[1], moved[0]) + + async def _frame_dblclick(selector: str, **kwargs: Any) -> None: + remaining_ms = _deadline_remaining(kwargs) + moved = await _move_to_frame_selector(selector, kwargs, False, remaining_ms) + if moved is None: + await _orig_frame_dblclick(selector, **_native_kwargs(kwargs)) + return + await raw_mouse.down(click_count=2) + await async_sleep_ms(rand(30, 60)) + await raw_mouse.up(click_count=2) + + async def _frame_hover(selector: str, **kwargs: Any) -> None: + remaining_ms = _deadline_remaining(kwargs) + moved = await _move_to_frame_selector(selector, kwargs, False, remaining_ms) + if moved is None: + await _orig_frame_hover(selector, **_native_kwargs(kwargs)) + + async def _frame_type(selector: str, text: str, **kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + await async_sleep_ms(rand_range(call_cfg.field_switch_delay)) + await _frame_click(selector, **kwargs) + await async_sleep_ms(rand(100, 250)) + try: + cdp = await _get_cdp() + await async_human_type(page, raw_keyboard, text, call_cfg, cdp_session=cdp) + except Exception: + await _orig_frame_type(selector, text, **_native_kwargs(kwargs)) + + async def _frame_fill(selector: str, value: str, **kwargs: Any) -> None: + call_cfg = merge_config(cfg, kwargs.get("human_config")) + await async_sleep_ms(rand_range(call_cfg.field_switch_delay)) + await _frame_click(selector, **kwargs) + await async_sleep_ms(rand(100, 250)) + await originals.keyboard_press(_SELECT_ALL) + await async_sleep_ms(rand(30, 80)) + await originals.keyboard_press("Backspace") + await async_sleep_ms(rand(50, 150)) + try: + cdp = await _get_cdp() + await async_human_type(page, raw_keyboard, value, call_cfg, cdp_session=cdp) + except Exception: + await _orig_frame_fill(selector, value, **_native_kwargs(kwargs)) + + async def _frame_check(selector: str, **kwargs: Any) -> None: + try: + checked = await frame.locator(selector).first.is_checked() + except Exception: + await _orig_frame_check(selector, **_native_kwargs(kwargs)) + return + if not checked: + try: + await _frame_click(selector, **kwargs) + except Exception: + await _orig_frame_check(selector, **_native_kwargs(kwargs)) + + async def _frame_uncheck(selector: str, **kwargs: Any) -> None: + try: + checked = await frame.locator(selector).first.is_checked() + except Exception: + await _orig_frame_uncheck(selector, **_native_kwargs(kwargs)) + return + if checked: + try: + await _frame_click(selector, **kwargs) + except Exception: + await _orig_frame_uncheck(selector, **_native_kwargs(kwargs)) + + async def _frame_select_option(selector: str, value: Any = None, **kwargs: Any) -> Any: + await _frame_hover(selector, **kwargs) + await async_sleep_ms(rand(100, 300)) + return await _orig_frame_select_option(selector, value, **_native_kwargs(kwargs)) + + async def _frame_press(selector: str, key: str, **kwargs: Any) -> None: + if not await _frame_is_focused(selector): + await _frame_click(selector, **kwargs) + await async_sleep_ms(rand(50, 150)) + await originals.keyboard_press(key) + + async def _frame_clear(selector: str, **kwargs: Any) -> None: + if not await _frame_is_focused(selector): + await _frame_click(selector, **kwargs) + await async_sleep_ms(rand(50, 100)) + await originals.keyboard_press(_SELECT_ALL) + await async_sleep_ms(rand(30, 80)) + await originals.keyboard_press("Backspace") + + async def _frame_drag_and_drop(source: str, target: str, **kwargs: Any) -> None: + try: + src_box = await frame.locator(source).bounding_box() + tgt_box = await frame.locator(target).bounding_box() + except Exception: + src_box = tgt_box = None + if src_box and tgt_box: + sx = src_box['x'] + src_box['width'] / 2 + sy = src_box['y'] + src_box['height'] / 2 + tx = tgt_box['x'] + tgt_box['width'] / 2 + ty = tgt_box['y'] + tgt_box['height'] / 2 + await page.mouse.move(sx, sy) + await async_sleep_ms(rand(100, 200)) + await originals.mouse_down() + await async_sleep_ms(rand(80, 150)) + await page.mouse.move(tx, ty) + await async_sleep_ms(rand(80, 150)) + await originals.mouse_up() + elif _orig_frame_drag_and_drop: + await _orig_frame_drag_and_drop(source, target, **kwargs) + + frame.click = _frame_click + frame.dblclick = _frame_dblclick + frame.hover = _frame_hover + frame.type = _frame_type + frame.fill = _frame_fill + frame.check = _frame_check + frame.uncheck = _frame_uncheck + frame.select_option = _frame_select_option + frame.press = _frame_press + frame.clear = _frame_clear + frame.drag_and_drop = _frame_drag_and_drop + + # --- Patch frame-level ElementHandle selectors (async) --- + _patch_frame_element_handles_async( + frame, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth_world, cdp_session_holder + ) + + +def _patch_frame_element_handles_async( + frame: Any, page: Any, cfg: HumanConfig, cursor: _CursorState, + raw_mouse: AsyncRawMouse, raw_keyboard: AsyncRawKeyboard, originals: Any, + stealth: Any, cdp_session_holder: Any, +) -> None: + """Patch frame.query_selector, query_selector_all, wait_for_selector (async).""" + _orig_qs = frame.query_selector + _orig_qsa = frame.query_selector_all + _orig_wfs = frame.wait_for_selector + + async def _patched_qs(selector: str, **kwargs: Any) -> Any: + el = await _orig_qs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_async( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return el + + async def _patched_qsa(selector: str, **kwargs: Any) -> Any: + els = await _orig_qsa(selector, **kwargs) + for el in els: + _patch_single_element_handle_async( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return els + + async def _patched_wfs(selector: str, **kwargs: Any) -> Any: + el = await _orig_wfs(selector, **kwargs) + if el is not None: + _patch_single_element_handle_async( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, cdp_session_holder + ) + return el + + frame.query_selector = _patched_qs + frame.query_selector_all = _patched_qsa + frame.wait_for_selector = _patched_wfs + + + +def patch_context_async(context: Any, cfg: HumanConfig) -> None: + cursor = _CursorState() + for page in context.pages: + patch_page_async(page, cfg, cursor) + context.on("page", lambda p: patch_page_async(p, cfg, _CursorState()) if not hasattr(p, '_original') else None) + + orig_new_page = context.new_page + + async def _patched_new_page(**kwargs: Any) -> Any: + page = await orig_new_page(**kwargs) + if not hasattr(page, '_original'): + patch_page_async(page, cfg, _CursorState()) + return page + + context.new_page = _patched_new_page + + +def patch_browser_async(browser: Any, cfg: HumanConfig) -> None: + for context in browser.contexts: + patch_context_async(context, cfg) + + orig_new_context = browser.new_context + + async def _patched_new_context(**kwargs: Any) -> Any: + context = await orig_new_context(**kwargs) + patch_context_async(context, cfg) + return context + + browser.new_context = _patched_new_context + + orig_new_page = browser.new_page + + async def _patched_new_page(**kwargs: Any) -> Any: + page = await orig_new_page(**kwargs) + if not hasattr(page, '_original'): + patch_page_async(page, cfg, _CursorState()) + return page + + browser.new_page = _patched_new_page diff --git a/cloakbrowser/human/actionability.py b/cloakbrowser/human/actionability.py new file mode 100644 index 0000000..9396571 --- /dev/null +++ b/cloakbrowser/human/actionability.py @@ -0,0 +1,354 @@ +"""Playwright-style actionability checks for the humanize layer (sync). + +Checks: attached, visible, stable, enabled, editable, receives pointer events. +Retry loop with backoff matching Playwright internals: [100, 250, 500, 1000]ms. +""" + +from __future__ import annotations + +import json +import logging +import time +from typing import Any, FrozenSet, Optional, Tuple + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Error hierarchy — all subclass RuntimeError for backward compat +# --------------------------------------------------------------------------- + +class ActionabilityError(RuntimeError): + """Base for all actionability failures.""" + + def __init__(self, selector: str, check: str, message: str): + self.selector = selector + self.check = check + super().__init__(f"Element {selector!r} failed {check} check: {message}") + + +class ElementNotAttachedError(ActionabilityError): + def __init__(self, selector: str): + super().__init__(selector, "attached", "element not found in DOM") + + +class ElementNotVisibleError(ActionabilityError): + def __init__(self, selector: str): + super().__init__(selector, "visible", "element is not visible") + + +class ElementNotStableError(ActionabilityError): + def __init__(self, selector: str): + super().__init__(selector, "stable", "element position is still changing") + + +class ElementNotEnabledError(ActionabilityError): + def __init__(self, selector: str): + super().__init__(selector, "enabled", "element is disabled") + + +class ElementNotEditableError(ActionabilityError): + def __init__(self, selector: str): + super().__init__(selector, "editable", "element is not editable") + + +class ElementNotReceivingEventsError(ActionabilityError): + def __init__(self, selector: str, covering_tag: str = "unknown"): + super().__init__( + selector, + "pointer_events", + f"element is covered by <{covering_tag}>", + ) + + +# --------------------------------------------------------------------------- +# Check-set constants +# --------------------------------------------------------------------------- + +CHECKS_CLICK: FrozenSet[str] = frozenset({"attached", "visible", "enabled", "pointer_events"}) +CHECKS_HOVER: FrozenSet[str] = frozenset({"attached", "visible", "pointer_events"}) +CHECKS_INPUT: FrozenSet[str] = frozenset({"attached", "visible", "enabled", "editable", "pointer_events"}) +CHECKS_FOCUS: FrozenSet[str] = frozenset({"attached", "visible", "enabled"}) +CHECKS_CHECK: FrozenSet[str] = frozenset({"attached", "visible", "enabled", "pointer_events"}) + +_BACKOFF_MS = [100, 250, 500, 1000] + + +def _backoff_sleep(attempt: int) -> None: + idx = min(attempt, len(_BACKOFF_MS) - 1) + time.sleep(_BACKOFF_MS[idx] / 1000.0) + + +# --------------------------------------------------------------------------- +# Pre-scroll actionability: attached, visible, enabled, editable +# --------------------------------------------------------------------------- + +def ensure_actionable( + page: Any, + selector: str, + checks: FrozenSet[str], + timeout: float = 30000, + force: bool = False, +) -> None: + """Wait for element to pass actionability checks (pre-scroll). + + Retries with backoff until *timeout* ms elapsed. + Raises a specific ``ActionabilityError`` subclass on failure. + If *force* is True, returns immediately. + """ + if force: + return + + deadline = time.monotonic() + timeout / 1000.0 + attempt = 0 + last_error: Optional[ActionabilityError] = None + + while True: + remaining_ms = max(0, (deadline - time.monotonic()) * 1000) + if remaining_ms <= 0: + if last_error is not None: + raise last_error + raise ActionabilityError(selector, "timeout", "timeout expired before first check") + + try: + loc = page.locator(selector).first + + if "attached" in checks: + try: + loc.wait_for(state="attached", timeout=max(1, min(remaining_ms, 2000))) + except Exception: + raise ElementNotAttachedError(selector) + + if "visible" in checks: + if not loc.is_visible(): + raise ElementNotVisibleError(selector) + + if "enabled" in checks: + if not loc.is_enabled(): + raise ElementNotEnabledError(selector) + + if "editable" in checks: + if not loc.is_editable(): + raise ElementNotEditableError(selector) + + return + + except ActionabilityError as e: + last_error = e + if time.monotonic() >= deadline: + raise last_error + _backoff_sleep(attempt) + attempt += 1 + + +# --------------------------------------------------------------------------- +# Post-scroll stability check +# --------------------------------------------------------------------------- + +def _boxes_differ(a: dict, b: dict) -> bool: + return ( + abs(a["x"] - b["x"]) > 1 + or abs(a["y"] - b["y"]) > 1 + or abs(a["width"] - b["width"]) > 1 + or abs(a["height"] - b["height"]) > 1 + ) + + +def ensure_stable( + page: Any, + selector: str, + timeout: float = 5000, +) -> None: + """Wait for element position to stabilize (two samples 100ms apart). + + Only call after scroll — skip if element was already in viewport. + """ + deadline = time.monotonic() + timeout / 1000.0 + attempt = 0 + + while True: + remaining_ms = max(0, (deadline - time.monotonic()) * 1000) + if remaining_ms <= 0: + raise ElementNotStableError(selector) + + loc = page.locator(selector).first + box1 = loc.bounding_box(timeout=max(1, min(remaining_ms, 1000))) + if box1 is None: + raise ElementNotAttachedError(selector) + + time.sleep(0.1) + + box2 = loc.bounding_box(timeout=max(1, min(remaining_ms, 1000))) + if box2 is None: + raise ElementNotAttachedError(selector) + + if not _boxes_differ(box1, box2): + return + + if time.monotonic() >= deadline: + raise ElementNotStableError(selector) + + _backoff_sleep(attempt) + attempt += 1 + + +# --------------------------------------------------------------------------- +# Pointer-events check (post-scroll, at actual click coordinates) +# --------------------------------------------------------------------------- + +# data.box is page-space (from bounding_box); rect is frame-local. Their delta +# is the iframe offset, needed to map page-space click coords into the frame's +# own viewport before elementFromPoint. For main-frame elements the offset is 0. +_POINTER_EVENTS_LOCATOR_JS = """(expected, data) => { + const rect = expected.getBoundingClientRect(); + const frameOffsetX = data.box ? data.box.x - rect.x : 0; + const frameOffsetY = data.box ? data.box.y - rect.y : 0; + const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY); + if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' }; + let node = target; + while (node) { if (node === expected) return { hit: true }; node = node.parentNode; } + if (expected.contains(target)) return { hit: true }; + return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' }; +}""" + +_POINTER_EVENTS_HANDLE_JS = """(expected, data) => { + const rect = expected.getBoundingClientRect(); + const frameOffsetX = data.box ? data.box.x - rect.x : 0; + const frameOffsetY = data.box ? data.box.y - rect.y : 0; + const target = document.elementFromPoint(data.x - frameOffsetX, data.y - frameOffsetY); + if (!target) return { hit: false, reason: 'no_element_at_point', covering: 'none' }; + let node = target; + while (node) { if (node === expected) return { hit: true }; node = node.parentNode; } + if (expected.contains(target)) return { hit: true }; + return { hit: false, reason: 'covered', covering: target.tagName || 'unknown' }; +}""" + + +def check_pointer_events( + page: Any, + selector: str, + x: float, + y: float, + stealth: Any = None, + timeout: float = 5000, +) -> None: + """Check that elementFromPoint(x, y) hits the expected element. + + Uses locator.evaluate() so all Playwright selector types work + (text=, role=, XPath, CSS, etc.). Retries with backoff for transient overlays. + """ + deadline = time.monotonic() + timeout / 1000.0 + attempt = 0 + + while True: + try: + loc = page.locator(selector).first + box = loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000))) + result = loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box}) + except Exception as exc: + logger.debug("pointer_events check failed for %r: %s", selector, exc) + result = None + + # Proceed if the check confirms a hit, or if it could not be determined + # (None) — failing closed would block legitimate clicks. + if result is None or result.get("hit", False): + return + + covering = (result or {}).get("covering", "unknown") + + if time.monotonic() >= deadline: + raise ElementNotReceivingEventsError(selector, covering) + + _backoff_sleep(attempt) + attempt += 1 + + +# --------------------------------------------------------------------------- +# ElementHandle variant +# --------------------------------------------------------------------------- + +def ensure_actionable_handle( + page: Any, + el: Any, + checks: FrozenSet[str], + timeout: float = 30000, + force: bool = False, +) -> None: + """Actionability checks for ElementHandle (no selector needed). + + Uses Playwright's wait_for_element_state where available. + """ + if force: + return + + deadline = time.monotonic() + timeout / 1000.0 + attempt = 0 + last_error: Optional[ActionabilityError] = None + label = "" + + while True: + remaining_ms = max(0, (deadline - time.monotonic()) * 1000) + if remaining_ms <= 0: + if last_error is not None: + raise last_error + raise ActionabilityError(label, "timeout", "timeout expired before first check") + + try: + if "visible" in checks: + try: + el.wait_for_element_state("visible", timeout=max(1, min(remaining_ms, 2000))) + except Exception: + raise ElementNotVisibleError(label) + + if "enabled" in checks: + try: + el.wait_for_element_state("enabled", timeout=max(1, min(remaining_ms, 2000))) + except Exception: + raise ElementNotEnabledError(label) + + if "editable" in checks: + try: + el.wait_for_element_state("editable", timeout=max(1, min(remaining_ms, 2000))) + except Exception: + raise ElementNotEditableError(label) + + return + + except ActionabilityError as e: + last_error = e + if time.monotonic() >= deadline: + raise last_error + _backoff_sleep(attempt) + attempt += 1 + + +def check_pointer_events_handle( + page: Any, + el: Any, + x: float, + y: float, + timeout: float = 5000, +) -> None: + """Pointer-events check for ElementHandle.""" + deadline = time.monotonic() + timeout / 1000.0 + attempt = 0 + + while True: + try: + box = el.bounding_box() + result = el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box}) + except Exception: + result = None + + # Proceed if the check confirms a hit, or if it could not be determined + # (None) — failing closed would block legitimate clicks. + if result is None or result.get("hit", False): + return + + covering = (result or {}).get("covering", "unknown") + + if time.monotonic() >= deadline: + raise ElementNotReceivingEventsError("", covering) + + _backoff_sleep(attempt) + attempt += 1 diff --git a/cloakbrowser/human/actionability_async.py b/cloakbrowser/human/actionability_async.py new file mode 100644 index 0000000..5ec5100 --- /dev/null +++ b/cloakbrowser/human/actionability_async.py @@ -0,0 +1,250 @@ +"""Playwright-style actionability checks for the humanize layer (async). + +Async mirror of actionability.py — same logic, uses asyncio.sleep and await. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Any, FrozenSet, Optional + +logger = logging.getLogger(__name__) + +from .actionability import ( + ActionabilityError, + ElementNotAttachedError, + ElementNotVisibleError, + ElementNotStableError, + ElementNotEnabledError, + ElementNotEditableError, + ElementNotReceivingEventsError, + _BACKOFF_MS, + _boxes_differ, + _POINTER_EVENTS_LOCATOR_JS, + _POINTER_EVENTS_HANDLE_JS, +) + + +async def _async_backoff_sleep(attempt: int) -> None: + idx = min(attempt, len(_BACKOFF_MS) - 1) + await asyncio.sleep(_BACKOFF_MS[idx] / 1000.0) + + +# --------------------------------------------------------------------------- +# Pre-scroll actionability +# --------------------------------------------------------------------------- + +async def async_ensure_actionable( + page: Any, + selector: str, + checks: FrozenSet[str], + timeout: float = 30000, + force: bool = False, +) -> None: + if force: + return + + deadline = time.monotonic() + timeout / 1000.0 + attempt = 0 + last_error: Optional[ActionabilityError] = None + + while True: + remaining_ms = max(0, (deadline - time.monotonic()) * 1000) + if remaining_ms <= 0: + if last_error is not None: + raise last_error + raise ActionabilityError(selector, "timeout", "timeout expired before first check") + + try: + loc = page.locator(selector).first + + if "attached" in checks: + try: + await loc.wait_for(state="attached", timeout=max(1, min(remaining_ms, 2000))) + except Exception: + raise ElementNotAttachedError(selector) + + if "visible" in checks: + if not await loc.is_visible(): + raise ElementNotVisibleError(selector) + + if "enabled" in checks: + if not await loc.is_enabled(): + raise ElementNotEnabledError(selector) + + if "editable" in checks: + if not await loc.is_editable(): + raise ElementNotEditableError(selector) + + return + + except ActionabilityError as e: + last_error = e + if time.monotonic() >= deadline: + raise last_error + await _async_backoff_sleep(attempt) + attempt += 1 + + +# --------------------------------------------------------------------------- +# Post-scroll stability check +# --------------------------------------------------------------------------- + +async def async_ensure_stable( + page: Any, + selector: str, + timeout: float = 5000, +) -> None: + deadline = time.monotonic() + timeout / 1000.0 + attempt = 0 + + while True: + remaining_ms = max(0, (deadline - time.monotonic()) * 1000) + if remaining_ms <= 0: + raise ElementNotStableError(selector) + + loc = page.locator(selector).first + box1 = await loc.bounding_box(timeout=max(1, min(remaining_ms, 1000))) + if box1 is None: + raise ElementNotAttachedError(selector) + + await asyncio.sleep(0.1) + + box2 = await loc.bounding_box(timeout=max(1, min(remaining_ms, 1000))) + if box2 is None: + raise ElementNotAttachedError(selector) + + if not _boxes_differ(box1, box2): + return + + if time.monotonic() >= deadline: + raise ElementNotStableError(selector) + + await _async_backoff_sleep(attempt) + attempt += 1 + + +# --------------------------------------------------------------------------- +# Pointer-events check +# --------------------------------------------------------------------------- + +async def async_check_pointer_events( + page: Any, + selector: str, + x: float, + y: float, + stealth: Any = None, + timeout: float = 5000, +) -> None: + deadline = time.monotonic() + timeout / 1000.0 + attempt = 0 + + while True: + try: + loc = page.locator(selector).first + box = await loc.bounding_box(timeout=max(1, min((deadline - time.monotonic()) * 1000, 1000))) + result = await loc.evaluate(_POINTER_EVENTS_LOCATOR_JS, {"x": x, "y": y, "box": box}) + except Exception as exc: + logger.debug("pointer_events check failed for %r: %s", selector, exc) + result = None + + # Proceed if the check confirms a hit, or if it could not be determined + # (None) — failing closed would block legitimate clicks. + if result is None or result.get("hit", False): + return + + covering = (result or {}).get("covering", "unknown") + + if time.monotonic() >= deadline: + raise ElementNotReceivingEventsError(selector, covering) + + await _async_backoff_sleep(attempt) + attempt += 1 + + +# --------------------------------------------------------------------------- +# ElementHandle variant +# --------------------------------------------------------------------------- + +async def async_ensure_actionable_handle( + page: Any, + el: Any, + checks: FrozenSet[str], + timeout: float = 30000, + force: bool = False, +) -> None: + if force: + return + + deadline = time.monotonic() + timeout / 1000.0 + attempt = 0 + last_error: Optional[ActionabilityError] = None + label = "" + + while True: + remaining_ms = max(0, (deadline - time.monotonic()) * 1000) + if remaining_ms <= 0: + if last_error is not None: + raise last_error + raise ActionabilityError(label, "timeout", "timeout expired before first check") + + try: + if "visible" in checks: + try: + await el.wait_for_element_state("visible", timeout=max(1, min(remaining_ms, 2000))) + except Exception: + raise ElementNotVisibleError(label) + + if "enabled" in checks: + try: + await el.wait_for_element_state("enabled", timeout=max(1, min(remaining_ms, 2000))) + except Exception: + raise ElementNotEnabledError(label) + + if "editable" in checks: + try: + await el.wait_for_element_state("editable", timeout=max(1, min(remaining_ms, 2000))) + except Exception: + raise ElementNotEditableError(label) + + return + + except ActionabilityError as e: + last_error = e + if time.monotonic() >= deadline: + raise last_error + await _async_backoff_sleep(attempt) + attempt += 1 + + +async def async_check_pointer_events_handle( + page: Any, + el: Any, + x: float, + y: float, + timeout: float = 5000, +) -> None: + deadline = time.monotonic() + timeout / 1000.0 + attempt = 0 + + while True: + try: + box = await el.bounding_box() + result = await el.evaluate(_POINTER_EVENTS_HANDLE_JS, {"x": x, "y": y, "box": box}) + except Exception: + result = None + + # Proceed if the check confirms a hit, or if it could not be determined + # (None) — failing closed would block legitimate clicks. + if result is None or result.get("hit", False): + return + + covering = (result or {}).get("covering", "unknown") + + if time.monotonic() >= deadline: + raise ElementNotReceivingEventsError("", covering) + + await _async_backoff_sleep(attempt) + attempt += 1 diff --git a/cloakbrowser/human/config.py b/cloakbrowser/human/config.py new file mode 100644 index 0000000..e9a5494 --- /dev/null +++ b/cloakbrowser/human/config.py @@ -0,0 +1,257 @@ +"""cloakbrowser-human — Configuration and presets. + +All numeric parameters for human-like behavior are centralized here. +Two built-in presets: 'default' (normal human speed) and 'careful' (slower, more cautious). +""" + +from __future__ import annotations + +import math +import random +import time +from dataclasses import dataclass, field +from typing import Literal, Tuple, TypedDict + +# --------------------------------------------------------------------------- +# Type alias +# --------------------------------------------------------------------------- + +Range = Tuple[float, float] +HumanPreset = Literal["default", "careful"] + + +class HumanConfigOverrides(TypedDict, total=False): + typing_delay: float + typing_delay_spread: float + typing_pause_chance: float + typing_pause_range: Range + shift_down_delay: Range + shift_up_delay: Range + key_hold: Range + field_switch_delay: Range + mistype_chance: float + mistype_delay_notice: Range + mistype_delay_correct: Range + mouse_steps_divisor: float + mouse_min_steps: int + mouse_max_steps: int + mouse_wobble_max: float + mouse_overshoot_chance: float + mouse_overshoot_px: Range + mouse_burst_size: Range + mouse_burst_pause: Range + click_aim_delay_input: Range + click_aim_delay_button: Range + click_hold_input: Range + click_hold_button: Range + click_input_x_range: Range + idle_drift_px: float + idle_pause_range: Range + scroll_delta_base: Range + scroll_delta_variance: float + scroll_pause_fast: Range + scroll_pause_slow: Range + scroll_accel_steps: Range + scroll_decel_steps: Range + scroll_overshoot_chance: float + scroll_overshoot_px: Range + scroll_settle_delay: Range + scroll_target_zone: Range + scroll_pre_move_delay: Range + initial_cursor_x: Range + initial_cursor_y: Range + idle_between_actions: bool + idle_between_duration: Range + + +# --------------------------------------------------------------------------- +# Configuration dataclass +# --------------------------------------------------------------------------- + +@dataclass +class HumanConfig: + """All tunable parameters for human-like behavior.""" + + # Keyboard + typing_delay: float = 70 + typing_delay_spread: float = 40 + typing_pause_chance: float = 0.1 + typing_pause_range: Range = (400, 1000) + shift_down_delay: Range = (30, 70) + shift_up_delay: Range = (20, 50) + key_hold: Range = (15, 35) + + # Mistype (typo simulation) + mistype_chance: float = 0.02 + mistype_delay_notice: Range = (100, 300) + mistype_delay_correct: Range = (50, 150) + + field_switch_delay: Range = (800, 1500) + + # Mouse — movement + mouse_steps_divisor: float = 8 + mouse_min_steps: int = 25 + mouse_max_steps: int = 80 + mouse_wobble_max: float = 1.5 + mouse_overshoot_chance: float = 0.15 + mouse_overshoot_px: Range = (3, 6) + mouse_burst_size: Range = (3, 5) + mouse_burst_pause: Range = (8, 18) + + # Mouse — clicks + click_aim_delay_input: Range = (60, 140) + click_aim_delay_button: Range = (80, 200) + click_hold_input: Range = (40, 100) + click_hold_button: Range = (60, 150) + click_input_x_range: Range = (0.05, 0.30) + + # Mouse — idle + idle_drift_px: float = 3 + idle_pause_range: Range = (300, 1000) + + # Scroll + scroll_delta_base: Range = (80, 130) + scroll_delta_variance: float = 0.2 + scroll_pause_fast: Range = (30, 80) + scroll_pause_slow: Range = (80, 200) + scroll_accel_steps: Range = (2, 3) + scroll_decel_steps: Range = (2, 3) + scroll_overshoot_chance: float = 0.1 + scroll_overshoot_px: Range = (50, 150) + scroll_settle_delay: Range = (300, 600) + scroll_target_zone: Range = (0.20, 0.80) + scroll_pre_move_delay: Range = (100, 300) + + # Initial cursor position (as if coming from the address bar area) + initial_cursor_x: Range = (400, 700) + initial_cursor_y: Range = (45, 60) + + # Idle micro-movements between actions (opt-in, adds latency) + idle_between_actions: bool = False + idle_between_duration: Range = (0.3, 0.8) + + +# --------------------------------------------------------------------------- +# Presets +# --------------------------------------------------------------------------- + +def _careful_config() -> HumanConfig: + """Careful preset — everything slower and more deliberate.""" + return HumanConfig( + # Keyboard — slower typing + typing_delay=100, + typing_delay_spread=50, + typing_pause_chance=0.15, + typing_pause_range=(500, 1200), + shift_down_delay=(40, 90), + shift_up_delay=(30, 70), + key_hold=(20, 45), + field_switch_delay=(1000, 2000), + # Mouse — slower, more precise + mouse_overshoot_chance=0.10, + mouse_burst_pause=(12, 25), + # Mouse — clicks (longer aiming and holding) + click_aim_delay_input=(80, 180), + click_aim_delay_button=(120, 280), + click_hold_input=(60, 140), + click_hold_button=(80, 200), + # Scroll — slower + scroll_pause_fast=(100, 200), + scroll_pause_slow=(250, 600), + scroll_settle_delay=(400, 800), + scroll_pre_move_delay=(150, 400), + # Idle between actions enabled for careful preset + idle_between_actions=True, + idle_between_duration=(0.4, 1.0), + ) + + +_PRESETS: dict[str, HumanConfig] = { + "default": HumanConfig(), + "careful": _careful_config(), +} + + +def resolve_config( + preset: HumanPreset = "default", + overrides: HumanConfigOverrides | None = None, +) -> HumanConfig: + """Resolve a preset name + optional overrides into a full HumanConfig. + + Args: + preset: 'default' or 'careful'. + overrides: Typed mapping of HumanConfig field names to override values. + + Returns: + A new HumanConfig instance. + + Raises: + ValueError: If preset is not a recognized name. + """ + if preset not in _PRESETS: + raise ValueError( + f"Unknown humanize preset {preset!r}. " + f"Valid presets: {', '.join(sorted(_PRESETS.keys()))}" + ) + base = _PRESETS[preset] + if not overrides: + return HumanConfig(**{k: getattr(base, k) for k in base.__dataclass_fields__}) + merged = {k: getattr(base, k) for k in base.__dataclass_fields__} + merged.update(overrides) + return HumanConfig(**merged) + + +def merge_config(base: HumanConfig, overrides: dict | None) -> HumanConfig: + """Merge ``overrides`` (a dict of HumanConfig field names → values) on top of + ``base``. Returns a new HumanConfig — ``base`` is never mutated. + + Used by per-call overrides like ``page.type(sel, text, human_config={...})`` + so the same page can use different timings for different inputs without + re-patching. + + Unknown keys are ignored silently to keep this forgiving for callers. + """ + if not overrides: + return base + merged = {k: getattr(base, k) for k in base.__dataclass_fields__} + for k, v in overrides.items(): + if k in base.__dataclass_fields__: + merged[k] = v + return HumanConfig(**merged) + + +# --------------------------------------------------------------------------- +# Utility functions +# --------------------------------------------------------------------------- + +def rand(lo: float, hi: float) -> float: + """Random float in [lo, hi].""" + return random.uniform(lo, hi) + + +def rand_int(lo: int, hi: int) -> int: + """Random integer in [lo, hi] inclusive.""" + return random.randint(lo, hi) + + +def rand_range(r: Range) -> float: + """Random float from a (min, max) tuple.""" + return random.uniform(r[0], r[1]) + + +def rand_int_range(r: Range) -> int: + """Random integer from a (min, max) tuple, inclusive.""" + return random.randint(int(r[0]), int(r[1])) + + +def sleep_ms(ms: float) -> None: + """Sleep for `ms` milliseconds.""" + if ms > 0: + time.sleep(ms / 1000.0) + + +async def async_sleep_ms(ms: float) -> None: + """Async sleep for `ms` milliseconds.""" + if ms > 0: + import asyncio + await asyncio.sleep(ms / 1000.0) diff --git a/cloakbrowser/human/keyboard.py b/cloakbrowser/human/keyboard.py new file mode 100644 index 0000000..5481f54 --- /dev/null +++ b/cloakbrowser/human/keyboard.py @@ -0,0 +1,189 @@ +"""cloakbrowser-human — Human-like keyboard input. + +Stealth-aware: when a CDP session is provided, shift symbols are typed +via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace). +Falls back to page.evaluate when no CDP session is available. +""" + +from __future__ import annotations + +import random +from typing import Any, Optional, Protocol + +from .config import HumanConfig, rand, rand_range, sleep_ms + + +class RawKeyboard(Protocol): + def down(self, key: str) -> None: ... + def up(self, key: str) -> None: ... + def type(self, text: str) -> None: ... + def insert_text(self, text: str) -> None: ... + + +SHIFT_SYMBOLS = frozenset('@#!$%^&*()_+{}|:"<>?~') + +NEARBY_KEYS = { + 'a': 'sqwz', 'b': 'vghn', 'c': 'xdfv', 'd': 'sfecx', 'e': 'wrsdf', + 'f': 'dgrtcv', 'g': 'fhtyb', 'h': 'gjybn', 'i': 'ujko', 'j': 'hkunm', + 'k': 'jloi', 'l': 'kop', 'm': 'njk', 'n': 'bhjm', 'o': 'iklp', + 'p': 'ol', 'q': 'wa', 'r': 'edft', 's': 'awedxz', 't': 'rfgy', + 'u': 'yhji', 'v': 'cfgb', 'w': 'qase', 'x': 'zsdc', 'y': 'tghu', + 'z': 'asx', + '1': '2q', '2': '13qw', '3': '24we', '4': '35er', '5': '46rt', + '6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p', +} + +# CDP key code for each shift symbol's physical key. +_SHIFT_SYMBOL_CODES: dict[str, str] = { + '!': 'Digit1', '@': 'Digit2', '#': 'Digit3', '$': 'Digit4', + '%': 'Digit5', '^': 'Digit6', '&': 'Digit7', '*': 'Digit8', + '(': 'Digit9', ')': 'Digit0', '_': 'Minus', '+': 'Equal', + '{': 'BracketLeft', '}': 'BracketRight', '|': 'Backslash', + ':': 'Semicolon', '"': 'Quote', '<': 'Comma', '>': 'Period', + '?': 'Slash', '~': 'Backquote', +} + +# Windows virtual key codes for Input.dispatchKeyEvent. +_SHIFT_SYMBOL_KEYCODES: dict[str, int] = { + '!': 49, '@': 50, '#': 51, '$': 52, '%': 53, + '^': 54, '&': 55, '*': 56, '(': 57, ')': 48, + '_': 189, '+': 187, '{': 219, '}': 221, '|': 220, + ':': 186, '"': 222, '<': 188, '>': 190, '?': 191, + '~': 192, +} + + +def _get_nearby_key(ch: str) -> str: + """Return a random adjacent key for the given character.""" + lower = ch.lower() + if lower in NEARBY_KEYS: + neighbors = NEARBY_KEYS[lower] + wrong = random.choice(neighbors) + return wrong.upper() if ch.isupper() else wrong + return ch + + +def human_type( + page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig, + cdp_session: Any = None, +) -> None: + """Type text with human-like per-character timing. + + Args: + cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent + producing isTrusted=true events with no evaluate stack trace. + If None, falls back to page.evaluate (detectable). + """ + for i, ch in enumerate(text): + # Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText + if not ch.isascii(): + sleep_ms(rand_range(cfg.key_hold)) + raw.insert_text(ch) + if i < len(text) - 1: + _inter_char_delay(cfg) + continue + + # Mistype chance — only for ASCII alphanumeric + if random.random() < cfg.mistype_chance and ch.isalnum(): + wrong = _get_nearby_key(ch) + _type_normal_char(raw, wrong, cfg) + sleep_ms(rand_range(cfg.mistype_delay_notice)) + raw.down("Backspace") + sleep_ms(rand_range(cfg.key_hold)) + raw.up("Backspace") + sleep_ms(rand_range(cfg.mistype_delay_correct)) + + if ch.isupper() and ch.isalpha(): + _type_shifted_char(page, raw, ch, cfg) + elif ch in SHIFT_SYMBOLS: + _type_shift_symbol(page, raw, ch, cfg, cdp_session) + else: + _type_normal_char(raw, ch, cfg) + + if i < len(text) - 1: + _inter_char_delay(cfg) + + +def _type_normal_char(raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None: + raw.down(ch) + sleep_ms(rand_range(cfg.key_hold)) + raw.up(ch) + + +def _type_shifted_char(page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig) -> None: + raw.down("Shift") + sleep_ms(rand_range(cfg.shift_down_delay)) + raw.down(ch) + sleep_ms(rand_range(cfg.key_hold)) + raw.up(ch) + sleep_ms(rand_range(cfg.shift_up_delay)) + raw.up("Shift") + + +def _type_shift_symbol( + page: Any, raw: RawKeyboard, ch: str, cfg: HumanConfig, + cdp_session: Any = None, +) -> None: + """Type a shift symbol character. + + Stealth path (cdp_session provided): + Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack. + + Fallback path (no cdp_session): + Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent. + Detectable via isTrusted=false and evaluate stack frame. + """ + if cdp_session is not None: + # --- Stealth path: CDP Input.dispatchKeyEvent --- + code = _SHIFT_SYMBOL_CODES.get(ch, '') + key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0) + + raw.down("Shift") + sleep_ms(rand_range(cfg.shift_down_delay)) + + cdp_session.send("Input.dispatchKeyEvent", { + "type": "keyDown", + "modifiers": 8, # Shift modifier flag + "key": ch, + "code": code, + "windowsVirtualKeyCode": key_code, + "text": ch, + "unmodifiedText": ch, + }) + sleep_ms(rand_range(cfg.key_hold)) + + cdp_session.send("Input.dispatchKeyEvent", { + "type": "keyUp", + "modifiers": 8, + "key": ch, + "code": code, + "windowsVirtualKeyCode": key_code, + }) + + sleep_ms(rand_range(cfg.shift_up_delay)) + raw.up("Shift") + else: + # --- Fallback path: page.evaluate (detectable) --- + raw.down("Shift") + sleep_ms(rand_range(cfg.shift_down_delay)) + raw.insert_text(ch) + page.evaluate( + """(key) => { + const el = document.activeElement; + if (el) { + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true })); + } + }""", + ch, + ) + sleep_ms(rand_range(cfg.shift_up_delay)) + raw.up("Shift") + + +def _inter_char_delay(cfg: HumanConfig) -> None: + if random.random() < cfg.typing_pause_chance: + sleep_ms(rand_range(cfg.typing_pause_range)) + else: + delay = cfg.typing_delay + (random.random() - 0.5) * 2 * cfg.typing_delay_spread + sleep_ms(max(10, delay)) diff --git a/cloakbrowser/human/keyboard_async.py b/cloakbrowser/human/keyboard_async.py new file mode 100644 index 0000000..0854925 --- /dev/null +++ b/cloakbrowser/human/keyboard_async.py @@ -0,0 +1,150 @@ +"""cloakbrowser-human — Async human-like keyboard input. + +Mirrors keyboard.py but uses ``await`` for all Playwright calls and +``async_sleep_ms`` instead of ``sleep_ms``. + +Stealth-aware: when a CDP session is provided, shift symbols are typed +via CDP Input.dispatchKeyEvent (isTrusted=true, no evaluate stack trace). +""" + +from __future__ import annotations + +import random +from typing import Any, Optional, Protocol + +from .config import HumanConfig, rand, rand_range, async_sleep_ms +from .keyboard import SHIFT_SYMBOLS, NEARBY_KEYS, _get_nearby_key +from .keyboard import _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES + + +class AsyncRawKeyboard(Protocol): + async def down(self, key: str) -> None: ... + async def up(self, key: str) -> None: ... + async def type(self, text: str) -> None: ... + async def insert_text(self, text: str) -> None: ... + + +async def async_human_type( + page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig, + cdp_session: Any = None, +) -> None: + """Type text with human-like per-character timing (async). + + Args: + cdp_session: If provided, shift symbols use CDP Input.dispatchKeyEvent + producing isTrusted=true events with no evaluate stack trace. + If None, falls back to page.evaluate (detectable). + """ + for i, ch in enumerate(text): + # Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText + if not ch.isascii(): + await async_sleep_ms(rand_range(cfg.key_hold)) + await raw.insert_text(ch) + if i < len(text) - 1: + await _inter_char_delay(cfg) + continue + + # Mistype chance — only for ASCII alphanumeric + if random.random() < cfg.mistype_chance and ch.isalnum(): + wrong = _get_nearby_key(ch) + await _type_normal_char(raw, wrong, cfg) + await async_sleep_ms(rand_range(cfg.mistype_delay_notice)) + await raw.down("Backspace") + await async_sleep_ms(rand_range(cfg.key_hold)) + await raw.up("Backspace") + await async_sleep_ms(rand_range(cfg.mistype_delay_correct)) + + if ch.isupper() and ch.isalpha(): + await _type_shifted_char(page, raw, ch, cfg) + elif ch in SHIFT_SYMBOLS: + await _type_shift_symbol(page, raw, ch, cfg, cdp_session) + else: + await _type_normal_char(raw, ch, cfg) + + if i < len(text) - 1: + await _inter_char_delay(cfg) + + +async def _type_normal_char(raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None: + await raw.down(ch) + await async_sleep_ms(rand_range(cfg.key_hold)) + await raw.up(ch) + + +async def _type_shifted_char(page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig) -> None: + await raw.down("Shift") + await async_sleep_ms(rand_range(cfg.shift_down_delay)) + await raw.down(ch) + await async_sleep_ms(rand_range(cfg.key_hold)) + await raw.up(ch) + await async_sleep_ms(rand_range(cfg.shift_up_delay)) + await raw.up("Shift") + + +async def _type_shift_symbol( + page: Any, raw: AsyncRawKeyboard, ch: str, cfg: HumanConfig, + cdp_session: Any = None, +) -> None: + """Type a shift symbol character (async). + + Stealth path (cdp_session provided): + Uses CDP Input.dispatchKeyEvent → isTrusted=true, clean stack. + + Fallback path (no cdp_session): + Uses raw.insertText + page.evaluate to dispatch synthetic KeyboardEvent. + Detectable via isTrusted=false and evaluate stack frame. + """ + if cdp_session is not None: + # --- Stealth path: CDP Input.dispatchKeyEvent --- + code = _SHIFT_SYMBOL_CODES.get(ch, '') + key_code = _SHIFT_SYMBOL_KEYCODES.get(ch, 0) + + await raw.down("Shift") + await async_sleep_ms(rand_range(cfg.shift_down_delay)) + + await cdp_session.send("Input.dispatchKeyEvent", { + "type": "keyDown", + "modifiers": 8, # Shift modifier flag + "key": ch, + "code": code, + "windowsVirtualKeyCode": key_code, + "text": ch, + "unmodifiedText": ch, + }) + await async_sleep_ms(rand_range(cfg.key_hold)) + + await cdp_session.send("Input.dispatchKeyEvent", { + "type": "keyUp", + "modifiers": 8, + "key": ch, + "code": code, + "windowsVirtualKeyCode": key_code, + }) + + await async_sleep_ms(rand_range(cfg.shift_up_delay)) + await raw.up("Shift") + else: + # --- Fallback path: page.evaluate (detectable) --- + await raw.down("Shift") + await async_sleep_ms(rand_range(cfg.shift_down_delay)) + await raw.insert_text(ch) + await page.evaluate( + """(key) => { + const el = document.activeElement; + if (el) { + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true })); + } + }""", + ch, + ) + await async_sleep_ms(rand_range(cfg.shift_up_delay)) + await raw.up("Shift") + + +async def _inter_char_delay(cfg: HumanConfig) -> None: + if random.random() < cfg.typing_pause_chance: + await async_sleep_ms(rand_range(cfg.typing_pause_range)) + else: + delay = cfg.typing_delay + (random.random() - 0.5) * 2 * cfg.typing_delay_spread + await async_sleep_ms(max(10, delay)) diff --git a/cloakbrowser/human/mouse.py b/cloakbrowser/human/mouse.py new file mode 100644 index 0000000..c936e65 --- /dev/null +++ b/cloakbrowser/human/mouse.py @@ -0,0 +1,132 @@ +"""cloakbrowser-human — Human-like mouse movement and clicking.""" + +from __future__ import annotations + +import math +import random +from typing import Any, Protocol, Tuple + +from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms + + +class RawMouse(Protocol): + def move(self, x: float, y: float) -> None: ... + def down(self) -> None: ... + def up(self) -> None: ... + def wheel(self, delta_x: float, delta_y: float) -> None: ... + + +class Point: + __slots__ = ("x", "y") + def __init__(self, x: float, y: float): + self.x = x + self.y = y + + +def _ease_in_out(t: float) -> float: + if t < 0.5: + return 4 * t * t * t + return 1 - pow(-2 * t + 2, 3) / 2 + + +def _bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: float) -> Point: + u = 1 - t + uu = u * u + uuu = uu * u + tt = t * t + ttt = tt * t + return Point( + uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x, + uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y, + ) + + +def _random_control_points(start: Point, end: Point) -> Tuple[Point, Point]: + dx = end.x - start.x + dy = end.y - start.y + dist = math.hypot(dx, dy) or 1 + px = -dy / dist + py = dx / dist + bias1 = rand(-0.3, 0.3) * dist + bias2 = rand(-0.3, 0.3) * dist + return ( + Point(start.x + dx * 0.25 + px * bias1, start.y + dy * 0.25 + py * bias1), + Point(start.x + dx * 0.75 + px * bias2, start.y + dy * 0.75 + py * bias2), + ) + + +def human_move( + raw: RawMouse, + start_x: float, start_y: float, + end_x: float, end_y: float, + cfg: HumanConfig, +) -> None: + dist = math.hypot(end_x - start_x, end_y - start_y) + if dist < 1: + return + + steps = max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(dist / cfg.mouse_steps_divisor))) + start = Point(start_x, start_y) + end = Point(end_x, end_y) + cp1, cp2 = _random_control_points(start, end) + + burst_counter = 0 + burst_size = rand_int_range(cfg.mouse_burst_size) + + for i in range(steps + 1): + progress = i / steps + eased_t = _ease_in_out(progress) + pt = _bezier(start, cp1, cp2, end, eased_t) + + wobble_amp = math.sin(math.pi * progress) * cfg.mouse_wobble_max + wx = pt.x + (random.random() - 0.5) * 2 * wobble_amp + wy = pt.y + (random.random() - 0.5) * 2 * wobble_amp + + raw.move(round(wx), round(wy)) + + burst_counter += 1 + if burst_counter >= burst_size and i < steps: + sleep_ms(rand_range(cfg.mouse_burst_pause)) + burst_counter = 0 + + if random.random() < cfg.mouse_overshoot_chance: + overshoot_dist = rand_range(cfg.mouse_overshoot_px) + angle = math.atan2(end_y - start_y, end_x - start_x) + raw.move(round(end_x + math.cos(angle) * overshoot_dist), + round(end_y + math.sin(angle) * overshoot_dist)) + sleep_ms(rand(30, 70)) + raw.move(round(end_x + (random.random() - 0.5) * 4), + round(end_y + (random.random() - 0.5) * 4)) + + +def click_target(box: dict, is_input: bool, cfg: HumanConfig) -> Point: + if is_input: + x_frac = rand_range(cfg.click_input_x_range) + y_frac = rand(0.30, 0.70) + else: + x_frac = rand(0.35, 0.65) + y_frac = rand(0.35, 0.65) + return Point(round(box["x"] + box["width"] * x_frac), + round(box["y"] + box["height"] * y_frac)) + + +def human_click(raw: RawMouse, is_input: bool, cfg: HumanConfig) -> None: + aim_delay = rand_range(cfg.click_aim_delay_input) if is_input else rand_range(cfg.click_aim_delay_button) + sleep_ms(aim_delay) + hold_time = rand_range(cfg.click_hold_input) if is_input else rand_range(cfg.click_hold_button) + raw.down() + sleep_ms(hold_time) + raw.up() + + +def human_idle(raw: RawMouse, seconds: float, cx: float, cy: float, cfg: HumanConfig) -> None: + import time as _time + end_time = _time.monotonic() + seconds + x, y = cx, cy + while _time.monotonic() < end_time: + dx = (random.random() - 0.5) * 2 * cfg.idle_drift_px + dy = (random.random() - 0.5) * 2 * cfg.idle_drift_px + x += dx + y += dy + raw.move(round(x), round(y)) + sleep_ms(rand_range(cfg.idle_pause_range)) diff --git a/cloakbrowser/human/mouse_async.py b/cloakbrowser/human/mouse_async.py new file mode 100644 index 0000000..750d197 --- /dev/null +++ b/cloakbrowser/human/mouse_async.py @@ -0,0 +1,87 @@ +"""cloakbrowser-human — Async human-like mouse movement and clicking. + +Mirrors mouse.py but uses ``await`` for all Playwright calls and +``async_sleep_ms`` instead of ``sleep_ms``. +""" + +from __future__ import annotations + +import math +import random +from typing import Any, Protocol + +from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms +from .mouse import Point, _ease_in_out, _bezier, _random_control_points, click_target # noqa: reuse pure math + + +class AsyncRawMouse(Protocol): + async def move(self, x: float, y: float) -> None: ... + async def down(self) -> None: ... + async def up(self) -> None: ... + async def wheel(self, delta_x: float, delta_y: float) -> None: ... + + +async def async_human_move( + raw: AsyncRawMouse, + start_x: float, start_y: float, + end_x: float, end_y: float, + cfg: HumanConfig, +) -> None: + dist = math.hypot(end_x - start_x, end_y - start_y) + if dist < 1: + return + + steps = max(cfg.mouse_min_steps, min(cfg.mouse_max_steps, round(dist / cfg.mouse_steps_divisor))) + start = Point(start_x, start_y) + end = Point(end_x, end_y) + cp1, cp2 = _random_control_points(start, end) + + burst_counter = 0 + burst_size = rand_int_range(cfg.mouse_burst_size) + + for i in range(steps + 1): + progress = i / steps + eased_t = _ease_in_out(progress) + pt = _bezier(start, cp1, cp2, end, eased_t) + + wobble_amp = math.sin(math.pi * progress) * cfg.mouse_wobble_max + wx = pt.x + (random.random() - 0.5) * 2 * wobble_amp + wy = pt.y + (random.random() - 0.5) * 2 * wobble_amp + + await raw.move(round(wx), round(wy)) + + burst_counter += 1 + if burst_counter >= burst_size and i < steps: + await async_sleep_ms(rand_range(cfg.mouse_burst_pause)) + burst_counter = 0 + + if random.random() < cfg.mouse_overshoot_chance: + overshoot_dist = rand_range(cfg.mouse_overshoot_px) + angle = math.atan2(end_y - start_y, end_x - start_x) + await raw.move(round(end_x + math.cos(angle) * overshoot_dist), + round(end_y + math.sin(angle) * overshoot_dist)) + await async_sleep_ms(rand(30, 70)) + await raw.move(round(end_x + (random.random() - 0.5) * 4), + round(end_y + (random.random() - 0.5) * 4)) + + +async def async_human_click(raw: AsyncRawMouse, is_input: bool, cfg: HumanConfig) -> None: + aim_delay = rand_range(cfg.click_aim_delay_input) if is_input else rand_range(cfg.click_aim_delay_button) + await async_sleep_ms(aim_delay) + hold_time = rand_range(cfg.click_hold_input) if is_input else rand_range(cfg.click_hold_button) + await raw.down() + await async_sleep_ms(hold_time) + await raw.up() + + +async def async_human_idle(raw: AsyncRawMouse, seconds: float, cx: float, cy: float, cfg: HumanConfig) -> None: + import time as _time + end_time = _time.monotonic() + seconds + x, y = cx, cy + while _time.monotonic() < end_time: + dx = (random.random() - 0.5) * 2 * cfg.idle_drift_px + dy = (random.random() - 0.5) * 2 * cfg.idle_drift_px + x += dx + y += dy + await raw.move(round(x), round(y)) + await async_sleep_ms(rand_range(cfg.idle_pause_range)) diff --git a/cloakbrowser/human/scroll.py b/cloakbrowser/human/scroll.py new file mode 100644 index 0000000..1087ea3 --- /dev/null +++ b/cloakbrowser/human/scroll.py @@ -0,0 +1,175 @@ +"""cloakbrowser-human — Human-like scrolling via mouse wheel events.""" + +from __future__ import annotations + +import math +import random +from typing import Any, Callable, Optional, Tuple + +from .config import HumanConfig, rand, rand_range, rand_int_range, sleep_ms +from .mouse import RawMouse, human_move + + +def _is_in_viewport(bounds: dict, viewport_height: int, cfg: HumanConfig) -> bool: + top_edge = bounds["y"] + bottom_edge = bounds["y"] + bounds["height"] + zone_top = viewport_height * cfg.scroll_target_zone[0] + zone_bottom = viewport_height * cfg.scroll_target_zone[1] + return top_edge >= zone_top and bottom_edge <= zone_bottom + + +def _get_element_box(page: Any, selector: str, timeout: float = 30000) -> Optional[dict]: + """Locate ``selector`` and return its bounding box. + + The ``timeout`` is forwarded to Playwright's ``boundingBox(timeout=...)`` + so callers can extend it for slow-loading elements (#172). + """ + try: + el = page.locator(selector).first + return el.bounding_box(timeout=max(1, timeout)) + except Exception: + return None + + +def _smooth_wheel(raw: RawMouse, delta: int, cfg: HumanConfig) -> None: + """Send one logical scroll as a burst of small wheel events (like real inertia).""" + abs_d = abs(delta) + sign = 1 if delta > 0 else -1 + sent = 0 + while sent < abs_d: + step_size = rand(20, 40) + chunk = min(step_size, abs_d - sent) + raw.wheel(0, round(chunk) * sign) + sent += chunk + sleep_ms(rand(8, 20)) + + +def human_scroll_into_view( + page: Any, + raw: RawMouse, + get_box: Callable[[], Optional[dict]], + cursor_x: float, cursor_y: float, + cfg: HumanConfig, +) -> Tuple[dict, float, float, bool]: + """Humanized scrolling that uses an arbitrary ``get_box`` callable + instead of a CSS selector. + + Used both by ``scroll_to_element`` (selector-based) and by + ``ElementHandle.scroll_into_view_if_needed`` / ``Locator.scroll_into_view_if_needed`` + (handle-based) so the same accelerate \u2192 cruise \u2192 decelerate \u2192 overshoot + behavior runs everywhere. + + Returns ``(box, cursor_x, cursor_y, did_scroll)`` \u2014 *did_scroll* is False + when the element was already in the viewport. + """ + viewport = page.viewport_size + if not viewport: + # Headed launches default to no_viewport so the page tracks the real OS + # window; page.viewport_size is then None. Fall back to the live window + # dimensions so humanize works headed (the stealth-relevant mode). + viewport = page.evaluate( + "() => ({ width: window.innerWidth, height: window.innerHeight })" + ) + if not viewport or not viewport.get("height"): + raise RuntimeError("Viewport size not available") + + viewport_height = viewport["height"] + viewport_width = viewport["width"] + + box = get_box() + if box is None: + raise RuntimeError("Element not found while scrolling into view") + + if _is_in_viewport(box, viewport_height, cfg): + return box, cursor_x, cursor_y, False + + # Move cursor into scroll area + scroll_area_x = round(viewport_width * rand(0.3, 0.7)) + scroll_area_y = round(viewport_height * rand(0.3, 0.7)) + human_move(raw, cursor_x, cursor_y, scroll_area_x, scroll_area_y, cfg) + cursor_x = scroll_area_x + cursor_y = scroll_area_y + sleep_ms(rand_range(cfg.scroll_pre_move_delay)) + + # Calculate scroll distance + target_y = viewport_height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]) + element_center = box["y"] + box["height"] / 2 + distance_to_scroll = element_center - target_y + + direction = 1 if distance_to_scroll > 0 else -1 + abs_distance = abs(distance_to_scroll) + avg_delta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2 + total_clicks = max(3, math.ceil(abs_distance / avg_delta)) + accel_steps = rand_int_range(cfg.scroll_accel_steps) + decel_steps = rand_int_range(cfg.scroll_decel_steps) + + # Scroll loop: accelerate → cruise → decelerate + scrolled = 0 + for i in range(total_clicks): + if i < accel_steps: + delta = rand(80, 100) + pause = rand_range(cfg.scroll_pause_slow) + elif i >= total_clicks - decel_steps: + delta = rand(60, 90) + pause = rand_range(cfg.scroll_pause_slow) + else: + delta = rand_range(cfg.scroll_delta_base) + pause = rand_range(cfg.scroll_pause_fast) + + delta *= 1 + (random.random() - 0.5) * 2 * cfg.scroll_delta_variance + delta = round(delta) * direction + + _smooth_wheel(raw, delta, cfg) + scrolled += abs(delta) + sleep_ms(pause) + + # Check visibility every 3 steps + if i % 3 == 2 or i == total_clicks - 1: + box = get_box() + if box and _is_in_viewport(box, viewport_height, cfg): + break + if scrolled >= abs_distance * 1.1: + break + + # Optional overshoot + correction + if random.random() < cfg.scroll_overshoot_chance: + overshoot_px = round(rand_range(cfg.scroll_overshoot_px)) * direction + _smooth_wheel(raw, overshoot_px, cfg) + sleep_ms(rand_range(cfg.scroll_settle_delay)) + corrections = rand_int_range((1, 2)) + for _ in range(corrections): + corr_delta = round(rand(40, 80)) * -direction + _smooth_wheel(raw, corr_delta, cfg) + sleep_ms(rand(100, 250)) + + # Settle + sleep_ms(rand_range(cfg.scroll_settle_delay)) + + box = get_box() + if box is None: + raise RuntimeError("Element lost after scrolling into view") + + return box, cursor_x, cursor_y, True + + +def scroll_to_element( + page: Any, + raw: RawMouse, + selector: str, + cursor_x: float, cursor_y: float, + cfg: HumanConfig, + timeout: float = 30000, +) -> Tuple[dict, float, float, bool]: + """Selector-based humanized scroll. + + ``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers + such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements + (#172). Default matches Playwright's 30000ms when not specified. + + Returns ``(box, cursor_x, cursor_y, did_scroll)``. + """ + return human_scroll_into_view( + page, raw, + lambda: _get_element_box(page, selector, timeout), + cursor_x, cursor_y, cfg, + ) diff --git a/cloakbrowser/human/scroll_async.py b/cloakbrowser/human/scroll_async.py new file mode 100644 index 0000000..7bbb981 --- /dev/null +++ b/cloakbrowser/human/scroll_async.py @@ -0,0 +1,171 @@ +"""cloakbrowser-human — Async human-like scrolling via mouse wheel events. + +Mirrors scroll.py but uses ``await`` for all Playwright calls and +``async_sleep_ms`` instead of ``sleep_ms``. +""" + +from __future__ import annotations + +import math +import random +from typing import Any, Awaitable, Callable, Optional, Tuple + +from .config import HumanConfig, rand, rand_range, rand_int_range, async_sleep_ms +from .mouse_async import AsyncRawMouse, async_human_move +from .scroll import _is_in_viewport + + +async def _get_element_box_async( + page: Any, selector: str, timeout: float = 30000, +) -> Optional[dict]: + """Async variant. ``timeout`` is forwarded to Playwright's + ``boundingBox(timeout=...)`` so callers can extend it for slow-loading + elements (#172).""" + try: + el = page.locator(selector).first + return await el.bounding_box(timeout=max(1, timeout)) + except Exception: + return None + + +async def _async_smooth_wheel(raw: AsyncRawMouse, delta: int, cfg: HumanConfig) -> None: + """Send one logical scroll as a burst of small wheel events (like real inertia).""" + abs_d = abs(delta) + sign = 1 if delta > 0 else -1 + sent = 0 + while sent < abs_d: + step_size = rand(20, 40) + chunk = min(step_size, abs_d - sent) + await raw.wheel(0, round(chunk) * sign) + sent += chunk + await async_sleep_ms(rand(8, 20)) + + +async def async_human_scroll_into_view( + page: Any, + raw: AsyncRawMouse, + get_box: Callable[[], Awaitable[Optional[dict]]], + cursor_x: float, cursor_y: float, + cfg: HumanConfig, +) -> Tuple[dict, float, float, bool]: + """Humanized scrolling using an arbitrary async ``get_box`` callable. + + Used by both ``async_scroll_to_element`` (selector-based) and the + ElementHandle / Locator ``scroll_into_view_if_needed`` patches so all + scrolling paths share the same accelerate \u2192 cruise \u2192 decelerate + \u2192 overshoot behavior. + + Returns ``(box, cursor_x, cursor_y, did_scroll)`` \u2014 *did_scroll* is False + when the element was already in the viewport. + """ + viewport = page.viewport_size + if not viewport: + # Headed launches default to no_viewport so the page tracks the real OS + # window; page.viewport_size is then None. Fall back to the live window + # dimensions so humanize works headed (the stealth-relevant mode). + viewport = await page.evaluate( + "() => ({ width: window.innerWidth, height: window.innerHeight })" + ) + if not viewport or not viewport.get("height"): + raise RuntimeError("Viewport size not available") + + viewport_height = viewport["height"] + viewport_width = viewport["width"] + + box = await get_box() + if box is None: + raise RuntimeError("Element not found while scrolling into view") + + if _is_in_viewport(box, viewport_height, cfg): + return box, cursor_x, cursor_y, False + + # Move cursor into scroll area + scroll_area_x = round(viewport_width * rand(0.3, 0.7)) + scroll_area_y = round(viewport_height * rand(0.3, 0.7)) + await async_human_move(raw, cursor_x, cursor_y, scroll_area_x, scroll_area_y, cfg) + cursor_x = scroll_area_x + cursor_y = scroll_area_y + await async_sleep_ms(rand_range(cfg.scroll_pre_move_delay)) + + # Calculate scroll distance + target_y = viewport_height * rand(cfg.scroll_target_zone[0], cfg.scroll_target_zone[1]) + element_center = box["y"] + box["height"] / 2 + distance_to_scroll = element_center - target_y + + direction = 1 if distance_to_scroll > 0 else -1 + abs_distance = abs(distance_to_scroll) + avg_delta = (cfg.scroll_delta_base[0] + cfg.scroll_delta_base[1]) / 2 + total_clicks = max(3, math.ceil(abs_distance / avg_delta)) + accel_steps = rand_int_range(cfg.scroll_accel_steps) + decel_steps = rand_int_range(cfg.scroll_decel_steps) + + # Scroll loop: accelerate → cruise → decelerate + scrolled = 0 + for i in range(total_clicks): + if i < accel_steps: + delta = rand(80, 100) + pause = rand_range(cfg.scroll_pause_slow) + elif i >= total_clicks - decel_steps: + delta = rand(60, 90) + pause = rand_range(cfg.scroll_pause_slow) + else: + delta = rand_range(cfg.scroll_delta_base) + pause = rand_range(cfg.scroll_pause_fast) + + delta *= 1 + (random.random() - 0.5) * 2 * cfg.scroll_delta_variance + delta = round(delta) * direction + + await _async_smooth_wheel(raw, delta, cfg) + scrolled += abs(delta) + await async_sleep_ms(pause) + + # Check visibility every 3 steps + if i % 3 == 2 or i == total_clicks - 1: + box = await get_box() + if box and _is_in_viewport(box, viewport_height, cfg): + break + if scrolled >= abs_distance * 1.1: + break + + # Optional overshoot + correction + if random.random() < cfg.scroll_overshoot_chance: + overshoot_px = round(rand_range(cfg.scroll_overshoot_px)) * direction + await _async_smooth_wheel(raw, overshoot_px, cfg) + await async_sleep_ms(rand_range(cfg.scroll_settle_delay)) + corrections = rand_int_range((1, 2)) + for _ in range(corrections): + corr_delta = round(rand(40, 80)) * -direction + await _async_smooth_wheel(raw, corr_delta, cfg) + await async_sleep_ms(rand(100, 250)) + + # Settle + await async_sleep_ms(rand_range(cfg.scroll_settle_delay)) + + box = await get_box() + if box is None: + raise RuntimeError("Element lost after scrolling into view") + + return box, cursor_x, cursor_y, True + + +async def async_scroll_to_element( + page: Any, + raw: AsyncRawMouse, + selector: str, + cursor_x: float, cursor_y: float, + cfg: HumanConfig, + timeout: float = 30000, +) -> Tuple[dict, float, float, bool]: + """Selector-based humanized scroll (async). + + ``timeout`` is forwarded to ``locator.bounding_box(timeout=...)`` so callers + such as ``page.click('#x', timeout=5000)`` can wait longer for slow elements + (#172). Default matches Playwright's 30000ms when not specified. + + Returns ``(box, cursor_x, cursor_y, did_scroll)``. + """ + async def _get(): + return await _get_element_box_async(page, selector, timeout) + return await async_human_scroll_into_view( + page, raw, _get, cursor_x, cursor_y, cfg, + ) diff --git a/cloakbrowser/license.py b/cloakbrowser/license.py new file mode 100644 index 0000000..d975b61 --- /dev/null +++ b/cloakbrowser/license.py @@ -0,0 +1,291 @@ +"""License validation and caching for CloakBrowser Pro. + +Handles license key resolution, server validation with local caching, +and Pro version checks. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import time +from dataclasses import dataclass +from pathlib import Path + +import httpx + +from .config import get_cache_dir, get_platform_tag + +logger = logging.getLogger("cloakbrowser") + +VALIDATE_URL = "https://cloakbrowser.dev/api/license/validate" +PRO_VERSION_URL = "https://cloakbrowser.dev/api/download/version" + +LICENSE_CACHE_TTL = 86400 # 24 hours +PRO_VERSION_CHECK_INTERVAL = 3600 # 1 hour + + +@dataclass +class LicenseInfo: + valid: bool + plan: str + expires: str | None + + +_LICENSE_KEY_SOURCE_PARAM = "param" +_LICENSE_KEY_SOURCE_ENV = "env" +_LICENSE_KEY_SOURCE_DEFAULT_FILE = "default_file" +_LICENSE_KEY_SOURCE_CUSTOM_FILE = "custom_file" +_LICENSE_KEY_SOURCE_NONE = "none" + + +def _resolve_license_key_with_source( + license_key: str | None = None, +) -> tuple[str | None, str]: + """Resolve license key with source tracking for env-injection decisions. + + Returns (key, source) where source is one of the _LICENSE_KEY_SOURCE_* + constants. The source tells the caller *how* the key was found so they + can decide whether env injection is needed (e.g. the binary reads the + default file path directly, so env injection is unnecessary). + """ + # 1. Explicit param + if license_key and license_key.strip(): + return (license_key.strip(), _LICENSE_KEY_SOURCE_PARAM) + + # 2. Environment variable + env_key = os.environ.get("CLOAKBROWSER_LICENSE_KEY", "").strip() + if env_key: + return (env_key, _LICENSE_KEY_SOURCE_ENV) + + # 3. File in the wrapper cache dir + cache_dir = get_cache_dir() + key_file = cache_dir / "license.key" + try: + content = key_file.read_text().strip() + if content: + default_cache = Path.home() / ".cloakbrowser" + if cache_dir.resolve() == default_cache.resolve(): + source = _LICENSE_KEY_SOURCE_DEFAULT_FILE + else: + source = _LICENSE_KEY_SOURCE_CUSTOM_FILE + return (content, source) + except OSError: + pass + + return (None, _LICENSE_KEY_SOURCE_NONE) + + +def resolve_license_key(license_key: str | None = None) -> str | None: + """Resolve the license key: explicit param > env var > file > None.""" + key, _ = _resolve_license_key_with_source(license_key) + return key + + +def build_launch_env( + license_key: str | None = None, + user_env: dict[str, str] | None = None, +) -> dict[str, str] | None: + """Build child process env dict with any needed license key injection. + + The Pro binary reads ``CLOAKBROWSER_LICENSE_KEY`` from its own process + environment at startup. This helper merges the resolved key into the + child process env dict **only** when injection is necessary: + + * **param** or **custom_file** source -> inject the key into the child env + (the binary cannot see the wrapper-only key or the custom file path). + * **env** source -> the key is already in ``os.environ``, so the child + inherits it naturally. No injection. + * **default_file** source -> the binary reads ``~/.cloakbrowser/license.key`` + directly, so injection is unnecessary (and keeps the key out of process + env for security) — *unless* the caller passes a custom ``user_env``, + which Playwright uses to replace (not merge) the child env; a replaced + env can drop ``HOME`` and hide the file, so the key is injected then. + * **none** -> no key at all, no injection. + + When *user_env* is provided (e.g. the caller passed ``env=`` via + Playwright kwargs), it is used as the base instead of ``os.environ``, + and the key is injected only when needed. + + Returns ``None`` when no injection is needed and no custom user_env was + given — Playwright treats ``env=None`` as "inherit parent env", which + is correct in those cases. + """ + key, source = _resolve_license_key_with_source(license_key) + + # Normalize the custom env once so every return path behaves identically: + # drop None values (Playwright's env is typed str->str). + base_env = ( + {k: v for k, v in user_env.items() if v is not None} + if user_env is not None + else None + ) + + # Default file: binary reads it directly — no env injection needed, + # UNLESS the caller passes a custom env. Playwright replaces (not merges) + # the child env, which can drop HOME and hide the file from the binary, + # so inject the key too in that case (fall through to the merge below). + if source == _LICENSE_KEY_SOURCE_DEFAULT_FILE and base_env is None: + return None + + # No key at all: pass through the custom env or None. + if source == _LICENSE_KEY_SOURCE_NONE or key is None: + return base_env + + # Env source, no custom user env: child inherits parent env, which + # already has CLOAKBROWSER_LICENSE_KEY. + if source == _LICENSE_KEY_SOURCE_ENV and base_env is None: + return None + + # Build the merged env dict. + merged = dict(base_env) if base_env is not None else dict(os.environ) + + # For param/custom_file this is THE injection into the child env. + # For env source with a custom user_env this ensures the key persists + # through the user's env override (Playwright replaces, not merges). + merged["CLOAKBROWSER_LICENSE_KEY"] = key + + return merged + + +def validate_license(license_key: str) -> LicenseInfo | None: + """Validate a license key with the CloakBrowser server. + + Checks a local file cache first (24h TTL). Falls back to stale + cache if the server is unreachable. + + Returns LicenseInfo if validation succeeded, None on total failure. + """ + cache_path = get_cache_dir() / ".license_cache" + key_sha = hashlib.sha256(license_key.encode()).hexdigest() + + cached = _read_cache(cache_path, key_sha) + if cached: + return cached + + try: + resp = httpx.post( + VALIDATE_URL, + json={"license_key": license_key}, + timeout=10.0, + ) + resp.raise_for_status() + data = resp.json() + + info = LicenseInfo( + valid=data.get("valid", False), + plan=data.get("plan", "solo"), + expires=data.get("expires"), + ) + + if info.valid: + _write_cache(cache_path, key_sha, info) + return info + + except Exception as e: + logger.warning("License validation request failed: %s", e) + + stale = _read_cache(cache_path, key_sha, ignore_ttl=True) + if stale: + logger.warning("Using cached license validation (server unreachable)") + return stale + + return None + + +def get_pro_latest_version() -> str | None: + """Get the latest Pro binary version from the server. + + Rate-limited to 1 call per hour via a marker file. + """ + marker = get_cache_dir() / f".last_pro_version_check_{get_platform_tag()}" + + if marker.exists(): + try: + age = time.time() - marker.stat().st_mtime + if age < PRO_VERSION_CHECK_INTERVAL: + content = marker.read_text().strip() + return content if content else None + except OSError: + pass + + try: + resp = httpx.get( + PRO_VERSION_URL, + headers={"X-Platform": get_platform_tag()}, + timeout=10.0, + ) + resp.raise_for_status() + version = resp.json().get("version") + if not version: + return None + + marker.parent.mkdir(parents=True, exist_ok=True) + tmp = marker.with_suffix(".tmp") + tmp.write_text(version) + os.replace(str(tmp), str(marker)) + return version + + except Exception as e: + logger.debug("Pro version check failed: %s", e) + return None + + +def _read_cache( + cache_path: Path, key_sha: str, ignore_ttl: bool = False +) -> LicenseInfo | None: + """Read cached license validation if it exists and is fresh.""" + try: + if not cache_path.exists(): + return None + + data = json.loads(cache_path.read_text()) + + if data.get("key_sha256") != key_sha: + return None + + if not ignore_ttl: + validated_at = data.get("validated_at", 0) + if time.time() - validated_at > LICENSE_CACHE_TTL: + return None + + expires = data.get("expires") + if expires: + try: + from datetime import datetime, timezone + exp_dt = datetime.fromisoformat(expires) + if exp_dt.tzinfo is None: + exp_dt = exp_dt.replace(tzinfo=timezone.utc) + if exp_dt < datetime.now(timezone.utc): + return LicenseInfo(valid=False, plan=data.get("plan", "solo"), expires=expires) + except (ValueError, TypeError): + pass + + return LicenseInfo( + valid=data.get("valid", False), + plan=data.get("plan", "solo"), + expires=expires, + ) + except (json.JSONDecodeError, OSError, KeyError, TypeError): + # TypeError: a corrupted cache with a non-numeric validated_at. Treat any + # unreadable cache as absent rather than crashing the caller. + return None + + +def _write_cache(cache_path: Path, key_sha: str, info: LicenseInfo) -> None: + """Write license validation result to local cache (atomic via tmp+rename).""" + try: + cache_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = cache_path.with_suffix(".tmp") + tmp_path.write_text(json.dumps({ + "key_sha256": key_sha, + "valid": info.valid, + "plan": info.plan, + "expires": info.expires, + "validated_at": time.time(), + })) + os.replace(str(tmp_path), str(cache_path)) + except OSError as e: + logger.debug("Failed to write license cache: %s", e) diff --git a/cloakbrowser/widevine.py b/cloakbrowser/widevine.py new file mode 100644 index 0000000..488847b --- /dev/null +++ b/cloakbrowser/widevine.py @@ -0,0 +1,128 @@ +"""Widevine CDM hint-file seeding for persistent contexts. + +CloakBrowser's binary is built with Widevine support but ships no CDM (the CDM +is a proprietary Google binary we can't redistribute). Users sideload it by +copying a ``WidevineCdm/`` directory from a real Chrome install next to the +binary (see issue #96). + +Chromium discovers a sideloaded CDM in two phases: an early-startup pass that +reads a "hint file" from the user-data-dir, and a later async component-updater +pass that writes that hint file. On a fresh profile the hint file doesn't exist +on the first launch, and Playwright passes ``--disable-component-update``, so the +updater never writes it — Widevine only works after a manual two-launch dance. + +This module pre-seeds the hint file before launch so a sideloaded CDM works on +the very first launch. It never bundles, downloads, or copies the CDM itself — +it only writes the hint when a CDM the user provided is already present. + +Linux only: Chromium's hint-file mechanism is Linux/ChromeOS-specific. On Windows +the CDM can't initialise (DRM host verification), and macOS uses a different CDM +layout, so seeding is a no-op there. +""" + +from __future__ import annotations + +import json +import logging +import os +import platform +from pathlib import Path + +logger = logging.getLogger("cloakbrowser") + +# Chromium reads this file from /WidevineCdm/ at early startup. +_HINT_FILENAME = "latest-component-updated-widevine-cdm" + + +def _seeding_disabled() -> bool: + """True if CLOAKBROWSER_WIDEVINE is set to a falsey value (kill switch).""" + val = os.environ.get("CLOAKBROWSER_WIDEVINE", "").strip().lower() + return val in ("0", "false", "off", "no") + + +def resolve_widevine_cdm_dir(binary_path: str | os.PathLike) -> Path | None: + """Locate a sideloaded Widevine CDM directory, or None if absent. + + Resolution order: + 1. If CLOAKBROWSER_WIDEVINE_CDM is set, it is used **exclusively** (overrides + auto-detection). An invalid value (no ``manifest.json``) skips seeding. + 2. ``/WidevineCdm`` — where a user naturally drops + a manual sideload, per Chromium binary version. + 3. ``/WidevineCdm`` (``~/.cloakbrowser/WidevineCdm``) — the + version-independent location the Docker auto-fetch and ``fetch-widevine.py`` + write to. This fallback lets one fetched CDM serve any binary (free or + Pro, any version) with no env var — the CDM ``.so`` is arch-specific but + not version-specific. + + A directory counts only if it contains ``manifest.json`` (so we don't seed a + hint pointing at a bogus path). The returned path is absolute and + symlink-resolved (``Path.resolve()``). + """ + custom = os.environ.get("CLOAKBROWSER_WIDEVINE_CDM") + if custom is not None: + # Set exclusively (overrides auto-detection). An empty/whitespace value is + # invalid — return None rather than let Path("") resolve to "." and match a + # stray manifest.json in the working directory. + if not custom.strip(): + return None + cdm_dir = Path(custom) + return cdm_dir.resolve() if (cdm_dir / "manifest.json").is_file() else None + + from .config import get_cache_dir # local import avoids any import-cycle risk + + for cdm_dir in (Path(os.fspath(binary_path)).parent / "WidevineCdm", + get_cache_dir() / "WidevineCdm"): + if (cdm_dir / "manifest.json").is_file(): + return cdm_dir.resolve() + return None + + +def seed_widevine_hint(user_data_dir: str | os.PathLike, binary_path: str | os.PathLike) -> None: + """Write the Widevine CDM hint file into a persistent profile before launch. + + ``binary_path`` is the resolved chrome executable; the CDM is looked for next + to it. No-op on non-Linux platforms, when seeding is disabled via + CLOAKBROWSER_WIDEVINE, or when no sideloaded CDM is present. Never raises — + a failure here must not break the browser launch. + """ + if platform.system() != "Linux": + return + if _seeding_disabled(): + logger.debug("Widevine hint seeding disabled via CLOAKBROWSER_WIDEVINE") + return + if not user_data_dir: + # Empty user_data_dir = Playwright's ephemeral profile (its own temp dir); + # a persistent hint can't be placed there, and "" would pollute the CWD. + return + + # Everything below is best-effort and must never break the browser launch, + # so the whole body (resolution + write) is guarded. + try: + cdm_dir = resolve_widevine_cdm_dir(binary_path) + if cdm_dir is None: + if os.environ.get("CLOAKBROWSER_WIDEVINE_CDM") is not None: + logger.warning( + "CLOAKBROWSER_WIDEVINE_CDM is set but has no manifest.json; " + "skipping Widevine hint seeding" + ) + else: + logger.debug("No sideloaded Widevine CDM found; skipping hint seeding") + return + + hint_dir = Path(os.fspath(user_data_dir)) / "WidevineCdm" + hint_dir.mkdir(parents=True, exist_ok=True) + hint_file = hint_dir / _HINT_FILENAME + # cdm_dir is already absolute/resolved. Compact separators + ensure_ascii=False + # byte-match the JS wrapper's JSON.stringify (UTF-8) output. + content = json.dumps({"Path": str(cdm_dir)}, separators=(",", ":"), ensure_ascii=False) + + try: + if hint_file.is_file() and hint_file.read_text(encoding="utf-8") == content: + return # already seeded correctly + except Exception: + logger.warning("Existing Widevine hint unreadable; rewriting") + + hint_file.write_text(content, encoding="utf-8") + logger.info("Seeded Widevine CDM hint -> %s", cdm_dir) + except Exception as e: + logger.warning("Failed to seed Widevine CDM hint file: %s", e) diff --git a/dotnet/.gitignore b/dotnet/.gitignore new file mode 100644 index 0000000..611ec97 --- /dev/null +++ b/dotnet/.gitignore @@ -0,0 +1,8 @@ +# .NET build artifacts +bin/ +obj/ +*.user +.vs/ + +*.png +cloak-persist-test/ diff --git a/dotnet/CloakBrowser.sln b/dotnet/CloakBrowser.sln new file mode 100644 index 0000000..0ee30e4 --- /dev/null +++ b/dotnet/CloakBrowser.sln @@ -0,0 +1,59 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{57F29479-09EB-40B8-8811-3EDAEC7E7294}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloakBrowser", "src\CloakBrowser\CloakBrowser.csproj", "{2451570B-6417-4615-9070-5C45A24B0A05}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloakBrowser.Cli", "src\CloakBrowser.Cli\CloakBrowser.Cli.csproj", "{E1A470E8-044F-4AB4-BABC-33B5179061B2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{8A508583-113A-430D-90C9-73C706332269}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloakBrowser.Tests", "tests\CloakBrowser.Tests\CloakBrowser.Tests.csproj", "{06500720-AF0C-434D-B4C6-FACE892A2F10}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{A19AACE1-BC14-4D60-8EBD-082E971B184B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloakBrowser.Examples", "examples\CloakBrowser.Examples\CloakBrowser.Examples.csproj", "{24A34FBF-6B3C-4EF7-8B4C-A881677565FA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloakBrowser.Generators", "src\CloakBrowser.Generators\CloakBrowser.Generators.csproj", "{598B07D4-0D2E-47F3-BC70-A81D3F7EFD45}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2451570B-6417-4615-9070-5C45A24B0A05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2451570B-6417-4615-9070-5C45A24B0A05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2451570B-6417-4615-9070-5C45A24B0A05}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2451570B-6417-4615-9070-5C45A24B0A05}.Release|Any CPU.Build.0 = Release|Any CPU + {E1A470E8-044F-4AB4-BABC-33B5179061B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E1A470E8-044F-4AB4-BABC-33B5179061B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E1A470E8-044F-4AB4-BABC-33B5179061B2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E1A470E8-044F-4AB4-BABC-33B5179061B2}.Release|Any CPU.Build.0 = Release|Any CPU + {06500720-AF0C-434D-B4C6-FACE892A2F10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {06500720-AF0C-434D-B4C6-FACE892A2F10}.Debug|Any CPU.Build.0 = Debug|Any CPU + {06500720-AF0C-434D-B4C6-FACE892A2F10}.Release|Any CPU.ActiveCfg = Release|Any CPU + {06500720-AF0C-434D-B4C6-FACE892A2F10}.Release|Any CPU.Build.0 = Release|Any CPU + {24A34FBF-6B3C-4EF7-8B4C-A881677565FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {24A34FBF-6B3C-4EF7-8B4C-A881677565FA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {24A34FBF-6B3C-4EF7-8B4C-A881677565FA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {24A34FBF-6B3C-4EF7-8B4C-A881677565FA}.Release|Any CPU.Build.0 = Release|Any CPU + {598B07D4-0D2E-47F3-BC70-A81D3F7EFD45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {598B07D4-0D2E-47F3-BC70-A81D3F7EFD45}.Debug|Any CPU.Build.0 = Debug|Any CPU + {598B07D4-0D2E-47F3-BC70-A81D3F7EFD45}.Release|Any CPU.ActiveCfg = Release|Any CPU + {598B07D4-0D2E-47F3-BC70-A81D3F7EFD45}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {2451570B-6417-4615-9070-5C45A24B0A05} = {57F29479-09EB-40B8-8811-3EDAEC7E7294} + {E1A470E8-044F-4AB4-BABC-33B5179061B2} = {57F29479-09EB-40B8-8811-3EDAEC7E7294} + {06500720-AF0C-434D-B4C6-FACE892A2F10} = {8A508583-113A-430D-90C9-73C706332269} + {24A34FBF-6B3C-4EF7-8B4C-A881677565FA} = {A19AACE1-BC14-4D60-8EBD-082E971B184B} + {598B07D4-0D2E-47F3-BC70-A81D3F7EFD45} = {57F29479-09EB-40B8-8811-3EDAEC7E7294} + EndGlobalSection +EndGlobal diff --git a/dotnet/README.md b/dotnet/README.md new file mode 100644 index 0000000..0156368 --- /dev/null +++ b/dotnet/README.md @@ -0,0 +1,678 @@ +# CloakBrowser for .NET + +A complete, faithful **.NET 8 / C#** port of the +[CloakBrowser](https://github.com/CloakHQ/CloakBrowser) Python wrapper - stealth +Chromium that passes bot-detection tests, built on top of +[`Microsoft.Playwright`](https://playwright.dev/dotnet/). + +CloakBrowser is a thin wrapper around a closed-source, source-level patched +Chromium binary (58 C++ fingerprint patches). This port reproduces **all** of the +wrapper functionality with identical behavior - same launch flags, same proxy / +GeoIP / WebRTC logic, and a humanize layer whose curves, timings, and stealth +paths match the Python and JavaScript clients exactly. + +--- + +## Table of contents + +- [Why a decorator layer?](#why-a-decorator-layer) +- [Feature matrix](#feature-matrix) +- [Requirements](#requirements) +- [Installation](#installation) +- [Project layout](#project-layout) +- [Quick start](#quick-start) +- [Humanized interactions (transparent)](#humanized-interactions-transparent) + - [What changes vs. plain Playwright](#what-changes-vs-plain-playwright) + - [Explicit `HumanPage` engine (advanced)](#explicit-humanpage-engine-advanced) +- [Launch API reference](#launch-api-reference) +- [Options reference](#options-reference) +- [`HumanConfig` reference](#humanconfig-reference) +- [How the humanize layer works](#how-the-humanize-layer-works) + - [Mouse motion (Bezier)](#mouse-motion-bezier) + - [Human typing & the CDP stealth path](#human-typing--the-cdp-stealth-path) + - [Non-ASCII input](#non-ascii-input) + - [Scrolling](#scrolling) + - [Actionability checks](#actionability-checks) + - [Shared timeout budget (#307)](#shared-timeout-budget-307) + - [Focus-aware press / clear](#focus-aware-press--clear) +- [Proxy, GeoIP & WebRTC](#proxy-geoip--webrtc) +- [CLI](#cli) +- [Environment variables](#environment-variables) +- [Building & testing](#building--testing) +- [Test suite map](#test-suite-map) +- [Mapping to the Python source](#mapping-to-the-python-source) +- [License](#license) + +--- + +## Why a decorator layer? + +.NET's Playwright exposes **sealed interfaces** (`IPage`, `ILocator`, `IMouse`, ...) +that **cannot** be monkey-patched the way the Python/JS clients replace methods at +runtime. CloakBrowser bridges this gap with a **transparent decorator layer**: + +1. You pass `Humanize = true` at launch. +2. `NewPageAsync()` / `NewContextAsync()` return a **wrapped** object. +3. Every standard Playwright call (`page.ClickAsync`, `page.FillAsync`, + `page.Mouse.MoveAsync`, ...) is automatically humanized - **no API changes** in + your code. +4. The wrappers are generated at **compile time** by a Roslyn source generator + (`[GenerateInterfaceDelegation]`), so they are fully statically typed with **no + reflection on the hot path**. Non-intercepted members are delegated verbatim. + +``` +your code ──> HumanizedPage ──┬─ intercepted member ─> humanize engine ─> raw IPage + (IPage) (generated) └─ everything else ─────────────────────> raw IPage (verbatim) +``` + +--- + +## Feature matrix + +| Capability | Status | Source | +| --- | :---: | --- | +| Automatic binary **download / cache / auto-update** (SHA-256 verified) | ✅ | `Download.cs`, `Config.cs` | +| **Stealth launch args** (random fingerprint seed, platform spoofing) | ✅ | `CloakLauncher.cs` | +| **Proxy** - HTTP/HTTPS + SOCKS5, inline URL-encoded credentials | ✅ | `ProxyResolver.cs` | +| **GeoIP** timezone/locale from proxy exit IP (MaxMind GeoLite2) | ✅ | `GeoIp.cs` | +| **WebRTC** IP spoofing (`--fingerprint-webrtc-ip=auto`) | ✅ | `CloakLauncher.cs` | +| **Widevine** CDM hint seeding (Linux) | ✅ | `Widevine.cs` | +| **Persistent contexts** (profile reuse) | ✅ | `CloakLauncher.cs` | +| **Humanize** - Bezier mouse, human typing, scrolling, actionability | ✅ | `Human/`, `Wrappers/` | +| Transparent humanize via **source generator** | ✅ | `CloakBrowser.Generators/` | +| **CLI** (`install` / `info` / `update` / `clear-cache`) | ✅ | `CloakBrowser.Cli/` | + +--- + +## Requirements + +| Dependency | Version | Why | +| --- | --- | --- | +| .NET SDK | **8.0** | target framework `net8.0` | +| `Microsoft.Playwright` | **1.49.0** | underlying browser automation | +| `MaxMind.GeoIP2` | **5.2.0** | GeoIP timezone/locale resolution | + +--- + +## Installation + +```bash +dotnet add package CloakBrowser +``` + +The patched Chromium binary downloads automatically on first launch, cached under +`~/.cloakbrowser` (override with `CLOAKBROWSER_CACHE_DIR`). + +--- + +## Project layout + +``` +dotnet/ +├── CloakBrowser.sln +├── src/ +│ ├── CloakBrowser/ # the library +│ │ ├── Config.cs # <- cloakbrowser/config.py +│ │ ├── Download.cs # <- cloakbrowser/download.py (+ zip-slip guard) +│ │ ├── GeoIp.cs # <- cloakbrowser/geoip.py +│ │ ├── Widevine.cs # <- cloakbrowser/widevine.py +│ │ ├── CloakLauncher.cs # <- cloakbrowser/browser.py (launch funcs, build_args) +│ │ ├── ProxyResolver.cs # <- cloakbrowser/browser.py (proxy URL helpers) +│ │ ├── ProxySettings.cs # <- ProxySettings TypedDict +│ │ ├── LaunchOptions.cs # launch option records +│ │ ├── Handles.cs # CloakBrowserHandle / CloakContextHandle +│ │ ├── CloakLog.cs # logging facade +│ │ ├── Human/ # <- cloakbrowser/human/* +│ │ │ ├── HumanConfig.cs # <- human/config.py (HumanConfig + merge) +│ │ │ ├── HumanRandom.cs # <- human/config.py (rand/sleep helpers) +│ │ │ ├── HumanMouse.cs # <- human/mouse.py (Bezier engine) +│ │ │ ├── HumanKeyboard.cs # <- human/keyboard.py (typing + CDP stealth) +│ │ │ ├── HumanScroll.cs # <- human/scroll.py +│ │ │ ├── Actionability.cs # <- human/actionability.py +│ │ │ ├── IsolatedWorld.cs # <- _AsyncIsolatedWorld +│ │ │ ├── PlaywrightAdapters.cs # IMouse/IKeyboard/ICDPSession -> raw protocols +│ │ │ └── HumanPage.cs # <- patch_page flows (explicit engine) +│ │ └── Wrappers/ # transparent humanize decorators (Humanize=true) +│ │ ├── Humanize.cs # wrap entry points + helpers (idempotent) +│ │ ├── HumanCursor.cs # shared per-page cursor / CDP stealth state +│ │ ├── LocatorHumanizer.cs # locator-based humanize engine +│ │ ├── HumanizedBrowser.cs # IBrowser decorator +│ │ ├── HumanizedBrowserContext.cs # IBrowserContext decorator +│ │ ├── HumanizedPage.cs # IPage decorator +│ │ ├── HumanizedFrame.cs # IFrame decorator +│ │ ├── HumanizedLocator.cs # ILocator decorator +│ │ ├── HumanizedElementHandle.cs # IElementHandle decorator +│ │ ├── HumanizedMouse.cs # IMouse decorator +│ │ └── HumanizedKeyboard.cs # IKeyboard decorator +│ ├── CloakBrowser.Generators/ # Roslyn source generator +│ │ └── InterfaceDelegationGenerator.cs # emits delegating members +│ └── CloakBrowser.Cli/ # <- cloakbrowser/__main__.py +├── examples/CloakBrowser.Examples/ # runnable examples (see below) +└── tests/CloakBrowser.Tests/ # xUnit tests +``` + +--- + +## Quick start + +```csharp +using CloakBrowser; + +await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions +{ + Headless = true, +}); + +var page = await browser.NewPageAsync(); +await page.GotoAsync("https://bot.incolumitas.com/"); +Console.WriteLine(await page.TitleAsync()); +``` + +--- + +## Humanized interactions (transparent) + +Pass `Humanize = true` and **write ordinary Playwright code** - mouse moves, +clicks, typing, and scrolling are humanized automatically. Nothing else in your +code changes: + +```csharp +using CloakBrowser; +using CloakBrowser.Human; + +await using var browser = await CloakLauncher.LaunchAsync(new LaunchOptions +{ + Headless = false, + Humanize = true, // <- the only change + HumanPreset = HumanPreset.Careful, // optional: Default | Careful + HumanConfig = new Dictionary { ["typing_delay"] = 90.0 }, // optional overrides +}); + +var page = await browser.NewPageAsync(); // returns a transparently-wrapped IPage +await page.GotoAsync("https://example.com/login"); + +await page.FillAsync("#username", "alice"); // per-character human typing +await page.FillAsync("#password", "s3cr3t!"); // (variable delays, thinking pauses, typos+fixes) +await page.ClickAsync("button[type=submit]"); // Bezier mouse curve to a realistic aim point +await page.Mouse.WheelAsync(0, 600); // accelerate -> cruise -> decelerate scroll +``` + +The wrapping is **complete and transitive** - anything you reach through the page +is wrapped too: + +```csharp +await page.Mouse.MoveAsync(400, 300); // humanized +await page.Keyboard.TypeAsync("hello"); // humanized +var btn = page.Locator("#submit"); // wrapped ILocator +await btn.ClickAsync(); // humanized +foreach (var frame in page.Frames) { /* wrapped IFrame */ } +``` + +### What changes vs. plain Playwright + +| Member | `Humanize = false` (default) | `Humanize = true` | +| --- | --- | --- | +| `IPage` / `ILocator` `ClickAsync`, `DblClickAsync`, `HoverAsync`, `TapAsync` | direct Playwright dispatch | Bezier-curve mouse move to a randomized in-element aim point, then click | +| `IPage` / `ILocator` `FillAsync`, `TypeAsync` / `PressSequentiallyAsync` | instant value set / fast type | per-character typing with variable delays, thinking pauses, occasional typos that self-correct | +| `IPage` / `ILocator` `PressAsync` | direct | human key timing; clicks to focus first only if not already focused | +| `ILocator` `CheckAsync`, `UncheckAsync`, `SetCheckedAsync`, `DragToAsync` | direct | humanized click / curved drag | +| `ILocator` `SelectOptionAsync` (all overloads) | instant native select | curved hover to the ` + // (curved), click, pause, then delegate the real select (native popups can't + // be mouse-driven). Unwrap any HumanizedElementHandle args. + // ----------------------------------------------------------------------- + + private static IElementHandle Unwrap(IElementHandle h) => h is HumanizedElementHandle w ? w.Original : h; + + private async Task SelectPrologueAsync(ElementHandleSelectOptionOptions? options) + { + var t = await MoveToAsync(OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false); + await HumanMouse.HumanClickAsync(_cursor.RawMouse, t.IsInput, _cfg).ConfigureAwait(false); + await HumanRandom.SleepMsAsync(HumanRandom.Rand(100, 300)).ConfigureAwait(false); + } + + public async Task> SelectOptionAsync(string values, ElementHandleSelectOptionOptions? options = null) + { + await SelectPrologueAsync(options).ConfigureAwait(false); + return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false); + } + + public async Task> SelectOptionAsync(IElementHandle values, ElementHandleSelectOptionOptions? options = null) + { + await SelectPrologueAsync(options).ConfigureAwait(false); + return await _inner.SelectOptionAsync(Unwrap(values), options).ConfigureAwait(false); + } + + public async Task> SelectOptionAsync(IEnumerable values, ElementHandleSelectOptionOptions? options = null) + { + await SelectPrologueAsync(options).ConfigureAwait(false); + return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false); + } + + public async Task> SelectOptionAsync(SelectOptionValue values, ElementHandleSelectOptionOptions? options = null) + { + await SelectPrologueAsync(options).ConfigureAwait(false); + return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false); + } + + public async Task> SelectOptionAsync(IEnumerable values, ElementHandleSelectOptionOptions? options = null) + { + await SelectPrologueAsync(options).ConfigureAwait(false); + return await _inner.SelectOptionAsync(values.Select(Unwrap), options).ConfigureAwait(false); + } + + public async Task> SelectOptionAsync(IEnumerable values, ElementHandleSelectOptionOptions? options = null) + { + await SelectPrologueAsync(options).ConfigureAwait(false); + return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false); + } + + // ----------------------------------------------------------------------- + // Handle-returning members - re-wrap. + // ----------------------------------------------------------------------- + + public async Task QuerySelectorAsync(string selector) + { + var h = await _inner.QuerySelectorAsync(selector).ConfigureAwait(false); + return h == null ? null : Humanize.WrapElementHandle(h, _cursor, _cfg); + } + + public async Task> QuerySelectorAllAsync(string selector) + { + var hs = await _inner.QuerySelectorAllAsync(selector).ConfigureAwait(false); + return Humanize.WrapHandles(hs, _cursor, _cfg); + } + + public async Task WaitForSelectorAsync(string selector, ElementHandleWaitForSelectorOptions? options = null) + { + var h = await _inner.WaitForSelectorAsync(selector, options).ConfigureAwait(false); + return h == null ? null : Humanize.WrapElementHandle(h, _cursor, _cfg); + } +} diff --git a/dotnet/src/CloakBrowser/Wrappers/HumanizedFrame.cs b/dotnet/src/CloakBrowser/Wrappers/HumanizedFrame.cs new file mode 100644 index 0000000..4a082cd --- /dev/null +++ b/dotnet/src/CloakBrowser/Wrappers/HumanizedFrame.cs @@ -0,0 +1,197 @@ +using System.Text.RegularExpressions; +using CloakBrowser.Human; +using Microsoft.Playwright; + +namespace CloakBrowser.Wrappers; + +/// +/// Transparent humanizing decorator over Playwright's . +/// +/// Frames have no Mouse/Keyboard of their own (those belong to the page), so the +/// selector actions are humanized by resolving frame.Locator(selector) and +/// running the shared locator humanizer with the page's cursor. Locator/frame +/// returning members are re-wrapped; everything else is delegated by the generator. +/// +[GenerateInterfaceDelegation(typeof(IFrame))] +public sealed partial class HumanizedFrame : IFrame +{ + private readonly IFrame _inner; + private readonly HumanCursor _cursor; + private readonly HumanConfig _cfg; + + internal HumanizedFrame(IFrame inner, HumanCursor cursor, HumanConfig cfg) + { + _inner = inner; + _cursor = cursor; + _cfg = cfg; + } + + /// The original, un-humanized Playwright frame (escape hatch for raw speed). + public IFrame Original => _inner; + + /// Alias of . + public IFrame Inner => _inner; + + private ILocator Wrap(ILocator l) => Humanize.WrapLocator(l, _cursor, _cfg); + private IFrame Wrap(IFrame f) => Humanize.WrapFrame(f, _cursor, _cfg); + private ILocator Loc(string selector) => _inner.Locator(selector); + + // ----------------------------------------------------------------------- + // Humanized selector actions (routed through the locator humanizer). + // ----------------------------------------------------------------------- + + public Task ClickAsync(string selector, FrameClickOptions? options = null) => + LocatorHumanizer.ClickAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)); + + public Task DblClickAsync(string selector, FrameDblClickOptions? options = null) => + LocatorHumanizer.DblClickAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)); + + public Task HoverAsync(string selector, FrameHoverOptions? options = null) => + LocatorHumanizer.HoverAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)); + + public Task TapAsync(string selector, FrameTapOptions? options = null) => + LocatorHumanizer.TapAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)); + + public Task FillAsync(string selector, string value, FrameFillOptions? options = null) => + LocatorHumanizer.FillAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), value); + + public Task TypeAsync(string selector, string text, FrameTypeOptions? options = null) => + LocatorHumanizer.TypeAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), text); + + public Task PressAsync(string selector, string key, FramePressOptions? options = null) => + LocatorHumanizer.PressAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options), key); + + public async Task CheckAsync(string selector, FrameCheckOptions? options = null) + { + if (!await _inner.IsCheckedAsync(selector).ConfigureAwait(false)) + await LocatorHumanizer.ClickAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false); + } + + public async Task UncheckAsync(string selector, FrameUncheckOptions? options = null) + { + if (await _inner.IsCheckedAsync(selector).ConfigureAwait(false)) + await LocatorHumanizer.ClickAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false); + } + + public async Task SetCheckedAsync(string selector, bool checkedState, FrameSetCheckedOptions? options = null) + { + bool current; + try { current = await _inner.IsCheckedAsync(selector).ConfigureAwait(false); } + catch (System.Exception) { current = !checkedState; } + if (current != checkedState) + await LocatorHumanizer.ClickAsync(Loc(selector), _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false); + } + + // ----------------------------------------------------------------------- + // SelectOptionAsync (all 6 IFrame overloads) - humanized pre-roll. + // Mirrors Python _frame_select_option: hover the popups can't be driven by synthetic mouse events). Unwrap any + // HumanizedElementHandle args so Playwright sees the raw handles. + + public async Task> SelectOptionAsync(string values, LocatorSelectOptionOptions? options = null) + { + await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false); + return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false); + } + + public async Task> SelectOptionAsync(IElementHandle values, LocatorSelectOptionOptions? options = null) + { + await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false); + return await _inner.SelectOptionAsync(Unwrap(values), options).ConfigureAwait(false); + } + + public async Task> SelectOptionAsync(IEnumerable values, LocatorSelectOptionOptions? options = null) + { + await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false); + return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false); + } + + public async Task> SelectOptionAsync(SelectOptionValue values, LocatorSelectOptionOptions? options = null) + { + await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false); + return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false); + } + + public async Task> SelectOptionAsync(IEnumerable values, LocatorSelectOptionOptions? options = null) + { + await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false); + return await _inner.SelectOptionAsync(values.Select(Unwrap), options).ConfigureAwait(false); + } + + public async Task> SelectOptionAsync(IEnumerable values, LocatorSelectOptionOptions? options = null) + { + await LocatorHumanizer.SelectOptionPrologueAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)).ConfigureAwait(false); + return await _inner.SelectOptionAsync(values, options).ConfigureAwait(false); + } + + // --- ClearAsync --------------------------------------------------------- + // Human path: focus (humanized click if needed) + select-all + Backspace, + // instead of an instant value reset. + + public Task ClearAsync(LocatorClearOptions? options = null) => + LocatorHumanizer.ClearAsync(_inner, _cursor, _cfg, OptionReader.Timeout(options), OptionReader.Force(options)); + + private static IElementHandle Unwrap(IElementHandle handle) => + handle is HumanizedElementHandle h ? h.Original : handle; + + // ----------------------------------------------------------------------- + // Locator-returning members - re-wrap so chains stay humanized. + // ----------------------------------------------------------------------- + + public ILocator First => Wrap(_inner.First); + public ILocator Last => Wrap(_inner.Last); + public ILocator Nth(int index) => Wrap(_inner.Nth(index)); + public ILocator Or(ILocator locator) => + Wrap(_inner.Or(locator is HumanizedLocator h ? h.Original : locator)); + public ILocator And(ILocator locator) => + Wrap(_inner.And(locator is HumanizedLocator h ? h.Original : locator)); + + public ILocator Locator(string selectorOrLocator, LocatorLocatorOptions? options = null) => + Wrap(_inner.Locator(selectorOrLocator, options)); + public ILocator Locator(ILocator selectorOrLocator, LocatorLocatorOptions? options = null) => + Wrap(_inner.Locator(selectorOrLocator is HumanizedLocator h ? h.Original : selectorOrLocator, options)); + + public ILocator GetByAltText(string text, LocatorGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options)); + public ILocator GetByAltText(Regex text, LocatorGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options)); + public ILocator GetByLabel(string text, LocatorGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options)); + public ILocator GetByLabel(Regex text, LocatorGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options)); + public ILocator GetByPlaceholder(string text, LocatorGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options)); + public ILocator GetByPlaceholder(Regex text, LocatorGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options)); + public ILocator GetByRole(AriaRole role, LocatorGetByRoleOptions? options = null) => Wrap(_inner.GetByRole(role, options)); + public ILocator GetByTestId(string testId) => Wrap(_inner.GetByTestId(testId)); + public ILocator GetByTestId(Regex testId) => Wrap(_inner.GetByTestId(testId)); + public ILocator GetByText(string text, LocatorGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options)); + public ILocator GetByText(Regex text, LocatorGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options)); + public ILocator GetByTitle(string text, LocatorGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options)); + public ILocator GetByTitle(Regex text, LocatorGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options)); +} diff --git a/dotnet/src/CloakBrowser/Wrappers/HumanizedMouse.cs b/dotnet/src/CloakBrowser/Wrappers/HumanizedMouse.cs new file mode 100644 index 0000000..6c7f66f --- /dev/null +++ b/dotnet/src/CloakBrowser/Wrappers/HumanizedMouse.cs @@ -0,0 +1,74 @@ +using CloakBrowser.Human; +using Microsoft.Playwright; + +namespace CloakBrowser.Wrappers; + +/// +/// Transparent humanizing decorator over Playwright's . +/// +/// Intercepted (humanized): MoveAsync, ClickAsync, DblClickAsync, +/// DownAsync, UpAsync, WheelAsync. Everything else is delegated +/// to the inner mouse by the source generator. +/// +[GenerateInterfaceDelegation(typeof(IMouse))] +public sealed partial class HumanizedMouse : IMouse +{ + private readonly IMouse _inner; + private readonly HumanCursor _cursor; + private readonly HumanConfig _cfg; + + internal HumanizedMouse(IMouse inner, HumanCursor cursor, HumanConfig cfg) + { + _inner = inner; + _cursor = cursor; + _cfg = cfg; + } + + /// The original, un-humanized Playwright mouse (escape hatch for raw speed). + public IMouse Original => _inner; + + /// Alias of . + public IMouse Inner => _inner; + + public async Task MoveAsync(float x, float y, MouseMoveOptions? options = null) + { + await _cursor.EnsureInitializedAsync(_cfg).ConfigureAwait(false); + await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, x, y, _cfg).ConfigureAwait(false); + _cursor.Set(x, y); + } + + public async Task ClickAsync(float x, float y, MouseClickOptions? options = null) + { + await _cursor.EnsureInitializedAsync(_cfg).ConfigureAwait(false); + await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, x, y, _cfg).ConfigureAwait(false); + _cursor.Set(x, y); + await HumanMouse.HumanClickAsync(_cursor.RawMouse, isInput: false, _cfg).ConfigureAwait(false); + } + + public async Task DblClickAsync(float x, float y, MouseDblClickOptions? options = null) + { + await _cursor.EnsureInitializedAsync(_cfg).ConfigureAwait(false); + await HumanMouse.HumanMoveAsync(_cursor.RawMouse, _cursor.X, _cursor.Y, x, y, _cfg).ConfigureAwait(false); + _cursor.Set(x, y); + await _cursor.RawMouseDownAsync(2).ConfigureAwait(false); + await HumanRandom.SleepMsAsync(HumanRandom.Rand(30, 60)).ConfigureAwait(false); + await _cursor.RawMouseUpAsync(2).ConfigureAwait(false); + } + + public async Task DownAsync(MouseDownOptions? options = null) + { + await HumanRandom.SleepMsAsync(HumanRandom.RandRange(_cfg.ClickHoldButton)).ConfigureAwait(false); + await _inner.DownAsync(options).ConfigureAwait(false); + } + + public async Task UpAsync(MouseUpOptions? options = null) + { + await HumanRandom.SleepMsAsync(HumanRandom.RandRange(_cfg.ClickHoldButton)).ConfigureAwait(false); + await _inner.UpAsync(options).ConfigureAwait(false); + } + + public async Task WheelAsync(float deltaX, float deltaY) + { + await HumanScroll.SmoothWheelAsync(_cursor.RawMouse, deltaX, deltaY, _cfg).ConfigureAwait(false); + } +} diff --git a/dotnet/src/CloakBrowser/Wrappers/HumanizedPage.cs b/dotnet/src/CloakBrowser/Wrappers/HumanizedPage.cs new file mode 100644 index 0000000..42bcbc5 --- /dev/null +++ b/dotnet/src/CloakBrowser/Wrappers/HumanizedPage.cs @@ -0,0 +1,172 @@ +using System.Text.RegularExpressions; +using CloakBrowser.Human; +using Microsoft.Playwright; + +namespace CloakBrowser.Wrappers; + +/// +/// Transparent humanizing decorator over Playwright's . +/// +/// Selector-based interaction methods (Click/Fill/Type/Hover/Press/Tap/Check/...) are +/// routed through the selector-driven engine. Mouse and +/// Keyboard return humanized wrappers; Locator/GetBy*/frames return +/// re-wrapped objects so the whole chain stays humanized. Everything else is delegated +/// to the inner page by the source generator. +/// +[GenerateInterfaceDelegation(typeof(IPage))] +public sealed partial class HumanizedPage : IPage +{ + private readonly IPage _inner; + private readonly HumanCursor _cursor; + private readonly HumanConfig _cfg; + private readonly HumanPage _human; + private readonly HumanizedMouse _mouse; + private readonly HumanizedKeyboard _keyboard; + + internal HumanizedPage(IPage inner, HumanCursor cursor, HumanConfig cfg) + { + _inner = inner; + _cursor = cursor; + _cfg = cfg; + _human = new HumanPage(inner, cfg); + _mouse = new HumanizedMouse(inner.Mouse, cursor, cfg); + _keyboard = new HumanizedKeyboard(inner.Keyboard, cursor, cfg); + } + + /// The original, un-humanized Playwright page (escape hatch for raw speed). + public IPage Original => _inner; + + /// Alias of . + public IPage Inner => _inner; + + private HumanActionOptions Opt(object? options) => new() + { + Timeout = OptionReader.Timeout(options), + Force = OptionReader.Force(options), + }; + + private ILocator Wrap(ILocator l) => Humanize.WrapLocator(l, _cursor, _cfg); + private IFrame Wrap(IFrame f) => Humanize.WrapFrame(f, _cursor, _cfg); + + // ----------------------------------------------------------------------- + // Humanized wrappers for nested objects + // ----------------------------------------------------------------------- + + public IMouse Mouse => _mouse; + public IKeyboard Keyboard => _keyboard; + + // ----------------------------------------------------------------------- + // Humanized selector actions + // ----------------------------------------------------------------------- + + public Task ClickAsync(string selector, PageClickOptions? options = null) => + _human.ClickAsync(selector, Opt(options)); + + public Task DblClickAsync(string selector, PageDblClickOptions? options = null) => + _human.DblClickAsync(selector, Opt(options)); + + public Task HoverAsync(string selector, PageHoverOptions? options = null) => + _human.HoverAsync(selector, Opt(options)); + + public Task TapAsync(string selector, PageTapOptions? options = null) => + _human.TapAsync(selector, Opt(options)); + + public Task FillAsync(string selector, string value, PageFillOptions? options = null) => + _human.FillAsync(selector, value, Opt(options)); + + public Task TypeAsync(string selector, string text, PageTypeOptions? options = null) => + _human.TypeAsync(selector, text, Opt(options)); + + public Task PressAsync(string selector, string key, PagePressOptions? options = null) => + _human.PressAsync(selector, key, Opt(options)); + + public Task CheckAsync(string selector, PageCheckOptions? options = null) => + _human.CheckAsync(selector, Opt(options)); + + public Task UncheckAsync(string selector, PageUncheckOptions? options = null) => + _human.UncheckAsync(selector, Opt(options)); + + public Task SetCheckedAsync(string selector, bool checkedState, PageSetCheckedOptions? options = null) => + _human.SetCheckedAsync(selector, checkedState, Opt(options)); + + public Task FocusAsync(string selector, PageFocusOptions? options = null) => + _human.FocusAsync(selector, Opt(options)); + + public Task DragAndDropAsync(string source, string target, PageDragAndDropOptions? options = null) => + _human.DragAndDropAsync(source, target, Opt(options)); + + public Task> SelectOptionAsync(string selector, string values, PageSelectOptionOptions? options = null) => + _human.SelectOptionAsync(selector, new[] { values }, Opt(options)); + + public Task> SelectOptionAsync(string selector, IEnumerable values, PageSelectOptionOptions? options = null) => + _human.SelectOptionAsync(selector, values.ToArray(), Opt(options)); + + // SelectOption overloads taking handles / SelectOptionValue have no humanized + // analogue; hover then delegate so the dropdown still gets a human approach. + public async Task> SelectOptionAsync(string selector, IElementHandle values, PageSelectOptionOptions? options = null) + { + await _human.HoverAsync(selector, Opt(options)).ConfigureAwait(false); + return await _inner.SelectOptionAsync(selector, Unwrap(values), options).ConfigureAwait(false); + } + + public async Task> SelectOptionAsync(string selector, IEnumerable values, PageSelectOptionOptions? options = null) + { + await _human.HoverAsync(selector, Opt(options)).ConfigureAwait(false); + return await _inner.SelectOptionAsync(selector, values.Select(Unwrap), options).ConfigureAwait(false); + } + + public async Task> SelectOptionAsync(string selector, SelectOptionValue values, PageSelectOptionOptions? options = null) + { + await _human.HoverAsync(selector, Opt(options)).ConfigureAwait(false); + return await _inner.SelectOptionAsync(selector, values, options).ConfigureAwait(false); + } + + public async Task> SelectOptionAsync(string selector, IEnumerable values, PageSelectOptionOptions? options = null) + { + await _human.HoverAsync(selector, Opt(options)).ConfigureAwait(false); + return await _inner.SelectOptionAsync(selector, values, options).ConfigureAwait(false); + } + + private static IElementHandle Unwrap(IElementHandle h) => h is HumanizedElementHandle hh ? hh.Original : h; + + // ----------------------------------------------------------------------- + // Locator-returning members - re-wrap. + // ----------------------------------------------------------------------- + + public ILocator Locator(string selector, PageLocatorOptions? options = null) => Wrap(_inner.Locator(selector, options)); + public ILocator GetByAltText(string text, PageGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options)); + public ILocator GetByAltText(Regex text, PageGetByAltTextOptions? options = null) => Wrap(_inner.GetByAltText(text, options)); + public ILocator GetByLabel(string text, PageGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options)); + public ILocator GetByLabel(Regex text, PageGetByLabelOptions? options = null) => Wrap(_inner.GetByLabel(text, options)); + public ILocator GetByPlaceholder(string text, PageGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options)); + public ILocator GetByPlaceholder(Regex text, PageGetByPlaceholderOptions? options = null) => Wrap(_inner.GetByPlaceholder(text, options)); + public ILocator GetByRole(AriaRole role, PageGetByRoleOptions? options = null) => Wrap(_inner.GetByRole(role, options)); + public ILocator GetByTestId(string testId) => Wrap(_inner.GetByTestId(testId)); + public ILocator GetByTestId(Regex testId) => Wrap(_inner.GetByTestId(testId)); + public ILocator GetByText(string text, PageGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options)); + public ILocator GetByText(Regex text, PageGetByTextOptions? options = null) => Wrap(_inner.GetByText(text, options)); + public ILocator GetByTitle(string text, PageGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options)); + public ILocator GetByTitle(Regex text, PageGetByTitleOptions? options = null) => Wrap(_inner.GetByTitle(text, options)); + + // ----------------------------------------------------------------------- + // Frame-returning members - re-wrap. + // ----------------------------------------------------------------------- + + public IReadOnlyList" + b"" +) +_IFRAME_CHILD = ( + b"" + b"" + b"" + b"" +) + + +@contextlib.contextmanager +def _iframe_server(): + """Serve a parent page + same-origin child page with a button/input.""" + class _H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = _IFRAME_CHILD if self.path.startswith("/child") else _IFRAME_PARENT + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *a): + pass + + srv = socketserver.TCPServer(("127.0.0.1", 0), _H) + port = srv.server_address[1] + threading.Thread(target=srv.serve_forever, daemon=True).start() + try: + yield f"http://127.0.0.1:{port}/" + finally: + srv.shutdown() + + +@pytest.mark.slow +class TestBrowserIframeHumanize: + """Regression for #428: humanize=True must interact with elements inside + sub-frames instead of misrouting to the top document.""" + + def test_humanized_click_inside_iframe(self): + from cloakbrowser import launch + with _iframe_server() as url: + browser = launch(headless=True, humanize=True) + try: + page = browser.new_page() + page.goto(url, wait_until="networkidle") + frame = page.frame(name="myframe") + assert frame is not None + # the #428 case: a Locator obtained from a sub-frame + frame.locator("#btn").click(timeout=5000) + assert frame.locator("#btn").text_content() == "CLICKED" + finally: + browser.close() + + def test_humanized_fill_inside_iframe(self): + from cloakbrowser import launch + with _iframe_server() as url: + browser = launch(headless=True, humanize=True) + try: + page = browser.new_page() + page.goto(url, wait_until="networkidle") + frame = page.frame(name="myframe") + frame.locator("#inp").fill("hello", timeout=5000) + assert frame.locator("#inp").input_value() == "hello" + finally: + browser.close() + + def test_native_control_click_inside_iframe(self): + """Control: humanize=False must also work (parity).""" + from cloakbrowser import launch + with _iframe_server() as url: + browser = launch(headless=True, humanize=False) + try: + page = browser.new_page() + page.goto(url, wait_until="networkidle") + frame = page.frame(name="myframe") + frame.locator("#btn").click(timeout=5000) + assert frame.locator("#btn").text_content() == "CLICKED" + finally: + browser.close() + + +@pytest.mark.slow +class TestBrowserIframeHumanizeAsync: + @pytest.mark.asyncio + async def test_async_humanized_click_inside_iframe(self): + from cloakbrowser import launch_async + with _iframe_server() as url: + browser = await launch_async(headless=True, humanize=True) + try: + page = await browser.new_page() + await page.goto(url, wait_until="networkidle") + frame = page.frame(name="myframe") + assert frame is not None + await frame.locator("#btn").click(timeout=5000) + assert await frame.locator("#btn").text_content() == "CLICKED" + finally: + await browser.close() + + +@pytest.mark.slow +class TestBrowserPatching: + def test_page_has_original(self): + from cloakbrowser import launch + browser = launch(headless=False, humanize=True) + page = browser.new_page() + assert hasattr(page, '_original') + assert hasattr(page, '_human_cfg') + browser.close() + + def test_locator_methods_patched(self): + from cloakbrowser import launch + browser = launch(headless=False, humanize=True) + page = browser.new_page() + from playwright.sync_api._generated import Locator + methods = ['fill', 'click', 'type', 'dblclick', 'hover', 'check', 'uncheck', + 'set_checked', 'select_option', 'press', 'press_sequentially', + 'tap', 'drag_to', 'clear'] + for method in methods: + fn = getattr(Locator, method) + assert 'humanized' in fn.__name__, f"{method} not patched" + browser.close() + + def test_non_humanized_page_normal(self): + from playwright.sync_api import sync_playwright + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + assert not hasattr(page, '_original') + browser.close() + + def test_page_human_cfg_persists(self): + from cloakbrowser import launch + browser = launch(headless=False, humanize=True) + page = browser.new_page() + assert page._human_cfg is not None + assert hasattr(page._human_cfg, 'idle_between_actions') + assert hasattr(page._human_cfg, 'mistype_chance') + browser.close() + + +@pytest.mark.slow +class TestBrowserBotDetection: + PROXY = None + + def test_behavioral_checks_pass(self): + from cloakbrowser import launch + browser = launch(headless=False, humanize=True, proxy=self.PROXY, geoip=True) + page = browser.new_page() + page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', + wait_until='domcontentloaded') + time.sleep(3) + page.locator('#email').click() + time.sleep(0.3) + page.locator('#email').fill('test@example.com') + time.sleep(0.5) + page.locator('#password').click() + time.sleep(0.3) + page.locator('#password').fill('SecurePass!123') + time.sleep(0.5) + page.locator('#loginForm button[type="submit"]').click() + time.sleep(5) + body = page.locator('body').text_content() + assert '"superHumanSpeed": true' not in body + assert '"suspiciousClientSideBehavior": true' not in body + browser.close() + + def test_form_timing(self): + from cloakbrowser import launch + browser = launch(headless=False, humanize=True, proxy=self.PROXY, geoip=True) + page = browser.new_page() + page.goto('https://deviceandbrowserinfo.com/are_you_a_bot_interactions', + wait_until='domcontentloaded') + time.sleep(2) + t0 = time.time() + page.locator('#email').fill('test@example.com') + page.locator('#password').fill('MyPassword!99') + page.locator('#loginForm button[type="submit"]').click() + elapsed_ms = int((time.time() - t0) * 1000) + time.sleep(3) + assert elapsed_ms > 3000 + browser.close() + + +@pytest.mark.slow +class TestAsyncEndToEnd: + @pytest.mark.asyncio + async def test_async_launch_click_fill(self): + """launch_async(humanize=True) — async page.click and page.fill work end-to-end.""" + from cloakbrowser import launch_async + + browser = await launch_async(headless=False, humanize=True) + page = await browser.new_page() + assert hasattr(page, '_original'), "async page not patched" + assert hasattr(page, '_human_cfg'), "async page missing _human_cfg" + + await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + await asyncio.sleep(1) + + t0 = time.time() + await page.locator('#searchInput').fill('async test') + elapsed_ms = int((time.time() - t0) * 1000) + assert elapsed_ms > 500, f"async fill too fast: {elapsed_ms}ms" + + val = await page.locator('#searchInput').input_value() + assert val == 'async test', f"async fill wrong value: {val}" + + await browser.close() + + +# ========================================================================= +# 12. ElementHandle patching — SYNC +# ========================================================================= + +class TestElementHandlePatchingSync: + """Test that ElementHandle objects returned by query_selector etc. are humanized.""" + + def test_patch_single_element_handle_marks_patched(self): + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 100 + cursor.y = 100 + page = MagicMock() + page._original = MagicMock() + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) + el.evaluate = _mock_el_evaluate(is_input=True) + el.is_checked = MagicMock(return_value=False) + el.query_selector = MagicMock(return_value=None) + el.query_selector_all = MagicMock(return_value=[]) + el.wait_for_selector = MagicMock(return_value=None) + + raw_mouse = MagicMock() + raw_keyboard = MagicMock() + + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None + ) + + assert el._human_patched is True + + def test_element_handle_click_calls_human_move(self): + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", {"idle_between_actions": False}) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 100 + cursor.y = 100 + page = MagicMock() + page._original = MagicMock() + + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) + el.evaluate = _mock_el_evaluate(is_input=False) + el.is_checked = MagicMock(return_value=False) + el.query_selector = MagicMock(return_value=None) + el.query_selector_all = MagicMock(return_value=[]) + el.wait_for_selector = MagicMock(return_value=None) + + raw_mouse = MagicMock() + raw_mouse.move = MagicMock() + raw_mouse.down = MagicMock() + raw_mouse.up = MagicMock() + raw_mouse.wheel = MagicMock() + raw_keyboard = MagicMock() + + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None + ) + + # Call the patched click + el.click() + + # Should call raw_mouse.move (Bezier path) and then down/up + assert raw_mouse.move.called + assert raw_mouse.down.called + assert raw_mouse.up.called + + def test_element_handle_hover_moves_cursor_without_click(self): + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", {"idle_between_actions": False}) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 50 + cursor.y = 50 + page = MagicMock() + page._original = MagicMock() + + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) + el.evaluate = _mock_el_evaluate(is_input=False) + el.is_checked = MagicMock(return_value=False) + el.query_selector = MagicMock(return_value=None) + el.query_selector_all = MagicMock(return_value=[]) + el.wait_for_selector = MagicMock(return_value=None) + + raw_mouse = MagicMock() + raw_mouse.move = MagicMock() + raw_mouse.down = MagicMock() + raw_mouse.up = MagicMock() + raw_mouse.wheel = MagicMock() + raw_keyboard = MagicMock() + + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, page._original, None, None + ) + + el.hover() + + # Move should be called, but NOT down/up (hover, not click) + assert raw_mouse.move.called + assert not raw_mouse.down.called + + def test_element_handle_type_calls_human_type(self): + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", {"idle_between_actions": False, "mistype_chance": 0}) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 50 + cursor.y = 50 + page = MagicMock() + originals = MagicMock() + page._original = originals + + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) + el.evaluate = _mock_el_evaluate(is_input=True) # is input + el.is_checked = MagicMock(return_value=False) + el.query_selector = MagicMock(return_value=None) + el.query_selector_all = MagicMock(return_value=[]) + el.wait_for_selector = MagicMock(return_value=None) + + raw_mouse = MagicMock() + raw_mouse.move = MagicMock() + raw_mouse.down = MagicMock() + raw_mouse.up = MagicMock() + raw_mouse.wheel = MagicMock() + raw_keyboard = MagicMock() + raw_keyboard.down = MagicMock() + raw_keyboard.up = MagicMock() + raw_keyboard.insert_text = MagicMock() + + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, None, None + ) + + el.type("hello") + + # Mouse moved + clicked (to focus), then keyboard used + assert raw_mouse.move.called + assert raw_mouse.down.called # click to focus the input + # Keyboard events should have fired (down/up for ASCII chars) + assert raw_keyboard.down.called or raw_keyboard.insert_text.called + + def test_element_handle_fill_clears_and_types(self): + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock, call + + cfg = resolve_config("default", {"idle_between_actions": False, "mistype_chance": 0}) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 50 + cursor.y = 50 + page = MagicMock() + originals = MagicMock() + page._original = originals + + pressed_keys = [] + originals.keyboard_press = MagicMock(side_effect=lambda k: pressed_keys.append(k)) + + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) + el.evaluate = _mock_el_evaluate(is_input=True) + el.is_checked = MagicMock(return_value=False) + el.query_selector = MagicMock(return_value=None) + el.query_selector_all = MagicMock(return_value=[]) + el.wait_for_selector = MagicMock(return_value=None) + + raw_mouse = MagicMock() + raw_mouse.move = MagicMock() + raw_mouse.down = MagicMock() + raw_mouse.up = MagicMock() + raw_mouse.wheel = MagicMock() + raw_keyboard = MagicMock() + raw_keyboard.down = MagicMock() + raw_keyboard.up = MagicMock() + raw_keyboard.insert_text = MagicMock() + + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, None, None + ) + + el.fill("replaced") + + # Should have pressed Select-All and Backspace to clear + import sys + expected_select = "Meta+a" if sys.platform == "darwin" else "Control+a" + assert expected_select in pressed_keys + assert "Backspace" in pressed_keys + + def test_element_handle_no_double_patching(self): + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + page = MagicMock() + page._original = MagicMock() + el = MagicMock() + el._human_patched = False + el.query_selector = MagicMock(return_value=None) + el.query_selector_all = MagicMock(return_value=[]) + el.wait_for_selector = MagicMock(return_value=None) + + _patch_single_element_handle_sync( + el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None + ) + + # Save patched click + first_click = el.click + + # Try to patch again + _patch_single_element_handle_sync( + el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None + ) + + # Should be the same — no double wrap + assert el.click is first_click + + def test_nested_query_selector_returns_patched_handle(self): + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + page = MagicMock() + page._original = MagicMock() + + child = MagicMock() + child._human_patched = False + child.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 30}) + child.evaluate = MagicMock(return_value=False) + child.is_checked = MagicMock(return_value=False) + child.query_selector = MagicMock(return_value=None) + child.query_selector_all = MagicMock(return_value=[]) + child.wait_for_selector = MagicMock(return_value=None) + + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) + el.evaluate = _mock_el_evaluate(is_input=False) + el.is_checked = MagicMock(return_value=False) + el.query_selector = MagicMock(return_value=child) + el.query_selector_all = MagicMock(return_value=[]) + el.wait_for_selector = MagicMock(return_value=None) + + _patch_single_element_handle_sync( + el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None + ) + + result = el.query_selector("span") + assert result._human_patched is True + + def test_page_query_selector_patched(self): + from cloakbrowser.human import _patch_page_element_handles_sync, _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) + el.evaluate = _mock_el_evaluate(is_input=False) + el.is_checked = MagicMock(return_value=False) + el.query_selector = MagicMock(return_value=None) + el.query_selector_all = MagicMock(return_value=[]) + el.wait_for_selector = MagicMock(return_value=None) + + page = MagicMock() + page._original = MagicMock() + page.query_selector = MagicMock(return_value=el) + page.query_selector_all = MagicMock(return_value=[el]) + page.wait_for_selector = MagicMock(return_value=el) + + _patch_page_element_handles_sync( + page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None + ) + + result = page.query_selector("#test") + assert result._human_patched is True + + def test_page_query_selector_all_patches_all(self): + from cloakbrowser.human import _patch_page_element_handles_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + + def make_el(): + e = MagicMock() + e._human_patched = False + e.bounding_box = MagicMock(return_value={"x": 10, "y": 10, "width": 50, "height": 30}) + e.evaluate = MagicMock(return_value=False) + e.is_checked = MagicMock(return_value=False) + e.query_selector = MagicMock(return_value=None) + e.query_selector_all = MagicMock(return_value=[]) + e.wait_for_selector = MagicMock(return_value=None) + return e + + el1, el2, el3 = make_el(), make_el(), make_el() + + page = MagicMock() + page._original = MagicMock() + page.query_selector = MagicMock(return_value=None) + page.query_selector_all = MagicMock(return_value=[el1, el2, el3]) + page.wait_for_selector = MagicMock(return_value=None) + + _patch_page_element_handles_sync( + page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None + ) + + results = page.query_selector_all("div") + for r in results: + assert r._human_patched is True + + def test_wait_for_selector_patched(self): + from cloakbrowser.human import _patch_page_element_handles_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) + el.evaluate = _mock_el_evaluate(is_input=False) + el.is_checked = MagicMock(return_value=False) + el.query_selector = MagicMock(return_value=None) + el.query_selector_all = MagicMock(return_value=[]) + el.wait_for_selector = MagicMock(return_value=None) + + page = MagicMock() + page._original = MagicMock() + page.query_selector = MagicMock(return_value=None) + page.query_selector_all = MagicMock(return_value=[]) + page.wait_for_selector = MagicMock(return_value=el) + + _patch_page_element_handles_sync( + page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None + ) + + result = page.wait_for_selector("#test") + assert result._human_patched is True + + def test_element_handle_all_methods_patched(self): + """Verify all expected interaction methods are replaced.""" + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + page = MagicMock() + page._original = MagicMock() + + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) + el.evaluate = _mock_el_evaluate(is_input=False) + el.is_checked = MagicMock(return_value=False) + el.query_selector = MagicMock(return_value=None) + el.query_selector_all = MagicMock(return_value=[]) + el.wait_for_selector = MagicMock(return_value=None) + el.set_checked = MagicMock() # ensure it exists + + _patch_single_element_handle_sync( + el, page, cfg, cursor, MagicMock(), MagicMock(), page._original, None, None + ) + + expected_methods = ['click', 'dblclick', 'hover', 'type', 'fill', 'press', + 'select_option', 'check', 'uncheck', 'set_checked', + 'tap', 'focus', 'query_selector', 'query_selector_all', + 'wait_for_selector'] + for method in expected_methods: + fn = getattr(el, method) + assert not isinstance(fn, MagicMock), f"el.{method} was not patched" + + +# ========================================================================= +# 13. ElementHandle patching — ASYNC +# ========================================================================= + +class TestElementHandlePatchingAsync: + @pytest.mark.asyncio + async def test_async_element_handle_click(self): + from cloakbrowser.human import _patch_single_element_handle_async, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock, AsyncMock + + cfg = resolve_config("default", {"idle_between_actions": False}) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 100 + cursor.y = 100 + + page = MagicMock() + originals = MagicMock() + originals.mouse_move = AsyncMock() + page._original = originals + + el = MagicMock() + el._human_patched = False + el.bounding_box = AsyncMock(return_value={"x": 200, "y": 200, "width": 100, "height": 30}) + el.evaluate = _async_mock_el_evaluate(is_input=False) + el.is_checked = AsyncMock(return_value=False) + el.wait_for_element_state = AsyncMock() + el.query_selector = AsyncMock(return_value=None) + el.query_selector_all = AsyncMock(return_value=[]) + el.wait_for_selector = AsyncMock(return_value=None) + + raw_mouse = MagicMock() + raw_mouse.move = AsyncMock() + raw_mouse.down = AsyncMock() + raw_mouse.up = AsyncMock() + raw_mouse.wheel = AsyncMock() + raw_keyboard = MagicMock() + raw_keyboard.down = AsyncMock() + raw_keyboard.up = AsyncMock() + raw_keyboard.insert_text = AsyncMock() + + stealth = MagicMock() + stealth.get_cdp_session = AsyncMock(return_value=None) + + _patch_single_element_handle_async( + el, page, cfg, cursor, raw_mouse, raw_keyboard, originals, stealth, [None] + ) + + await el.click() + + assert raw_mouse.move.called + assert raw_mouse.down.called + assert raw_mouse.up.called + + @pytest.mark.asyncio + async def test_async_page_query_selector_patched(self): + from cloakbrowser.human import _patch_page_element_handles_async, _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock, AsyncMock + + cfg = resolve_config("default", None) + cursor = _CursorState() + + el = MagicMock() + el._human_patched = False + el.bounding_box = AsyncMock(return_value={"x": 50, "y": 50, "width": 100, "height": 30}) + el.evaluate = _async_mock_el_evaluate(is_input=False) + el.is_checked = AsyncMock(return_value=False) + el.wait_for_element_state = AsyncMock() + el.query_selector = AsyncMock(return_value=None) + el.query_selector_all = AsyncMock(return_value=[]) + el.wait_for_selector = AsyncMock(return_value=None) + + page = MagicMock() + page._original = MagicMock() + page.query_selector = AsyncMock(return_value=el) + page.query_selector_all = AsyncMock(return_value=[el]) + page.wait_for_selector = AsyncMock(return_value=el) + + stealth = MagicMock() + stealth.get_cdp_session = AsyncMock(return_value=None) + + _patch_page_element_handles_async( + page, cfg, cursor, MagicMock(), MagicMock(), page._original, stealth, [None] + ) + + result = await page.query_selector("#test") + assert result._human_patched is True + + +# ========================================================================= +# 14. SLOW: Browser ElementHandle end-to-end +# ========================================================================= + +@pytest.mark.slow +class TestBrowserElementHandle: + def test_query_selector_click_humanized(self): + """page.query_selector() returns a patched handle — el.click() uses human curves.""" + from cloakbrowser import launch + browser = launch(headless=False, humanize=True) + page = browser.new_page() + page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + time.sleep(1) + + el = page.query_selector('#searchInput') + assert el is not None + assert getattr(el, '_human_patched', False), "ElementHandle not patched" + + t0 = time.time() + el.click() + click_ms = int((time.time() - t0) * 1000) + assert click_ms > 100, f"ElementHandle click too fast: {click_ms}ms (not humanized)" + browser.close() + + def test_query_selector_type_humanized(self): + """el.type() should type character-by-character with human timing.""" + from cloakbrowser import launch + browser = launch(headless=False, humanize=True) + page = browser.new_page() + page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + time.sleep(1) + + el = page.query_selector('#searchInput') + assert el is not None + + t0 = time.time() + el.type('ElementHandle test') + type_ms = int((time.time() - t0) * 1000) + assert type_ms > 1000, f"ElementHandle type too fast: {type_ms}ms" + + val = page.locator('#searchInput').input_value() + assert val == 'ElementHandle test' + browser.close() + + def test_query_selector_fill_humanized(self): + """el.fill() should clear + type with human timing.""" + from cloakbrowser import launch + browser = launch(headless=False, humanize=True) + page = browser.new_page() + page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + time.sleep(1) + + el = page.query_selector('#searchInput') + el.type('initial') + time.sleep(0.3) + + t0 = time.time() + el.fill('replaced') + fill_ms = int((time.time() - t0) * 1000) + assert fill_ms > 500, f"ElementHandle fill too fast: {fill_ms}ms" + + val = page.locator('#searchInput').input_value() + assert val == 'replaced' + browser.close() + + def test_query_selector_all_returns_patched(self): + """page.query_selector_all() returns all handles patched.""" + from cloakbrowser import launch + browser = launch(headless=False, humanize=True) + page = browser.new_page() + page.goto('https://the-internet.herokuapp.com/checkboxes', wait_until='domcontentloaded') + time.sleep(1) + + els = page.query_selector_all('input[type="checkbox"]') + assert len(els) >= 2 + for el in els: + assert getattr(el, '_human_patched', False), "ElementHandle not patched" + browser.close() + + def test_query_selector_hover_humanized(self): + """el.hover() should move cursor with human Bezier curve.""" + from cloakbrowser import launch + browser = launch(headless=False, humanize=True) + page = browser.new_page() + page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + time.sleep(1) + + el = page.query_selector('#searchInput') + t0 = time.time() + el.hover() + hover_ms = int((time.time() - t0) * 1000) + assert hover_ms > 50, f"ElementHandle hover too fast: {hover_ms}ms" + browser.close() + + +@pytest.mark.slow +class TestAsyncElementHandle: + @pytest.mark.asyncio + async def test_async_query_selector_click(self): + from cloakbrowser import launch_async + + browser = await launch_async(headless=False, humanize=True) + page = await browser.new_page() + await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + await asyncio.sleep(1) + + el = await page.query_selector('#searchInput') + assert el is not None + assert getattr(el, '_human_patched', False), "Async ElementHandle not patched" + + t0 = time.time() + await el.click() + click_ms = int((time.time() - t0) * 1000) + assert click_ms > 100, f"Async ElementHandle click too fast: {click_ms}ms" + + await browser.close() + + +# ========================================================================= +# 15. Per-call timeout forwarding (issue #137) +# ========================================================================= + +class TestPerCallTimeoutForwarding: + """page.click('#x', timeout=5000) must forward 5000 to bounding_box(), + not silently use the hardcoded 2000ms in scroll.""" + + def test_get_element_box_default_timeout(self): + """Default timeout matches Playwright's 30000ms.""" + from cloakbrowser.human.scroll import _get_element_box + from unittest.mock import MagicMock + + page = MagicMock() + loc = MagicMock() + loc.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 1, "height": 1}) + page.locator = MagicMock(return_value=MagicMock(first=loc)) + + _get_element_box(page, "#x") + loc.bounding_box.assert_called_once_with(timeout=30000) + + def test_get_element_box_custom_timeout(self): + """Caller can pass a custom timeout that overrides the default.""" + from cloakbrowser.human.scroll import _get_element_box + from unittest.mock import MagicMock + + page = MagicMock() + loc = MagicMock() + loc.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 1, "height": 1}) + page.locator = MagicMock(return_value=MagicMock(first=loc)) + + _get_element_box(page, "#x", timeout=5000) + loc.bounding_box.assert_called_once_with(timeout=5000) + + def test_scroll_to_element_forwards_timeout(self): + """scroll_to_element passes timeout through to bounding_box().""" + from cloakbrowser.human.scroll import scroll_to_element + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + page = MagicMock() + page.viewport_size = {"width": 1280, "height": 720} + loc = MagicMock() + # Already in viewport so we don't actually scroll — just verify + # the timeout was forwarded on the first bounding_box() call. + loc.bounding_box = MagicMock(return_value={"x": 100, "y": 200, "width": 50, "height": 30}) + page.locator = MagicMock(return_value=MagicMock(first=loc)) + + raw = MagicMock() + scroll_to_element(page, raw, "#x", 0, 0, cfg, timeout=7500) + loc.bounding_box.assert_called_with(timeout=7500) + + def test_page_click_forwards_timeout_kwarg(self): + """page.click(selector, timeout=...) reaches scroll_to_element. + + Patches scroll_to_element module-side via monkey-patching the + cloakbrowser.human module attribute used by patch_page. + """ + import cloakbrowser.human as h + from cloakbrowser.human import _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock, patch + + cfg = resolve_config("default", {"idle_between_actions": False}) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 100 + cursor.y = 100 + + # Build a minimal page mock + page = MagicMock() + page.click = MagicMock() + page.dblclick = MagicMock() + page.hover = MagicMock() + page.type = MagicMock() + page.fill = MagicMock() + page.goto = MagicMock() + page.is_checked = MagicMock(return_value=False) + page.viewport_size = {"width": 1280, "height": 720} + page.evaluate = MagicMock(return_value={"hit": True}) + page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp")) + page.mouse = MagicMock() + page.keyboard = MagicMock() + page.query_selector = MagicMock(return_value=None) + page.query_selector_all = MagicMock(return_value=[]) + page.wait_for_selector = MagicMock(return_value=None) + page.main_frame = MagicMock() + page.main_frame.child_frames = [] + + captured = {} + def fake_scroll(page_arg, raw, selector, cx, cy, cfg_arg, timeout=30000): + captured["timeout"] = timeout + return ({"x": 100, "y": 100, "width": 50, "height": 30}, cx, cy, False) + + with patch.object(h, "scroll_to_element", side_effect=fake_scroll), \ + patch.object(h, "ensure_actionable"): + h.patch_page(page, cfg, cursor) + page.click("#slow-button", timeout=5000) + + assert 4900 <= captured.get("timeout", 0) <= 5000, f"expected ~5000, got {captured}" + + +# ========================================================================= +# 16. Per-call human_config override (typing speed customization) +# ========================================================================= + +class TestPerCallHumanConfigOverride: + """page.type('#email', text, human_config={'typing_delay': 30}) lets users + override typing speed (and any other HumanConfig field) on a per-call + basis without re-patching the page.""" + + def test_merge_config_creates_new_instance(self): + from cloakbrowser.human.config import resolve_config, merge_config + + base = resolve_config("default", None) + merged = merge_config(base, {"typing_delay": 30}) + + assert merged.typing_delay == 30 + assert base.typing_delay != 30 # not mutated + # Non-overridden fields are preserved + assert merged.mouse_min_steps == base.mouse_min_steps + + def test_merge_config_none_returns_base(self): + from cloakbrowser.human.config import resolve_config, merge_config + + base = resolve_config("default", None) + merged = merge_config(base, None) + assert merged is base + + def test_merge_config_ignores_unknown_keys(self): + from cloakbrowser.human.config import resolve_config, merge_config + + base = resolve_config("default", None) + # ``not_a_real_field`` is silently dropped — callers shouldn't crash + # if they pass typos or future field names. + merged = merge_config(base, {"typing_delay": 30, "not_a_real_field": 99}) + assert merged.typing_delay == 30 + + def test_page_type_uses_per_call_typing_delay(self): + """page.type(..., human_config={'typing_delay': 30}) reaches human_type + with cfg.typing_delay == 30 even when patch was done with default 70.""" + import cloakbrowser.human as h + from cloakbrowser.human import _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock, patch + + cfg = resolve_config("default", { + "idle_between_actions": False, + "field_switch_delay": (0, 1), + }) + assert cfg.typing_delay == 70 # baseline + + cursor = _CursorState() + cursor.initialized = True + cursor.x = 100 + cursor.y = 100 + + page = MagicMock() + page.click = MagicMock() + page.dblclick = MagicMock() + page.hover = MagicMock() + page.type = MagicMock() + page.fill = MagicMock() + page.goto = MagicMock() + page.is_checked = MagicMock(return_value=False) + page.viewport_size = {"width": 1280, "height": 720} + page.evaluate = MagicMock(return_value={"hit": True}) + page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp")) + page.mouse = MagicMock() + page.keyboard = MagicMock() + page.query_selector = MagicMock(return_value=None) + page.query_selector_all = MagicMock(return_value=[]) + page.wait_for_selector = MagicMock(return_value=None) + page.main_frame = MagicMock() + page.main_frame.child_frames = [] + + captured = {} + def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None): + captured["typing_delay"] = cfg_arg.typing_delay + captured["mistype_chance"] = cfg_arg.mistype_chance + + def fake_scroll(*args, **kwargs): + return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100, False) + + with patch.object(h, "human_type", side_effect=fake_human_type), \ + patch.object(h, "scroll_to_element", side_effect=fake_scroll), \ + patch.object(h, "ensure_actionable"), \ + patch.object(h, "check_pointer_events"): + h.patch_page(page, cfg, cursor) + page.type( + "#email", "hi", + human_config={"typing_delay": 30, "mistype_chance": 0}, + ) + + assert captured["typing_delay"] == 30 + assert captured["mistype_chance"] == 0 + assert cfg.typing_delay == 70 + + def test_page_fill_uses_per_call_typing_delay(self): + """Same as type, but for fill (which also clears the field first).""" + import cloakbrowser.human as h + from cloakbrowser.human import _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock, patch + + cfg = resolve_config("default", { + "idle_between_actions": False, + "field_switch_delay": (0, 1), + }) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 100 + cursor.y = 100 + + page = MagicMock() + page.viewport_size = {"width": 1280, "height": 720} + page.is_checked = MagicMock(return_value=False) + page.evaluate = MagicMock(return_value={"hit": True}) + page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp")) + page.mouse = MagicMock() + page.keyboard = MagicMock() + page.query_selector = MagicMock(return_value=None) + page.query_selector_all = MagicMock(return_value=[]) + page.wait_for_selector = MagicMock(return_value=None) + page.main_frame = MagicMock() + page.main_frame.child_frames = [] + + captured = {} + def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None): + captured["typing_delay"] = cfg_arg.typing_delay + + def fake_scroll(*args, **kwargs): + return ({"x": 100, "y": 100, "width": 50, "height": 30}, 100, 100, False) + + with patch.object(h, "human_type", side_effect=fake_human_type), \ + patch.object(h, "scroll_to_element", side_effect=fake_scroll), \ + patch.object(h, "ensure_actionable"), \ + patch.object(h, "check_pointer_events"): + h.patch_page(page, cfg, cursor) + page.fill("#password", "secret", human_config={"typing_delay": 150}) + + assert captured["typing_delay"] == 150 + + def test_element_handle_type_uses_per_call_human_config(self): + """el.type(text, human_config={...}) merges per-call overrides on the + ElementHandle path (which doesn't go through page.type).""" + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + import cloakbrowser.human as h + from unittest.mock import MagicMock, patch + + cfg = resolve_config("default", { + "idle_between_actions": False, + "field_switch_delay": (0, 1), + }) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 50 + cursor.y = 50 + + page = MagicMock() + page.viewport_size = {"width": 1280, "height": 720} + page._original = MagicMock() + + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock( + return_value={"x": 200, "y": 200, "width": 100, "height": 30} + ) + el.evaluate = _mock_el_evaluate(is_input=True) + el.is_checked = MagicMock(return_value=False) + el.query_selector = MagicMock(return_value=None) + el.query_selector_all = MagicMock(return_value=[]) + el.wait_for_selector = MagicMock(return_value=None) + el.scroll_into_view_if_needed = MagicMock() + + raw_mouse = MagicMock() + raw_keyboard = MagicMock() + + captured = {} + def fake_human_type(page_arg, raw, text, cfg_arg, cdp_session=None): + captured["typing_delay"] = cfg_arg.typing_delay + + with patch.object(h, "human_type", side_effect=fake_human_type): + _patch_single_element_handle_sync( + el, page, cfg, cursor, raw_mouse, raw_keyboard, + page._original, None, None, + ) + el.type("abc", human_config={"typing_delay": 25}) + + assert captured["typing_delay"] == 25 + + +# ========================================================================= +# 17. scroll_into_view_if_needed humanization +# ========================================================================= + +class TestScrollIntoViewIfNeeded: + """scroll_into_view_if_needed should run through the same + accelerate → cruise → decelerate → overshoot wheel sequence as page.click— + not Playwright's instant-snap default.""" + + def test_human_scroll_into_view_skips_when_in_viewport(self): + """Already-visible elements: no wheel events, just return.""" + from cloakbrowser.human.scroll import human_scroll_into_view + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", None) + page = MagicMock() + page.viewport_size = {"width": 1280, "height": 720} + raw = MagicMock() + # Box is dead-center of viewport — squarely in scroll_target_zone + in_view_box = {"x": 200, "y": 300, "width": 50, "height": 30} + + box, cx, cy, did_scroll = human_scroll_into_view( + page, raw, lambda: in_view_box, 0, 0, cfg, + ) + assert not did_scroll, "In-viewport elements shouldn't report scrolling" + assert box == in_view_box + assert not raw.wheel.called, "In-viewport elements shouldn't trigger wheel events" + + def test_human_scroll_into_view_scrolls_when_below_fold(self): + """Below-fold elements: wheel events fire, eventually box becomes visible.""" + from cloakbrowser.human.scroll import human_scroll_into_view + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock + + cfg = resolve_config("default", { + "scroll_overshoot_chance": 0, # deterministic + "scroll_pre_move_delay": (0, 1), + "scroll_pause_fast": (0, 1), + "scroll_pause_slow": (0, 1), + "scroll_settle_delay": (0, 1), + }) + page = MagicMock() + page.viewport_size = {"width": 1280, "height": 720} + raw = MagicMock() + + # First box is far below the fold; subsequent boxes "come into view" + # so the loop terminates after a few wheel bursts. + boxes = [ + {"x": 200, "y": 2000, "width": 50, "height": 30}, + {"x": 200, "y": 1500, "width": 50, "height": 30}, + {"x": 200, "y": 1000, "width": 50, "height": 30}, + {"x": 200, "y": 400, "width": 50, "height": 30}, # in viewport + {"x": 200, "y": 400, "width": 50, "height": 30}, + {"x": 200, "y": 400, "width": 50, "height": 30}, + ] + idx = {"i": 0} + def get_box(): + i = min(idx["i"], len(boxes) - 1) + idx["i"] += 1 + return boxes[i] + + human_scroll_into_view(page, raw, get_box, 0, 0, cfg) + assert raw.wheel.called, "Below-fold scroll should produce wheel events" + + def test_element_handle_scroll_into_view_if_needed_humanized(self): + """el.scroll_into_view_if_needed() routes through human_scroll_into_view.""" + from cloakbrowser.human import _patch_single_element_handle_sync, _CursorState + from cloakbrowser.human.config import resolve_config + import cloakbrowser.human as h + from unittest.mock import MagicMock, patch + + cfg = resolve_config("default", None) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 50 + cursor.y = 50 + + page = MagicMock() + page.viewport_size = {"width": 1280, "height": 720} + page._original = MagicMock() + + el = MagicMock() + el._human_patched = False + el.bounding_box = MagicMock( + return_value={"x": 200, "y": 200, "width": 50, "height": 30} + ) + el.evaluate = _mock_el_evaluate(is_input=False) + el.is_checked = MagicMock(return_value=False) + el.query_selector = MagicMock(return_value=None) + el.query_selector_all = MagicMock(return_value=[]) + el.wait_for_selector = MagicMock(return_value=None) + # Make sure the original method exists so the patch is wired up + el.scroll_into_view_if_needed = MagicMock() + + called = {"count": 0} + def fake(*args, **kwargs): + called["count"] += 1 + return ({"x": 200, "y": 200, "width": 50, "height": 30}, 100, 100, False) + + with patch.object(h, "human_scroll_into_view", side_effect=fake): + _patch_single_element_handle_sync( + el, page, cfg, cursor, MagicMock(), MagicMock(), + page._original, None, None, + ) + el.scroll_into_view_if_needed() + + assert called["count"] >= 1, "humanized scroll helper was never called" + + def test_locator_scroll_into_view_if_needed_humanized(self): + """Locator.scroll_into_view_if_needed() also goes through humanized scroll.""" + import cloakbrowser.human as h + from cloakbrowser.human import _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock, patch + + # Patch Locator class fresh + _ensure_locator_patched() + + from playwright.sync_api._generated import Locator + + cfg = resolve_config("default", None) + cursor = _CursorState() + cursor.x = 50 + cursor.y = 50 + cursor.initialized = True + + page = MagicMock() + page._original = MagicMock() + page._human_cfg = cfg + page._human_cursor = cursor + page._human_raw_mouse = MagicMock() + page.viewport_size = {"width": 1280, "height": 720} + + # Build a Locator-like object satisfying the patched method + loc = MagicMock(spec=Locator) + loc.page = page + impl_obj = MagicMock() + impl_obj._selector = "#x" + loc._impl_obj = impl_obj + loc.bounding_box = MagicMock( + return_value={"x": 100, "y": 100, "width": 50, "height": 30} + ) + + called = {"count": 0, "cfg": None} + def fake(*args, **kwargs): + called["count"] += 1 + # cfg is the 6th positional arg (page, raw, get_box, cx, cy, cfg) + called["cfg"] = args[5] if len(args) >= 6 else kwargs.get("cfg") + return ({"x": 100, "y": 100, "width": 50, "height": 30}, 200, 200, False) + + with patch.object(h, "human_scroll_into_view", side_effect=fake): + Locator.scroll_into_view_if_needed( + loc, human_config={"scroll_overshoot_chance": 0.5}, + ) + + assert called["count"] == 1 + # Per-call override merged into the cfg passed downstream + assert called["cfg"].scroll_overshoot_chance == 0.5 + # Cursor was updated from the helper's return value + assert cursor.x == 200 and cursor.y == 200 + + +# ========================================================================= +# Issue #307: frame/page click timeout should not multiply +# ========================================================================= + +class TestTimeoutBudget307: + """Verify timeout budget is shared across sequential operations.""" + + def test_page_click_total_time_within_budget(self): + """page.click on a missing element should not exceed ~1x the timeout.""" + import cloakbrowser.human as h + from cloakbrowser.human import _CursorState + from cloakbrowser.human.config import resolve_config + from unittest.mock import MagicMock, patch + + TIMEOUT_MS = 500 + cfg = resolve_config("default", {"idle_between_actions": False}) + cursor = _CursorState() + cursor.initialized = True + cursor.x = 100 + cursor.y = 100 + + page = MagicMock() + page.click = MagicMock() + page.dblclick = MagicMock() + page.hover = MagicMock() + page.type = MagicMock() + page.fill = MagicMock() + page.goto = MagicMock() + page.is_checked = MagicMock(return_value=False) + page.viewport_size = {"width": 1280, "height": 720} + page.evaluate = MagicMock(return_value={"hit": True}) + page.context.new_cdp_session = MagicMock(side_effect=Exception("no cdp")) + page.mouse = MagicMock() + page.keyboard = MagicMock() + page.query_selector = MagicMock(return_value=None) + page.query_selector_all = MagicMock(return_value=[]) + page.wait_for_selector = MagicMock(return_value=None) + page.main_frame = MagicMock() + page.main_frame.child_frames = [] + + loc = MagicMock() + loc.wait_for = MagicMock(side_effect=lambda **kw: time.sleep(kw.get("timeout", 30000) / 1000.0)) + loc.is_visible = MagicMock(return_value=False) + loc.first = loc + page.locator = MagicMock(return_value=loc) + + h.patch_page(page, cfg, cursor) + + start = time.monotonic() + try: + page.click("#does-not-exist", timeout=TIMEOUT_MS) + except Exception: + pass + elapsed_ms = (time.monotonic() - start) * 1000 + + assert elapsed_ms < TIMEOUT_MS * 1.8, ( + f"expected <{TIMEOUT_MS * 1.8}ms, got {elapsed_ms:.0f}ms" + ) + + +class TestPointerEventsFailOpen: + """The pointer-events check must fail open: when it cannot run (evaluate / + bounding_box throws -> result None), proceed with the click instead of + blocking it until the timeout expires.""" + + def test_handle_failopen_returns_on_evaluate_error(self): + from cloakbrowser.human.actionability import check_pointer_events_handle + el = MagicMock() + el.bounding_box = MagicMock(side_effect=Exception("stale handle")) + el.evaluate = MagicMock(side_effect=Exception("execution context destroyed")) + start = time.monotonic() + check_pointer_events_handle(MagicMock(), el, 100, 100, timeout=2000) # must not raise + elapsed_ms = (time.monotonic() - start) * 1000 + assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms" + + def test_locator_failopen_returns_on_evaluate_error(self): + from cloakbrowser.human.actionability import check_pointer_events + page = MagicMock() + loc = MagicMock() + loc.first = loc + loc.bounding_box = MagicMock(side_effect=Exception("no element")) + loc.evaluate = MagicMock(side_effect=Exception("no element")) + page.locator = MagicMock(return_value=loc) + start = time.monotonic() + check_pointer_events(page, "#x", 100, 100, timeout=2000) # must not raise + elapsed_ms = (time.monotonic() - start) * 1000 + assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms" + + def test_handle_still_raises_when_covered(self): + """A genuine 'covered' result (not None) must still raise — fail-open + only applies when the check could not be determined.""" + from cloakbrowser.human.actionability import ( + check_pointer_events_handle, ElementNotReceivingEventsError, + ) + el = MagicMock() + el.bounding_box = MagicMock(return_value={"x": 0, "y": 0, "width": 10, "height": 10}) + el.evaluate = MagicMock(return_value={"hit": False, "covering": "DIV"}) + with pytest.raises(ElementNotReceivingEventsError): + check_pointer_events_handle(MagicMock(), el, 5, 5, timeout=200) + + def test_async_handle_failopen_returns_on_evaluate_error(self): + from cloakbrowser.human.actionability_async import async_check_pointer_events_handle + from unittest.mock import AsyncMock + el = MagicMock() + el.bounding_box = AsyncMock(side_effect=Exception("stale handle")) + el.evaluate = AsyncMock(side_effect=Exception("execution context destroyed")) + start = time.monotonic() + asyncio.run(async_check_pointer_events_handle(MagicMock(), el, 100, 100, timeout=2000)) + elapsed_ms = (time.monotonic() - start) * 1000 + assert elapsed_ms < 500, f"fail-open should return promptly, took {elapsed_ms:.0f}ms" + + +# ========================================================================= +# Direct runner (backwards compat) +# ========================================================================= + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v", "--tb=short", "-x"])) diff --git a/tests/test_lambda_security.py b/tests/test_lambda_security.py new file mode 100644 index 0000000..52ca779 --- /dev/null +++ b/tests/test_lambda_security.py @@ -0,0 +1,171 @@ +"""Security tests for the AWS Lambda handler URL validation.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +sys.path.insert( + 0, str(Path(__file__).resolve().parent.parent / "examples" / "integrations" / "aws_lambda") +) + +from lambda_handler import _build_launch_kwargs, _classify_error, _validate_url + + +class TestSchemeValidation: + """Fix 1: only http:// and https:// are accepted.""" + + @pytest.mark.parametrize("url", [ + "file:///etc/passwd", + "file:///proc/self/environ", + "data:text/html,

pwned

", + "javascript:alert(1)", + "chrome://settings", + "about:blank", + "ftp://example.com/file", + "", + ]) + def test_rejects_non_http_schemes(self, url): + with pytest.raises(ValueError, match="Only http"): + _validate_url(url) + + @pytest.mark.parametrize("url", [ + "https://example.com", + "http://example.com", + "https://example.com/path?q=1", + "HTTP://EXAMPLE.COM", + ]) + def test_accepts_http_and_https(self, url): + _validate_url(url) + + def test_rejects_missing_hostname(self): + with pytest.raises(ValueError, match="no hostname"): + _validate_url("http://") + + +class TestSSRFProtection: + """Fix 2: block private, loopback, link-local, reserved, and metadata IPs.""" + + @pytest.mark.parametrize("url,label", [ + ("http://169.254.169.254", "AWS metadata"), + ("http://169.254.169.254/latest/meta-data/", "AWS metadata path"), + ("http://127.0.0.1", "loopback"), + ("http://127.0.0.2", "loopback range"), + ("http://localhost", "localhost"), + ("http://10.0.0.1", "private 10.x"), + ("http://172.16.0.1", "private 172.16"), + ("http://192.168.1.1", "private 192.168"), + ("http://0.0.0.0", "unspecified"), + ("http://[::1]", "IPv6 loopback"), + ]) + def test_rejects_private_ips(self, url, label): + with pytest.raises(ValueError, match="private/internal"): + _validate_url(url) + + def test_rejects_carrier_grade_nat(self): + with pytest.raises(ValueError, match="private/internal"): + _validate_url("http://100.64.0.1") + + def test_rejects_unresolvable_hostname(self): + with pytest.raises(ValueError, match="Cannot resolve"): + _validate_url("http://this-host-does-not-exist-cb-test.invalid") + + def test_rejects_ipv4_mapped_ipv6(self): + """::ffff:127.0.0.1 should be blocked even though it's technically IPv6.""" + with pytest.raises(ValueError, match="private/internal"): + _validate_url("http://[::ffff:127.0.0.1]") + + +class TestExtraArgsRemoval: + """Fix 3: caller-controlled extra_args are ignored; internal _strategy_args work.""" + + def test_ignores_caller_extra_args(self): + event = {"url": "https://example.com", "extra_args": ["--remote-debugging-port=9222"]} + kwargs = _build_launch_kwargs(event) + assert "--remote-debugging-port=9222" not in kwargs["args"] + + def test_includes_strategy_args(self): + event = {"url": "https://example.com", "_strategy_args": ["--ignore-certificate-errors"]} + kwargs = _build_launch_kwargs(event) + assert "--ignore-certificate-errors" in kwargs["args"] + + def test_classify_error_uses_strategy_args(self): + result = _classify_error(Exception("ERR_CERT_AUTHORITY_INVALID")) + assert "_strategy_args" in result + assert "extra_args" not in result + + def test_always_includes_lambda_hardening_flags(self): + kwargs = _build_launch_kwargs({"url": "https://example.com"}) + assert "--disable-dev-shm-usage" in kwargs["args"] + assert "--no-zygote" in kwargs["args"] + + def test_caller_cannot_inject_strategy_args(self): + """_strategy_args in the caller event must be stripped by _run() before launch.""" + from lambda_handler import _run + import inspect + source = inspect.getsource(_run) + assert '"_strategy_args"' in source and "extra_args" in source, \ + "_run must strip both _strategy_args and extra_args from caller event" + + +class TestRedirectSSRF: + """Fix 5: post-navigation re-validation catches redirects to blocked IPs. + + These mock socket.getaddrinfo to simulate redirect scenarios without + needing a real browser or HTTP server. + """ + + def test_validate_url_catches_redirect_target(self): + """If Chromium followed a redirect to 169.254.169.254, the post-nav + _validate_url(page.url) call should reject it.""" + with pytest.raises(ValueError, match="private/internal"): + _validate_url("http://169.254.169.254/latest/meta-data/iam/security-credentials/") + + def test_validate_url_catches_localhost_redirect(self): + with pytest.raises(ValueError, match="private/internal"): + _validate_url("http://127.0.0.1:8080/admin") + + def test_code_flow_validates_before_content(self): + """Verify that _attempt_scrape calls _validate_url(page.url) at line 282 + BEFORE building the result dict at line 290 (sequential code path).""" + import ast + handler_path = ( + Path(__file__).resolve().parent.parent + / "examples" / "integrations" / "aws_lambda" / "lambda_handler.py" + ) + source = handler_path.read_text() + tree = ast.parse(source) + for node in ast.walk(tree): + if isinstance(node, ast.AsyncFunctionDef) and node.name == "_attempt_scrape": + body = node.body + # Find the try block + for stmt in body: + if isinstance(stmt, ast.Try): + try_body = stmt.body + validate_lines = [] + content_line = None + for s in try_body: + if isinstance(s, ast.Expr) and isinstance(s.value, ast.Call): + func = s.value.func + if isinstance(func, ast.Name) and func.id == "_validate_url": + validate_lines.append(s.lineno) + if isinstance(s, ast.AnnAssign): + if isinstance(s.target, ast.Name) and s.target.id == "result": + content_line = s.lineno + elif isinstance(s, ast.Assign): + for target in s.targets: + if isinstance(target, ast.Name) and target.id == "result": + content_line = s.lineno + assert len(validate_lines) >= 2, ( + f"Expected 2 _validate_url calls, found {len(validate_lines)}" + ) + assert content_line is not None + assert all(v < content_line for v in validate_lines), ( + f"_validate_url (lines {validate_lines}) must come before " + f"result assignment (line {content_line})" + ) + return + pytest.fail("Could not find _attempt_scrape function in source") diff --git a/tests/test_launch.py b/tests/test_launch.py new file mode 100644 index 0000000..a2e159d --- /dev/null +++ b/tests/test_launch.py @@ -0,0 +1,111 @@ +"""Basic launch tests for cloakbrowser.""" + +import pytest +from cloakbrowser import ( + launch, + launch_async, + launch_context, + launch_persistent_context, + binary_info, +) +from cloakbrowser.config import get_chromium_version + + +@pytest.mark.parametrize("env", [None, "patchright"]) +def test_removed_backend_kwarg_raises(env, monkeypatch): + """The removed `backend` parameter raises a clear TypeError before any + launch side effects, regardless of the (also removed) CLOAKBROWSER_BACKEND + env var. Guards the patchright removal.""" + if env is None: + monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False) + else: + monkeypatch.setenv("CLOAKBROWSER_BACKEND", env) + with pytest.raises(TypeError, match="backend"): + launch(backend="patchright") + with pytest.raises(TypeError, match="backend"): + launch_context(backend="patchright") + with pytest.raises(TypeError, match="backend"): + launch_persistent_context("/tmp/cloakbrowser-test-profile", backend="patchright") + + +def test_binary_info(tmp_path, monkeypatch): + """binary_info() returns expected structure. + + Isolate the cache dir: with no cached Pro binary present, binary_info reports + the free base version. (Without isolation this reads the developer's real + ~/.cloakbrowser, which may hold a cached Pro build and flip the version.) + """ + monkeypatch.setenv("CLOAKBROWSER_CACHE_DIR", str(tmp_path)) + info = binary_info() + assert "version" in info + assert "platform" in info + assert "binary_path" in info + assert "installed" in info + assert info["version"] == get_chromium_version() + + +def test_launch_and_close(): + """Can launch browser and close it.""" + browser = launch(headless=True) + assert browser.is_connected() + browser.close() + + +def test_launch_new_page(): + """Can create a page and navigate.""" + browser = launch(headless=True) + page = browser.new_page() + page.goto("https://example.com") + assert "Example Domain" in page.title() + browser.close() + + +def test_launch_with_extra_args(): + """Can pass extra Chrome args.""" + browser = launch(headless=True, args=["--disable-gpu"]) + page = browser.new_page() + page.goto("https://example.com") + assert page.title() + browser.close() + + +def test_webdriver_flag(): + """navigator.webdriver should be false (patched).""" + browser = launch(headless=True) + page = browser.new_page() + page.goto("https://example.com") + webdriver = page.evaluate("navigator.webdriver") + assert webdriver is False, f"navigator.webdriver should be false, got {webdriver}" + browser.close() + + +def test_chrome_object_exists(): + """window.chrome should exist (Playwright leaks undefined).""" + browser = launch(headless=True) + page = browser.new_page() + page.goto("https://example.com") + chrome_exists = page.evaluate("typeof window.chrome") + assert chrome_exists == "object", f"window.chrome should be 'object', got '{chrome_exists}'" + browser.close() + + +def test_plugins_count(): + """navigator.plugins should have entries (Playwright has 0).""" + browser = launch(headless=True) + page = browser.new_page() + page.goto("https://example.com") + plugins = page.evaluate("navigator.plugins.length") + assert plugins > 0, f"Expected plugins > 0, got {plugins}" + browser.close() + + +@pytest.mark.asyncio +async def test_launch_async(): + """Async launch works.""" + browser = await launch_async(headless=True) + assert browser.is_connected() + page = await browser.new_page() + await page.goto("https://example.com") + title = await page.title() + assert "Example Domain" in title + await browser.close() diff --git a/tests/test_launch_context.py b/tests/test_launch_context.py new file mode 100644 index 0000000..7b23f8e --- /dev/null +++ b/tests/test_launch_context.py @@ -0,0 +1,446 @@ +"""Unit tests for launch_context() — context kwargs, viewport defaults, close cleanup.""" + +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest + +from cloakbrowser.config import DEFAULT_VIEWPORT + + +# All tests mock launch() to avoid needing a binary. +# launch_context() calls launch() internally, then browser.new_context(). + + +def _make_mock_browser(): + """Create a mock browser with new_context() returning a mock context.""" + browser = MagicMock() + context = MagicMock() + browser.new_context.return_value = context + return browser, context + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_default_viewport(mock_launch, _mock_bin): + """DEFAULT_VIEWPORT applied when no viewport given.""" + browser, context = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + launch_context() + + ctx_kwargs = browser.new_context.call_args + assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_headed_no_viewport(mock_launch, _mock_bin): + """Headed (headless=False): no emulated viewport — no_viewport=True so the page + tracks the real window (CDP viewport emulation would force outerWidth < innerWidth).""" + browser, context = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + launch_context(headless=False) + + ctx_kwargs = browser.new_context.call_args[1] + assert ctx_kwargs.get("no_viewport") is True + assert "viewport" not in ctx_kwargs + + +def test_default_no_viewport_helper(): + """_default_no_viewport defaults new_page()/new_context() to no_viewport=True, + but never overrides an explicit viewport (Playwright rejects passing both).""" + from cloakbrowser.browser import _default_no_viewport + + browser = MagicMock() + orig_new_page = browser.new_page + orig_new_context = browser.new_context + _default_no_viewport(browser) + + browser.new_page() + orig_new_page.assert_called_once_with(no_viewport=True) + browser.new_context() + orig_new_context.assert_called_once_with(no_viewport=True) + + # Explicit viewport respected — no_viewport NOT injected. + orig_new_page.reset_mock() + browser.new_page(viewport={"width": 800, "height": 600}) + orig_new_page.assert_called_once_with(viewport={"width": 800, "height": 600}) + + +@pytest.mark.asyncio +async def test_default_no_viewport_helper_async(): + """_default_no_viewport_async mirrors the sync helper for async new_page/new_context.""" + from cloakbrowser.browser import _default_no_viewport_async + + browser = MagicMock() + browser.new_page = AsyncMock() + browser.new_context = AsyncMock() + orig_new_page = browser.new_page + orig_new_context = browser.new_context + _default_no_viewport_async(browser) + + await browser.new_page() + orig_new_page.assert_awaited_once_with(no_viewport=True) + await browser.new_context() + orig_new_context.assert_awaited_once_with(no_viewport=True) + + # Explicit viewport respected — no_viewport NOT injected. + orig_new_page.reset_mock() + await browser.new_page(viewport={"width": 800, "height": 600}) + orig_new_page.assert_awaited_once_with(viewport={"width": 800, "height": 600}) + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_conflicting_viewport_kwargs_deduped(mock_launch, _mock_bin): + """If a caller forces no_viewport via **kwargs alongside viewport=, only one + reaches Playwright (which rejects both). The explicit kwargs value wins.""" + browser, context = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + launch_context(viewport={"width": 1280, "height": 800}, no_viewport=True) + + ctx_kwargs = browser.new_context.call_args[1] + assert ctx_kwargs.get("no_viewport") is True + assert "viewport" not in ctx_kwargs + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_custom_viewport(mock_launch, _mock_bin): + """Custom viewport overrides DEFAULT_VIEWPORT.""" + browser, context = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + custom = {"width": 1280, "height": 720} + launch_context(viewport=custom) + + ctx_kwargs = browser.new_context.call_args + assert ctx_kwargs[1]["viewport"] == custom + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_default_does_not_suppress_maximize(mock_launch, _mock_bin): + """No explicit viewport → let launch() auto-maximize (parity with JS/.NET).""" + browser, _ = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + launch_context() + + assert mock_launch.call_args.kwargs["_suppress_maximize"] is False + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_explicit_viewport_suppresses_maximize(mock_launch, _mock_bin): + """Caller chose a viewport → suppress auto --start-maximized (parity with JS/.NET).""" + browser, _ = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + launch_context(viewport={"width": 800, "height": 600}) + + assert mock_launch.call_args.kwargs["_suppress_maximize"] is True + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_no_viewport_kwarg_suppresses_maximize(mock_launch, _mock_bin): + """Explicit no_viewport also counts as a chosen geometry → suppress.""" + browser, _ = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + launch_context(no_viewport=True) + + assert mock_launch.call_args.kwargs["_suppress_maximize"] is True + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_user_agent(mock_launch, _mock_bin): + """user_agent forwarded to new_context().""" + browser, context = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + launch_context(user_agent="Mozilla/5.0 Custom") + + ctx_kwargs = browser.new_context.call_args + assert ctx_kwargs[1]["user_agent"] == "Mozilla/5.0 Custom" + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_locale_forwarded(mock_launch, _mock_bin): + """locale flows to launch() for --lang binary flag, NOT to new_context() CDP.""" + browser, context = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + launch_context(locale="de-DE") + + # Locale in launch() call (for --lang binary flag) + assert mock_launch.call_args[1]["locale"] == "de-DE" + # NOT in new_context() — would trigger detectable CDP emulation + ctx_kwargs = browser.new_context.call_args + assert "locale" not in ctx_kwargs[1] + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_timezone_via_binary_not_cdp(mock_launch, _mock_bin): + """timezone passed to launch() for binary flag, NOT to new_context() CDP. + + --fingerprint-timezone is process-wide (reads CommandLine in renderer), + so it applies to ALL contexts, not just the default one. + """ + browser, context = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + launch_context(timezone="America/New_York") + + # timezone in launch() — binary flag set + assert mock_launch.call_args[1]["timezone"] == "America/New_York" + # NOT in new_context() — no CDP emulation + ctx_kwargs = browser.new_context.call_args + assert "timezone_id" not in ctx_kwargs[1] + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_color_scheme(mock_launch, _mock_bin): + """color_scheme forwarded to new_context().""" + browser, context = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + launch_context(color_scheme="dark") + + ctx_kwargs = browser.new_context.call_args + assert ctx_kwargs[1]["color_scheme"] == "dark" + + +@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")) +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip): + """geoip fills timezone+locale, both flow to binary args only.""" + browser, context = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + launch_context(proxy="http://proxy:8080", geoip=True) + + # Both go to launch() for binary flags + assert mock_launch.call_args[1]["locale"] == "de-DE" + assert mock_launch.call_args[1]["timezone"] == "Europe/Berlin" + # Neither in context — no CDP emulation + ctx_kwargs = browser.new_context.call_args + assert "timezone_id" not in ctx_kwargs[1] + assert "locale" not in ctx_kwargs[1] + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_timezone_id_alias(mock_launch, _mock_bin): + """timezone_id kwarg accepted as alias for timezone.""" + browser, context = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + launch_context(timezone_id="Europe/Paris") + + # Resolved value flows to launch() for binary flag + assert mock_launch.call_args[1]["timezone"] == "Europe/Paris" + # NOT in context — no CDP emulation + ctx_kwargs = browser.new_context.call_args + assert "timezone_id" not in ctx_kwargs[1] + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_close_closes_browser(mock_launch, _mock_bin): + """context.close() also calls browser.close().""" + browser, context = _make_mock_browser() + # Save reference before launch_context() monkey-patches context.close + original_ctx_close = context.close + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + ctx = launch_context() + + # The returned context has a patched close() + ctx.close() + # Original context close was called + original_ctx_close.assert_called_once() + # Browser close was also called + browser.close.assert_called_once() + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_error_closes_browser(mock_launch, _mock_bin): + """If new_context() raises, browser is still closed.""" + browser = MagicMock() + browser.new_context.side_effect = RuntimeError("context creation failed") + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + with pytest.raises(RuntimeError, match="context creation failed"): + launch_context() + + browser.close.assert_called_once() + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch") +def test_kwargs_passthrough(mock_launch, _mock_bin): + """Extra kwargs forwarded to new_context(), NOT to launch(). + + Important contract: kwargs like record_video_dir go to context creation, + not browser launch. + """ + browser, context = _make_mock_browser() + mock_launch.return_value = browser + + from cloakbrowser.browser import launch_context + launch_context(record_video_dir="/tmp/videos") + + # Verify kwarg reached new_context() + ctx_kwargs = browser.new_context.call_args + assert ctx_kwargs[1]["record_video_dir"] == "/tmp/videos" + + # Verify kwarg did NOT leak to launch() + launch_kwargs = mock_launch.call_args[1] + assert "record_video_dir" not in launch_kwargs + + +# --------------------------------------------------------------------------- +# Async: launch_context_async() +# --------------------------------------------------------------------------- + + +def _make_mock_async_browser(): + """Create a mock async browser whose new_context() returns a mock context.""" + browser = AsyncMock() + context = AsyncMock() + browser.new_context.return_value = context + return browser, context + + +@pytest.mark.asyncio +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch_async") +async def test_async_storage_state_forwarded(mock_launch_async, _mock_bin): + """storage_state kwarg forwarded to browser.new_context() in async path. + + This is the motivating use case from issue #141. + """ + browser, context = _make_mock_async_browser() + mock_launch_async.return_value = browser + + from cloakbrowser.browser import launch_context_async + await launch_context_async(storage_state="state.json") + + ctx_kwargs = browser.new_context.call_args + assert ctx_kwargs[1]["storage_state"] == "state.json" + + +@pytest.mark.asyncio +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch_async") +async def test_async_default_viewport(mock_launch_async, _mock_bin): + """DEFAULT_VIEWPORT applied when no viewport given (async).""" + browser, context = _make_mock_async_browser() + mock_launch_async.return_value = browser + + from cloakbrowser.browser import launch_context_async + await launch_context_async() + + ctx_kwargs = browser.new_context.call_args + assert ctx_kwargs[1]["viewport"] == DEFAULT_VIEWPORT + + +@pytest.mark.asyncio +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch_async") +async def test_async_locale_flows_to_binary_not_cdp(mock_launch_async, _mock_bin): + """locale flows to launch_async() for --lang flag, NOT to new_context() CDP.""" + browser, context = _make_mock_async_browser() + mock_launch_async.return_value = browser + + from cloakbrowser.browser import launch_context_async + await launch_context_async(locale="de-DE", timezone="Europe/Berlin") + + # Binary flags + assert mock_launch_async.call_args[1]["locale"] == "de-DE" + assert mock_launch_async.call_args[1]["timezone"] == "Europe/Berlin" + # Not in context — would trigger detectable CDP emulation + ctx_kwargs = browser.new_context.call_args + assert "locale" not in ctx_kwargs[1] + assert "timezone_id" not in ctx_kwargs[1] + + +@pytest.mark.asyncio +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch_async") +async def test_async_close_closes_browser(mock_launch_async, _mock_bin): + """await ctx.close() also closes the underlying browser.""" + browser, context = _make_mock_async_browser() + original_ctx_close = context.close + mock_launch_async.return_value = browser + + from cloakbrowser.browser import launch_context_async + ctx = await launch_context_async() + + await ctx.close() + original_ctx_close.assert_called_once() + browser.close.assert_called_once() + + +@pytest.mark.asyncio +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch_async") +async def test_async_error_closes_browser(mock_launch_async, _mock_bin): + """If new_context() raises in async path, browser is still closed.""" + browser = AsyncMock() + browser.new_context.side_effect = RuntimeError("context creation failed") + mock_launch_async.return_value = browser + + from cloakbrowser.browser import launch_context_async + with pytest.raises(RuntimeError, match="context creation failed"): + await launch_context_async() + + browser.close.assert_called_once() + + +@pytest.mark.asyncio +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.launch_async") +async def test_async_cancellation_closes_browser(mock_launch_async, _mock_bin): + """asyncio.CancelledError during new_context() still closes browser. + + CancelledError derives from BaseException (not Exception) in Python 3.8+, + so the cleanup must catch BaseException to prevent browser process leaks + when the awaiting task is cancelled. + """ + import asyncio + + browser = AsyncMock() + browser.new_context.side_effect = asyncio.CancelledError() + mock_launch_async.return_value = browser + + from cloakbrowser.browser import launch_context_async + with pytest.raises(asyncio.CancelledError): + await launch_context_async() + + browser.close.assert_called_once() diff --git a/tests/test_license.py b/tests/test_license.py new file mode 100644 index 0000000..5cc4891 --- /dev/null +++ b/tests/test_license.py @@ -0,0 +1,542 @@ +"""Tests for the CloakBrowser Pro license module.""" + +import hashlib +import json +import os +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from cloakbrowser.download import BinaryVerificationError, ensure_binary +from cloakbrowser.license import ( + LicenseInfo, + build_launch_env, + get_pro_latest_version, + resolve_license_key, + validate_license, +) + + +# ── resolve_license_key ─────────────────────────────── + + +class TestResolveLicenseKey: + def test_explicit_param_wins(self): + with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}): + assert resolve_license_key("explicit") == "explicit" + + def test_env_var_fallback(self): + with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}): + assert resolve_license_key() == "env-key" + + def test_returns_none_when_absent(self): + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("CLOAKBROWSER_LICENSE_KEY", None) + assert resolve_license_key() is None + + def test_empty_string_param_uses_env(self): + with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}): + assert resolve_license_key("") == "env-key" + + def test_file_fallback(self, tmp_path): + key_file = tmp_path / "license.key" + key_file.write_text("file-key-123\n") + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("CLOAKBROWSER_LICENSE_KEY", None) + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + assert resolve_license_key() == "file-key-123" + + def test_env_takes_precedence_over_file(self, tmp_path): + key_file = tmp_path / "license.key" + key_file.write_text("file-key") + with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "env-key"}): + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + assert resolve_license_key() == "env-key" + + def test_no_file_returns_none(self, tmp_path): + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("CLOAKBROWSER_LICENSE_KEY", None) + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + assert resolve_license_key() is None + + +# ── validate_license ────────────────────────────────── + + +class TestValidateLicense: + def test_fresh_cache_skips_server(self, tmp_path): + cache_path = tmp_path / ".license_cache" + key_sha = hashlib.sha256(b"test-key").hexdigest() + cache_path.write_text(json.dumps({ + "key_sha256": key_sha, + "valid": True, + "plan": "team", + "expires": "2026-12-01", + "validated_at": time.time(), + })) + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post") as mock_post: + result = validate_license("test-key") + + mock_post.assert_not_called() + assert result is not None + assert result.valid is True + assert result.plan == "team" + + def test_stale_cache_calls_server(self, tmp_path): + cache_path = tmp_path / ".license_cache" + key_sha = hashlib.sha256(b"test-key").hexdigest() + cache_path.write_text(json.dumps({ + "key_sha256": key_sha, + "valid": True, + "plan": "solo", + "expires": None, + "validated_at": time.time() - 90000, # 25 hours ago + })) + + mock_resp = MagicMock() + mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", return_value=mock_resp) as mock_post: + result = validate_license("test-key") + + mock_post.assert_called_once() + assert result is not None + assert result.valid is True + + def test_server_success(self, tmp_path): + mock_resp = MagicMock() + mock_resp.json.return_value = {"valid": True, "plan": "business", "expires": "2026-07-13"} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", return_value=mock_resp): + result = validate_license("pro-key") + + assert result is not None + assert result.valid is True + assert result.plan == "business" + assert result.expires == "2026-07-13" + + def test_server_rejection(self, tmp_path): + mock_resp = MagicMock() + mock_resp.json.return_value = {"valid": False, "plan": "solo", "expires": None} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", return_value=mock_resp): + result = validate_license("bad-key") + + assert result is not None + assert result.valid is False + + def test_server_unreachable_uses_stale_cache(self, tmp_path): + cache_path = tmp_path / ".license_cache" + key_sha = hashlib.sha256(b"test-key").hexdigest() + cache_path.write_text(json.dumps({ + "key_sha256": key_sha, + "valid": True, + "plan": "solo", + "expires": "2026-12-01", + "validated_at": time.time() - 90000, + })) + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", side_effect=Exception("timeout")): + result = validate_license("test-key") + + assert result is not None + assert result.valid is True + + def test_server_unreachable_no_cache_returns_none(self, tmp_path): + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", side_effect=Exception("timeout")): + result = validate_license("test-key") + + assert result is None + + def test_cache_stores_hash_not_raw_key(self, tmp_path): + mock_resp = MagicMock() + mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", return_value=mock_resp): + validate_license("secret-key-123") + + cache_path = tmp_path / ".license_cache" + content = cache_path.read_text() + assert "secret-key-123" not in content + expected_sha = hashlib.sha256(b"secret-key-123").hexdigest() + assert expected_sha in content + + def test_expired_license_rejected_from_cache(self, tmp_path): + cache_path = tmp_path / ".license_cache" + key_sha = hashlib.sha256(b"test-key").hexdigest() + cache_path.write_text(json.dumps({ + "key_sha256": key_sha, + "valid": True, + "plan": "solo", + "expires": "2020-01-01T00:00:00+00:00", + "validated_at": time.time(), + })) + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + result = validate_license("test-key") + + assert result is not None + assert result.valid is False + + def test_expired_license_naive_date_rejected(self, tmp_path): + """Date-only string (naive datetime) should also be detected as expired.""" + cache_path = tmp_path / ".license_cache" + key_sha = hashlib.sha256(b"test-key").hexdigest() + cache_path.write_text(json.dumps({ + "key_sha256": key_sha, + "valid": True, + "plan": "solo", + "expires": "2020-01-01", + "validated_at": time.time(), + })) + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + result = validate_license("test-key") + + assert result is not None + assert result.valid is False + + def test_wrong_key_cache_ignored(self, tmp_path): + cache_path = tmp_path / ".license_cache" + cache_path.write_text(json.dumps({ + "key_sha256": "other-hash", + "valid": True, + "plan": "solo", + "expires": None, + "validated_at": time.time(), + })) + + mock_resp = MagicMock() + mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", return_value=mock_resp) as mock_post: + validate_license("different-key") + + mock_post.assert_called_once() + + def test_corrupted_validated_at_does_not_crash(self, tmp_path): + """A non-numeric validated_at must be treated as an absent cache, not crash.""" + cache_path = tmp_path / ".license_cache" + key_sha = hashlib.sha256(b"test-key").hexdigest() + cache_path.write_text(json.dumps({ + "key_sha256": key_sha, + "valid": True, + "plan": "solo", + "expires": None, + "validated_at": "not-a-number", + })) + + mock_resp = MagicMock() + mock_resp.json.return_value = {"valid": True, "plan": "solo", "expires": None} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.post", return_value=mock_resp) as mock_post: + result = validate_license("test-key") + + mock_post.assert_called_once() # corrupted cache ignored → server hit + assert result is not None + assert result.valid is True + + +# ── get_pro_latest_version ──────────────────────────── + + +class TestGetProLatestVersion: + def test_fetches_version(self, tmp_path): + mock_resp = MagicMock() + mock_resp.json.return_value = {"version": "147.0.1234.5"} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.get", return_value=mock_resp): + version = get_pro_latest_version() + + assert version == "147.0.1234.5" + + def test_sends_platform_header(self, tmp_path): + mock_resp = MagicMock() + mock_resp.json.return_value = {"version": "147.0.1234.5"} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch( + "cloakbrowser.license.get_platform_tag", return_value="darwin-arm64" + ): + with patch( + "cloakbrowser.license.httpx.get", return_value=mock_resp + ) as mock_get: + get_pro_latest_version() + + _, kwargs = mock_get.call_args + assert kwargs["headers"]["X-Platform"] == "darwin-arm64" + + def test_rate_limited(self, tmp_path): + marker = tmp_path / ".last_pro_version_check_darwin-arm64" + marker.write_text("147.0.1234.5") + + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch( + "cloakbrowser.license.get_platform_tag", return_value="darwin-arm64" + ): + with patch("cloakbrowser.license.httpx.get") as mock_get: + version = get_pro_latest_version() + + mock_get.assert_not_called() + assert version == "147.0.1234.5" + + def test_network_error_returns_none(self, tmp_path): + with patch("cloakbrowser.license.get_cache_dir", return_value=tmp_path): + with patch("cloakbrowser.license.httpx.get", side_effect=Exception("network")): + version = get_pro_latest_version() + + assert version is None + + +# ── Config pro parameter ────────────────────────────── + + +class TestConfigPro: + def test_binary_dir_pro_suffix(self, tmp_path): + from cloakbrowser.config import get_binary_dir + + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + normal = get_binary_dir("147.0.0.0") + pro = get_binary_dir("147.0.0.0", pro=True) + + assert str(normal).endswith("chromium-147.0.0.0") + assert str(pro).endswith("chromium-147.0.0.0-pro") + + def test_binary_dir_default_no_suffix(self, tmp_path): + from cloakbrowser.config import get_binary_dir + + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + normal = get_binary_dir("147.0.0.0") + + assert not str(normal).endswith("-pro") + + def test_effective_version_pro_marker(self, tmp_path): + from cloakbrowser.config import get_effective_version, get_platform_tag + + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + tag = get_platform_tag() + marker = tmp_path / f"latest_pro_version_{tag}" + marker.write_text("147.0.5555.1") + + # Create the binary so effective version returns it + from cloakbrowser.config import get_binary_path + bp = get_binary_path("147.0.5555.1", pro=True) + bp.parent.mkdir(parents=True, exist_ok=True) + bp.write_text("fake") + bp.chmod(0o755) # get_effective_version(pro) requires an executable binary + + version = get_effective_version(pro=True) + + assert version == "147.0.5555.1" + + +# ── binary_info tier reporting ──────────────────────── + + +class TestBinaryInfoTier: + """binary_info() reports tier from the binary actually on disk — NOT from a + cached license, which can disagree with what's installed or the active key.""" + + def test_free_when_no_pro_binary_even_if_license_cached(self, tmp_path): + from cloakbrowser.download import binary_info + + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}, clear=False): + # A valid, fresh license is cached... + (tmp_path / ".license_cache").write_text(json.dumps({ + "key_sha256": hashlib.sha256(b"cb_x").hexdigest(), + "valid": True, "plan": "solo", "expires": None, + "validated_at": time.time(), + })) + # ...but no Pro binary is on disk → must report free, not pro. + info = binary_info() + + assert info["tier"] == "free" + + def test_pro_when_pro_binary_installed(self, tmp_path): + from cloakbrowser.config import get_binary_path, get_platform_tag + from cloakbrowser.download import binary_info + + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}, clear=False): + tag = get_platform_tag() + (tmp_path / f"latest_pro_version_{tag}").write_text("147.0.5555.1") + bp = get_binary_path("147.0.5555.1", pro=True) + bp.parent.mkdir(parents=True, exist_ok=True) + bp.write_text("fake") + bp.chmod(0o755) + info = binary_info() + + assert info["tier"] == "pro" + assert info["version"] == "147.0.5555.1" + + +# ── ensure_binary Pro routing (fail-closed vs fall-back) ────────────────────── + + +class TestEnsureBinaryProRouting: + """A valid-license user is NEVER silently downgraded to the free binary. Both + a tampering signal (verification failure) and a transient failure + (network/server) surface a clear error — they differ only in the message: + tampering is re-raised verbatim (security, no 'retry'); transient is rewrapped + as an actionable 'Pro binary unavailable, retry' error carrying the cause.""" + + def test_verification_failure_propagates_verbatim(self): + """A BinaryVerificationError must surface verbatim — never reach free.""" + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}, clear=False), \ + patch("cloakbrowser.download.get_local_binary_override", return_value=None), \ + patch("cloakbrowser.license.resolve_license_key", return_value="cb_x"), \ + patch("cloakbrowser.license.validate_license", + return_value=LicenseInfo(valid=True, plan="solo", expires=None)), \ + patch("cloakbrowser.download._ensure_pro_binary", + side_effect=BinaryVerificationError("bad signature")), \ + patch("cloakbrowser.download.check_platform_available", + side_effect=AssertionError("MUST NOT reach the free-tier path")): + with pytest.raises(BinaryVerificationError, match="bad signature"): + ensure_binary("cb_x") + + def test_transient_failure_hard_errors_not_free(self): + """A transient Pro failure must surface a clear, actionable error carrying + the underlying cause — NOT silently download the free binary.""" + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}, clear=False), \ + patch("cloakbrowser.download.get_local_binary_override", return_value=None), \ + patch("cloakbrowser.license.resolve_license_key", return_value="cb_x"), \ + patch("cloakbrowser.license.validate_license", + return_value=LicenseInfo(valid=True, plan="solo", expires=None)), \ + patch("cloakbrowser.download._ensure_pro_binary", + side_effect=RuntimeError("network blip")), \ + patch("cloakbrowser.download.check_platform_available", + side_effect=AssertionError("MUST NOT reach the free-tier path")): + with pytest.raises(RuntimeError, match="Pro binary unavailable: network blip"): + ensure_binary("cb_x") + + def test_macos_pro_404_hard_errors_not_free(self): + """macOS now has a Pro binary, so a 404 on the Pro download is a real error + and must hard-fail like every other platform — NOT silently fall back to the + free binary (the v0.4.2 darwin-404→free stopgap was reverted in v0.4.3).""" + import httpx + + req = httpx.Request("GET", "https://example.com/download") + not_found = httpx.HTTPStatusError( + "404 Not Found", request=req, response=httpx.Response(404, request=req) + ) + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}, clear=False), \ + patch("cloakbrowser.download.get_local_binary_override", return_value=None), \ + patch("cloakbrowser.download.get_platform_tag", return_value="darwin-x64"), \ + patch("cloakbrowser.license.resolve_license_key", return_value="cb_x"), \ + patch("cloakbrowser.license.validate_license", + return_value=LicenseInfo(valid=True, plan="solo", expires=None)), \ + patch("cloakbrowser.download._ensure_pro_binary", side_effect=not_found), \ + patch("cloakbrowser.download.check_platform_available", + side_effect=AssertionError("MUST NOT reach the free-tier path on macOS")): + with pytest.raises(RuntimeError, match="Pro binary unavailable"): + ensure_binary("cb_x") + + +# ── build_launch_env ────────────────────────────────── + + +class TestBuildLaunchEnv: + """Tests for the build_launch_env helper that decides whether license key + env injection is needed in the spawned browser process.""" + + def test_no_key_no_env_returns_none(self): + """No key anywhere → None (no env dict to inject).""" + with patch.dict(os.environ, {}, clear=True), \ + patch("cloakbrowser.license.get_cache_dir") as mock_cache: + mock_cache.return_value = Path("/tmp/no-such-dir") + assert build_launch_env() is None + assert build_launch_env(user_env={"FOO": "bar"}) == {"FOO": "bar"} + # None values are filtered consistently across all return paths. + assert build_launch_env(user_env={"FOO": "bar", "BAZ": None}) == {"FOO": "bar"} + + def test_explicit_param_injects_env(self): + """Explicit license_key param → env dict with key injected.""" + result = build_launch_env(license_key="cb_test_key") + assert result is not None + assert result["CLOAKBROWSER_LICENSE_KEY"] == "cb_test_key" + # Should also preserve the rest of the parent env + assert "HOME" in result + + def test_env_source_no_user_env_returns_none(self): + """Key from env var, no custom user_env → None (child inherits parent).""" + with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "cb_env"}): + assert build_launch_env() is None + + def test_env_source_with_user_env_preserves_key(self): + """Key from env var + explicit user_env → merged env with key.""" + with patch.dict(os.environ, {"CLOAKBROWSER_LICENSE_KEY": "cb_env"}): + result = build_launch_env(user_env={"MY_VAR": "1"}) + assert result is not None + assert result["CLOAKBROWSER_LICENSE_KEY"] == "cb_env" + assert result["MY_VAR"] == "1" + + def test_default_file_skips_injection(self, tmp_path): + """Key from default ~/.cloakbrowser/license.key → no env injection. + The binary reads that file directly.""" + home_dir = tmp_path / "home" + default_cache = home_dir / ".cloakbrowser" + default_cache.mkdir(parents=True) + (default_cache / "license.key").write_text("cb_file_key\n") + + with patch.dict(os.environ, {}, clear=True), \ + patch("cloakbrowser.license.get_cache_dir", return_value=default_cache), \ + patch("pathlib.Path.home", return_value=home_dir): + result = build_launch_env() + assert result is None + + # With a custom user_env, Playwright replaces the child env (which + # could drop HOME and hide the file), so the key IS injected. + result2 = build_launch_env(user_env={"KEEP": "me"}) + assert result2 == {"KEEP": "me", "CLOAKBROWSER_LICENSE_KEY": "cb_file_key"} + + def test_custom_cache_dir_injects_env(self, tmp_path): + """Key from CLOAKBROWSER_CACHE_DIR/license.key → env injection needed + because the binary looks at ~/.cloakbrowser/license.key, not the custom path.""" + home_dir = tmp_path / "home" + home_dir.mkdir() + custom_cache = tmp_path / "custom-cache" + custom_cache.mkdir() + (custom_cache / "license.key").write_text("cb_custom\n") + + with patch.dict(os.environ, {}, clear=True), \ + patch("cloakbrowser.license.get_cache_dir", return_value=custom_cache), \ + patch("pathlib.Path.home", return_value=home_dir): + result = build_launch_env() + assert result is not None + assert result["CLOAKBROWSER_LICENSE_KEY"] == "cb_custom" + # User env preserved — but os.environ was cleared so only the key exists + assert len(result) == 1 + + def test_explicit_param_merges_user_env(self): + """Explicit param + user_env → user_env entries preserved alongside key.""" + result = build_launch_env(license_key="cb_mine", user_env={"PATH": "/bin"}) + assert result is not None + assert result["CLOAKBROWSER_LICENSE_KEY"] == "cb_mine" + assert result["PATH"] == "/bin" + # Should NOT contain the full os.environ (user env replaces it) + assert "HOME" not in result + + def test_empty_license_key_treated_as_missing(self): + """Empty/whitespace key param treated as absent → None.""" + assert build_launch_env(license_key="") is None + assert build_launch_env(license_key=" ") is None diff --git a/tests/test_persistent_context.py b/tests/test_persistent_context.py new file mode 100644 index 0000000..5f28119 --- /dev/null +++ b/tests/test_persistent_context.py @@ -0,0 +1,305 @@ +"""Unit tests for launch_persistent_context() and launch_persistent_context_async(). + +All tests mock playwright to avoid needing a binary. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from cloakbrowser.config import DEFAULT_VIEWPORT + + +def _make_mock_pw_and_context(): + """Create mock sync_playwright chain returning a mock context.""" + context = MagicMock() + pw = MagicMock() + pw.chromium.launch_persistent_context.return_value = context + pw_cm = MagicMock() + pw_cm.start.return_value = pw + return pw_cm, pw, context + + +# --------------------------------------------------------------------------- +# Sync: launch_persistent_context() +# --------------------------------------------------------------------------- + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) +def test_persistent_context_args_built(_mock_geoip, _mock_bin): + """Stealth args + extra args combined correctly.""" + pw_cm, pw, context = _make_mock_pw_and_context() + + with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context + launch_persistent_context("/tmp/profile", args=["--disable-gpu"]) + + call_kwargs = pw.chromium.launch_persistent_context.call_args[1] + assert "--disable-gpu" in call_kwargs["args"] + # Stealth args present by default + assert any(a.startswith("--fingerprint=") for a in call_kwargs["args"]) + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) +def test_persistent_context_default_viewport(_mock_geoip, _mock_bin): + """DEFAULT_VIEWPORT applied when no viewport given.""" + pw_cm, pw, context = _make_mock_pw_and_context() + + with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context + launch_persistent_context("/tmp/profile") + + call_kwargs = pw.chromium.launch_persistent_context.call_args[1] + assert call_kwargs["viewport"] == DEFAULT_VIEWPORT + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) +def test_persistent_context_headed_no_viewport(_mock_geoip, _mock_bin): + """Headed (headless=False): no_viewport=True instead of DEFAULT_VIEWPORT so the + page tracks the real window (avoids the outerWidth < innerWidth tell).""" + pw_cm, pw, context = _make_mock_pw_and_context() + + with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context + launch_persistent_context("/tmp/profile", headless=False) + + call_kwargs = pw.chromium.launch_persistent_context.call_args[1] + assert call_kwargs.get("no_viewport") is True + assert "viewport" not in call_kwargs + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) +def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin): + """Custom viewport overrides DEFAULT_VIEWPORT.""" + pw_cm, pw, context = _make_mock_pw_and_context() + custom = {"width": 1280, "height": 720} + + with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context + launch_persistent_context("/tmp/profile", viewport=custom) + + call_kwargs = pw.chromium.launch_persistent_context.call_args[1] + assert call_kwargs["viewport"] == custom + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) +def test_persistent_context_user_agent(_mock_geoip, _mock_bin): + """user_agent forwarded to launch_persistent_context().""" + pw_cm, pw, context = _make_mock_pw_and_context() + + with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context + launch_persistent_context("/tmp/profile", user_agent="Custom/1.0") + + call_kwargs = pw.chromium.launch_persistent_context.call_args[1] + assert call_kwargs["user_agent"] == "Custom/1.0" + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +def test_persistent_context_locale_and_timezone(_mock_bin): + """Timezone and locale flow to binary args only, NOT to CDP context kwargs.""" + pw_cm, pw, context = _make_mock_pw_and_context() + + with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context + launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP") + + call_kwargs = pw.chromium.launch_persistent_context.call_args[1] + # Binary args (native, undetectable) + assert "--fingerprint-timezone=Asia/Tokyo" in call_kwargs["args"] + assert "--lang=ja-JP" in call_kwargs["args"] + # NOT in context kwargs (would trigger detectable CDP emulation) + assert "timezone_id" not in call_kwargs + assert "locale" not in call_kwargs + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) +def test_persistent_context_color_scheme(_mock_geoip, _mock_bin): + """color_scheme forwarded correctly.""" + pw_cm, pw, context = _make_mock_pw_and_context() + + with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context + launch_persistent_context("/tmp/profile", color_scheme="dark") + + call_kwargs = pw.chromium.launch_persistent_context.call_args[1] + assert call_kwargs["color_scheme"] == "dark" + + +@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")) +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +def test_persistent_context_geoip(_mock_bin, _mock_geoip): + """geoip fills missing tz/locale — flows to binary args, not CDP context.""" + pw_cm, pw, context = _make_mock_pw_and_context() + + with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context + launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True) + + call_kwargs = pw.chromium.launch_persistent_context.call_args[1] + # Binary args + assert "--fingerprint-timezone=Europe/Berlin" in call_kwargs["args"] + assert "--lang=de-DE" in call_kwargs["args"] + # NOT in context kwargs + assert "timezone_id" not in call_kwargs + assert "locale" not in call_kwargs + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +def test_persistent_context_timezone_id_alias(_mock_bin): + """timezone_id kwarg accepted as alias for timezone.""" + pw_cm, pw, context = _make_mock_pw_and_context() + + with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context + launch_persistent_context("/tmp/profile", timezone_id="Europe/Paris") + + call_kwargs = pw.chromium.launch_persistent_context.call_args[1] + assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"] + assert "timezone_id" not in call_kwargs + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) +def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin): + """context.close() also calls pw.stop().""" + pw_cm, pw, context = _make_mock_pw_and_context() + original_close = context.close + + with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context + ctx = launch_persistent_context("/tmp/profile") + + ctx.close() + original_close.assert_called_once() + pw.stop.assert_called_once() + + +@patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64") +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) +def test_persistent_context_proxy_string(_mock_geoip, _mock_bin, _mock_platform): + """Proxy string parsed and passed (unsupported platform → Playwright dict).""" + pw_cm, pw, context = _make_mock_pw_and_context() + + with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context + launch_persistent_context("/tmp/profile", proxy="http://user:pass@proxy:8080") + + call_kwargs = pw.chromium.launch_persistent_context.call_args[1] + assert call_kwargs["proxy"]["server"] == "http://proxy:8080" + assert call_kwargs["proxy"]["username"] == "user" + assert call_kwargs["proxy"]["password"] == "pass" + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) +def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin): + """Proxy dict passed through.""" + pw_cm, pw, context = _make_mock_pw_and_context() + proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"} + + with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context + launch_persistent_context("/tmp/profile", proxy=proxy_dict) + + call_kwargs = pw.chromium.launch_persistent_context.call_args[1] + assert call_kwargs["proxy"] == proxy_dict + + +# --------------------------------------------------------------------------- +# Async: launch_persistent_context_async() +# --------------------------------------------------------------------------- + + +def _make_mock_async_pw_and_context(): + """Create mock async_playwright chain returning a mock context.""" + context = AsyncMock() + pw = AsyncMock() + pw.chromium.launch_persistent_context.return_value = context + pw_cm = AsyncMock() + pw_cm.start.return_value = pw + return pw_cm, pw, context + + +@pytest.mark.asyncio +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) +async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin): + """Async launch builds args correctly.""" + pw_cm, pw, context = _make_mock_async_pw_and_context() + + with patch("playwright.async_api.async_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context_async + await launch_persistent_context_async("/tmp/profile", args=["--disable-gpu"]) + + call_kwargs = pw.chromium.launch_persistent_context.call_args[1] + assert "--disable-gpu" in call_kwargs["args"] + assert any(a.startswith("--fingerprint=") for a in call_kwargs["args"]) + + +@pytest.mark.asyncio +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) +async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin): + """await context.close() calls await pw.stop().""" + pw_cm, pw, context = _make_mock_async_pw_and_context() + original_close = context.close + + with patch("playwright.async_api.async_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context_async + ctx = await launch_persistent_context_async("/tmp/profile") + + await ctx.close() + original_close.assert_called_once() + pw.stop.assert_called_once() + + +@pytest.mark.asyncio +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +async def test_persistent_context_async_timezone_id_alias(_mock_bin): + """timezone_id kwarg accepted as alias in async path.""" + pw_cm, pw, context = _make_mock_async_pw_and_context() + + with patch("playwright.async_api.async_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context_async + await launch_persistent_context_async("/tmp/profile", timezone_id="Europe/Paris") + + call_kwargs = pw.chromium.launch_persistent_context.call_args[1] + assert "--fingerprint-timezone=Europe/Paris" in call_kwargs["args"] + assert "timezone_id" not in call_kwargs + + +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) +@patch("cloakbrowser.browser.seed_widevine_hint") +def test_persistent_context_seeds_widevine(_mock_seed, _mock_geoip, _mock_bin): + """Sync persistent launch seeds the Widevine hint with the profile path.""" + pw_cm, pw, context = _make_mock_pw_and_context() + + with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context + launch_persistent_context("/tmp/profile") + + _mock_seed.assert_called_once_with("/tmp/profile", "/fake/chrome") + + +@pytest.mark.asyncio +@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") +@patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None, None)) +@patch("cloakbrowser.browser.seed_widevine_hint") +async def test_persistent_context_async_seeds_widevine(_mock_seed, _mock_geoip, _mock_bin): + """Async persistent launch seeds the Widevine hint with the profile path.""" + pw_cm, pw, context = _make_mock_async_pw_and_context() + + with patch("playwright.async_api.async_playwright", return_value=pw_cm): + from cloakbrowser.browser import launch_persistent_context_async + await launch_persistent_context_async("/tmp/profile") + + _mock_seed.assert_called_once_with("/tmp/profile", "/fake/chrome") diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 0000000..f3137c9 --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,514 @@ +"""Tests for proxy URL parsing and credential extraction.""" + +from unittest.mock import patch + +from cloakbrowser.browser import ( + _is_socks_proxy, + _parse_proxy_url, + _resolve_proxy_config, + maybe_resolve_geoip, +) + + +class TestParseProxyUrl: + def test_no_credentials(self): + assert _parse_proxy_url("http://proxy:8080") == {"server": "http://proxy:8080"} + + def test_with_credentials(self): + result = _parse_proxy_url("http://user:pass@proxy:8080") + assert result == {"server": "http://proxy:8080", "username": "user", "password": "pass"} + + def test_url_encoded_password(self): + result = _parse_proxy_url("http://user:p%40ss%3Aword@proxy:8080") + assert result["password"] == "p@ss:word" + assert result["username"] == "user" + assert result["server"] == "http://proxy:8080" + + def test_socks5(self): + result = _parse_proxy_url("socks5://user:pass@proxy:1080") + assert result["server"] == "socks5://proxy:1080" + assert result["username"] == "user" + assert result["password"] == "pass" + + def test_no_port(self): + result = _parse_proxy_url("http://user:pass@proxy") + assert result["server"] == "http://proxy" + assert result["username"] == "user" + + def test_username_only(self): + result = _parse_proxy_url("http://user@proxy:8080") + assert result["server"] == "http://proxy:8080" + assert result["username"] == "user" + assert "password" not in result + + +class TestBuildProxyKwargs: + """Tests for _resolve_proxy_config (formerly _build_proxy_kwargs) HTTP path.""" + + def test_none(self): + kwargs, args = _resolve_proxy_config(None) + assert kwargs == {} + assert args == [] + + def test_simple_proxy(self): + kwargs, args = _resolve_proxy_config("http://proxy:8080") + assert kwargs == {"proxy": {"server": "http://proxy:8080"}} + assert args == [] + + @patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5") + @patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64") + def test_proxy_with_auth(self, *_): + kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080") + assert kwargs == {} + assert args == ["--proxy-server=http://user:pass@proxy:8080"] + + def test_proxy_dict_passthrough(self): + proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com,localhost"} + kwargs, args = _resolve_proxy_config(proxy_dict) + assert kwargs == {"proxy": proxy_dict} + assert args == [] + + @patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64") + def test_pinned_old_version_disables_inline_auth(self, _mock): + # Pin a binary BELOW the inline-auth floor (146.0.7680.177.5) on a + # platform that otherwise supports it. The gate must read the pin — an + # older binary lacks inline proxy auth — and fall back to Playwright's + # dict, else rolled-back binaries break proxy auth (#182). + kwargs, args = _resolve_proxy_config( + "http://user:pass@proxy:8080", browser_version="146.0.7680.177.3" + ) + assert args == [] + assert kwargs == {"proxy": {"server": "http://proxy:8080", "username": "user", "password": "pass"}} + + @patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5") + @patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64") + def test_pinned_new_version_keeps_inline_auth(self, *_): + # Pinning a version at/above the floor keeps inline credentials. + kwargs, args = _resolve_proxy_config( + "http://user:pass@proxy:8080", browser_version="146.0.7680.177.5" + ) + assert kwargs == {} + assert args == ["--proxy-server=http://user:pass@proxy:8080"] + + @patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5") + @patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64") + def test_proxy_dict_with_auth(self, *_): + proxy_dict = { + "server": "http://proxy:8080", + "username": "user", + "password": "pass", + "bypass": ".example.com", + } + kwargs, args = _resolve_proxy_config(proxy_dict) + assert kwargs == {} + assert args == [ + "--proxy-server=http://user:pass@proxy:8080", + "--proxy-bypass-list=.example.com", + ] + + +class TestMaybeResolveGeoip: + @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")) + def test_geoip_with_string_proxy(self, mock_geo): + tz, locale, ip = maybe_resolve_geoip(True, "http://proxy:8080", None, None) + mock_geo.assert_called_once_with("http://proxy:8080") + assert tz == "America/New_York" + assert locale == "en-US" + assert ip == "1.2.3.4" + + @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/London", "en-GB", "5.6.7.8")) + def test_geoip_with_dict_proxy_extracts_server(self, mock_geo): + proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"} + tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None) + mock_geo.assert_called_once_with("http://proxy:8080") + assert tz == "Europe/London" + assert locale == "en-GB" + + def test_geoip_disabled_skips_resolution(self): + tz, locale, ip = maybe_resolve_geoip(False, "http://proxy:8080", None, None) + assert tz is None + assert locale is None + assert ip is None + + @patch( + "cloakbrowser.geoip.resolve_proxy_geo_with_ip", + return_value=("America/New_York", "en-US", "1.2.3.4"), + ) + def test_geoip_no_proxy_uses_machine_ip(self, mock_geo): + # No proxy → resolve the machine's own public IP (proxy_url=None). + tz, locale, ip = maybe_resolve_geoip(True, None, None, None) + mock_geo.assert_called_once_with(None) + assert tz == "America/New_York" + assert locale == "en-US" + assert ip == "1.2.3.4" + + @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Asia/Tokyo", "ja-JP", "9.8.7.6")) + def test_geoip_preserves_explicit_timezone(self, mock_geo): + tz, locale, _ip = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None) + assert tz == "Europe/Berlin" + assert locale == "ja-JP" + + @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")) + def test_geoip_normalizes_bare_proxy_with_creds(self, mock_geo): + # "user:pass@host:port" must be normalized to http:// before geoip lookup. + tz, locale, _ip = maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None) + mock_geo.assert_called_once_with("http://user:pass@proxy:8080") + assert tz == "America/New_York" + assert locale == "en-US" + + @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("America/New_York", "en-US", "1.2.3.4")) + def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo): + # "host:port" (no @ and no scheme) must also be normalized. + tz, locale, _ip = maybe_resolve_geoip(True, "proxy:8080", None, None) + mock_geo.assert_called_once_with("http://proxy:8080") + assert tz == "America/New_York" + + @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")) + def test_geoip_socks5_dict_reconstructs_credentials(self, mock_geo): + proxy_dict = {"server": "socks5://proxy:1080", "username": "user", "password": "pass"} + tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None) + mock_geo.assert_called_once_with("socks5://user:pass@proxy:1080") + assert tz == "Europe/Berlin" + assert locale == "de-DE" + + @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/Berlin", "de-DE", "5.6.7.8")) + def test_geoip_socks5_dict_no_auth_uses_server(self, mock_geo): + proxy_dict = {"server": "socks5://proxy:1080"} + tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None) + mock_geo.assert_called_once_with("socks5://proxy:1080") + + @patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip", return_value=("Europe/London", "en-GB", "1.1.1.1")) + def test_geoip_http_dict_does_not_inline_creds(self, mock_geo): + # HTTP dict: credentials stay separate, only server URL passed + proxy_dict = {"server": "http://proxy:8080", "username": "user", "password": "pass"} + tz, locale, ip = maybe_resolve_geoip(True, proxy_dict, None, None) + mock_geo.assert_called_once_with("http://proxy:8080") + + +class TestBareProxyFormat: + """_parse_proxy_url must handle bare 'user:pass@host:port' strings (no scheme).""" + + def test_bare_with_credentials(self): + r = _parse_proxy_url("user:pass@proxy:8080") + assert r["username"] == "user" + assert r["password"] == "pass" + assert r["server"] == "http://proxy:8080" + + def test_bare_credentials_not_in_server(self): + r = _parse_proxy_url("user:pass@proxy1.example.com:5610") + assert "user" not in r["server"] + assert "pass" not in r["server"] + + def test_bare_username_only(self): + r = _parse_proxy_url("user@proxy:8080") + assert r["username"] == "user" + assert "password" not in r + assert r["server"] == "http://proxy:8080" + + def test_bare_no_port(self): + r = _parse_proxy_url("user:pass@proxy.example.com") + assert r["username"] == "user" + assert r["password"] == "pass" + assert r["server"] == "http://proxy.example.com" + + def test_bare_no_credentials_passthrough(self): + # "host:port" without @ — no scheme, no creds — pass through unchanged + r = _parse_proxy_url("proxy:8080") + assert r == {"server": "proxy:8080"} + + @patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5") + @patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64") + def test_resolve_proxy_config_bare(self, *_): + kwargs, args = _resolve_proxy_config("user:pass@proxy:8080") + assert kwargs == {} + assert args == ["--proxy-server=http://user:pass@proxy:8080"] + + +class TestIsSocksProxy: + def test_socks5_string(self): + assert _is_socks_proxy("socks5://user:pass@host:1080") is True + + def test_socks5h_string(self): + assert _is_socks_proxy("socks5h://host:1080") is True + + def test_socks5_uppercase(self): + assert _is_socks_proxy("SOCKS5://host:1080") is True + + def test_http_string(self): + assert _is_socks_proxy("http://host:8080") is False + + def test_dict_socks5(self): + assert _is_socks_proxy({"server": "socks5://host:1080"}) is True + + def test_dict_http(self): + assert _is_socks_proxy({"server": "http://host:8080"}) is False + + def test_none(self): + assert _is_socks_proxy(None) is False + + +class TestResolveProxyConfig: + def test_none(self): + kwargs, args = _resolve_proxy_config(None) + assert kwargs == {} + assert args == [] + + @patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5") + @patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64") + def test_http_string_with_creds_returns_chrome_arg(self, *_): + kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080") + assert kwargs == {} + assert args == ["--proxy-server=http://user:pass@proxy:8080"] + + def test_http_string_no_creds_returns_playwright_dict(self): + kwargs, args = _resolve_proxy_config("http://proxy:8080") + assert "proxy" in kwargs + assert kwargs["proxy"]["server"] == "http://proxy:8080" + assert args == [] + + def test_http_dict_passthrough(self): + proxy = {"server": "http://proxy:8080", "bypass": ".example.com"} + kwargs, args = _resolve_proxy_config(proxy) + assert kwargs == {"proxy": proxy} + assert args == [] + + def test_socks5_string_returns_chrome_arg(self): + kwargs, args = _resolve_proxy_config("socks5://user:pass@host:1080") + assert kwargs == {} + assert args == ["--proxy-server=socks5://user:pass@host:1080"] + + def test_socks5_no_auth_returns_chrome_arg(self): + kwargs, args = _resolve_proxy_config("socks5://host:1080") + assert kwargs == {} + assert args == ["--proxy-server=socks5://host:1080"] + + def test_socks5h_returns_chrome_arg(self): + kwargs, args = _resolve_proxy_config("socks5h://user:pass@host:1080") + assert kwargs == {} + assert args == ["--proxy-server=socks5h://user:pass@host:1080"] + + def test_socks5_dict_reconstructs_url(self): + proxy = {"server": "socks5://host:1080", "username": "user", "password": "p@ss"} + kwargs, args = _resolve_proxy_config(proxy) + assert kwargs == {} + assert len(args) == 1 + assert args[0].startswith("--proxy-server=socks5://user:p%40ss@host:1080") + + def test_socks5_dict_ipv6_preserves_brackets(self): + proxy = {"server": "socks5://[::1]:1080", "username": "user", "password": "pass"} + kwargs, args = _resolve_proxy_config(proxy) + assert kwargs == {} + assert "[::1]" in args[0] + + def test_socks5_dict_with_bypass(self): + proxy = {"server": "socks5://host:1080", "bypass": ".example.com"} + kwargs, args = _resolve_proxy_config(proxy) + assert kwargs == {} + assert "--proxy-server=socks5://host:1080" in args + assert "--proxy-bypass-list=.example.com" in args + + def test_socks5_string_encodes_equals_in_password(self): + # Chromium's --proxy-server parser truncates passwords at '=' (#157). + # Wrapper must auto URL-encode before passing to Chrome. + _, args = _resolve_proxy_config("socks5://user:pass=123@host:1080") + assert args == ["--proxy-server=socks5://user:pass%3D123@host:1080"] + + def test_socks5_string_encodes_at_in_password(self): + _, args = _resolve_proxy_config("socks5://user:p@ss@host:1080") + # Note: parsing "user:p@ss@host" — urlparse takes everything up to LAST @ + # as userinfo, so password = "p@ss". + assert args == ["--proxy-server=socks5://user:p%40ss@host:1080"] + + def test_socks5_string_encoding_idempotent(self): + # Already-encoded input should remain encoded (not double-encoded). + _, args = _resolve_proxy_config("socks5://user:pass%3D123@host:1080") + assert args == ["--proxy-server=socks5://user:pass%3D123@host:1080"] + + def test_socks5_string_logs_info_when_reencoding(self, caplog): + # When wrapper actually rewrites the URL (e.g. unencoded '=' in pwd), + # surface an INFO log so users debugging SOCKS5 connectivity (#157) + # can see what the wrapper did instead of being silently surprised. + import logging + with caplog.at_level(logging.INFO, logger="cloakbrowser"): + _resolve_proxy_config("socks5://user:pass=123@host:1080") + assert any("Auto URL-encoded SOCKS5" in r.message for r in caplog.records) + # Credentials must not leak into the log. + for r in caplog.records: + assert "pass=123" not in r.message + assert "pass%3D123" not in r.message + + def test_socks5_string_silent_when_already_encoded(self, caplog): + # Idempotent path: pre-encoded URL produces no log noise. + import logging + with caplog.at_level(logging.INFO, logger="cloakbrowser"): + _resolve_proxy_config("socks5://user:pass%3D123@host:1080") + assert not any("Auto URL-encoded SOCKS5" in r.message for r in caplog.records) + + def test_socks5_string_silent_when_no_credentials(self, caplog): + # No userinfo at all → no encoding work → no log. + import logging + with caplog.at_level(logging.INFO, logger="cloakbrowser"): + _resolve_proxy_config("socks5://host:1080") + assert not any("Auto URL-encoded SOCKS5" in r.message for r in caplog.records) + + def test_socks5_string_silent_when_only_cosmetic_change(self, caplog): + # urlparse lowercases scheme and hostname, but credentials are + # untouched. The log must NOT fire for these cosmetic-only rewrites + # (regression for Copilot's review on PR #209). + import logging + with caplog.at_level(logging.INFO, logger="cloakbrowser"): + _resolve_proxy_config("socks5://USER:pass@HOST.com:1080") + assert not any("Auto URL-encoded SOCKS5" in r.message for r in caplog.records) + + def test_socks5_string_no_creds_unchanged(self): + _, args = _resolve_proxy_config("socks5://host:1080") + assert args == ["--proxy-server=socks5://host:1080"] + + def test_socks5_string_password_only_still_encoded(self): + # Empty username with password: fix must still re-encode the password + # (regression test for empty-username bypass). + _, args = _resolve_proxy_config("socks5://:pass=123@host:1080") + assert args == ["--proxy-server=socks5://:pass%3D123@host:1080"] + + def test_socks5_string_empty_password_preserves_colon(self): + # `user:@host` (empty password) must NOT collapse to `user@host` — + # semantics differ between the two forms. + _, args = _resolve_proxy_config("socks5://user:@host:1080") + assert args == ["--proxy-server=socks5://user:@host:1080"] + + def test_socks5_string_literal_percent_in_password(self): + # Literal '%' not followed by 2 hex digits must be encoded as '%25' + # so Chrome decodes it back to '%'. Must not crash. + _, args = _resolve_proxy_config("socks5://user:100%sure@host:1080") + assert args == ["--proxy-server=socks5://user:100%25sure@host:1080"] + + def test_socks5_string_malformed_port_passes_through(self, caplog): + # Invalid port (non-numeric) raises in urlparse.port. Wrapper should + # log a warning and pass original through to Chromium. + import logging + with caplog.at_level(logging.WARNING, logger="cloakbrowser"): + _, args = _resolve_proxy_config("socks5://user:pass@host:abc") + assert args == ["--proxy-server=socks5://user:pass@host:abc"] + assert any("Malformed SOCKS5" in r.message for r in caplog.records) + + def test_socks5_string_malformed_ipv6_passes_through(self, caplog): + # Broken IPv6 bracket — must not crash, and must reach Chromium + # verbatim so its own error surfaces instead of a silent rewrite. + import logging + with caplog.at_level(logging.WARNING, logger="cloakbrowser"): + _, args = _resolve_proxy_config("socks5://user:pass@[::1") + assert args == ["--proxy-server=socks5://user:pass@[::1"] + + def test_socks5_string_preserves_path_and_query(self): + # Nonstandard for SOCKS5, but don't silently drop user-supplied suffixes. + # Matches JS behavior. + _, args = _resolve_proxy_config("socks5://user:pass@host:1080/p?x=1#f") + assert args[0] == "--proxy-server=socks5://user:pass@host:1080/p?x=1#f" + + def test_socks5_string_ipv6_with_special_char_password(self): + # IPv6 host + special char in password — both must be handled. + _, args = _resolve_proxy_config("socks5://user:pass=eq@[::1]:1080") + assert args[0] == "--proxy-server=socks5://user:pass%3Deq@[::1]:1080" + + def test_socks5_string_port_zero_preserved(self): + # Port 0 is an unusual but valid URL component; don't silently strip it. + _, args = _resolve_proxy_config("socks5://user:pass=1@host:0") + assert args[0] == "--proxy-server=socks5://user:pass%3D1@host:0" + + # --- HTTP with credentials → --proxy-server (supported platforms + version) --- + + @patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5") + @patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64") + def test_http_string_with_creds_on_supported_platform(self, *_): + kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080") + assert kwargs == {} + assert args == ["--proxy-server=http://user:pass@proxy:8080"] + + @patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5") + @patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64") + def test_http_dict_with_creds_on_supported_platform(self, *_): + proxy = {"server": "http://proxy:8080", "username": "user", "password": "pass"} + kwargs, args = _resolve_proxy_config(proxy) + assert kwargs == {} + assert args == ["--proxy-server=http://user:pass@proxy:8080"] + + @patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5") + @patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64") + def test_http_dict_with_creds_and_bypass(self, *_): + proxy = { + "server": "http://proxy:8080", + "username": "user", + "password": "pass", + "bypass": ".google.com", + } + kwargs, args = _resolve_proxy_config(proxy) + assert kwargs == {} + assert "--proxy-server=http://user:pass@proxy:8080" in args + assert "--proxy-bypass-list=.google.com" in args + + @patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5") + @patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64") + def test_http_string_encodes_special_chars_in_password(self, *_): + _, args = _resolve_proxy_config("http://user:pass=123@proxy:8080") + assert args == ["--proxy-server=http://user:pass%3D123@proxy:8080"] + + @patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5") + @patch("cloakbrowser.config.get_platform_tag", return_value="linux-x64") + def test_http_string_encoding_idempotent(self, *_): + _, args = _resolve_proxy_config("http://user:pass%3D123@proxy:8080") + assert args == ["--proxy-server=http://user:pass%3D123@proxy:8080"] + + @patch("cloakbrowser.config.get_chromium_version", return_value="146.0.7680.177.5") + @patch("cloakbrowser.config.get_platform_tag", return_value="windows-x64") + def test_http_string_with_creds_on_windows(self, *_): + kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080") + assert kwargs == {} + assert args == ["--proxy-server=http://user:pass@proxy:8080"] + + # --- HTTP with credentials on binaries without inline proxy auth → fallback --- + # Free macOS (145.x) and linux-arm64 (146.0.7680.177.3) predate the inline + # proxy-auth patch, so their credentialed HTTP proxies route through + # Playwright's proxy dict instead of --proxy-server. + + @patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64") + def test_http_string_with_creds_on_macos_falls_back(self, _mock): + kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080") + assert "proxy" in kwargs + assert kwargs["proxy"]["username"] == "user" + assert args == [] + + @patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64") + def test_http_dict_with_creds_on_macos_falls_back(self, _mock): + proxy = {"server": "http://proxy:8080", "username": "user", "password": "pass"} + kwargs, args = _resolve_proxy_config(proxy) + assert kwargs == {"proxy": proxy} + assert args == [] + + @patch("cloakbrowser.config.get_platform_tag", return_value="linux-arm64") + def test_http_string_with_creds_on_linux_arm_falls_back(self, _mock): + kwargs, args = _resolve_proxy_config("http://user:pass@proxy:8080") + assert "proxy" in kwargs + assert args == [] + + @patch("cloakbrowser.config.get_platform_tag", return_value="darwin-arm64") + def test_http_with_creds_on_macos_inline_when_pinned_new(self, _mock): + # A macOS binary at/above the floor (e.g. a pinned 148 build with the + # inline proxy-auth patch) uses --proxy-server, not the fallback. + kwargs, args = _resolve_proxy_config( + "http://user:pass@proxy:8080", browser_version="148.0.7778.215.3" + ) + assert kwargs == {} + assert args == ["--proxy-server=http://user:pass@proxy:8080"] + + # --- HTTP without credentials (all platforms) --- + + def test_http_no_creds_returns_playwright_dict(self): + kwargs, args = _resolve_proxy_config("http://proxy:8080") + assert "proxy" in kwargs + assert args == [] + + def test_http_dict_no_creds_returns_playwright_dict(self): + proxy = {"server": "http://proxy:8080", "bypass": ".example.com"} + kwargs, args = _resolve_proxy_config(proxy) + assert kwargs == {"proxy": proxy} + assert args == [] diff --git a/tests/test_stealth.py b/tests/test_stealth.py new file mode 100644 index 0000000..2fa13dc --- /dev/null +++ b/tests/test_stealth.py @@ -0,0 +1,280 @@ +"""Stealth detection tests for cloakbrowser. + +These tests verify that the stealth Chromium binary passes common +bot detection checks. They require network access. +""" + +import os +import time + +import pytest +from cloakbrowser import launch + +PROXY = os.environ.get("CLOAKBROWSER_TEST_PROXY") + + +@pytest.fixture(scope="module") +def browser(): + """Shared browser instance for stealth tests.""" + b = launch(headless=True, proxy=PROXY) + yield b + b.close() + + +@pytest.fixture +def page(browser): + """Fresh page for each test.""" + p = browser.new_page() + yield p + p.close() + + +class TestWebDriverDetection: + """Tests for WebDriver/automation detection signals.""" + + def test_navigator_webdriver_false(self, page): + """navigator.webdriver must be false.""" + page.goto("https://example.com") + assert page.evaluate("navigator.webdriver") is False + + def test_no_headless_chrome_ua(self, page): + """User agent must not contain 'HeadlessChrome'.""" + page.goto("https://example.com") + ua = page.evaluate("navigator.userAgent") + assert "HeadlessChrome" not in ua + assert "Chrome/" in ua + + def test_window_chrome_exists(self, page): + """window.chrome must be an object (not undefined).""" + page.goto("https://example.com") + assert page.evaluate("typeof window.chrome") == "object" + + def test_plugins_present(self, page): + """Must have browser plugins (real Chrome has 5).""" + page.goto("https://example.com") + count = page.evaluate("navigator.plugins.length") + assert count >= 5, f"Expected 5+ plugins (real Chrome), got {count}" + + def test_languages_present(self, page): + """navigator.languages must be populated.""" + page.goto("https://example.com") + langs = page.evaluate("navigator.languages") + assert len(langs) >= 1 + + def test_cdp_not_detected(self, page): + """Chrome DevTools Protocol should not be detectable.""" + page.goto("https://example.com") + # Common CDP detection: check for Runtime.evaluate artifacts + has_cdp = page.evaluate(""" + () => { + try { + // Check common CDP leak: window.cdc_ + const keys = Object.keys(window); + return keys.some(k => k.startsWith('cdc_') || k.startsWith('__webdriver')); + } catch(e) { + return false; + } + } + """) + assert has_cdp is False + + +class TestBotDetectionSites: + """Live tests against bot detection services. + + These require network access and may be slow. + Mark with pytest -m slow to skip in CI. + """ + + @pytest.mark.slow + def test_bot_sannysoft(self, page): + """bot.sannysoft.com — all checks should pass (0 failures).""" + page.goto("https://bot.sannysoft.com", wait_until="networkidle", timeout=30000) + time.sleep(3) + + results = page.evaluate("""() => { + const rows = document.querySelectorAll('table tr'); + const failed = []; + let total = 0; + rows.forEach(r => { + const cells = r.querySelectorAll('td'); + if (cells.length >= 2) { + total++; + const cls = cells[1].className || ''; + if (cls.includes('failed')) { + failed.push(cells[0].innerText.trim()); + } + } + }); + return {total, failed}; + }""") + + failed = results["failed"] + assert len(failed) == 0, f"Sannysoft failures: {', '.join(failed)}" + + @pytest.mark.slow + def test_bot_incolumitas(self, page): + """bot.incolumitas.com — max 1 failure (WEBDRIVER false positive expected).""" + page.goto("https://bot.incolumitas.com", wait_until="networkidle", timeout=30000) + time.sleep(12) + + # Known acceptable failures (not browser fingerprint issues): + # - WEBDRIVER: spec-level false positive across all builds + # - connectionRTT: detects datacenter/proxy network latency, not browser + KNOWN_ACCEPTABLE = {"WEBDRIVER", "connectionRTT"} + + results = page.evaluate("""() => { + const text = document.body.innerText; + const okMatches = text.match(/"\\w+":\\s*"OK"/g) || []; + const failMatches = text.match(/"\\w+":\\s*"FAIL"/g) || []; + const failedTests = failMatches.map(m => m.match(/"(\\w+)"/)[1]); + return {passed: okMatches.length, failed: failMatches.length, failedTests}; + }""") + + failed_names = results["failedTests"] + real_failures = [f for f in failed_names if f not in KNOWN_ACCEPTABLE] + assert len(real_failures) == 0, f"Incolumitas unexpected failures: {', '.join(real_failures)}" + + @pytest.mark.slow + def test_browserscan(self, page): + """BrowserScan bot detection — 0 abnormal checks.""" + page.goto("https://www.browserscan.net/bot-detection", wait_until="networkidle", timeout=30000) + time.sleep(5) + + results = page.evaluate("""() => { + const text = document.body.innerText; + const normalMatches = text.match(/Normal/g); + const abnormalMatches = text.match(/Abnormal/g); + return { + normal: normalMatches ? normalMatches.length : 0, + abnormal: abnormalMatches ? abnormalMatches.length : 0 + }; + }""") + + assert results["abnormal"] == 0, \ + f"BrowserScan: {results['abnormal']} abnormal, {results['normal']} normal" + + @pytest.mark.slow + def test_device_and_browser_info(self, page): + """deviceandbrowserinfo.com — isBot must be false.""" + page.goto("https://deviceandbrowserinfo.com/are_you_a_bot", wait_until="domcontentloaded", timeout=30000) + time.sleep(8) + + results = page.evaluate("""() => { + const text = document.body.innerText; + const botMatch = text.match(/"isBot":\\s*(true|false)/); + const isBot = botMatch ? botMatch[1] === 'true' : null; + const checks = {}; + ['isBot', 'hasBotUserAgent', 'hasWebdriverTrue', 'isHeadlessChrome', + 'isAutomatedWithCDP', 'hasSuspiciousWeakSignals', 'isPlaywright', + 'hasInconsistentChromeObject'].forEach(p => { + const match = text.match(new RegExp('"' + p + '":\\s*(true|false)')); + if (match) checks[p] = match[1] === 'true'; + }); + return {isBot, checks}; + }""") + + assert results["isBot"] is False, f"Detected as bot! Checks: {results['checks']}" + + @pytest.mark.slow + def test_fingerprintjs(self, page): + """FingerprintJS — must not be blocked, should see flight data.""" + page.goto("https://demo.fingerprint.com/web-scraping", wait_until="domcontentloaded", timeout=30000) + time.sleep(8) + + try: + page.click("button:has-text('Search')", timeout=5000) + time.sleep(5) + except Exception: + pass + + results = page.evaluate("""() => { + const text = document.body.innerText; + const hasFlights = text.includes('Price per adult') || text.includes('$'); + const isBlocked = text.includes('request was blocked') || text.includes('bot visit detected'); + return {passed: hasFlights && !isBlocked, isBlocked, hasFlights}; + }""") + + assert not results["isBlocked"], "FingerprintJS blocked us as a bot" + assert results["passed"], "FingerprintJS: no flight data shown" + + @pytest.mark.slow + def test_recaptcha_v3(self, page): + """reCAPTCHA v3 — score must be >= 0.7.""" + page.goto( + "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php", + wait_until="domcontentloaded", + timeout=60000, + ) + time.sleep(8) + + results = page.evaluate("""() => { + const text = document.body.innerText; + const scoreMatch = text.match(/"score":\\s*(\\d+\\.\\d+)/); + return {score: scoreMatch ? parseFloat(scoreMatch[1]) : null}; + }""") + + score = results["score"] + assert score is not None, "Could not extract reCAPTCHA score" + assert score >= 0.7, f"reCAPTCHA score too low: {score}" + + +class TestIssueRegressions: + """Regression tests for specific GitHub issues. + + Uses the shared browser fixture to avoid "Sync API inside asyncio loop" + errors when pytest-asyncio is active. + """ + + @pytest.mark.slow + def test_immediate_goto_works(self, browser): + """Issue #9: page.goto() immediately after launch must not fail. + + User reported reCAPTCHA fails if goto is called too quickly after + launch. This test verifies that immediate navigation works without + needing an artificial delay. + """ + page = browser.new_page() + # No delay — goto immediately + page.goto("https://example.com", timeout=30000) + title = page.title() + page.close() + assert "Example Domain" in title, f"Immediate goto failed, title={title}" + + @pytest.mark.slow + def test_add_init_script_without_proxy(self, browser): + """Issue #27: add_init_script must work (baseline without proxy). + + The bug is proxy + add_init_script, but we first verify init_script + alone works so we have a baseline. + """ + page = browser.new_page() + page.add_init_script("window.__cloaktest = 42;") + page.goto("https://example.com", timeout=30000) + val = page.evaluate("window.__cloaktest") + page.close() + assert val == 42, f"add_init_script failed, got {val}" + + @pytest.mark.slow + def test_add_init_script_with_proxy(self, browser): + """Issue #27: add_init_script + proxy must not cause ERR_TUNNEL_CONNECTION_FAILED. + + Uses context-level proxy to avoid launching a separate browser + (event loop conflict). + """ + proxy = os.environ.get("CLOAKBROWSER_TEST_PROXY") + if not proxy: + pytest.skip("CLOAKBROWSER_TEST_PROXY not set") + + ctx = browser.new_context(proxy={"server": proxy}) + page = ctx.new_page() + page.add_init_script("window.__cloaktest = 99;") + try: + page.goto("https://httpbin.org/ip", timeout=30000) + body = page.evaluate("document.body.innerText") + val = page.evaluate("window.__cloaktest") + assert val == 99, f"init_script value wrong: {val}" + assert "origin" in body, f"Page didn't load through proxy: {body[:100]}" + finally: + page.close() + ctx.close() diff --git a/tests/test_stealth_reproduction_110.py b/tests/test_stealth_reproduction_110.py new file mode 100644 index 0000000..74c9318 --- /dev/null +++ b/tests/test_stealth_reproduction_110.py @@ -0,0 +1,168 @@ +# tests/test_stealth_reproduction_110.py +""" +Exact reproduction of issue #110 detection vectors. +Proves all three leaks (isInputElement, isSelectorFocused, typeShiftSymbol) +are fixed with CDP isolated worlds. +""" +import asyncio +import pytest + +@pytest.mark.slow +class TestIssue110Reproduction: + + @pytest.mark.asyncio + async def test_exact_reproduction_from_issue(self): + """Exact detection script from issue #110 — must produce zero detections.""" + from cloakbrowser import launch_async + + browser = await launch_async(headless=True, humanize=True) + page = await browser.new_page() + + await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + await asyncio.sleep(1) + + # === EXACT detection from issue #110 === + await page.evaluate(""" + () => { + window.__detections = { + evaluateQS: [], + untrustedKeydown: [] + }; + + // Detection 1: querySelector from evaluate context + const origQS = document.querySelector.bind(document); + document.querySelector = function(sel) { + try { throw new Error(); } catch (e) { + if (e.stack.includes(':302:')) { + window.__detections.evaluateQS.push(sel); + } + } + return origQS(sel); + }; + + // Detection 2: untrusted keyboard events + document.addEventListener('keydown', (e) => { + if (!e.isTrusted) { + window.__detections.untrustedKeydown.push(e.key); + } + }, true); + } + """) + + # === Trigger all three vectors from issue === + + # Vector 1: isInputElement — click triggers querySelector check + await page.click('#searchInput') + await asyncio.sleep(0.3) + + # Vector 2+3: typeShiftSymbol — type text with shift symbols + await page.keyboard.type('Hello!@#$%^&*()') + await asyncio.sleep(0.5) + + # === Verify: zero detections === + detections = await page.evaluate('() => window.__detections') + + qs_leaks = detections['evaluateQS'] + untrusted = detections['untrustedKeydown'] + + print(f"\n{'='*60}") + print(f"Issue #110 Reproduction Results:") + print(f" querySelector from evaluate: {len(qs_leaks)} detections") + print(f" Untrusted keyboard events: {len(untrusted)} detections") + print(f"{'='*60}") + + assert len(qs_leaks) == 0, ( + f"LEAK: querySelector called from evaluate context: {qs_leaks}" + ) + assert len(untrusted) == 0, ( + f"LEAK: Untrusted keyboard events detected: {untrusted}" + ) + + await browser.close() + + @pytest.mark.asyncio + async def test_all_21_shift_symbols_trusted(self): + """Every single shift symbol must produce isTrusted=true.""" + from cloakbrowser import launch_async + + browser = await launch_async(headless=True, humanize=True) + page = await browser.new_page() + + await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + await asyncio.sleep(1) + + await page.evaluate(""" + () => { + window.__keyResults = { trusted: [], untrusted: [] }; + const input = document.querySelector('#searchInput'); + input.addEventListener('keydown', (e) => { + const list = e.isTrusted ? 'trusted' : 'untrusted'; + window.__keyResults[list].push(e.key); + }, true); + } + """) + + await page.click('#searchInput') + await asyncio.sleep(0.3) + + # Type ALL 21 shift symbols + all_shift = '!@#$%^&*()_+{}|:"<>?~' + await page.keyboard.type(all_shift) + await asyncio.sleep(1) + + results = await page.evaluate('() => window.__keyResults') + + print(f"\n{'='*60}") + print(f"All 21 Shift Symbols Test:") + print(f" Trusted: {results['trusted']}") + print(f" Untrusted: {results['untrusted']}") + print(f"{'='*60}") + + # Every shift symbol must be trusted + for sym in all_shift: + assert sym in results['trusted'], f"'{sym}' NOT in trusted events" + assert sym not in results['untrusted'], f"'{sym}' IS in untrusted events" + + await browser.close() + + @pytest.mark.asyncio + async def test_clear_uses_isolated_world(self): + """clear() calls isSelectorFocused — must not leak evaluate.""" + from cloakbrowser import launch_async + + browser = await launch_async(headless=True, humanize=True) + page = await browser.new_page() + + await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + await asyncio.sleep(1) + + await page.evaluate(""" + () => { + window.__evalLeaks = []; + const origQS = document.querySelector.bind(document); + document.querySelector = function(sel) { + try { throw new Error(); } catch (e) { + if (e.stack.includes(':302:')) { + window.__evalLeaks.push(sel); + } + } + return origQS(sel); + }; + } + """) + + # fill → click + type (isInputElement + isSelectorFocused) + await page.locator('#searchInput').fill('some text') + await asyncio.sleep(0.3) + + # clear → isSelectorFocused check + await page.locator('#searchInput').clear() + await asyncio.sleep(0.3) + + leaks = await page.evaluate('() => window.__evalLeaks') + assert len(leaks) == 0, f"clear() leaked via evaluate: {leaks}" + + val = await page.locator('#searchInput').input_value() + assert val == '', f"clear() didn't clear: '{val}'" + + await browser.close() diff --git a/tests/test_stealth_unit.py b/tests/test_stealth_unit.py new file mode 100644 index 0000000..d1b9746 --- /dev/null +++ b/tests/test_stealth_unit.py @@ -0,0 +1,1485 @@ +""" +Unit tests for stealth / anti-detection fixes (issue #110). + +Covers: + - _SyncIsolatedWorld / _AsyncIsolatedWorld — CDP isolated-world lifecycle + - _is_input_element / _is_selector_focused — stealth DOM queries with fallback + - _type_shift_symbol / _type_shift_symbol (async) — CDP Input.dispatchKeyEvent path + - Navigation invalidation (goto → stealth.invalidate) + - patch_page stealth infrastructure wiring + - SHIFT_SYMBOL_CODES / SHIFT_SYMBOL_KEYCODES completeness + +All tests are fast, mock-based, and do NOT require a browser. +""" + +import asyncio +import json +import math +import sys +import time + +import pytest +from unittest.mock import MagicMock, AsyncMock, patch as mock_patch, call + + +# ========================================================================= +# Helper: quick config +# ========================================================================= + +def _cfg(**overrides): + from cloakbrowser.human.config import resolve_config + return resolve_config("default", overrides or None) + + +# ========================================================================= +# 12. _SyncIsolatedWorld +# ========================================================================= + +class TestSyncIsolatedWorld: + """Tests for the synchronous CDP isolated-world wrapper.""" + + def _make_world(self, cdp_send_side_effect=None): + """Return (_SyncIsolatedWorld, mock_page, mock_cdp).""" + from cloakbrowser.human import _SyncIsolatedWorld + + mock_cdp = MagicMock() + mock_cdp.send = MagicMock(side_effect=cdp_send_side_effect) + + mock_context = MagicMock() + mock_context.new_cdp_session = MagicMock(return_value=mock_cdp) + + mock_page = MagicMock() + mock_page.context = mock_context + + world = _SyncIsolatedWorld(mock_page) + return world, mock_page, mock_cdp + + def test_initial_state(self): + from cloakbrowser.human import _SyncIsolatedWorld + page = MagicMock() + w = _SyncIsolatedWorld(page) + assert w._cdp is None + assert w._context_id is None + + def test_evaluate_creates_world_and_returns_value(self): + """First evaluate() should create CDP session → isolated world → Runtime.evaluate.""" + call_counter = {"n": 0} + + def cdp_send(method, params=None): + call_counter["n"] += 1 + if method == "Page.getFrameTree": + return {"frameTree": {"frame": {"id": "F1"}}} + if method == "Page.createIsolatedWorld": + return {"executionContextId": 42} + if method == "Runtime.evaluate": + return {"result": {"value": True}} + return {} + + world, page, cdp = self._make_world(cdp_send_side_effect=cdp_send) + result = world.evaluate("1 + 1") + + assert result is True + assert world._context_id == 42 + assert world._cdp is not None + + def test_evaluate_caches_context_id(self): + """Second evaluate() reuses cached context_id — no second createIsolatedWorld.""" + create_calls = {"n": 0} + + def cdp_send(method, params=None): + if method == "Page.getFrameTree": + return {"frameTree": {"frame": {"id": "F1"}}} + if method == "Page.createIsolatedWorld": + create_calls["n"] += 1 + return {"executionContextId": 99} + if method == "Runtime.evaluate": + return {"result": {"value": "ok"}} + return {} + + world, _, _ = self._make_world(cdp_send_side_effect=cdp_send) + world.evaluate("a") + world.evaluate("b") + + assert create_calls["n"] == 1 # world created only once + + def test_evaluate_retries_on_exception_details(self): + """If Runtime.evaluate returns exceptionDetails, recreate world and retry.""" + attempt = {"n": 0} + + def cdp_send(method, params=None): + if method == "Page.getFrameTree": + return {"frameTree": {"frame": {"id": "F1"}}} + if method == "Page.createIsolatedWorld": + return {"executionContextId": 50 + attempt["n"]} + if method == "Runtime.evaluate": + attempt["n"] += 1 + if attempt["n"] == 1: + return {"exceptionDetails": {"text": "stale"}} + return {"result": {"value": "recovered"}} + return {} + + world, _, _ = self._make_world(cdp_send_side_effect=cdp_send) + result = world.evaluate("test") + assert result == "recovered" + + def test_evaluate_retries_on_cdp_exception(self): + """If cdp.send raises, recreate world and retry.""" + attempt = {"n": 0} + + def cdp_send(method, params=None): + if method == "Page.getFrameTree": + return {"frameTree": {"frame": {"id": "F1"}}} + if method == "Page.createIsolatedWorld": + return {"executionContextId": 60 + attempt["n"]} + if method == "Runtime.evaluate": + attempt["n"] += 1 + if attempt["n"] == 1: + raise Exception("Target closed") + return {"result": {"value": "ok"}} + return {} + + world, _, _ = self._make_world(cdp_send_side_effect=cdp_send) + result = world.evaluate("test") + assert result == "ok" + + def test_evaluate_returns_none_after_double_failure(self): + """If both attempts fail, evaluate returns None.""" + def cdp_send(method, params=None): + if method == "Page.getFrameTree": + return {"frameTree": {"frame": {"id": "F1"}}} + if method == "Page.createIsolatedWorld": + return {"executionContextId": 70} + if method == "Runtime.evaluate": + return {"exceptionDetails": {"text": "always broken"}} + return {} + + world, _, _ = self._make_world(cdp_send_side_effect=cdp_send) + result = world.evaluate("broken") + assert result is None + + def test_invalidate_resets_context_id(self): + """invalidate() sets _context_id to None.""" + def cdp_send(method, params=None): + if method == "Page.getFrameTree": + return {"frameTree": {"frame": {"id": "F1"}}} + if method == "Page.createIsolatedWorld": + return {"executionContextId": 80} + if method == "Runtime.evaluate": + return {"result": {"value": "val"}} + return {} + + world, _, _ = self._make_world(cdp_send_side_effect=cdp_send) + world.evaluate("x") + assert world._context_id == 80 + + world.invalidate() + assert world._context_id is None + + def test_get_cdp_session_creates_and_caches(self): + """get_cdp_session() creates and caches the CDP session.""" + world, page, cdp = self._make_world() + session = world.get_cdp_session() + assert session is cdp + # Second call returns same session + session2 = world.get_cdp_session() + assert session2 is session + + def test_evaluate_returns_none_when_create_world_fails_on_retry(self): + """If _create_world fails during retry, return None gracefully.""" + attempt = {"n": 0} + + def cdp_send(method, params=None): + if method == "Page.getFrameTree": + if attempt["n"] > 0: + raise Exception("Frame tree gone") + return {"frameTree": {"frame": {"id": "F1"}}} + if method == "Page.createIsolatedWorld": + return {"executionContextId": 90} + if method == "Runtime.evaluate": + attempt["n"] += 1 + raise Exception("Fail") + return {} + + world, _, _ = self._make_world(cdp_send_side_effect=cdp_send) + result = world.evaluate("test") + assert result is None + + +# ========================================================================= +# 13. _AsyncIsolatedWorld +# ========================================================================= + +class TestAsyncIsolatedWorld: + """Tests for the async CDP isolated-world wrapper.""" + + def _make_world(self, cdp_send_side_effect=None): + from cloakbrowser.human import _AsyncIsolatedWorld + + mock_cdp = MagicMock() + if cdp_send_side_effect: + mock_cdp.send = AsyncMock(side_effect=cdp_send_side_effect) + else: + mock_cdp.send = AsyncMock() + + mock_context = MagicMock() + mock_context.new_cdp_session = AsyncMock(return_value=mock_cdp) + + mock_page = MagicMock() + mock_page.context = mock_context + + world = _AsyncIsolatedWorld(mock_page) + return world, mock_page, mock_cdp + + @pytest.mark.asyncio + async def test_initial_state(self): + from cloakbrowser.human import _AsyncIsolatedWorld + page = MagicMock() + w = _AsyncIsolatedWorld(page) + assert w._cdp is None + assert w._context_id is None + + @pytest.mark.asyncio + async def test_evaluate_creates_world_and_returns_value(self): + def cdp_send(method, params=None): + if method == "Page.getFrameTree": + return {"frameTree": {"frame": {"id": "AF1"}}} + if method == "Page.createIsolatedWorld": + return {"executionContextId": 142} + if method == "Runtime.evaluate": + return {"result": {"value": True}} + return {} + + world, _, _ = self._make_world(cdp_send_side_effect=cdp_send) + result = await world.evaluate("async test") + assert result is True + assert world._context_id == 142 + + @pytest.mark.asyncio + async def test_evaluate_retries_on_exception_details(self): + attempt = {"n": 0} + + def cdp_send(method, params=None): + if method == "Page.getFrameTree": + return {"frameTree": {"frame": {"id": "AF1"}}} + if method == "Page.createIsolatedWorld": + return {"executionContextId": 150 + attempt["n"]} + if method == "Runtime.evaluate": + attempt["n"] += 1 + if attempt["n"] == 1: + return {"exceptionDetails": {"text": "stale"}} + return {"result": {"value": "async_recovered"}} + return {} + + world, _, _ = self._make_world(cdp_send_side_effect=cdp_send) + result = await world.evaluate("test") + assert result == "async_recovered" + + @pytest.mark.asyncio + async def test_invalidate_and_recreate(self): + create_count = {"n": 0} + + def cdp_send(method, params=None): + if method == "Page.getFrameTree": + return {"frameTree": {"frame": {"id": "AF1"}}} + if method == "Page.createIsolatedWorld": + create_count["n"] += 1 + return {"executionContextId": 200 + create_count["n"]} + if method == "Runtime.evaluate": + return {"result": {"value": "ok"}} + return {} + + world, _, _ = self._make_world(cdp_send_side_effect=cdp_send) + + await world.evaluate("first") + assert create_count["n"] == 1 + + world.invalidate() + assert world._context_id is None + + await world.evaluate("second") + assert create_count["n"] == 2 # recreated + + @pytest.mark.asyncio + async def test_get_cdp_session_async(self): + world, page, cdp = self._make_world() + session = await world.get_cdp_session() + assert session is cdp + + +# ========================================================================= +# 14. _is_input_element stealth +# ========================================================================= + +class TestIsInputElementStealth: + """Tests for stealth-aware _is_input_element.""" + + def test_uses_isolated_world_when_available(self): + from cloakbrowser.human import _is_input_element + + mock_world = MagicMock() + mock_world.evaluate = MagicMock(return_value=True) + + page = MagicMock() + page._stealth_world = mock_world + + result = _is_input_element(page, "#myInput") + assert result is True + # Should have called isolated world, NOT page.evaluate + mock_world.evaluate.assert_called_once() + page.evaluate.assert_not_called() + + def test_isolated_world_receives_escaped_selector(self): + from cloakbrowser.human import _is_input_element + + mock_world = MagicMock() + mock_world.evaluate = MagicMock(return_value=False) + + page = MagicMock() + page._stealth_world = mock_world + + _is_input_element(page, 'input[name="email"]') + + call_args = mock_world.evaluate.call_args[0][0] + # The escaped selector must appear in the expression + assert json.dumps('input[name="email"]') in call_args or 'input[name=\\"email\\"]' in call_args + + def test_returns_false_for_non_input(self): + from cloakbrowser.human import _is_input_element + + mock_world = MagicMock() + mock_world.evaluate = MagicMock(return_value=False) + + page = MagicMock() + page._stealth_world = mock_world + + result = _is_input_element(page, "#btn") + assert result is False + + def test_falls_back_to_evaluate_when_no_stealth_world(self): + from cloakbrowser.human import _is_input_element + + page = MagicMock() + page._stealth_world = None + page.evaluate = MagicMock(return_value=True) + + result = _is_input_element(page, "#inp") + assert result is True + page.evaluate.assert_called_once() + + def test_falls_back_to_evaluate_when_isolated_world_raises(self): + from cloakbrowser.human import _is_input_element + + mock_world = MagicMock() + mock_world.evaluate = MagicMock(side_effect=Exception("CDP gone")) + + page = MagicMock() + page._stealth_world = mock_world + page.evaluate = MagicMock(return_value=True) + + result = _is_input_element(page, "#inp") + assert result is True + page.evaluate.assert_called_once() + + def test_returns_false_when_both_paths_fail(self): + from cloakbrowser.human import _is_input_element + + mock_world = MagicMock() + mock_world.evaluate = MagicMock(side_effect=Exception("CDP gone")) + + page = MagicMock() + page._stealth_world = mock_world + page.evaluate = MagicMock(side_effect=Exception("page gone")) + + result = _is_input_element(page, "#inp") + assert result is False + + def test_no_stealth_world_attr_falls_back(self): + """If page doesn't have _stealth_world at all, use fallback.""" + from cloakbrowser.human import _is_input_element + + page = MagicMock(spec=[]) # no _stealth_world attribute + page.evaluate = MagicMock(return_value=False) + + result = _is_input_element(page, "#x") + assert result is False + + +# ========================================================================= +# 14b. _async_is_input_element stealth +# ========================================================================= + +class TestAsyncIsInputElementStealth: + + @pytest.mark.asyncio + async def test_uses_isolated_world_when_available(self): + from cloakbrowser.human import _async_is_input_element + + mock_world = MagicMock() + mock_world.evaluate = AsyncMock(return_value=True) + + page = MagicMock() + page._stealth_world = mock_world + page.evaluate = AsyncMock() + + result = await _async_is_input_element(page, "#myInput") + assert result is True + mock_world.evaluate.assert_called_once() + page.evaluate.assert_not_called() + + @pytest.mark.asyncio + async def test_falls_back_when_isolated_world_fails(self): + from cloakbrowser.human import _async_is_input_element + + mock_world = MagicMock() + mock_world.evaluate = AsyncMock(side_effect=Exception("dead")) + + page = MagicMock() + page._stealth_world = mock_world + page.evaluate = AsyncMock(return_value=True) + + result = await _async_is_input_element(page, "#inp") + assert result is True + + +# ========================================================================= +# 15. _is_selector_focused stealth +# ========================================================================= + +class TestIsSelectorFocusedStealth: + """Tests for stealth-aware _is_selector_focused.""" + + def test_uses_isolated_world_when_available(self): + from cloakbrowser.human import _is_selector_focused + + mock_world = MagicMock() + mock_world.evaluate = MagicMock(return_value=True) + + page = MagicMock() + page._stealth_world = mock_world + + result = _is_selector_focused(page, "#field") + assert result is True + mock_world.evaluate.assert_called_once() + page.evaluate.assert_not_called() + + def test_returns_false_when_not_focused(self): + from cloakbrowser.human import _is_selector_focused + + mock_world = MagicMock() + mock_world.evaluate = MagicMock(return_value=False) + + page = MagicMock() + page._stealth_world = mock_world + + result = _is_selector_focused(page, "#field") + assert result is False + + def test_falls_back_when_no_stealth_world(self): + from cloakbrowser.human import _is_selector_focused + + page = MagicMock() + page._stealth_world = None + page.evaluate = MagicMock(return_value=True) + + result = _is_selector_focused(page, "#f") + assert result is True + page.evaluate.assert_called_once() + + def test_falls_back_when_isolated_world_raises(self): + from cloakbrowser.human import _is_selector_focused + + mock_world = MagicMock() + mock_world.evaluate = MagicMock(side_effect=Exception("CDP fail")) + + page = MagicMock() + page._stealth_world = mock_world + page.evaluate = MagicMock(return_value=False) + + result = _is_selector_focused(page, "#f") + assert result is False + + def test_returns_false_when_both_paths_fail(self): + from cloakbrowser.human import _is_selector_focused + + mock_world = MagicMock() + mock_world.evaluate = MagicMock(side_effect=Exception("gone")) + + page = MagicMock() + page._stealth_world = mock_world + page.evaluate = MagicMock(side_effect=Exception("also gone")) + + result = _is_selector_focused(page, "#f") + assert result is False + + +# ========================================================================= +# 15b. _async_is_selector_focused stealth +# ========================================================================= + +class TestAsyncIsSelectorFocusedStealth: + + @pytest.mark.asyncio + async def test_uses_isolated_world_when_available(self): + from cloakbrowser.human import _async_is_selector_focused + + mock_world = MagicMock() + mock_world.evaluate = AsyncMock(return_value=True) + + page = MagicMock() + page._stealth_world = mock_world + page.evaluate = AsyncMock() + + result = await _async_is_selector_focused(page, "#field") + assert result is True + mock_world.evaluate.assert_called_once() + page.evaluate.assert_not_called() + + @pytest.mark.asyncio + async def test_falls_back_when_isolated_world_raises(self): + from cloakbrowser.human import _async_is_selector_focused + + mock_world = MagicMock() + mock_world.evaluate = AsyncMock(side_effect=Exception("dead")) + + page = MagicMock() + page._stealth_world = mock_world + page.evaluate = AsyncMock(return_value=False) + + result = await _async_is_selector_focused(page, "#f") + assert result is False + + +# ========================================================================= +# 16. Shift symbol CDP stealth path (sync) +# ========================================================================= + +class TestShiftSymbolCDPSync: + """Tests for _type_shift_symbol using CDP Input.dispatchKeyEvent.""" + + def test_shift_symbol_codes_completeness(self): + """Every SHIFT_SYMBOL must have an entry in _SHIFT_SYMBOL_CODES and _SHIFT_SYMBOL_KEYCODES.""" + from cloakbrowser.human.keyboard import SHIFT_SYMBOLS, _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES + + for sym in SHIFT_SYMBOLS: + assert sym in _SHIFT_SYMBOL_CODES, f"Missing code for '{sym}'" + assert sym in _SHIFT_SYMBOL_KEYCODES, f"Missing keycode for '{sym}'" + + def test_cdp_path_sends_key_down_and_key_up(self): + """When cdp_session is provided, _type_shift_symbol sends keyDown + keyUp via CDP.""" + from cloakbrowser.human.keyboard import _type_shift_symbol + from cloakbrowser.human.keyboard import _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES + + cfg = _cfg() + page = MagicMock() + raw = MagicMock() + + cdp_session = MagicMock() + cdp_calls = [] + cdp_session.send = MagicMock(side_effect=lambda m, p: cdp_calls.append((m, p))) + + _type_shift_symbol(page, raw, "!", cfg, cdp_session=cdp_session) + + # Should have called raw.down("Shift") and raw.up("Shift") + raw.down.assert_any_call("Shift") + raw.up.assert_any_call("Shift") + + # Should have called CDP Input.dispatchKeyEvent (keyDown + keyUp) + assert len(cdp_calls) == 2 + assert cdp_calls[0][0] == "Input.dispatchKeyEvent" + assert cdp_calls[0][1]["type"] == "keyDown" + assert cdp_calls[0][1]["key"] == "!" + assert cdp_calls[0][1]["code"] == _SHIFT_SYMBOL_CODES["!"] + assert cdp_calls[0][1]["windowsVirtualKeyCode"] == _SHIFT_SYMBOL_KEYCODES["!"] + assert cdp_calls[0][1]["modifiers"] == 8 # Shift flag + assert cdp_calls[0][1]["text"] == "!" + + assert cdp_calls[1][0] == "Input.dispatchKeyEvent" + assert cdp_calls[1][1]["type"] == "keyUp" + assert cdp_calls[1][1]["key"] == "!" + + def test_cdp_path_does_not_call_page_evaluate(self): + """When cdp_session is provided, page.evaluate must NOT be called.""" + from cloakbrowser.human.keyboard import _type_shift_symbol + + cfg = _cfg() + page = MagicMock() + raw = MagicMock() + cdp_session = MagicMock() + + _type_shift_symbol(page, raw, "@", cfg, cdp_session=cdp_session) + + page.evaluate.assert_not_called() + + def test_cdp_path_does_not_call_insert_text(self): + """CDP path inserts characters via keyDown text field, not insertText.""" + from cloakbrowser.human.keyboard import _type_shift_symbol + + cfg = _cfg() + page = MagicMock() + raw = MagicMock() + cdp_session = MagicMock() + + _type_shift_symbol(page, raw, "#", cfg, cdp_session=cdp_session) + + raw.insert_text.assert_not_called() + + def test_fallback_path_uses_page_evaluate(self): + """When no cdp_session, falls back to page.evaluate (detectable path).""" + from cloakbrowser.human.keyboard import _type_shift_symbol + + cfg = _cfg() + page = MagicMock() + raw = MagicMock() + + _type_shift_symbol(page, raw, "$", cfg, cdp_session=None) + + page.evaluate.assert_called_once() + raw.insert_text.assert_called_once_with("$") + raw.down.assert_any_call("Shift") + raw.up.assert_any_call("Shift") + + def test_cdp_path_all_shift_symbols(self): + """All 21 shift symbols should work via CDP path without error.""" + from cloakbrowser.human.keyboard import _type_shift_symbol, SHIFT_SYMBOLS + + cfg = _cfg() + page = MagicMock() + raw = MagicMock() + cdp_session = MagicMock() + + for sym in SHIFT_SYMBOLS: + page.reset_mock() + raw.reset_mock() + cdp_session.reset_mock() + + _type_shift_symbol(page, raw, sym, cfg, cdp_session=cdp_session) + + page.evaluate.assert_not_called() + assert cdp_session.send.call_count == 2, f"Expected 2 CDP calls for '{sym}'" + + def test_cdp_keydown_has_text_field(self): + """keyDown event must include 'text' and 'unmodifiedText' for char insertion.""" + from cloakbrowser.human.keyboard import _type_shift_symbol + + cfg = _cfg() + page = MagicMock() + raw = MagicMock() + + cdp_calls = [] + cdp_session = MagicMock() + cdp_session.send = MagicMock(side_effect=lambda m, p: cdp_calls.append((m, p))) + + _type_shift_symbol(page, raw, "%", cfg, cdp_session=cdp_session) + + keydown = cdp_calls[0][1] + assert keydown["text"] == "%" + assert keydown["unmodifiedText"] == "%" + + def test_cdp_keyup_has_no_text_field(self): + """keyUp event should NOT have 'text' or 'unmodifiedText' fields.""" + from cloakbrowser.human.keyboard import _type_shift_symbol + + cfg = _cfg() + page = MagicMock() + raw = MagicMock() + + cdp_calls = [] + cdp_session = MagicMock() + cdp_session.send = MagicMock(side_effect=lambda m, p: cdp_calls.append((m, p))) + + _type_shift_symbol(page, raw, "^", cfg, cdp_session=cdp_session) + + keyup = cdp_calls[1][1] + assert "text" not in keyup + assert "unmodifiedText" not in keyup + + +# ========================================================================= +# 16b. human_type end-to-end: shift symbols route via CDP +# ========================================================================= + +class TestHumanTypeShiftCDP: + """Integration: human_type() routes shift symbols through CDP path.""" + + def test_shift_symbol_in_text_uses_cdp(self): + from cloakbrowser.human.keyboard import human_type + + cfg = _cfg(mistype_chance=0) + page = MagicMock() + raw = MagicMock() + + cdp_calls = [] + cdp_session = MagicMock() + cdp_session.send = MagicMock(side_effect=lambda m, p: cdp_calls.append((m, p))) + + human_type(page, raw, "a!", cfg, cdp_session=cdp_session) + + # 'a' — normal char: raw.down('a'), raw.up('a') + raw.down.assert_any_call("a") + raw.up.assert_any_call("a") + + # '!' — shift symbol via CDP + page.evaluate.assert_not_called() + cdp_key_events = [(m, p) for m, p in cdp_calls if m == "Input.dispatchKeyEvent"] + assert len(cdp_key_events) == 2 # keyDown + keyUp for '!' + + def test_text_without_shift_symbols_no_cdp(self): + from cloakbrowser.human.keyboard import human_type + + cfg = _cfg(mistype_chance=0) + page = MagicMock() + raw = MagicMock() + cdp_session = MagicMock() + + human_type(page, raw, "hello", cfg, cdp_session=cdp_session) + + cdp_session.send.assert_not_called() + page.evaluate.assert_not_called() + + def test_multiple_shift_symbols_all_use_cdp(self): + from cloakbrowser.human.keyboard import human_type + + cfg = _cfg(mistype_chance=0) + page = MagicMock() + raw = MagicMock() + + cdp_calls = [] + cdp_session = MagicMock() + cdp_session.send = MagicMock(side_effect=lambda m, p: cdp_calls.append((m, p))) + + human_type(page, raw, "!@#", cfg, cdp_session=cdp_session) + + page.evaluate.assert_not_called() + # 3 symbols × 2 events = 6 CDP calls + assert len(cdp_calls) == 6 + + def test_mixed_text_no_evaluate_leak(self): + """'Hello World!' — the '!' must go via CDP, uppercase via Shift+raw, lowercase via raw.""" + from cloakbrowser.human.keyboard import human_type + + cfg = _cfg(mistype_chance=0) + page = MagicMock() + raw = MagicMock() + cdp_session = MagicMock() + + human_type(page, raw, "Hello World!", cfg, cdp_session=cdp_session) + + page.evaluate.assert_not_called() + + +# ========================================================================= +# 17. Shift symbol CDP stealth path (async) +# ========================================================================= + +class TestShiftSymbolCDPAsync: + + @pytest.mark.asyncio + async def test_cdp_path_sends_events_async(self): + from cloakbrowser.human.keyboard_async import _type_shift_symbol + from cloakbrowser.human.keyboard import _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES + + cfg = _cfg() + page = MagicMock() + raw = MagicMock() + raw.down = AsyncMock() + raw.up = AsyncMock() + raw.insert_text = AsyncMock() + + cdp_calls = [] + cdp_session = MagicMock() + cdp_session.send = AsyncMock(side_effect=lambda m, p: cdp_calls.append((m, p))) + + await _type_shift_symbol(page, raw, "@", cfg, cdp_session=cdp_session) + + raw.down.assert_any_call("Shift") + raw.up.assert_any_call("Shift") + + assert len(cdp_calls) == 2 + assert cdp_calls[0][1]["type"] == "keyDown" + assert cdp_calls[0][1]["key"] == "@" + assert cdp_calls[1][1]["type"] == "keyUp" + + @pytest.mark.asyncio + async def test_cdp_path_no_evaluate_async(self): + from cloakbrowser.human.keyboard_async import _type_shift_symbol + + cfg = _cfg() + page = MagicMock() + page.evaluate = AsyncMock() + raw = MagicMock() + raw.down = AsyncMock() + raw.up = AsyncMock() + cdp_session = MagicMock() + cdp_session.send = AsyncMock() + + await _type_shift_symbol(page, raw, "#", cfg, cdp_session=cdp_session) + + page.evaluate.assert_not_called() + + @pytest.mark.asyncio + async def test_fallback_path_async(self): + from cloakbrowser.human.keyboard_async import _type_shift_symbol + + cfg = _cfg() + page = MagicMock() + page.evaluate = AsyncMock() + raw = MagicMock() + raw.down = AsyncMock() + raw.up = AsyncMock() + raw.insert_text = AsyncMock() + + await _type_shift_symbol(page, raw, "$", cfg, cdp_session=None) + + page.evaluate.assert_called_once() + raw.insert_text.assert_called_once_with("$") + + @pytest.mark.asyncio + async def test_async_human_type_routes_via_cdp(self): + from cloakbrowser.human.keyboard_async import async_human_type + + cfg = _cfg(mistype_chance=0) + page = MagicMock() + page.evaluate = AsyncMock() + raw = MagicMock() + raw.down = AsyncMock() + raw.up = AsyncMock() + raw.insert_text = AsyncMock() + + cdp_calls = [] + cdp_session = MagicMock() + cdp_session.send = AsyncMock(side_effect=lambda m, p: cdp_calls.append((m, p))) + + await async_human_type(page, raw, "test!", cfg, cdp_session=cdp_session) + + page.evaluate.assert_not_called() + # Only '!' should trigger CDP calls (2 events) + assert len(cdp_calls) == 2 + + +# ========================================================================= +# 18. Navigation invalidation +# ========================================================================= + +class TestNavigationInvalidation: + """Tests that goto invalidates the isolated world context.""" + + def test_goto_invalidates_stealth_world_sync(self): + from cloakbrowser.human import patch_page, _CursorState + from cloakbrowser.human.config import resolve_config + + cfg = resolve_config("default") + cursor = _CursorState() + + page = MagicMock() + page.mouse = MagicMock() + page.keyboard = MagicMock() + page.context = MagicMock() + page.main_frame = MagicMock(return_value=MagicMock(child_frames=[])) + page.frames = MagicMock(return_value=[]) + + # Make CDP session creation succeed + mock_cdp = MagicMock() + mock_cdp.send = MagicMock(side_effect=lambda m, p=None: { + "Page.getFrameTree": {"frameTree": {"frame": {"id": "F1"}}}, + "Page.createIsolatedWorld": {"executionContextId": 500}, + }.get(m, {})) + page.context.new_cdp_session = MagicMock(return_value=mock_cdp) + + patch_page(page, cfg, cursor) + + # Get reference to stealth world + stealth_world = page._stealth_world + assert stealth_world is not None + + # Warm up the context_id + stealth_world._context_id = 500 + + # Call patched goto + orig_goto = page._original.goto + orig_goto.return_value = MagicMock() # response object + page.goto("https://example.com") + + # After goto, context_id should be invalidated + assert stealth_world._context_id is None + + +# ========================================================================= +# 19. patch_page stealth infrastructure wiring +# ========================================================================= + +class TestPatchPageStealthWiring: + """Tests that patch_page creates and attaches stealth infrastructure.""" + + def _make_mock_page(self): + page = MagicMock() + page.mouse = MagicMock() + page.keyboard = MagicMock() + page.context = MagicMock() + page.main_frame = MagicMock(return_value=MagicMock(child_frames=[])) + page.frames = MagicMock(return_value=[]) + + mock_cdp = MagicMock() + mock_cdp.send = MagicMock(return_value={}) + page.context.new_cdp_session = MagicMock(return_value=mock_cdp) + + return page, mock_cdp + + def test_patch_page_sets_stealth_world(self): + from cloakbrowser.human import patch_page, _CursorState, _SyncIsolatedWorld + + page, cdp = self._make_mock_page() + cfg = _cfg() + cursor = _CursorState() + + patch_page(page, cfg, cursor) + + assert hasattr(page, '_stealth_world') + assert isinstance(page._stealth_world, _SyncIsolatedWorld) + + def test_patch_page_sets_original(self): + from cloakbrowser.human import patch_page, _CursorState + + page, _ = self._make_mock_page() + cfg = _cfg() + cursor = _CursorState() + + patch_page(page, cfg, cursor) + + assert hasattr(page, '_original') + assert hasattr(page, '_human_cfg') + assert page._human_cfg is cfg + + def test_stealth_world_none_when_cdp_fails(self): + """If CDP session creation fails, stealth_world should be None.""" + from cloakbrowser.human import patch_page, _CursorState + + page = MagicMock() + page.mouse = MagicMock() + page.keyboard = MagicMock() + page.context = MagicMock() + page.context.new_cdp_session = MagicMock(side_effect=Exception("no CDP")) + page.main_frame = MagicMock(return_value=MagicMock(child_frames=[])) + page.frames = MagicMock(return_value=[]) + + cfg = _cfg() + cursor = _CursorState() + + patch_page(page, cfg, cursor) + + assert page._stealth_world is None + + def test_click_passes_through_stealth_dom_query(self): + """Verify that patched click() calls _is_input_element which uses _stealth_world. + + We mock scroll_to_element to bypass viewport/scrolling complexity, + then intercept CDP send() to verify Runtime.evaluate is called in + the isolated world for the isInputElement DOM query. + """ + from cloakbrowser.human import patch_page, _CursorState + + # Track all cdp.send() calls + runtime_eval_expressions: list[str] = [] + + def tracking_cdp_send(method, params=None): + if method == "Page.getFrameTree": + return {"frameTree": {"frame": {"id": "F1"}}} + if method == "Page.createIsolatedWorld": + return {"executionContextId": 300} + if method == "Runtime.evaluate": + runtime_eval_expressions.append(params.get("expression", "")) + return {"result": {"value": False}} # not an input element + return {} + + page, _ = self._make_mock_page() + cfg = _cfg(idle_between_actions=False) + cursor = _CursorState() + + # Wire up the tracking CDP mock + mock_cdp = MagicMock() + mock_cdp.send = MagicMock(side_effect=tracking_cdp_send) + page.context.new_cdp_session = MagicMock(return_value=mock_cdp) + + patch_page(page, cfg, cursor) + + stealth_world = page._stealth_world + assert stealth_world is not None + + # Mock scroll_to_element to bypass all scrolling logic and return + # a bounding box immediately — this lets click() proceed to + # _is_input_element without getting stuck in viewport checks. + fake_box = {"x": 100, "y": 200, "width": 200, "height": 30} + with mock_patch( + "cloakbrowser.human.scroll_to_element", + return_value=(fake_box, 200.0, 215.0, False), + ), mock_patch( + "cloakbrowser.human.ensure_actionable", + ), mock_patch( + "cloakbrowser.human.check_pointer_events", + ): + try: + page.click("#btn") + except Exception: + pass + + # The isolated world should have been used for the isInputElement check. + # Runtime.evaluate calls from the isolated world contain querySelector + tagName. + assert len(runtime_eval_expressions) >= 1 + assert any("tagName" in expr or "querySelector" in expr for expr in runtime_eval_expressions) + + +# ========================================================================= +# 20. SHIFT_SYMBOL_CODES / SHIFT_SYMBOL_KEYCODES correctness +# ========================================================================= + +class TestShiftSymbolMaps: + """Verify the code/keycode mappings are correct.""" + + def test_all_codes_are_valid_key_codes(self): + from cloakbrowser.human.keyboard import _SHIFT_SYMBOL_CODES + + valid_prefixes = ("Digit", "Minus", "Equal", "Bracket", "Backslash", + "Semicolon", "Quote", "Comma", "Period", "Slash", "Backquote") + for sym, code in _SHIFT_SYMBOL_CODES.items(): + assert any(code.startswith(p) for p in valid_prefixes), \ + f"Invalid code '{code}' for symbol '{sym}'" + + def test_all_keycodes_are_positive_integers(self): + from cloakbrowser.human.keyboard import _SHIFT_SYMBOL_KEYCODES + + for sym, keycode in _SHIFT_SYMBOL_KEYCODES.items(): + assert isinstance(keycode, int), f"Keycode for '{sym}' is not int" + assert keycode > 0, f"Keycode for '{sym}' is not positive" + + def test_digit_symbols_have_correct_keycodes(self): + """!@#$%^&*() should map to keycodes 49-57, 48 (digits 1-9, 0).""" + from cloakbrowser.human.keyboard import _SHIFT_SYMBOL_KEYCODES + + digit_symbols = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')'] + expected_keycodes = [49, 50, 51, 52, 53, 54, 55, 56, 57, 48] + + for sym, expected in zip(digit_symbols, expected_keycodes): + assert _SHIFT_SYMBOL_KEYCODES[sym] == expected, \ + f"Keycode for '{sym}': expected {expected}, got {_SHIFT_SYMBOL_KEYCODES[sym]}" + + def test_codes_and_keycodes_have_same_keys(self): + from cloakbrowser.human.keyboard import _SHIFT_SYMBOL_CODES, _SHIFT_SYMBOL_KEYCODES + assert set(_SHIFT_SYMBOL_CODES.keys()) == set(_SHIFT_SYMBOL_KEYCODES.keys()) + + def test_shift_symbols_set_matches_codes_keys(self): + from cloakbrowser.human.keyboard import SHIFT_SYMBOLS, _SHIFT_SYMBOL_CODES + assert SHIFT_SYMBOLS == frozenset(_SHIFT_SYMBOL_CODES.keys()) + + +# ========================================================================= +# 21. CDP modifiers flag +# ========================================================================= + +class TestCDPModifiers: + """Ensure the shift modifier flag is always 8 (correct CDP constant).""" + + def test_keydown_modifier_is_8(self): + from cloakbrowser.human.keyboard import _type_shift_symbol + + cfg = _cfg() + page = MagicMock() + raw = MagicMock() + + cdp_calls = [] + cdp_session = MagicMock() + cdp_session.send = MagicMock(side_effect=lambda m, p: cdp_calls.append((m, p))) + + _type_shift_symbol(page, raw, "!", cfg, cdp_session=cdp_session) + + for method, params in cdp_calls: + assert params["modifiers"] == 8 + + +# ========================================================================= +# 22. Isolated world expression injection safety +# ========================================================================= + +class TestIsolatedWorldSafety: + """Ensure selectors are properly escaped in JS expressions.""" + + def test_selector_with_quotes_is_escaped(self): + from cloakbrowser.human import _is_input_element + + mock_world = MagicMock() + calls = [] + mock_world.evaluate = MagicMock(side_effect=lambda expr: calls.append(expr) or False) + + page = MagicMock() + page._stealth_world = mock_world + + dangerous_selector = 'input[data-x="\\");alert(1)//"]' + _is_input_element(page, dangerous_selector) + + assert len(calls) == 1 + # The expression should contain JSON-escaped selector + escaped = json.dumps(dangerous_selector) + assert escaped in calls[0] + + def test_selector_with_backticks_escaped(self): + from cloakbrowser.human import _is_selector_focused + + mock_world = MagicMock() + calls = [] + mock_world.evaluate = MagicMock(side_effect=lambda expr: calls.append(expr) or False) + + page = MagicMock() + page._stealth_world = mock_world + + _is_selector_focused(page, "div[class=`test`]") + + assert len(calls) == 1 + assert json.dumps("div[class=`test`]") in calls[0] + + +# ========================================================================= +# SLOW TESTS — require browser (skipped in CI unless pytest -m slow) +# +# Pattern: all browser tests use launch_async + @pytest.mark.asyncio to +# avoid Playwright Sync API / event loop conflicts with pytest-asyncio. +# ========================================================================= + + +def _launch_kwargs(**extra): + """Build launch() kwargs, omitting proxy when empty.""" + kw = {"humanize": True, "headless": False} + kw.update(extra) + if not kw.get("proxy"): + kw.pop("proxy", None) + return kw + + +@pytest.mark.slow +class TestStealthBrowserReal: + """Real-browser tests that verify the stealth fixes from #110. + + All tests use launch_async + @pytest.mark.asyncio to be compatible + with pytest-asyncio mode=AUTO. + """ + + @pytest.mark.asyncio + async def test_stealth_world_attached_to_page(self): + """Verify stealth infrastructure is wired up on real browser page.""" + from cloakbrowser import launch_async + browser = await launch_async(**_launch_kwargs()) + page = await browser.new_page() + + assert hasattr(page, '_stealth_world'), "_stealth_world not attached" + assert page._stealth_world is not None, "_stealth_world is None" + assert hasattr(page, '_original'), "_original not attached" + assert hasattr(page, '_human_cfg'), "_human_cfg not attached" + + await browser.close() + + @pytest.mark.asyncio + async def test_no_evaluate_leak_on_click(self): + """click() on input/button must NOT trigger page.evaluate (querySelector leak). + + We inject a detection script that catches querySelector calls from + evaluate context (Error.stack ':302:' pattern). If humanize is using + the stealth isolated world, these should NOT fire. + """ + import asyncio + from cloakbrowser import launch_async + browser = await launch_async(**_launch_kwargs()) + page = await browser.new_page() + + await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + await asyncio.sleep(1) + + # Inject detection hook directly + await page.evaluate(""" + () => { + window.__evaluateDetections = []; + const origQS = document.querySelector.bind(document); + document.querySelector = function(sel) { + try { throw new Error(); } catch (e) { + if (e.stack && e.stack.includes(':302:')) { + window.__evaluateDetections.push(sel); + } + } + return origQS(sel); + }; + return true; + } + """) + + # Click on the search input — this triggers isInputElement check + await page.locator('#searchInput').click() + await asyncio.sleep(0.5) + + # Check if any querySelector calls came from evaluate context + leaks = await page.evaluate('() => window.__evaluateDetections || []') + assert len(leaks) == 0, f"Evaluate leak detected: {leaks}" + + await browser.close() + + @pytest.mark.asyncio + async def test_shift_symbols_produce_trusted_events(self): + """Shift symbols (!@#) must produce isTrusted=true keyboard events. + + Before #110 fix, these used page.evaluate to dispatch synthetic + KeyboardEvent with isTrusted=false. + """ + import asyncio + from cloakbrowser import launch_async + browser = await launch_async(**_launch_kwargs()) + page = await browser.new_page() + + await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + await asyncio.sleep(1) + + # Inject isTrusted tracker on the search input + await page.evaluate(""" + () => { + window.__untrustedKeys = []; + window.__trustedKeys = []; + const input = document.querySelector('#searchInput'); + if (input) { + input.addEventListener('keydown', (e) => { + if (!e.isTrusted) { + window.__untrustedKeys.push(e.key); + } else { + window.__trustedKeys.push(e.key); + } + }, true); + } + } + """) + + # Click into search, then type text with shift symbols + await page.locator('#searchInput').click() + await asyncio.sleep(0.3) + await page.keyboard.type('test!') + await asyncio.sleep(0.5) + + untrusted = await page.evaluate('() => window.__untrustedKeys || []') + trusted = await page.evaluate('() => window.__trustedKeys || []') + + # '!' should appear in trusted, NOT in untrusted + assert '!' not in untrusted, f"Untrusted shift symbol detected: {untrusted}" + assert '!' in trusted, f"'!' not in trusted keys: {trusted}" + + await browser.close() + + @pytest.mark.asyncio + async def test_stealth_world_survives_navigation(self): + """After page.goto(), the isolated world must be invalidated and + re-created transparently — subsequent click/type should still work.""" + import asyncio + from cloakbrowser import launch_async + browser = await launch_async(**_launch_kwargs()) + page = await browser.new_page() + + assert hasattr(page, '_stealth_world') + + # First navigation + await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + await asyncio.sleep(1) + await page.locator('#searchInput').click() + await asyncio.sleep(0.3) + + # Second navigation — triggers invalidate() + await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + await asyncio.sleep(1) + + # This should still work (isolated world auto-recreated) + await page.locator('#searchInput').click() + await asyncio.sleep(0.3) + await page.locator('#searchInput').type('after navigation') + await asyncio.sleep(0.5) + + val = await page.locator('#searchInput').input_value() + assert 'after navigation' in val + + await browser.close() + + @pytest.mark.asyncio + async def test_no_evaluate_leak_on_type_shift_symbols(self): + """Typing '!@#$%' must NOT produce any page.evaluate calls + (Error.stack ':302:' detection).""" + import asyncio + from cloakbrowser import launch_async + browser = await launch_async(**_launch_kwargs()) + page = await browser.new_page() + + await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + await asyncio.sleep(1) + + # Inject detection + await page.evaluate(""" + () => { + window.__evalLeaks = []; + const origQS = document.querySelector.bind(document); + document.querySelector = function(sel) { + try { throw new Error(); } catch (e) { + if (e.stack && e.stack.includes(':302:')) { + window.__evalLeaks.push({sel, stack: e.stack.substring(0, 200)}); + } + } + return origQS(sel); + }; + } + """) + + await page.locator('#searchInput').click() + await asyncio.sleep(0.3) + await page.keyboard.type('Hello!@#') + await asyncio.sleep(0.5) + + leaks = await page.evaluate('() => window.__evalLeaks || []') + assert len(leaks) == 0, ( + f"Evaluate stack leak during shift symbol typing: {leaks}" + ) + + await browser.close() + + @pytest.mark.asyncio + async def test_form_fill_no_untrusted_events(self): + """Full form fill (email + password with shift symbols) — all events + must be isTrusted=true, no evaluate leaks.""" + import asyncio + from cloakbrowser import launch_async + browser = await launch_async(**_launch_kwargs(headless=False, geoip=True)) + page = await browser.new_page() + + await page.goto( + 'https://deviceandbrowserinfo.com/are_you_a_bot_interactions', + wait_until='domcontentloaded', + ) + await asyncio.sleep(3) + + # Inject both detection hooks + await page.evaluate(""" + () => { + window.__evalLeaks = []; + window.__untrustedKeys = []; + + // Track querySelector from evaluate context + const origQS = document.querySelector.bind(document); + document.querySelector = function(sel) { + try { throw new Error(); } catch (e) { + if (e.stack && e.stack.includes(':302:')) { + window.__evalLeaks.push(sel); + } + } + return origQS(sel); + }; + + // Track untrusted keyboard events + document.addEventListener('keydown', (e) => { + if (!e.isTrusted) { + window.__untrustedKeys.push(e.key); + } + }, true); + } + """) + + # Fill the form with shift symbols in the password + await page.locator('#email').click() + await asyncio.sleep(0.3) + await page.locator('#email').fill('test@example.com') + await asyncio.sleep(0.5) + await page.locator('#password').click() + await asyncio.sleep(0.3) + await page.locator('#password').fill('SecurePass!@#123') + await asyncio.sleep(0.5) + + eval_leaks = await page.evaluate('() => window.__evalLeaks || []') + untrusted = await page.evaluate('() => window.__untrustedKeys || []') + + assert len(eval_leaks) == 0, f"Evaluate leaks: {eval_leaks}" + assert len(untrusted) == 0, f"Untrusted key events: {untrusted}" + + await page.locator('button[type="submit"]').click() + await asyncio.sleep(5) + + body = await page.locator('body').text_content() + assert '"superHumanSpeed": true' not in body + assert '"suspiciousClientSideBehavior": true' not in body + + await browser.close() + + @pytest.mark.asyncio + async def test_async_no_evaluate_leak(self): + """launch_async variant — click + shift symbols, zero evaluate leaks.""" + import asyncio + from cloakbrowser import launch_async + browser = await launch_async(**_launch_kwargs()) + page = await browser.new_page() + + assert hasattr(page, '_stealth_world') + assert page._stealth_world is not None + + await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + await asyncio.sleep(1) + + # Inject detection + await page.evaluate(""" + () => { + window.__evalLeaks = []; + const origQS = document.querySelector.bind(document); + document.querySelector = function(sel) { + try { throw new Error(); } catch (e) { + if (e.stack && e.stack.includes(':302:')) { + window.__evalLeaks.push(sel); + } + } + return origQS(sel); + }; + } + """) + + await page.locator('#searchInput').click() + await asyncio.sleep(0.3) + await page.keyboard.type('async!') + await asyncio.sleep(0.5) + + leaks = await page.evaluate('() => window.__evalLeaks || []') + assert len(leaks) == 0, f"Async evaluate leak: {leaks}" + + await browser.close() + + @pytest.mark.asyncio + async def test_async_shift_symbols_trusted(self): + """launch_async variant — shift symbols produce isTrusted=true.""" + import asyncio + from cloakbrowser import launch_async + browser = await launch_async(**_launch_kwargs()) + page = await browser.new_page() + + await page.goto('https://www.wikipedia.org', wait_until='domcontentloaded') + await asyncio.sleep(1) + + await page.evaluate(""" + () => { + window.__untrusted = []; + const input = document.querySelector('#searchInput'); + if (input) { + input.addEventListener('keydown', (e) => { + if (!e.isTrusted) window.__untrusted.push(e.key); + }, true); + } + } + """) + + await page.locator('#searchInput').click() + await asyncio.sleep(0.3) + await page.keyboard.type('Hello!@#') + await asyncio.sleep(0.5) + + untrusted = await page.evaluate('() => window.__untrusted || []') + assert len(untrusted) == 0, f"Async untrusted keys: {untrusted}" + + await browser.close() + + +# ========================================================================= +# Direct runner +# ========================================================================= + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v", "--tb=short", "-x"])) diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..811b1f8 --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,1420 @@ +"""Tests for auto-update and version management.""" + +from __future__ import annotations + +import base64 +import hashlib +import os +from unittest.mock import MagicMock, patch + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from cloakbrowser.config import ( + _version_newer, + _version_tuple, + get_chromium_version, + get_download_url, + get_effective_version, + get_platform_tag, +) +from cloakbrowser.download import ( + BinaryVerificationError, + _check_wrapper_update, + _download_and_extract, + _download_pro_binary, + _ensure_pro_binary, + _fetch_checksums, + _fetch_signed_manifest, + _get_latest_chromium_version, + _parse_checksums, + _parse_manifest_version, + _should_check_for_update, + _verify_checksum, + _verify_download_checksum, + _verify_pro_download, + _verify_signature, + _write_version_marker, + check_for_pro_update, + check_for_update, + clear_cache, + ensure_binary, +) + + +class TestVersionComparison: + def test_version_tuple_parsing(self): + assert _version_tuple("145.0.7718.0") == (145, 0, 7718, 0) + assert _version_tuple("142.0.7444.175") == (142, 0, 7444, 175) + + def test_newer_version(self): + assert _version_newer("145.0.7718.0", "142.0.7444.175") is True + + def test_older_version(self): + assert _version_newer("142.0.7444.175", "145.0.7718.0") is False + + def test_same_version(self): + assert _version_newer("142.0.7444.175", "142.0.7444.175") is False + + def test_patch_bump(self): + assert _version_newer("142.0.7444.176", "142.0.7444.175") is True + + def test_major_bump(self): + assert _version_newer("143.0.0.0", "142.9.9999.999") is True + + def test_5th_segment_parsing(self): + assert _version_tuple("145.0.7632.109.2") == (145, 0, 7632, 109, 2) + + def test_build_bump(self): + assert _version_newer("145.0.7632.109.3", "145.0.7632.109.2") is True + + def test_build_suffix_newer_than_no_suffix(self): + assert _version_newer("145.0.7632.109.2", "145.0.7632.109") is True + + def test_no_suffix_older_than_build_suffix(self): + assert _version_newer("145.0.7632.109", "145.0.7632.109.2") is False + + def test_new_chromium_beats_old_build(self): + assert _version_newer("146.0.0.0", "145.0.7632.109.2") is True + + +class TestDownloadUrl: + def test_default_url_format(self): + url = get_download_url() + assert "cloakbrowser.dev" in url + assert f"chromium-v{get_chromium_version()}" in url + assert url.endswith(".tar.gz") + + def test_custom_version_url(self): + url = get_download_url("145.0.7718.0") + assert "chromium-v145.0.7718.0" in url + + def test_no_old_repo_reference(self): + url = get_download_url() + assert "chromium-stealth-builds" not in url + + +class TestShouldCheckForUpdate: + def test_disabled_by_env(self): + with patch.dict(os.environ, {"CLOAKBROWSER_AUTO_UPDATE": "false"}): + assert _should_check_for_update() is False + + def test_disabled_by_env_case_insensitive(self): + with patch.dict(os.environ, {"CLOAKBROWSER_AUTO_UPDATE": "False"}): + assert _should_check_for_update() is False + + def test_disabled_by_binary_override(self): + with patch.dict(os.environ, {"CLOAKBROWSER_BINARY_PATH": "/some/path"}): + assert _should_check_for_update() is False + + def test_disabled_by_custom_download_url(self): + with patch.dict( + os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": "https://my-mirror.com"} + ): + assert _should_check_for_update() is False + + def test_rate_limited(self, tmp_path): + import time + + with patch.dict( + os.environ, + { + "CLOAKBROWSER_CACHE_DIR": str(tmp_path), + "CLOAKBROWSER_BINARY_PATH": "", + "CLOAKBROWSER_AUTO_UPDATE": "", + "CLOAKBROWSER_DOWNLOAD_URL": "", + }, + ): + check_file = tmp_path / ".last_update_check" + check_file.write_text(str(time.time())) + assert _should_check_for_update() is False + + def test_stale_rate_limit_allows_check(self, tmp_path): + import time + + with patch.dict( + os.environ, + { + "CLOAKBROWSER_CACHE_DIR": str(tmp_path), + "CLOAKBROWSER_BINARY_PATH": "", + "CLOAKBROWSER_AUTO_UPDATE": "", + "CLOAKBROWSER_DOWNLOAD_URL": "", + }, + ): + check_file = tmp_path / ".last_update_check" + check_file.write_text(str(time.time() - 7200)) # 2 hours ago + assert _should_check_for_update() is True + + +class TestEffectiveVersion: + def test_no_marker_returns_platform_version(self, tmp_path): + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + assert get_effective_version() == get_chromium_version() + + def test_marker_with_newer_version(self, tmp_path): + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + marker = tmp_path / f"latest_version_{get_platform_tag()}" + marker.write_text("999.0.0.0") + # Binary doesn't exist, so should fall back + assert get_effective_version() == get_chromium_version() + + def test_marker_with_older_version_ignored(self, tmp_path): + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + marker = tmp_path / f"latest_version_{get_platform_tag()}" + marker.write_text("100.0.0.0") + assert get_effective_version() == get_chromium_version() + + +class TestGetLatestVersion: + """Tests for _get_latest_chromium_version with platform-aware asset checking.""" + + def _make_assets(self, platforms: list[str]) -> list[dict]: + """Helper to build asset list from platform tags.""" + return [{"name": f"cloakbrowser-{p}.tar.gz"} for p in platforms] + + def _platform_tarball(self) -> str: + return f"cloakbrowser-{get_platform_tag()}.tar.gz" + + def test_parses_chromium_tag_with_platform_asset(self): + mock_response = MagicMock() + mock_response.json.return_value = [ + { + "tag_name": "chromium-v145.0.7718.0", + "draft": False, + "assets": self._make_assets( + ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"] + ), + }, + ] + mock_response.raise_for_status = MagicMock() + + with patch("cloakbrowser.download.httpx.get", return_value=mock_response): + result = _get_latest_chromium_version() + assert result == "145.0.7718.0" + + def test_skips_release_without_platform_asset(self): + """If latest release has no asset for our platform, fall back to older release.""" + mock_response = MagicMock() + mock_response.json.return_value = [ + { + "tag_name": "chromium-v145.0.7718.0", + "draft": False, + "assets": self._make_assets(["linux-x64"]), # Linux only + }, + { + "tag_name": "chromium-v142.0.7444.175", + "draft": False, + "assets": self._make_assets( + ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"] + ), + }, + ] + mock_response.raise_for_status = MagicMock() + + with patch("cloakbrowser.download.httpx.get", return_value=mock_response): + result = _get_latest_chromium_version() + tag = get_platform_tag() + if tag == "linux-x64": + assert result == "145.0.7718.0" + else: + assert result == "142.0.7444.175" + + def test_skips_draft_releases(self): + mock_response = MagicMock() + all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"] + mock_response.json.return_value = [ + { + "tag_name": "chromium-v999.0.0.0", + "draft": True, + "assets": self._make_assets(all_platforms), + }, + { + "tag_name": "chromium-v145.0.7718.0", + "draft": False, + "assets": self._make_assets(all_platforms), + }, + ] + mock_response.raise_for_status = MagicMock() + + with patch("cloakbrowser.download.httpx.get", return_value=mock_response): + result = _get_latest_chromium_version() + assert result == "145.0.7718.0" + + def test_skips_non_chromium_tags(self): + mock_response = MagicMock() + all_platforms = ["linux-x64", "darwin-arm64", "darwin-x64", "windows-x64"] + mock_response.json.return_value = [ + { + "tag_name": "v0.2.0", + "draft": False, + "assets": self._make_assets(all_platforms), + }, + { + "tag_name": "chromium-v145.0.7718.0", + "draft": False, + "assets": self._make_assets(all_platforms), + }, + ] + mock_response.raise_for_status = MagicMock() + + with patch("cloakbrowser.download.httpx.get", return_value=mock_response): + result = _get_latest_chromium_version() + assert result == "145.0.7718.0" + + def test_returns_none_when_no_platform_assets(self): + """If no release has our platform, return None.""" + mock_response = MagicMock() + mock_response.json.return_value = [ + { + "tag_name": "chromium-v145.0.7718.0", + "draft": False, + "assets": [{"name": "cloakbrowser-freebsd-x64.tar.gz"}], + }, + ] + mock_response.raise_for_status = MagicMock() + + with patch("cloakbrowser.download.httpx.get", return_value=mock_response): + result = _get_latest_chromium_version() + assert result is None + + def test_network_error_returns_none(self): + with patch("cloakbrowser.download.httpx.get", side_effect=Exception("timeout")): + result = _get_latest_chromium_version() + assert result is None + + +class TestWrapperUpdateCheck: + """Tests for _check_wrapper_update (PyPI version check).""" + + def setup_method(self): + import cloakbrowser.download as dl + + dl._wrapper_update_checked = False + + def test_warns_when_newer_version_available(self, caplog): + mock_resp = MagicMock() + mock_resp.json.return_value = {"info": {"version": "99.0.0"}} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.download.httpx.get", return_value=mock_resp): + import logging + + with caplog.at_level(logging.WARNING): + _check_wrapper_update() + assert "Update available" in caplog.text + assert "99.0.0" in caplog.text + + def test_silent_when_current(self, caplog): + import cloakbrowser.download as dl + + mock_resp = MagicMock() + mock_resp.json.return_value = {"info": {"version": dl._wrapper_version}} + mock_resp.raise_for_status = MagicMock() + + with patch("cloakbrowser.download.httpx.get", return_value=mock_resp): + import logging + + with caplog.at_level(logging.WARNING): + _check_wrapper_update() + assert "Update available" not in caplog.text + + def test_disabled_by_auto_update_env(self): + with patch.dict(os.environ, {"CLOAKBROWSER_AUTO_UPDATE": "false"}): + with patch("cloakbrowser.download.httpx.get") as mock_get: + _check_wrapper_update() + mock_get.assert_not_called() + + def test_disabled_by_custom_download_url(self): + with patch.dict( + os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": "https://mirror.example.com"} + ): + with patch("cloakbrowser.download.httpx.get") as mock_get: + _check_wrapper_update() + mock_get.assert_not_called() + + def test_network_error_silent(self, caplog): + with patch("cloakbrowser.download.httpx.get", side_effect=Exception("timeout")): + import logging + + with caplog.at_level(logging.WARNING): + _check_wrapper_update() + assert "Update available" not in caplog.text + + def test_runs_only_once(self): + mock_resp = MagicMock() + mock_resp.json.return_value = {"info": {"version": "0.0.1"}} + mock_resp.raise_for_status = MagicMock() + + with patch( + "cloakbrowser.download.httpx.get", return_value=mock_resp + ) as mock_get: + _check_wrapper_update() + _check_wrapper_update() + assert mock_get.call_count == 1 + + +class TestParseChecksums: + HASH_A = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + HASH_B = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + + def test_standard_format(self): + text = ( + f"{self.HASH_A} cloakbrowser-linux-x64.tar.gz\n" + f"{self.HASH_B} cloakbrowser-darwin-arm64.tar.gz\n" + ) + result = _parse_checksums(text) + assert result["cloakbrowser-linux-x64.tar.gz"] == self.HASH_A + assert result["cloakbrowser-darwin-arm64.tar.gz"] == self.HASH_B + + def test_binary_mode_asterisk(self): + text = f"{self.HASH_A} *cloakbrowser-linux-x64.tar.gz\n" + result = _parse_checksums(text) + assert "cloakbrowser-linux-x64.tar.gz" in result + + def test_empty_lines_skipped(self): + text = f"\n\n{self.HASH_A} file.tar.gz\n\n" + result = _parse_checksums(text) + assert len(result) == 1 + + def test_uppercase_lowered(self): + text = f"{self.HASH_A.upper()} file.tar.gz\n" + result = _parse_checksums(text) + assert result["file.tar.gz"] == self.HASH_A + + def test_empty_input(self): + assert _parse_checksums("") == {} + assert _parse_checksums(" \n \n") == {} + + +class TestVerifyChecksum: + def test_matching_checksum(self, tmp_path): + content = b"test binary content" + file = tmp_path / "test.tar.gz" + file.write_bytes(content) + expected = hashlib.sha256(content).hexdigest() + # Should not raise + _verify_checksum(file, expected) + + def test_mismatched_checksum(self, tmp_path): + file = tmp_path / "test.tar.gz" + file.write_bytes(b"real content") + with pytest.raises(RuntimeError, match="Checksum verification failed"): + _verify_checksum(file, "0" * 64) + + +class TestClearCache: + def test_removes_dir(self, tmp_path): + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + # Create some content + (tmp_path / "chromium-145").mkdir() + (tmp_path / "chromium-145" / "chrome").write_bytes(b"binary") + clear_cache() + assert not tmp_path.exists() + + def test_noop_if_missing(self, tmp_path): + nonexistent = tmp_path / "nonexistent" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(nonexistent)}): + clear_cache() # Should not raise + + +class TestCheckForUpdate: + @patch("cloakbrowser.download._maybe_trigger_update_check") + def test_returns_none_when_current(self, _mock_update): + with patch( + "cloakbrowser.download._get_latest_chromium_version", return_value=None + ): + assert check_for_update() is None + + @patch("cloakbrowser.download._maybe_trigger_update_check") + def test_returns_none_on_network_error(self, _mock_update): + with patch( + "cloakbrowser.download._get_latest_chromium_version", + side_effect=Exception("timeout"), + ): + # _get_latest_chromium_version catches exceptions internally, but + # check_for_update itself can also fail — test graceful None return + with patch( + "cloakbrowser.download._get_latest_chromium_version", return_value=None + ): + assert check_for_update() is None + + @patch("cloakbrowser.download._maybe_trigger_update_check") + def test_returns_version_when_newer(self, _mock_update, tmp_path): + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + with patch( + "cloakbrowser.download._get_latest_chromium_version", + return_value="999.0.0.0", + ): + with patch("cloakbrowser.download._download_and_extract"): + result = check_for_update() + assert result == "999.0.0.0" + + @patch("cloakbrowser.download._maybe_trigger_update_check") + def test_skips_download_if_already_cached(self, _mock_update, tmp_path): + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + # Create the binary dir so it looks already downloaded + binary_dir = tmp_path / "chromium-999.0.0.0" + binary_dir.mkdir() + with patch( + "cloakbrowser.download._get_latest_chromium_version", + return_value="999.0.0.0", + ): + with patch("cloakbrowser.download._download_and_extract") as mock_dl: + result = check_for_update() + assert result == "999.0.0.0" + mock_dl.assert_not_called() + + +class TestEnsureBinary: + @patch("cloakbrowser.download._maybe_trigger_update_check") + def test_local_override(self, _mock_update, tmp_path): + binary = tmp_path / "chrome" + binary.write_bytes(b"binary") + with patch.dict(os.environ, {"CLOAKBROWSER_BINARY_PATH": str(binary)}): + result = ensure_binary() + assert result == str(binary) + + @patch("cloakbrowser.download._maybe_trigger_update_check") + def test_local_override_missing_file(self, _mock_update): + with patch.dict( + os.environ, {"CLOAKBROWSER_BINARY_PATH": "/nonexistent/chrome"} + ): + with pytest.raises(FileNotFoundError, match="does not exist"): + ensure_binary() + + @patch("cloakbrowser.download._maybe_trigger_update_check") + def test_cached_binary_found(self, _mock_update, tmp_path): + with patch.dict( + os.environ, + { + "CLOAKBROWSER_CACHE_DIR": str(tmp_path), + "CLOAKBROWSER_BINARY_PATH": "", + }, + ): + # Create a fake cached binary + with patch("cloakbrowser.download.get_binary_path") as mock_path: + fake_binary = tmp_path / "chrome" + fake_binary.write_bytes(b"binary") + fake_binary.chmod(0o755) + mock_path.return_value = fake_binary + with patch("cloakbrowser.download.check_platform_available"): + result = ensure_binary() + assert result == str(fake_binary) + + @patch("cloakbrowser.download._maybe_trigger_update_check") + def test_pinned_free_version_uses_exact_download(self, _mock_update, tmp_path): + with patch.dict( + os.environ, + { + "CLOAKBROWSER_CACHE_DIR": str(tmp_path), + "CLOAKBROWSER_BINARY_PATH": "", + }, + ): + requested = "146.0.7680.177.5" + fake_binary = tmp_path / "chrome" + with patch("cloakbrowser.download.check_platform_available"): + with patch( + "cloakbrowser.download.get_binary_path", return_value=fake_binary + ): + + def fake_download(version): + assert version == requested + fake_binary.write_bytes(b"binary") + fake_binary.chmod(0o755) + + with patch( + "cloakbrowser.download._download_and_extract", + side_effect=fake_download, + ) as mock_dl: + result = ensure_binary(browser_version=requested) + mock_dl.assert_called_once_with(requested) + assert result == str(fake_binary) + # A pinned (rollback) download must NOT write the 'latest' + # marker — an unpinned launch must still resolve to latest. + assert not ( + tmp_path / f"latest_version_{get_platform_tag()}" + ).exists() + assert not (tmp_path / "latest_version").exists() + + def test_pinned_pro_version_skips_latest_marker(self, tmp_path): + # A pinned Pro (rollback) download must NOT write latest_pro_version_*, + # so an unpinned Pro launch still resolves to the latest build. + requested = "148.0.7778.215.2" + fake_binary = tmp_path / "Chromium" + marker = tmp_path / f"latest_pro_version_{get_platform_tag()}" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + with patch( + "cloakbrowser.download.get_binary_path", return_value=fake_binary + ): + + def fake_pro_download(version, key): + assert version == requested + fake_binary.write_bytes(b"binary") + fake_binary.chmod(0o755) + + with patch( + "cloakbrowser.download._download_pro_binary", + side_effect=fake_pro_download, + ): + result = _ensure_pro_binary("cb_key", requested_version=requested) + assert result == str(fake_binary) + assert not marker.exists() + + @patch("cloakbrowser.download._maybe_trigger_update_check") + def test_pinned_free_env_rejects_unsafe_version(self, _mock_update, tmp_path): + with patch.dict( + os.environ, + { + "CLOAKBROWSER_CACHE_DIR": str(tmp_path), + "CLOAKBROWSER_BINARY_PATH": "", + "CLOAKBROWSER_VERSION": "../../146.0.0.0", + }, + ): + with pytest.raises(ValueError, match="Invalid browser version pin"): + ensure_binary() + + @patch("cloakbrowser.download._maybe_trigger_update_check") + def test_downloads_when_missing(self, _mock_update, tmp_path): + with patch.dict( + os.environ, + { + "CLOAKBROWSER_CACHE_DIR": str(tmp_path), + "CLOAKBROWSER_BINARY_PATH": "", + }, + ): + fake_binary = tmp_path / "chrome" + with patch("cloakbrowser.download.check_platform_available"): + with patch("cloakbrowser.download.get_binary_path") as mock_path: + # effective == platform_version (no marker), so fallback block skipped. + # Call 1: get_binary_path(effective) → nonexistent (triggers download) + # Call 2: get_binary_path() → fake_binary (post-download verify) + mock_path.side_effect = [ + tmp_path / "nonexistent", # pre-download: not cached + fake_binary, # post-download: binary ready + ] + with patch( + "cloakbrowser.download._download_and_extract" + ) as mock_dl: + fake_binary.write_bytes(b"binary") + result = ensure_binary() + mock_dl.assert_called_once() + assert result == str(fake_binary) + + +def _make_pro_binary(version: str): + """Create a fake cached, executable Pro binary for `version`.""" + from cloakbrowser.config import get_binary_path + + bp = get_binary_path(version, pro=True) + bp.parent.mkdir(parents=True, exist_ok=True) + bp.write_bytes(b"binary") + bp.chmod(0o755) + return bp + + +class TestUnpinnedProUpgrade: + """Ticket 431: an unpinned Pro launch must track the server's latest stable, + never roll down to a stale cached build, and never fall back to the free binary.""" + + OLD = "148.0.7778.215.3" + NEW = "148.0.7778.215.5" + + def test_upgrades_to_server_latest(self, tmp_path): + marker = tmp_path / f"latest_pro_version_{get_platform_tag()}" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + marker.write_text(self.OLD) + _make_pro_binary(self.OLD) # stale build cached + + def fake_download(version, key): + assert version == self.NEW + _make_pro_binary(self.NEW) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch( + "cloakbrowser.download._download_pro_binary", + side_effect=fake_download, + ) as mock_dl, + ): + from cloakbrowser.config import get_binary_path + + result = _ensure_pro_binary("cb_key") + mock_dl.assert_called_once() + assert result == str(get_binary_path(self.NEW, pro=True)) + assert marker.read_text() == self.NEW # marker advanced, not stuck + + def test_cached_newer_build_advances_marker_no_download(self, tmp_path): + """Marker names an OLD build but a NEWER build is already cached (the customer's + multi-version cache): resolve to newest, no download, and advance the marker so + `info` never diverges from what launches.""" + marker = tmp_path / f"latest_pro_version_{get_platform_tag()}" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + marker.write_text(self.OLD) + _make_pro_binary(self.OLD) + _make_pro_binary(self.NEW) # newer build already on disk + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch("cloakbrowser.download._download_pro_binary") as mock_dl, + ): + from cloakbrowser.config import get_binary_path + + result = _ensure_pro_binary("cb_key") + mock_dl.assert_not_called() # already cached, no download + assert result == str(get_binary_path(self.NEW, pro=True)) + assert marker.read_text() == self.NEW # marker advanced, no stale divergence + + def test_steady_state_no_download(self, tmp_path): + marker = tmp_path / f"latest_pro_version_{get_platform_tag()}" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + marker.write_text(self.NEW) + _make_pro_binary(self.NEW) # already on latest + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch("cloakbrowser.download._download_pro_binary") as mock_dl, + ): + from cloakbrowser.config import get_binary_path + + result = _ensure_pro_binary("cb_key") + mock_dl.assert_not_called() + assert result == str(get_binary_path(self.NEW, pro=True)) + + def test_server_down_uses_cached_pro(self, tmp_path): + """Server unreachable → launch the cached Pro build, never fail, never free.""" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + (tmp_path / f"latest_pro_version_{get_platform_tag()}").write_text(self.OLD) + _make_pro_binary(self.OLD) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", return_value=None + ), + patch("cloakbrowser.download._download_pro_binary") as mock_dl, + ): + from cloakbrowser.config import get_binary_path + + result = _ensure_pro_binary("cb_key") + mock_dl.assert_not_called() + assert result == str(get_binary_path(self.OLD, pro=True)) + + def test_download_failure_falls_back_to_cached(self, tmp_path): + """A failed upgrade download falls back to the cached Pro build, not free.""" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + (tmp_path / f"latest_pro_version_{get_platform_tag()}").write_text(self.OLD) + _make_pro_binary(self.OLD) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch( + "cloakbrowser.download._download_pro_binary", + side_effect=RuntimeError("network down"), + ), + ): + from cloakbrowser.config import get_binary_path + + result = _ensure_pro_binary("cb_key") + assert result == str(get_binary_path(self.OLD, pro=True)) + + def test_verification_error_surfaces_not_cached_fallback(self, tmp_path): + """A tampering signal (BinaryVerificationError) must propagate verbatim, even + with a cached Pro build present — never masked by the cached-fallback path.""" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + (tmp_path / f"latest_pro_version_{get_platform_tag()}").write_text(self.OLD) + _make_pro_binary(self.OLD) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch( + "cloakbrowser.download._download_pro_binary", + side_effect=BinaryVerificationError("checksum mismatch"), + ), + ): + with pytest.raises(BinaryVerificationError, match="checksum mismatch"): + _ensure_pro_binary("cb_key") + + def test_no_cache_no_server_raises_never_free(self, tmp_path): + """No cached Pro build AND no server → hard error, never the free binary.""" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", return_value=None + ), + patch("cloakbrowser.download._download_pro_binary") as mock_dl, + ): + with pytest.raises(RuntimeError, match="latest Pro version"): + _ensure_pro_binary("cb_key") + mock_dl.assert_not_called() + + def test_auto_update_false_keeps_cached_no_server_check(self, tmp_path): + """CLOAKBROWSER_AUTO_UPDATE=false + a cached Pro build → keep it, no upgrade, + no server check (parity with the free path's freeze semantics).""" + with patch.dict( + os.environ, + { + "CLOAKBROWSER_CACHE_DIR": str(tmp_path), + "CLOAKBROWSER_AUTO_UPDATE": "false", + }, + ): + (tmp_path / f"latest_pro_version_{get_platform_tag()}").write_text(self.OLD) + _make_pro_binary(self.OLD) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ) as mock_latest, + patch("cloakbrowser.download._download_pro_binary") as mock_dl, + ): + from cloakbrowser.config import get_binary_path + + result = _ensure_pro_binary("cb_key") + mock_dl.assert_not_called() + mock_latest.assert_not_called() # frozen → no server check at all + assert result == str(get_binary_path(self.OLD, pro=True)) + + def test_missing_cache_downloads_latest_never_free(self, tmp_path): + """Marker names a build whose binary is gone → fetch latest Pro, never 146.x.""" + marker = tmp_path / f"latest_pro_version_{get_platform_tag()}" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + marker.write_text(self.OLD) # marker present, but NO binary on disk + + def fake_download(version, key): + assert version == self.NEW # never the free base (146.x) + _make_pro_binary(self.NEW) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch( + "cloakbrowser.download._download_pro_binary", + side_effect=fake_download, + ), + ): + from cloakbrowser.config import get_binary_path + + result = _ensure_pro_binary("cb_key") + assert result == str(get_binary_path(self.NEW, pro=True)) + + +class TestCheckForProUpdate: + """`cloakbrowser update` for Pro installs (ticket 431 Fix 1).""" + + OLD = "148.0.7778.215.3" + NEW = "148.0.7778.215.5" + + def test_downloads_and_writes_marker(self, tmp_path): + marker = tmp_path / f"latest_pro_version_{get_platform_tag()}" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + marker.write_text(self.OLD) + _make_pro_binary(self.OLD) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch( + "cloakbrowser.download._download_pro_binary", + side_effect=lambda v, k: _make_pro_binary(v), + ) as mock_dl, + ): + result = check_for_pro_update("cb_key") + mock_dl.assert_called_once() + assert result == self.NEW + assert marker.read_text() == self.NEW + + def test_already_latest_returns_none(self, tmp_path): + marker = tmp_path / f"latest_pro_version_{get_platform_tag()}" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + marker.write_text(self.NEW) + _make_pro_binary(self.NEW) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch("cloakbrowser.download._download_pro_binary") as mock_dl, + ): + assert check_for_pro_update("cb_key") is None + mock_dl.assert_not_called() + + def test_server_down_returns_none(self, tmp_path): + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + with patch( + "cloakbrowser.license.get_pro_latest_version", return_value=None + ): + assert check_for_pro_update("cb_key") is None + + +class TestEffectiveVersionProNoFreeFallback: + """get_effective_version(pro=True) must return None — never the free base — + when no cached Pro binary matches the marker (ticket 431 Fix 4).""" + + def test_none_when_no_cached_pro_binary(self, tmp_path): + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + # Marker points at a version whose binary is not on disk. + (tmp_path / f"latest_pro_version_{get_platform_tag()}").write_text( + "148.0.7778.215.5" + ) + assert get_effective_version(pro=True) is None + + def test_none_when_no_marker(self, tmp_path): + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + assert get_effective_version(pro=True) is None + # Free tier still resolves to a concrete version. + assert get_effective_version(pro=False) == get_chromium_version() + + +class TestWriteVersionMarker: + def test_creates_file(self, tmp_path): + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + _write_version_marker("999.0.0.0") + marker = tmp_path / f"latest_version_{get_platform_tag()}" + assert marker.exists() + assert marker.read_text() == "999.0.0.0" + + +class TestDownloadFallback: + """Verify primary server (cloakbrowser.dev) → GitHub Releases fallback on HTTP errors.""" + + def test_binary_download_falls_back_on_http_error(self, tmp_path): + """HTTP error from primary triggers GitHub Releases fallback for binary download.""" + with patch.dict( + os.environ, + { + "CLOAKBROWSER_CACHE_DIR": str(tmp_path), + "CLOAKBROWSER_DOWNLOAD_URL": "", + }, + ): + urls_called = [] + + def mock_download_file(url, dest): + urls_called.append(url) + if "cloakbrowser.dev" in url: + raise Exception("HTTP 429 Too Many Requests") + # GitHub fallback succeeds + dest.write_bytes(b"fake") + + # This test exercises URL fallback, not verification — stub the + # (now signature-based, non-bypassable) verify step. + with ( + patch( + "cloakbrowser.download._download_file", + side_effect=mock_download_file, + ), + patch("cloakbrowser.download._verify_download_checksum"), + patch("cloakbrowser.download._extract_archive"), + patch("cloakbrowser.download._show_welcome"), + ): + _download_and_extract() + + assert len(urls_called) == 2 + assert "cloakbrowser.dev" in urls_called[0] + assert "github.com" in urls_called[1] + + def test_binary_download_no_fallback_with_custom_url(self, tmp_path): + """Custom CLOAKBROWSER_DOWNLOAD_URL disables GitHub fallback — error propagates.""" + with patch.dict( + os.environ, + { + "CLOAKBROWSER_CACHE_DIR": str(tmp_path), + "CLOAKBROWSER_DOWNLOAD_URL": "https://my-mirror.com/releases", + "CLOAKBROWSER_SKIP_CHECKSUM": "true", + }, + ): + with patch( + "cloakbrowser.download._download_file", side_effect=Exception("503") + ): + with pytest.raises(Exception, match="503"): + _download_and_extract() + + def test_checksum_fetch_falls_back_on_http_error(self): + """HTTP error from primary checksum URL triggers GitHub fallback.""" + valid_checksums = ( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + " cloakbrowser-linux-x64.tar.gz\n" + ) + + def mock_get(url, **kwargs): + resp = MagicMock() + if "cloakbrowser.dev" in url: + resp.raise_for_status.side_effect = Exception("HTTP 429") + return resp + # GitHub URL succeeds + resp.text = valid_checksums + resp.raise_for_status = MagicMock() + return resp + + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}): + with patch("cloakbrowser.download.httpx.get", side_effect=mock_get): + result = _fetch_checksums() + + assert result is not None + assert "cloakbrowser-linux-x64.tar.gz" in result + + def test_checksum_fetch_returns_none_when_both_fail(self): + """Both primary and GitHub checksum URLs fail → returns None (skip verification).""" + with patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}): + with patch( + "cloakbrowser.download.httpx.get", + side_effect=Exception("network error"), + ): + result = _fetch_checksums() + + assert result is None + + +# --------------------------------------------------------------------------- +# Signed-manifest verification (Ed25519). Trust root is the pinned public key, +# not the same-origin SHA256SUMS — this is what closes M1 (#308). +# --------------------------------------------------------------------------- +def _make_key(): + priv = Ed25519PrivateKey.generate() + from cryptography.hazmat.primitives import serialization + + raw = priv.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + return priv, base64.b64encode(raw).decode() + + +def _sign(priv, manifest_bytes: bytes) -> bytes: + """Return SHA256SUMS.sig content (base64 of the raw signature), as served.""" + return base64.b64encode(priv.sign(manifest_bytes)) + + +class TestSignatureVerification: + """_verify_signature: the cryptographic gate over the raw manifest bytes.""" + + def test_valid_signature_passes(self): + priv, pub_b64 = _make_key() + manifest = b"abc cloakbrowser-linux-x64.tar.gz\n" + sig = _sign(priv, manifest) + with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]): + _verify_signature(manifest, sig) # no raise + + def test_tampered_manifest_fails(self): + priv, pub_b64 = _make_key() + manifest = b"abc cloakbrowser-linux-x64.tar.gz\n" + sig = _sign(priv, manifest) + tampered = manifest.replace(b"abc", b"xyz") + with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]): + with pytest.raises(RuntimeError, match="signature verification failed"): + _verify_signature(tampered, sig) + + def test_wrong_key_fails(self): + priv, _ = _make_key() + _, other_pub = _make_key() + manifest = b"data\n" + sig = _sign(priv, manifest) + with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [other_pub]): + with pytest.raises(RuntimeError, match="signature verification failed"): + _verify_signature(manifest, sig) + + def test_malformed_signature_fails(self): + _, pub_b64 = _make_key() + with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]): + with pytest.raises(RuntimeError, match="Malformed"): + _verify_signature(b"data\n", b"!!!not base64!!!") + + def test_placeholder_key_is_skipped_not_crashing(self): + """An unparseable pinned key (placeholder) must not abort — a real key still validates.""" + priv, pub_b64 = _make_key() + manifest = b"data\n" + sig = _sign(priv, manifest) + with patch( + "cloakbrowser.download.BINARY_SIGNING_PUBKEYS", + ["REPLACE_WITH_REAL_ED25519_PUBLIC_KEY_BASE64", pub_b64], + ): + _verify_signature(manifest, sig) # no raise + + def test_key_rotation_second_key_accepts(self): + """A manifest signed with the new key validates while the old key stays pinned.""" + old_priv, old_pub = _make_key() + new_priv, new_pub = _make_key() + manifest = b"rotated\n" + sig = _sign(new_priv, manifest) + with patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [old_pub, new_pub]): + _verify_signature(manifest, sig) # no raise + + +class TestVerifyDownloadChecksumSigned: + """_verify_download_checksum on the official path: signature + version + hash, fail-closed.""" + + def _hash(self, data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + def _manifest(self, body: str, version: str | None = None) -> bytes: + """Build a signed-manifest body with the bound version line prepended.""" + v = version if version is not None else get_chromium_version() + return f"version={v}\n{body}".encode() + + def test_valid_manifest_and_hash_passes(self, tmp_path): + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"the real binary") + tarball = get_download_url().rsplit("/", 1)[-1] + manifest = self._manifest(f"{self._hash(b'the real binary')} {tarball}\n") + sig = _sign(priv, manifest) + + with ( + patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), + patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), + patch( + "cloakbrowser.download._fetch_signed_manifest", + return_value=(manifest, sig), + ), + ): + _verify_download_checksum(archive) # no raise + + def test_tampered_binary_fails_hash(self, tmp_path): + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"a malicious binary") # different bytes + tarball = get_download_url().rsplit("/", 1)[-1] + manifest = self._manifest(f"{self._hash(b'the real binary')} {tarball}\n") + sig = _sign(priv, manifest) + + with ( + patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), + patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), + patch( + "cloakbrowser.download._fetch_signed_manifest", + return_value=(manifest, sig), + ), + ): + with pytest.raises(RuntimeError, match="Checksum verification failed"): + _verify_download_checksum(archive) + + def test_wrong_version_fails_downgrade(self, tmp_path): + """A genuinely-signed manifest for a DIFFERENT version is rejected (downgrade).""" + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"the real binary") + tarball = get_download_url().rsplit("/", 1)[-1] + # Manifest declares an old version, but we ask for get_chromium_version(). + manifest = self._manifest( + f"{self._hash(b'the real binary')} {tarball}\n", version="1.0.0.0" + ) + sig = _sign(priv, manifest) + with ( + patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), + patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), + patch( + "cloakbrowser.download._fetch_signed_manifest", + return_value=(manifest, sig), + ), + ): + with pytest.raises(RuntimeError, match="Version mismatch"): + _verify_download_checksum(archive) + + def test_missing_version_line_fails(self, tmp_path): + """A signed manifest without a version line is rejected (binding required).""" + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"the real binary") + tarball = get_download_url().rsplit("/", 1)[-1] + manifest = ( + f"{self._hash(b'the real binary')} {tarball}\n".encode() + ) # no version= + sig = _sign(priv, manifest) + with ( + patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), + patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), + patch( + "cloakbrowser.download._fetch_signed_manifest", + return_value=(manifest, sig), + ), + ): + with pytest.raises(RuntimeError, match="Version mismatch"): + _verify_download_checksum(archive) + + def test_missing_signed_manifest_fails_closed(self, tmp_path): + archive = tmp_path / "binary" + archive.write_bytes(b"x") + with ( + patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), + patch("cloakbrowser.download._fetch_signed_manifest", return_value=None), + ): + with pytest.raises(RuntimeError, match="signed SHA256SUMS"): + _verify_download_checksum(archive) + + def test_manifest_without_entry_fails(self, tmp_path): + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"x") + manifest = self._manifest( + "deadbeef some-other-file.tar.gz\n" + ) # no entry for our tarball + sig = _sign(priv, manifest) + with ( + patch.dict(os.environ, {"CLOAKBROWSER_DOWNLOAD_URL": ""}), + patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), + patch( + "cloakbrowser.download._fetch_signed_manifest", + return_value=(manifest, sig), + ), + ): + with pytest.raises(RuntimeError, match="no entry for"): + _verify_download_checksum(archive) + + def test_custom_url_uses_plain_checksum_and_skip(self, tmp_path): + """Self-hosted CLOAKBROWSER_DOWNLOAD_URL keeps the legacy skippable path.""" + archive = tmp_path / "binary" + archive.write_bytes(b"x") + with patch.dict( + os.environ, + { + "CLOAKBROWSER_DOWNLOAD_URL": "https://my-mirror.test", + "CLOAKBROWSER_SKIP_CHECKSUM": "true", + }, + ): + # Signature path must NOT be consulted for a custom mirror. + with patch("cloakbrowser.download._fetch_signed_manifest") as mocked: + _verify_download_checksum(archive) # skip honored, no raise + mocked.assert_not_called() + + +class TestVerifyProDownloadSigned: + """_verify_pro_download: Pro binaries get the SAME non-bypassable signature + check as the free official path (parity — closes the Pro M1 gap).""" + + PRO_VERSION = "147.0.1.0" + + def _hash(self, data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + def _tarball(self) -> str: + return get_download_url().rsplit("/", 1)[-1] + + def _mock_fetch(self, manifest: bytes, sig: bytes): + """httpx.get stub: returns the .sig for *.sig URLs, manifest otherwise.""" + + def mock_get(url, **kwargs): + resp = MagicMock() + resp.raise_for_status = MagicMock() + resp.content = sig if url.endswith(".sig") else manifest + return resp + + return mock_get + + def test_valid_pro_manifest_passes(self, tmp_path): + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"the real pro binary") + manifest = ( + f"version={self.PRO_VERSION}\n" + f"{self._hash(b'the real pro binary')} {self._tarball()}\n" + ).encode() + sig = _sign(priv, manifest) + with ( + patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), + patch( + "cloakbrowser.download.httpx.get", + side_effect=self._mock_fetch(manifest, sig), + ), + ): + _verify_pro_download(archive, self.PRO_VERSION) # no raise + + def test_skip_checksum_does_not_bypass(self, tmp_path): + """CLOAKBROWSER_SKIP_CHECKSUM must NOT weaken Pro verification (the point).""" + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"a malicious pro binary") # bytes differ from manifest + manifest = ( + f"version={self.PRO_VERSION}\n" + f"{self._hash(b'the real pro binary')} {self._tarball()}\n" + ).encode() + sig = _sign(priv, manifest) + with ( + patch.dict(os.environ, {"CLOAKBROWSER_SKIP_CHECKSUM": "true"}), + patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), + patch( + "cloakbrowser.download.httpx.get", + side_effect=self._mock_fetch(manifest, sig), + ), + ): + with pytest.raises(RuntimeError, match="Checksum verification failed"): + _verify_pro_download(archive, self.PRO_VERSION) + + def test_missing_manifest_is_transient_not_tampering(self, tmp_path): + """A failed manifest FETCH is transient (router falls back to free), so it + must be a plain RuntimeError — NOT a BinaryVerificationError, which the + router re-raises as a hard failure.""" + archive = tmp_path / "binary" + archive.write_bytes(b"x") + with patch("cloakbrowser.download.httpx.get", side_effect=Exception("404")): + with pytest.raises(RuntimeError) as ei: + _verify_pro_download(archive, self.PRO_VERSION) + assert not isinstance(ei.value, BinaryVerificationError) + + def test_wrong_version_fails_downgrade(self, tmp_path): + """A genuinely-signed Pro manifest for a DIFFERENT version is rejected.""" + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"the real pro binary") + manifest = ( + f"version=1.0.0.0\n" # declares old version, we ask for PRO_VERSION + f"{self._hash(b'the real pro binary')} {self._tarball()}\n" + ).encode() + sig = _sign(priv, manifest) + with ( + patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), + patch( + "cloakbrowser.download.httpx.get", + side_effect=self._mock_fetch(manifest, sig), + ), + ): + with pytest.raises(RuntimeError, match="Version mismatch"): + _verify_pro_download(archive, self.PRO_VERSION) + + def test_tampered_manifest_fails_signature(self, tmp_path): + """A manifest tampered after signing fails the signature gate (not the hash).""" + priv, pub_b64 = _make_key() + archive = tmp_path / "binary" + archive.write_bytes(b"the real pro binary") + manifest = ( + f"version={self.PRO_VERSION}\n" + f"{self._hash(b'the real pro binary')} {self._tarball()}\n" + ).encode() + sig = _sign(priv, manifest) + tampered = manifest.replace(self._tarball().encode(), b"evil.tar.gz") + with ( + patch("cloakbrowser.download.BINARY_SIGNING_PUBKEYS", [pub_b64]), + patch( + "cloakbrowser.download.httpx.get", + side_effect=self._mock_fetch(tampered, sig), + ), + ): + with pytest.raises(RuntimeError, match="signature verification failed"): + _verify_pro_download(archive, self.PRO_VERSION) + + +class TestProDownloadVersionPinned: + """The Pro download must request the explicit version, NOT /latest, so the + served artifact matches the version-pinned signed manifest it's verified + against (no latest-advances TOCTOU).""" + + def test_download_url_is_version_pinned(self): + from cloakbrowser.config import DOWNLOAD_BASE_URL + + captured = {} + + def fake_download_file(url, dest, headers=None): + captured["url"] = url + + with ( + patch( + "cloakbrowser.download._download_file", side_effect=fake_download_file + ), + patch("cloakbrowser.download._verify_pro_download"), + patch("cloakbrowser.download._extract_archive"), + ): + _download_pro_binary("147.0.1.0", "cb_key") + + assert captured["url"] == f"{DOWNLOAD_BASE_URL}/api/download/147.0.1.0" + assert not captured["url"].endswith("/latest") + + +class TestVersionBinding: + """The 'version=' line: read by new wrappers, ignored by old parsers.""" + + def test_parse_manifest_version(self): + manifest = "version=146.0.7680.177.5\nabc cloakbrowser-linux-x64.tar.gz\n" + assert _parse_manifest_version(manifest) == "146.0.7680.177.5" + + def test_parse_manifest_version_absent(self): + assert _parse_manifest_version("abc cloakbrowser-linux-x64.tar.gz\n") is None + + def test_old_checksum_parser_ignores_version_line(self): + """Regression: the version line must not pollute the old hash map.""" + h = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + manifest = f"version=146.0.7680.177.5\n{h} cloakbrowser-linux-x64.tar.gz\n" + result = _parse_checksums(manifest) + assert result == {"cloakbrowser-linux-x64.tar.gz": h} + + +class TestFetchSignedManifest: + """_fetch_signed_manifest pairs SHA256SUMS + .sig from the same origin.""" + + def test_fetches_both_from_primary(self): + def mock_get(url, **kwargs): + resp = MagicMock() + resp.raise_for_status = MagicMock() + resp.content = b"SIG" if url.endswith(".sig") else b"MANIFEST" + return resp + + with patch("cloakbrowser.download.httpx.get", side_effect=mock_get): + result = _fetch_signed_manifest("1.2.3.4") + assert result == (b"MANIFEST", b"SIG") + + def test_falls_back_to_github_when_primary_missing_sig(self): + def mock_get(url, **kwargs): + resp = MagicMock() + resp.content = b"SIG" if url.endswith(".sig") else b"MANIFEST" + if "cloakbrowser.dev" in url and url.endswith(".sig"): + resp.raise_for_status.side_effect = Exception("404") + else: + resp.raise_for_status = MagicMock() + return resp + + with patch("cloakbrowser.download.httpx.get", side_effect=mock_get): + result = _fetch_signed_manifest("1.2.3.4") + assert result == (b"MANIFEST", b"SIG") + + def test_returns_none_when_all_fail(self): + with patch("cloakbrowser.download.httpx.get", side_effect=Exception("network")): + assert _fetch_signed_manifest("1.2.3.4") is None + + +# --------------------------------------------------------------------------- +# Welcome-banner cadence: free re-shows every 3 days, Pro shows once ever. +# --------------------------------------------------------------------------- +class TestWelcomeCadence: + def test_pro_shows_once_then_never(self, tmp_path): + import time as _time + from cloakbrowser.download import _welcome_due + + marker = tmp_path / ".welcome_shown" + assert _welcome_due(marker, pro=True) is True # absent -> show + marker.write_text(str(int(_time.time()))) + assert _welcome_due(marker, pro=True) is False # exists -> never again + + def test_free_reshows_after_interval(self, tmp_path): + import time as _time + from cloakbrowser.download import _welcome_due, WELCOME_FREE_INTERVAL + + marker = tmp_path / ".welcome_shown" + assert _welcome_due(marker, pro=False) is True # absent -> show + marker.write_text(str(int(_time.time()))) + assert _welcome_due(marker, pro=False) is False # fresh -> skip + marker.write_text(str(int(_time.time()) - WELCOME_FREE_INTERVAL - 10)) + assert _welcome_due(marker, pro=False) is True # stale -> show again + + def test_legacy_empty_marker(self, tmp_path): + from cloakbrowser.download import _welcome_due + + marker = tmp_path / ".welcome_shown" + marker.write_text("") # pre-cadence empty marker + assert _welcome_due(marker, pro=False) is True # unparseable -> free re-shows + assert _welcome_due(marker, pro=True) is False # pro: existence = already shown diff --git a/tests/test_widevine.py b/tests/test_widevine.py new file mode 100644 index 0000000..dd67e41 --- /dev/null +++ b/tests/test_widevine.py @@ -0,0 +1,198 @@ +"""Unit tests for Widevine CDM hint-file seeding (cloakbrowser/widevine.py).""" + +import json + +import pytest + +from cloakbrowser import widevine +from cloakbrowser.widevine import resolve_widevine_cdm_dir, seed_widevine_hint + +_HINT = "WidevineCdm/latest-component-updated-widevine-cdm" + + +@pytest.fixture(autouse=True) +def _force_linux(monkeypatch, tmp_path): + """Run as if on Linux unless a test overrides it (seeding is Linux-only).""" + monkeypatch.setattr(widevine.platform, "system", lambda: "Linux") + monkeypatch.delenv("CLOAKBROWSER_WIDEVINE", raising=False) + monkeypatch.delenv("CLOAKBROWSER_WIDEVINE_CDM", raising=False) + # Isolate the cache-root fallback from any real ~/.cloakbrowser on the host. + monkeypatch.setenv("CLOAKBROWSER_CACHE_DIR", str(tmp_path / "_isolated_cache")) + + +def _make_cdm(dirpath): + """Create a fake WidevineCdm dir with a manifest.json.""" + dirpath.mkdir(parents=True, exist_ok=True) + (dirpath / "manifest.json").write_text('{"version": "4.10.3050.0"}') + return dirpath + + +def _binary(tmp_path): + """Return a fake chrome binary path inside its own dir.""" + bdir = tmp_path / "bin" + bdir.mkdir(parents=True, exist_ok=True) + return bdir / "chrome" + + +def test_seeds_hint_next_to_binary(tmp_path): + """CDM in /WidevineCdm -> hint file written with abs Path.""" + binary = _binary(tmp_path) + cdm = _make_cdm(binary.parent / "WidevineCdm") + + profile = tmp_path / "profile" + seed_widevine_hint(profile, binary) + + hint = profile / _HINT + assert hint.is_file() + assert json.loads(hint.read_text())["Path"] == str(cdm.resolve()) + + +def test_seeds_hint_from_env_var(tmp_path, monkeypatch): + """CLOAKBROWSER_WIDEVINE_CDM takes priority and is used as the Path.""" + cdm = _make_cdm(tmp_path / "custom_cdm") + monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm)) + + profile = tmp_path / "profile" + seed_widevine_hint(profile, _binary(tmp_path)) + + assert json.loads((profile / _HINT).read_text())["Path"] == str(cdm.resolve()) + + +def test_no_cdm_no_file(tmp_path): + """No CDM present -> nothing written, no exception.""" + profile = tmp_path / "profile" + seed_widevine_hint(profile, _binary(tmp_path)) + assert not (profile / _HINT).exists() + + +def test_kill_switch_disables(tmp_path, monkeypatch): + """CLOAKBROWSER_WIDEVINE=0 disables seeding even when a CDM exists.""" + monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(_make_cdm(tmp_path / "custom_cdm"))) + monkeypatch.setenv("CLOAKBROWSER_WIDEVINE", "0") + + profile = tmp_path / "profile" + seed_widevine_hint(profile, _binary(tmp_path)) + assert not (profile / _HINT).exists() + + +def test_idempotent(tmp_path, monkeypatch): + """Seeding twice leaves the same correct content and doesn't error.""" + cdm = _make_cdm(tmp_path / "custom_cdm") + monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm)) + + profile = tmp_path / "profile" + binary = _binary(tmp_path) + seed_widevine_hint(profile, binary) + seed_widevine_hint(profile, binary) + assert json.loads((profile / _HINT).read_text())["Path"] == str(cdm.resolve()) + + +def test_noop_on_non_linux(tmp_path, monkeypatch): + """On non-Linux, seeding is a no-op even with a CDM present.""" + monkeypatch.setattr(widevine.platform, "system", lambda: "Windows") + monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(_make_cdm(tmp_path / "cdm"))) + + profile = tmp_path / "profile" + seed_widevine_hint(profile, _binary(tmp_path)) + assert not (profile / _HINT).exists() + + +def test_resolve_requires_manifest(tmp_path, monkeypatch): + """A WidevineCdm dir without manifest.json is not treated as a CDM.""" + bogus = tmp_path / "custom_cdm" + bogus.mkdir() + monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(bogus)) + assert resolve_widevine_cdm_dir(_binary(tmp_path)) is None + + +def test_env_var_is_exclusive(tmp_path, monkeypatch): + """An invalid CLOAKBROWSER_WIDEVINE_CDM skips seeding — no fallback to binary dir.""" + binary = _binary(tmp_path) + _make_cdm(binary.parent / "WidevineCdm") # valid CDM next to binary + bogus = tmp_path / "bogus" + bogus.mkdir() # set but no manifest.json + monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(bogus)) + assert resolve_widevine_cdm_dir(binary) is None + + +def test_resolve_falls_back_to_cache_root(tmp_path, monkeypatch): + """No CDM next to the binary -> auto-detect falls back to /WidevineCdm. + + Simulates the Pro case: a Pro binary sits in its own chromium--pro dir + with no adjacent CDM, while the Docker auto-fetch left one at the cache root. + """ + cache = tmp_path / "cache" + monkeypatch.setenv("CLOAKBROWSER_CACHE_DIR", str(cache)) + cdm = _make_cdm(cache / "WidevineCdm") + pro_binary = tmp_path / "chromium-148.0-pro" / "chrome" + pro_binary.parent.mkdir(parents=True) # binary dir exists, but has no CDM + assert resolve_widevine_cdm_dir(pro_binary) == cdm.resolve() + + +def test_resolve_binary_dir_wins_over_cache_root(tmp_path, monkeypatch): + """A manual sideload next to the binary takes precedence over the cache root.""" + cache = tmp_path / "cache" + monkeypatch.setenv("CLOAKBROWSER_CACHE_DIR", str(cache)) + _make_cdm(cache / "WidevineCdm") # cache-root CDM present... + binary = _binary(tmp_path) + next_to = _make_cdm(binary.parent / "WidevineCdm") # ...but sideload wins + assert resolve_widevine_cdm_dir(binary) == next_to.resolve() + + +def test_seeds_hint_from_cache_root_fallback(tmp_path, monkeypatch): + """End-to-end: a cache-root CDM seeds the hint for a binary with none adjacent.""" + cache = tmp_path / "cache" + monkeypatch.setenv("CLOAKBROWSER_CACHE_DIR", str(cache)) + cdm = _make_cdm(cache / "WidevineCdm") + profile = tmp_path / "profile" + seed_widevine_hint(profile, _binary(tmp_path)) # binary has no adjacent CDM + assert json.loads((profile / _HINT).read_text())["Path"] == str(cdm.resolve()) + + +def test_empty_env_var_is_exclusive(tmp_path, monkeypatch): + """An empty (but set) CLOAKBROWSER_WIDEVINE_CDM resolves to None — and must NOT + pick up a stray manifest.json in the working directory (``Path("")`` -> ``.``).""" + binary = _binary(tmp_path) + _make_cdm(binary.parent / "WidevineCdm") # valid CDM next to binary + monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", "") + cwd = tmp_path / "cwd" + cwd.mkdir() + (cwd / "manifest.json").write_text("{}") # stray manifest in CWD must be ignored + monkeypatch.chdir(cwd) + assert resolve_widevine_cdm_dir(binary) is None + + +def test_empty_user_data_dir_skips(tmp_path, monkeypatch): + """Empty user_data_dir (ephemeral profile) -> no CWD pollution, no seeding.""" + cdm = _make_cdm(tmp_path / "custom_cdm") + monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm)) + monkeypatch.chdir(tmp_path) + seed_widevine_hint("", _binary(tmp_path)) + assert not (tmp_path / "WidevineCdm").exists() + + +def test_never_raises_on_write_failure(tmp_path, monkeypatch): + """A write failure (hint dir path is a file) must not raise — launch must not break.""" + cdm = _make_cdm(tmp_path / "custom_cdm") + monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm)) + profile = tmp_path / "profile" + profile.mkdir() + # Block mkdir of /WidevineCdm by occupying that path with a file. + (profile / "WidevineCdm").write_text("not a dir") + + seed_widevine_hint(profile, _binary(tmp_path)) # must not raise + + +def test_rewrites_corrupt_existing_hint(tmp_path, monkeypatch): + """A non-UTF8 / mismatched existing hint is overwritten, without raising.""" + cdm = _make_cdm(tmp_path / "custom_cdm") + monkeypatch.setenv("CLOAKBROWSER_WIDEVINE_CDM", str(cdm)) + profile = tmp_path / "profile" + hint = profile / "WidevineCdm" / _HINT.split("/")[-1] + hint.parent.mkdir(parents=True) + hint.write_bytes(b"\xff\xfe not valid utf-8") + + seed_widevine_hint(profile, _binary(tmp_path)) # must not raise + + # corrupt content replaced with a valid hint pointing at the CDM + assert json.loads(hint.read_text())["Path"] == str(cdm.resolve())