Compare commits

...
Author SHA1 Message Date
CloakHQ d63ddd4d43 feat(cloakserve): add connection tracking, configurable data dir, better status endpoint
- Move `import websockets` to top-level (guaranteed by [serve] extra)
- Add --data-dir flag with smart default (Docker → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve)
- Store launch params (tz/locale/proxy) on ChromeProcess for conflict logging
- Enhance GET / to return per-process detail (pid, port, seed, connections, config)
- Add connection refcounting in WS handlers for status visibility
- Add first-launch-wins note to README
- Add tests for data-dir, Docker detection, and connection tracking
2026-04-05 22:41:33 +02:00
CloakHQandDurafen 03a0ad732f feat: rewrite cloakserve as CDP multiplexer with per-connection fingerprint seeds
Spawns a separate Chrome process per unique fingerprint seed, all behind
a single port (9222). Clients specify seeds and fingerprint params via
query string on the CDP URL:

  connect_over_cdp("http://host:9222?fingerprint=12345&timezone=Asia/Tokyo")

Supports all --fingerprint-* flags as query params, geoip=true for
auto timezone/locale from proxy IP, and proxy= for per-process proxies.

- Rewrite bin/cloakserve from 57-line wrapper to aiohttp CDP multiplexer
- Add ChromePool with per-seed process management and port allocation
- Bidirectional WebSocket proxy for CDP traffic
- URL rewriting for /json/version, /json/list, and WS paths
- Rename _build_args -> build_args, _maybe_resolve_geoip -> maybe_resolve_geoip
- Add aiohttp + websockets to serve optional deps
- Dockerfile installs .[serve] extras
- Add 20 unit tests for cloakserve (param parsing, CLI args, URL rewriting)
2026-04-05 22:30:18 +02:00
CloakHQ c58b691f1c chore(deps): bump actions/checkout, docker/setup-qemu, docker/setup-buildx, docker/login-action; group Dependabot PRs 2026-04-05 22:26:14 +02:00
CloakHQ 1b91a33e51 chore: update integration examples and gitignore
- browser_use: migrate to BrowserSession + bundled ChatOpenAI API
- crawl4ai: add browser_mode="cdp" param
- gitignore: add captures/
2026-04-02 01:32:08 +02:00
CloakHQ 1bfd5ca036 docs: update patch count to 42, add --fingerprint-noise flag, bump version refs 2026-03-30 20:46:16 +02:00
CloakHQ f46f8e9364 release: v0.3.19 — upgrade Linux x64 binary to 145.0.7632.159.8 (42 patches) 2026-03-30 19:17:41 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
592b3d5661 chore(deps): bump docker/build-push-action from 6.19.2 to 7.0.0 (#90)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.0.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/10e90e3645eae34f1e60eeb005ba3a3d33f178e8...d08e5c354a6adb9ed34480a06d141179aa583294)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 22:54:19 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a0a8210e35 chore(deps): bump actions/setup-python from 5.6.0 to 6.2.0 (#89)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.6.0 to 6.2.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/a26af69be951a213d495a4c3e4e4022e16d87065...a309ff8b426b58ec0e2a45f0f869d46889d02405)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 22:54:17 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
468964ff30 chore(deps): bump actions/setup-node from 4.4.0 to 6.3.0 (#88)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.4.0 to 6.3.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/49933ea5288caeca8642d1e84afbd3f7d6820020...53b83947a5a98c8d113130e565377fae1a50d02f)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 22:54:15 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c1b93e634b chore(deps): bump sigstore/cosign-installer from 3.9.1 to 4.1.1 (#87)
Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 3.9.1 to 4.1.1.
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/398d4b0eeef1380460a10c8013a76f728fb906ac...cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003)

---
updated-dependencies:
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.1.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 22:54:12 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5ccb4a32a5 chore(deps): bump actions/attest-build-provenance from 2.4.0 to 4.1.0 (#86)
Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 2.4.0 to 4.1.0.
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/e8998f949152b193b063cb0ec769d69d929409be...a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32)

---
updated-dependencies:
- dependency-name: actions/attest-build-provenance
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-29 22:54:10 +02:00
CloakHQ 49d80d3b57 security: pin GitHub Actions to commit SHAs, add Dependabot
Pin all 22 action references across ci.yml, publish.yml, and
attest-release.yml to immutable commit SHAs. Mutable tags can be
force-pushed by attackers (cf. TeamPCP supply chain campaign).

Add Dependabot for github-actions to get weekly PRs when pinned
actions have new versions.
2026-03-27 21:20:08 +01:00
CloakHQ 2813b3dc4c docs: warn that ElementHandle bypasses humanize patches 2026-03-18 07:51:07 +01:00
dgtlmoonandGitHub 6550f3ad6c fix: ensure pw.stop() runs even if browser.close() raises or is cancelled (#60) 2026-03-15 17:51:03 +01:00
CloakHQ 132cafe13c release: v0.3.18 — fix welcome banner polluting stdout (fixes #59) 2026-03-15 17:19:44 +01:00
CloakHQ 6c94b9e985 feat: add GitHub issue template for bug reports 2026-03-15 07:11:33 +01:00
CloakHQ fdc1ae0484 fix: add --ignore-gpu-blocklist to cloakserve for Docker WebGL support
cloakserve bypasses the wrapper and launches Chrome directly, missing
the GPU blocklist fix from 1380c86. Fixes #58.
2026-03-15 06:02:47 +01:00
CloakHQ 2ded0c1866 docs: add Crawlee integration example 2026-03-15 05:42:23 +01:00
CloakHQ f91700c4a4 release: v0.3.17 — Windows x64 binary upgrade to 145.0.7632.159.7
- Bump wrapper version to 0.3.17 (Python + JS)
- Update PLATFORM_CHROMIUM_VERSIONS: windows-x64 109.2 → 159.7
- Update patch counts in platform tables (Linux 33, Windows 33)
- Add Linux arm64 to JS README platform table
- Update CHANGELOG with all changes since v0.3.16
2026-03-15 02:42:13 +01:00
CloakHQ 1380c86847 fix: auto-inject --ignore-gpu-blocklist for headed mode and Windows
Headed mode (all platforms): Chromium's GPU blocklist disables WebGL on
software GPUs in Docker/VNC/Xvfb. Flag lets SwiftShader serve WebGL.
Harmless on real GPUs. Headless unaffected. Ref #56.

Windows (all modes): GPU blocklist also blocks WebGPU for the Microsoft
Basic Render Driver. Dawn's adapter_blocklist bypass alone isn't enough.
2026-03-15 02:23:48 +01:00
CloakHQ 83e3b30117 feat: add 8 framework integration examples + README integrations section
Add examples/integrations/ with tested examples for browser-use, Crawl4AI,
Scrapling, LangChain, Selenium, undetected-chromedriver, and agent-browser.
Add js/examples/stagehand.ts for Stagehand (TypeScript).

README: new "Framework Integrations" subsection with two integration
patterns (direct binary launch vs CDP connect) and table linking all 8 examples.
2026-03-14 20:56:25 +01:00
CloakHQ 5649620545 release: v0.3.16 — Linux ARM64 binary, multi-arch Docker, donate link
- Add linux-arm64 to PLATFORM_CHROMIUM_VERSIONS (Python + JS)
- Multi-arch Docker build (linux/amd64 + linux/arm64) via QEMU in CI
- Add ko-fi donate link to welcome banner (Python + JS)
- Version bump to 0.3.16
2026-03-14 00:42:06 +01:00
CloakHQ d2a42fc86b release: v0.3.15 — upgrade Linux binary to .159.7, StorageBuckets normalization
Binary:
- Upgrade Linux x64 build to Chromium 145.0.7632.159.7 (33 C++ patches)
- StorageBuckets API quota normalization — closes last storage-based incognito detection vector

Wrapper:
- Fix non-ASCII character support in humanized typing (Cyrillic, CJK, emoji)
- Document storage quota tradeoff for persistent contexts
- Add Ko-fi funding link
2026-03-13 18:59:18 +01:00
lilos 1af25d67bc fix: support non-ASCII characters (Cyrillic, CJK, emoji) in humanized typing 2026-03-13 00:34:01 +01:00
CloakHQ 1bef989404 test: add download fallback tests for primary → GitHub failover
Verify that HTTP errors (429, 503, etc.) from cloakbrowser.dev
correctly trigger GitHub Releases fallback for both binary and
checksum downloads. Also test that custom CLOAKBROWSER_DOWNLOAD_URL
disables fallback, and both-sources-fail returns gracefully.
2026-03-12 19:47:49 +01:00
CloakHQ 0aa4ea56bd docs: add Browser Profile Manager section to README 2026-03-12 01:58:26 +01:00
45 changed files with 1938 additions and 177 deletions
+1
View File
@@ -0,0 +1 @@
ko_fi: cloakhq
+29
View File
@@ -0,0 +1,29 @@
---
name: Bug Report
about: Report a bug or detection issue
labels: bug
---
Description: <!-- What happened? What did you expect? -->
CloakBrowser version: <!-- pip show cloakbrowser / npm list cloakbrowser -->
Wrapper: <!-- Python or JavaScript -->
Environment: <!-- OS, Docker y/n, base image, architecture -->
Launch options:
Tested with a different IP or proxy? <!-- Yes (same result) / Yes (works with different IP) / No -->
Works outside Docker / on host machine? <!-- Yes / No / Not using Docker -->
Steps to reproduce:
Error output / screenshots:
Dockerfile (if applicable):
Additional notes:
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: true
+10
View File
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns:
- "*"
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
- name: Attest build provenance - name: Attest build provenance
uses: actions/attest-build-provenance@v2 uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with: with:
subject-path: | subject-path: |
cloakbrowser-*.tar.gz cloakbrowser-*.tar.gz
+4 -4
View File
@@ -10,8 +10,8 @@ jobs:
python: python:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@v5 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.12" python-version: "3.12"
- name: Install dependencies - name: Install dependencies
@@ -22,8 +22,8 @@ jobs:
javascript: javascript:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 20 node-version: 20
- name: Install and build - name: Install and build
+18 -16
View File
@@ -24,15 +24,15 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@v5 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.12" python-version: "3.12"
- name: Python tests - name: Python tests
run: | run: |
pip install -e ".[dev]" pytest pytest-asyncio pip install -e ".[dev]" pytest pytest-asyncio
pytest tests/ -v -m "not slow" pytest tests/ -v -m "not slow"
- uses: actions/setup-node@v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 22 node-version: 22
- name: JavaScript tests - name: JavaScript tests
@@ -42,8 +42,8 @@ jobs:
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@v5 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.12" python-version: "3.12"
- name: Check tag matches package versions - name: Check tag matches package versions
@@ -62,8 +62,8 @@ jobs:
permissions: permissions:
id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed id-token: write # OIDC trusted publishing — no PYPI_TOKEN needed
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@v5 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.12" python-version: "3.12"
- name: Build - name: Build
@@ -71,7 +71,7 @@ jobs:
pip install build pip install build
python -m build python -m build
- name: Publish to PyPI - name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1 uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1
publish-npm: publish-npm:
needs: [test, validate-version] needs: [test, validate-version]
@@ -80,8 +80,8 @@ jobs:
permissions: permissions:
id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed id-token: write # OIDC trusted publishing + provenance — no NPM_TOKEN needed
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 22 node-version: 22
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
@@ -102,32 +102,34 @@ jobs:
attestations: write attestations: write
packages: write packages: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Extract version - name: Extract version
run: | run: |
VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))') VERSION=$(python -c 'import re; print(re.search(r"__version__\s*=\s*[\"'\'']([^\"'\'']+)", open("cloakbrowser/_version.py").read()).group(1))')
echo "VERSION=$VERSION" >> $GITHUB_ENV echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- uses: docker/login-action@v3 - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
username: ${{ secrets.DOCKER_USER }} username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PAT }} password: ${{ secrets.DOCKER_PAT }}
- name: Build and push - name: Build and push
id: build id: build
uses: docker/build-push-action@v6 uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
cloakhq/cloakbrowser:${{ env.VERSION }} cloakhq/cloakbrowser:${{ env.VERSION }}
cloakhq/cloakbrowser:latest cloakhq/cloakbrowser:latest
provenance: true provenance: true
sbom: true sbom: true
- uses: sigstore/cosign-installer@v3 - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Sign image - name: Sign image
run: cosign sign --yes cloakhq/cloakbrowser@${{ steps.build.outputs.digest }} run: cosign sign --yes cloakhq/cloakbrowser@${{ steps.build.outputs.digest }}
- name: Attest build provenance - name: Attest build provenance
uses: actions/attest-build-provenance@v2 uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with: with:
subject-name: index.docker.io/cloakhq/cloakbrowser subject-name: index.docker.io/cloakhq/cloakbrowser
subject-digest: ${{ steps.build.outputs.digest }} subject-digest: ${{ steps.build.outputs.digest }}
+2
View File
@@ -65,3 +65,5 @@ deploy.sh
.env .env
debug debug
publish-docker.sh publish-docker.sh
captures
20[0-9][0-9]-[0-9][0-9]-[0-9][0-9]-*.txt
+37
View File
@@ -6,6 +6,43 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
--- ---
## [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 ## [0.3.14] — 2026-03-12
- **[binary]** Upgrade Linux build to 145.0.7632.159.6 — fix persistent context detection by FingerprintJS - **[binary]** Upgrade Linux build to 145.0.7632.159.6 — fix persistent context detection by FingerprintJS
+1 -1
View File
@@ -20,7 +20,7 @@ WORKDIR /app
# Python wrapper # Python wrapper
COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./ COPY pyproject.toml README.md LICENSE BINARY-LICENSE.md CHANGELOG.md ./
COPY cloakbrowser/ cloakbrowser/ COPY cloakbrowser/ cloakbrowser/
RUN pip install --no-cache-dir . RUN pip install --no-cache-dir ".[serve]"
# JS wrapper # JS wrapper
COPY js/ js/ COPY js/ js/
+102 -27
View File
@@ -14,6 +14,10 @@
<a href="https://hub.docker.com/r/cloakhq/cloakbrowser"><img src="https://img.shields.io/docker/pulls/cloakhq/cloakbrowser?label=docker&logo=docker&logoColor=white" alt="Docker Pulls"></a> <a href="https://hub.docker.com/r/cloakhq/cloakbrowser"><img src="https://img.shields.io/docker/pulls/cloakhq/cloakbrowser?label=docker&logo=docker&logoColor=white" alt="Docker Pulls"></a>
</p> </p>
<p align="center">
<a href="https://ko-fi.com/cloakhq"><img src="https://ko-fi.com/img/githubbutton_sm.svg" alt="Support on Ko-fi"></a>
</p>
<br> <br>
<h3 align="center">Stealth Chromium that passes every bot detection test.</h3> <h3 align="center">Stealth Chromium that passes every bot detection test.</h3>
@@ -36,7 +40,7 @@ Drop-in Playwright/Puppeteer replacement for Python and JavaScript.<br>
Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong> Same API, same code — just swap the import. <strong>3 lines of code, 30 seconds to unblock.</strong>
</p> </p>
- **32 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals, CDP input behavior - **42 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals, CDP input behavior
- **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes - **`humanize=True`** — human-like mouse curves, keyboard timing, and scroll patterns. One flag, behavioral detection passes
- **0.9 reCAPTCHA v3 score** — human-level, server-verified - **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites - **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
@@ -110,13 +114,27 @@ page.goto("https://example.com")
> ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop. > ⭐ **Star** to show support — **[Watch releases](https://github.com/CloakHQ/CloakBrowser/subscription)** to get notified when new builds drop.
## Latest: v0.3.14 (Chromium 145.0.7632.159.6) ## Browser Profile Manager
Self-hosted alternative to Multilogin, GoLogin, and AdsPower. Create browser profiles with unique fingerprints, proxies, and persistent sessions. Launch and interact with them in your browser via noVNC.
```bash
docker run -p 8080:8080 -v cloakprofiles:/data cloakhq/cloakbrowser-manager
```
Open [http://localhost:8080](http://localhost:8080). Create a profile. Click **Launch**. Done.
**[CloakBrowser Manager](https://github.com/CloakHQ/CloakBrowser-Manager)** — free, open source (MIT)
---
## Latest: v0.3.19 (Chromium 145.0.7632.159.8)
- **`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. Two presets: `default` and `careful` - **`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. Two presets: `default` and `careful`
- **CDP input behavior mimicking** — input events sent via CDP now produce the same signals as real user interactions. 5 new source-level patches covering pointer, keyboard, and mouse behavior - **CDP input behavior mimicking** — input events sent via CDP now produce the same signals as real user interactions. 4 source-level patches covering pointer, keyboard, and mouse behavior
- **Native locale spoofing** — new C++ patch replaces detectable CDP-level locale emulation - **Native locale spoofing** — new C++ patch replaces detectable CDP-level locale emulation
- **WebGPU fingerprint hardening** — adapter features, limits, and device ID spoofed for cross-API consistency - **WebGPU fingerprint hardening** — adapter features, limits, and device ID spoofed for cross-API consistency
- **32 fingerprint patches** (Linux x64) — all 4 platforms on Chromium 145 - **42 fingerprint patches** (Linux x64) — all 4 platforms on Chromium 145
- **Stealthy with zero flags** — binary auto-generates a random fingerprint seed at startup. No configuration required - **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 - **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 - **Persistent profiles** — `launch_persistent_context()` keeps cookies and localStorage across sessions, bypasses incognito detection
@@ -129,7 +147,7 @@ See the full [CHANGELOG.md](CHANGELOG.md) for details.
- **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. - **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. - **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. - **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, agent-browser, Claude computer use, and OpenAI Operator. Also tested with Playwright, Puppeteer, and Selenium — point any Chromium-based framework at the binary path. - **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 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.
@@ -203,7 +221,7 @@ CloakBrowser is a thin wrapper (Python + JavaScript) around a custom-built Chrom
3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args 3. **Every launch** → Playwright or Puppeteer starts with our binary + stealth args
4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn 4. **You write code** → standard Playwright/Puppeteer API, nothing new to learn
The binary includes 32 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, hardware reporting, automation signal removal, and CDP input behavior mimicking. The binary includes 42 source-level patches covering canvas, WebGL, audio, fonts, GPU, screen properties, 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. These are compiled into the Chromium binary — not injected via JavaScript, not set via flags.
@@ -288,7 +306,7 @@ context.close()
### `launch_persistent_context()` ### `launch_persistent_context()`
Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions. Also avoids incognito detection by services like BrowserScan. Same as `launch_context()`, but with a persistent user profile. Cookies, localStorage, and cache persist across sessions.
Use this when you need to: Use this when you need to:
- **Stay logged in** across runs (cookies/sessions survive restarts) - **Stay logged in** across runs (cookies/sessions survive restarts)
@@ -313,6 +331,17 @@ Supports all the same options as `launch_context()`: `proxy`, `user_agent`, `vie
Async version: `launch_persistent_context_async()`. Async version: `launch_persistent_context_async()`.
**Storage quota and detection tradeoff:** By default, the binary normalizes storage quota to pass FingerprintJS, which blocks persistent contexts that report non-incognito quota values. This means detection services that penalize incognito mode (like BrowserScan's `notPrivate` check, -10 points) will still flag it. If your target site penalizes incognito but doesn't use FingerprintJS, set a higher quota to appear as a regular profile:
```python
ctx = launch_persistent_context("./my-profile", args=["--fingerprint-storage-quota=5000"])
```
| Quota setting | FingerprintJS | BrowserScan `notPrivate` |
|---|---|---|
| Default (auto, ~500MB) | PASS | -10 (flagged as incognito) |
| `--fingerprint-storage-quota=5000` | May trigger detection | PASS (appears non-incognito) |
### CLI ### CLI
Pre-download the binary or check installation status from the command line: Pre-download the binary or check installation status from the command line:
@@ -472,6 +501,8 @@ const browser = await launch({
Access the original un-patched Playwright page at `page._original` if you need raw speed for a specific call. Access the original un-patched Playwright page at `page._original` if you need raw speed for a specific call.
> **Note:** 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.
> Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright API coverage. > Contributed by [@evelaa123](https://github.com/evelaa123) — full Playwright API coverage.
## Configuration ## Configuration
@@ -539,8 +570,10 @@ Supported by the binary but **not set by default** — pass via `args` to custom
| `--fingerprint-location` | Geolocation coordinates | | `--fingerprint-location` | Geolocation coordinates |
| `--fingerprint-timezone` | Timezone (e.g. `America/New_York`) | | `--fingerprint-timezone` | Timezone (e.g. `America/New_York`) |
| `--fingerprint-locale` | Locale (e.g. `en-US`) | | `--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-taskbar-height` | Override taskbar height (binary defaults: Win=48, Mac=95, Linux=0) |
| `--fingerprint-fonts-dir` | Path to cross-platform font directory | | `--fingerprint-fonts-dir` | Path to cross-platform font directory |
| `--fingerprint-noise=false` | Disable noise injection (canvas, WebGL, audio, client rects) while keeping the deterministic fingerprint seed active |
| `--enable-blink-features=FakeShadowRoot` | Access closed shadow DOM elements | | `--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. > **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.
@@ -580,14 +613,44 @@ browser = launch(args=[
- [`basic-puppeteer.ts`](js/examples/basic-puppeteer.ts) — Puppeteer 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 - [`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
```
| 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) |
## Platforms ## Platforms
| Platform | Chromium | Patches | Status | | Platform | Chromium | Patches | Status |
|---|---|---|---| |---|---|---|---|
| Linux x86_64 | 145 | 31 | ✅ Latest | | Linux x86_64 | 145 | 42 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest | | Linux arm64 (RPi, Graviton) | 145 | 33 | ✅ |
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest | | macOS arm64 (Apple Silicon) | 145 | 26 | ✅ |
| Windows x86_64 | 145 | 26 | ✅ Latest | | macOS x86_64 (Intel) | 145 | 26 | ✅ |
| Windows x86_64 | 145 | 33 | ✅ |
The wrapper auto-downloads the correct binary for your platform. The wrapper auto-downloads the correct binary for your platform.
@@ -691,7 +754,28 @@ services:
start_period: 10s start_period: 10s
``` ```
Run multiple instances with different fingerprint seeds on different ports — each gets unique canvas noise, client rects, and other browser signals. Pass `--fingerprint=<seed>` in the command (e.g., `cloakserve --fingerprint=12345`). **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). Check active processes at `GET /` (returns JSON with PIDs, ports, and connection counts).
**Persistent profiles** — mount a volume to keep cookies and sessions across container restarts: **Persistent profiles** — mount a volume to keep cookies and sessions across container restarts:
@@ -861,25 +945,15 @@ The macOS fingerprint profile has known inconsistencies that aggressive bot dete
### Site detects incognito / private browsing mode ### Site detects incognito / private browsing mode
By default, `launch()` opens an incognito context. Some sites (like BrowserScan) detect this. Use `launch_persistent_context()` instead — it runs with a real user profile, so incognito detection passes: By default, `launch()` opens an incognito context. Some sites penalize this. Use `launch_persistent_context()` to get a real profile with cookie persistence:
```python ```python
from cloakbrowser import launch_persistent_context from cloakbrowser import launch_persistent_context
ctx = launch_persistent_context("./my-profile", headless=False) ctx = launch_persistent_context("./my-profile", headless=False)
page = ctx.new_page()
``` ```
```javascript 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.
import { launchPersistentContext } from 'cloakbrowser';
const ctx = await launchPersistentContext({
userDataDir: './my-profile',
headless: false,
});
```
This also gives you cookie and localStorage persistence across sessions.
--- ---
@@ -935,9 +1009,9 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
| Feature | Status | | Feature | Status |
|---------|--------| |---------|--------|
| Linux x64 — Chromium 145 (26 patches) | ✅ Released | | Linux x64 — Chromium 145 (42 patches) | ✅ Released |
| macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released | | macOS arm64/x64 — Chromium 145 (26 patches) | ✅ Released |
| Windows x64 — Chromium 145 (26 patches) | ✅ Released | | Windows x64 — Chromium 145 (33 patches) | ✅ Released |
| JavaScript/Puppeteer + Playwright support | ✅ Released | | JavaScript/Puppeteer + Playwright support | ✅ Released |
| Fingerprint rotation per session | ✅ Released | | Fingerprint rotation per session | ✅ Released |
| Built-in proxy rotation | 📋 Planned | | Built-in proxy rotation | 📋 Planned |
@@ -949,6 +1023,7 @@ A: Yes. Pass `proxy="http://user:pass@host:port"` to `launch()`.
- 🐛 **Bug reports & feature requests** — [GitHub Issues](https://github.com/CloakHQ/CloakBrowser/issues) - 🐛 **Bug reports & feature requests** — [GitHub Issues](https://github.com/CloakHQ/CloakBrowser/issues)
- 📦 **PyPI** — [pypi.org/project/cloakbrowser](https://pypi.org/project/cloakbrowser/) - 📦 **PyPI** — [pypi.org/project/cloakbrowser](https://pypi.org/project/cloakbrowser/)
- 📦 **npm** — [npmjs.com/package/cloakbrowser](https://www.npmjs.com/package/cloakbrowser) - 📦 **npm** — [npmjs.com/package/cloakbrowser](https://www.npmjs.com/package/cloakbrowser)
- ☕ **Support** — [ko-fi.com/cloakhq](https://ko-fi.com/cloakhq)
- 📧 **Contact** — cloakhq@pm.me - 📧 **Contact** — cloakhq@pm.me
## Security ## Security
@@ -958,7 +1033,7 @@ All releases are signed for supply chain verification.
```bash ```bash
# Verify GPG signature (binary release tag) # Verify GPG signature (binary release tag)
gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD gpg --keyserver keyserver.ubuntu.com --recv-keys C60C0DDC9D0DE2DD
git verify-tag chromium-v145.0.7632.159.6 git verify-tag chromium-v145.0.7632.159.8
# Verify GitHub binary attestation (Sigstore) # Verify GitHub binary attestation (Sigstore)
gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser gh attestation verify cloakbrowser-linux-x64.tar.gz --repo CloakHQ/cloakbrowser
+604 -28
View File
@@ -1,31 +1,54 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Launch stealth Chromium as a CDP server for remote connections. """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: Usage:
cloakserve # headless on port 9222 cloakserve # default, backward compat
cloakserve --headless=false # headed (uses Xvfb in Docker) cloakserve --port=9222 # custom port
cloakserve --proxy-server=host:port # with proxy
Connect from host: Client:
playwright.chromium.connect_over_cdp("http://localhost:9222") 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"
)
""" """
import signal
from __future__ import annotations
import asyncio
import json
import logging
import os
import random
import shutil
import socket
import subprocess import subprocess
import sys import sys
import time import time
from dataclasses import dataclass
from urllib.parse import parse_qs
from cloakbrowser.config import get_default_stealth_args from pathlib import Path
import aiohttp
import websockets
from aiohttp import web
from cloakbrowser.browser import build_args, maybe_resolve_geoip
from cloakbrowser.download import ensure_binary from cloakbrowser.download import ensure_binary
PORT = 9222 logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("cloakserve")
binary = ensure_binary() # Args for running Chrome directly (outside Playwright).
# Playwright normally adds its own version of these.
chrome_args = [ BASE_CHROME_ARGS = [
binary,
f"--remote-debugging-port={PORT}",
"--remote-debugging-address=0.0.0.0",
# Sane defaults for running Chrome directly (outside Playwright)
"--no-first-run", "--no-first-run",
"--no-default-browser-check", "--no-default-browser-check",
"--disable-dev-shm-usage", "--disable-dev-shm-usage",
@@ -33,21 +56,574 @@ chrome_args = [
"--disable-popup-blocking", "--disable-popup-blocking",
"--disable-background-networking", "--disable-background-networking",
"--metrics-recording-only", "--metrics-recording-only",
] + get_default_stealth_args() + sys.argv[1:] "--ignore-gpu-blocklist",
]
chrome = subprocess.Popen(chrome_args) BASE_CDP_PORT = 5100
time.sleep(2)
print(f"CloakBrowser CDP server ready on port {PORT}", flush=True)
def cleanup(sig, frame): # ---------------------------------------------------------------------------
chrome.terminate() # ChromeProcess — one running Chrome instance
sys.exit(0) # ---------------------------------------------------------------------------
@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
signal.signal(signal.SIGTERM, cleanup) # ---------------------------------------------------------------------------
signal.signal(signal.SIGINT, cleanup) # ChromePool — manages multiple Chrome processes keyed by seed
# ---------------------------------------------------------------------------
chrome.wait() class ChromePool:
def __init__(
self,
binary: str,
global_args: list[str],
headless: bool,
data_dir: str = "/tmp/cloakserve",
):
self._binary = binary
self._global_args = global_args
self._headless = headless
self._data_dir = data_dir
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] = {}
def _get_lock(self, seed: str) -> asyncio.Lock:
if seed not in self._locks:
self._locks[seed] = asyncio.Lock()
return self._locks[seed]
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._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)
else:
self._connections[seed_key] = count
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."""
# No seed = default shared process
if seed is None:
seed_key = "__default__"
actual_seed = str(random.randint(10000, 99999))
else:
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 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
if geoip and proxy:
timezone, locale = 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={proxy}")
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(shutil.rmtree, user_data_dir, True)
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."""
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()
# Clean up user data dir (can be slow for large profiles)
await asyncio.to_thread(shutil.rmtree, proc.user_data_dir, True)
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."""
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)
return "wss" if proto == "https" else "ws"
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),
"timezone": proc.timezone,
"locale": proc.locale,
"proxy": proc.proxy,
}
return web.json_response({
"status": "ok",
"active": len(processes),
"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 = request.headers.get("Host", f"localhost:{request.app['port']}")
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 = request.headers.get("Host", f"localhost:{request.app['port']}")
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.WebSocketResponse:
"""WebSocket proxy for default (no-seed) Chrome: /devtools/{type}/{guid}"""
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.WebSocketResponse:
"""WebSocket proxy for seed-specific Chrome: /fingerprint/{seed}/devtools/{type}/{guid}"""
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: Docker → /tmp/cloakserve, bare metal → ~/.cloakbrowser/cloakserve."""
if os.path.exists("/.dockerenv"):
return "/tmp/cloakserve"
return str(Path.home() / ".cloakbrowser" / "cloakserve")
def parse_cli_args(argv: list[str]) -> tuple[dict, list[str]]:
"""Parse cloakserve-specific args, return (config, passthrough_args)."""
config: dict = {
"port": 9222,
"headless": True,
"data_dir": None,
}
passthrough = []
# Flags consumed by cloakserve (not passed to Chrome)
consumed_prefixes = (
"--port=",
"--data-dir=",
"--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 == "--headless=false" or arg == "--headless=False":
config["headless"] = False
passthrough.append(arg)
elif arg.startswith(consumed_prefixes):
pass # Strip these silently
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:])
pool = ChromePool(
binary=binary,
global_args=global_args,
headless=config["headless"],
data_dir=config["data_dir"],
)
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=<seed>\")",
port,
)
web.run_app(app, host="0.0.0.0", port=port, print=None)
if __name__ == "__main__":
main()
+3 -1
View File
@@ -11,7 +11,7 @@ Usage:
browser.close() browser.close()
""" """
from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings from .browser import launch, launch_async, launch_context, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip
from .config import CHROMIUM_VERSION, get_default_stealth_args from .config import CHROMIUM_VERSION, get_default_stealth_args
from .download import binary_info, check_for_update, clear_cache, ensure_binary from .download import binary_info, check_for_update, clear_cache, ensure_binary
from ._version import __version__ from ._version import __version__
@@ -40,6 +40,8 @@ __all__ = [
"check_for_update", "check_for_update",
"CHROMIUM_VERSION", "CHROMIUM_VERSION",
"get_default_stealth_args", "get_default_stealth_args",
"build_args",
"maybe_resolve_geoip",
"ProxySettings", "ProxySettings",
"HumanConfig", "HumanConfig",
"resolve_human_config", "resolve_human_config",
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.14" __version__ = "0.3.19"
+32 -11
View File
@@ -101,8 +101,8 @@ def launch(
sync_playwright = _import_sync_playwright(_resolve_backend(backend)) sync_playwright = _import_sync_playwright(_resolve_backend(backend))
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale) chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args)) logger.debug("Launching stealth Chromium (headless=%s, args=%d)", headless, len(chrome_args))
@@ -120,7 +120,9 @@ def launch(
_original_close = browser.close _original_close = browser.close
def _close_with_cleanup() -> None: def _close_with_cleanup() -> None:
try:
_original_close() _original_close()
finally:
pw.stop() pw.stop()
browser.close = _close_with_cleanup browser.close = _close_with_cleanup
@@ -184,8 +186,8 @@ async def launch_async( # noqa: C901
async_playwright = _import_async_playwright(_resolve_backend(backend)) async_playwright = _import_async_playwright(_resolve_backend(backend))
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale) chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args)) logger.debug("Launching stealth Chromium async (headless=%s, args=%d)", headless, len(chrome_args))
@@ -203,7 +205,9 @@ async def launch_async( # noqa: C901
_original_close = browser.close _original_close = browser.close
async def _close_with_cleanup() -> None: async def _close_with_cleanup() -> None:
try:
await _original_close() await _original_close()
finally:
await pw.stop() await pw.stop()
browser.close = _close_with_cleanup browser.close = _close_with_cleanup
@@ -280,8 +284,8 @@ def launch_persistent_context(
timezone = _resolve_timezone(timezone, kwargs) timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale) chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug( logger.debug(
"Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)", "Launching persistent stealth Chromium (headless=%s, user_data_dir=%s)",
@@ -314,7 +318,9 @@ def launch_persistent_context(
_original_close = context.close _original_close = context.close
def _close_with_cleanup() -> None: def _close_with_cleanup() -> None:
try:
_original_close() _original_close()
finally:
pw.stop() pw.stop()
context.close = _close_with_cleanup context.close = _close_with_cleanup
@@ -393,8 +399,8 @@ async def launch_persistent_context_async(
timezone = _resolve_timezone(timezone, kwargs) timezone = _resolve_timezone(timezone, kwargs)
binary_path = ensure_binary() binary_path = ensure_binary()
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
chrome_args = _build_args(stealth_args, args, timezone=timezone, locale=locale) chrome_args = build_args(stealth_args, args, timezone=timezone, locale=locale, headless=headless)
logger.debug( logger.debug(
"Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)", "Launching persistent stealth Chromium async (headless=%s, user_data_dir=%s)",
@@ -427,7 +433,9 @@ async def launch_persistent_context_async(
_original_close = context.close _original_close = context.close
async def _close_with_cleanup() -> None: async def _close_with_cleanup() -> None:
try:
await _original_close() await _original_close()
finally:
await pw.stop() await pw.stop()
context.close = _close_with_cleanup context.close = _close_with_cleanup
@@ -489,7 +497,7 @@ def launch_context(
# Resolve geoip BEFORE launch() to avoid double-resolution and ensure # Resolve geoip BEFORE launch() to avoid double-resolution and ensure
# resolved values flow to binary flags # resolved values flow to binary flags
timezone, locale = _maybe_resolve_geoip(geoip, proxy, timezone, locale) timezone, locale = maybe_resolve_geoip(geoip, proxy, timezone, locale)
# --fingerprint-timezone is process-wide (reads CommandLine in renderer), # --fingerprint-timezone is process-wide (reads CommandLine in renderer),
# so it applies to ALL contexts, not just the default one. # so it applies to ALL contexts, not just the default one.
# locale and timezone are set via binary flags only — no CDP emulation. # locale and timezone are set via binary flags only — no CDP emulation.
@@ -514,7 +522,9 @@ def launch_context(
_original_ctx_close = context.close _original_ctx_close = context.close
def _close_context_with_cleanup() -> None: def _close_context_with_cleanup() -> None:
try:
_original_ctx_close() _original_ctx_close()
finally:
browser.close() browser.close()
context.close = _close_context_with_cleanup context.close = _close_context_with_cleanup
@@ -580,7 +590,7 @@ def _ensure_proxy_scheme(proxy_url: str) -> str:
return proxy_url if "://" in proxy_url else f"http://{proxy_url}" return proxy_url if "://" in proxy_url else f"http://{proxy_url}"
def _maybe_resolve_geoip( def maybe_resolve_geoip(
geoip: bool, geoip: bool,
proxy: str | ProxySettings | None, proxy: str | ProxySettings | None,
timezone: str | None, timezone: str | None,
@@ -604,11 +614,12 @@ def _maybe_resolve_geoip(
return timezone, locale return timezone, locale
def _build_args( def build_args(
stealth_args: bool, stealth_args: bool,
extra_args: list[str] | None, extra_args: list[str] | None,
timezone: str | None = None, timezone: str | None = None,
locale: str | None = None, locale: str | None = None,
headless: bool = True,
) -> list[str]: ) -> list[str]:
"""Combine stealth args with user-provided args and locale flags. """Combine stealth args with user-provided args and locale flags.
@@ -621,6 +632,16 @@ def _build_args(
for arg in get_default_stealth_args(): for arg in get_default_stealth_args():
seen[arg.split("=", 1)[0]] = arg 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: if extra_args:
for arg in extra_args: for arg in extra_args:
key = arg.split("=", 1)[0] key = arg.split("=", 1)[0]
+4 -3
View File
@@ -15,13 +15,14 @@ from ._version import __version__
# CHROMIUM_VERSION is the latest across all platforms (for display/reference). # CHROMIUM_VERSION is the latest across all platforms (for display/reference).
# Use get_chromium_version() for the current platform's actual version. # Use get_chromium_version() for the current platform's actual version.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CHROMIUM_VERSION = "145.0.7632.159.6" CHROMIUM_VERSION = "145.0.7632.159.8"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = { PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.159.6", "linux-x64": "145.0.7632.159.8",
"linux-arm64": "145.0.7632.159.7",
"darwin-arm64": "145.0.7632.109.2", "darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2", "darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2", "windows-x64": "145.0.7632.159.7",
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+9 -7
View File
@@ -12,6 +12,7 @@ import os
import platform import platform
import stat import stat
import subprocess import subprocess
import sys
import tarfile import tarfile
import tempfile import tempfile
import threading import threading
@@ -55,13 +56,14 @@ def _show_welcome() -> None:
marker = get_cache_dir() / ".welcome_shown" marker = get_cache_dir() / ".welcome_shown"
if marker.exists(): if marker.exists():
return return
print() sys.stderr.write("\n")
print(" CloakBrowser — stealth Chromium for automation") sys.stderr.write(" CloakBrowser — stealth Chromium for automation\n")
print(" https://github.com/CloakHQ/CloakBrowser") sys.stderr.write(" https://github.com/CloakHQ/CloakBrowser\n")
print() sys.stderr.write("\n")
print(" Issues? https://github.com/CloakHQ/CloakBrowser/issues") sys.stderr.write(" Issues? https://github.com/CloakHQ/CloakBrowser/issues\n")
print(" Star us if CloakBrowser helps your project!") sys.stderr.write(" Donate? https://ko-fi.com/cloakhq\n")
print() sys.stderr.write(" Star us if CloakBrowser helps your project!\n")
sys.stderr.write("\n")
try: try:
marker.parent.mkdir(parents=True, exist_ok=True) marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text("") marker.write_text("")
+9 -1
View File
@@ -41,7 +41,15 @@ def _get_nearby_key(ch: str) -> str:
def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None: def human_type(page: Any, raw: RawKeyboard, text: str, cfg: HumanConfig) -> None:
for i, ch in enumerate(text): for i, ch in enumerate(text):
# Mistype chance — press wrong key, notice, backspace, then correct # 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(): if random.random() < cfg.mistype_chance and ch.isalnum():
wrong = _get_nearby_key(ch) wrong = _get_nearby_key(ch)
_type_normal_char(raw, wrong, cfg) _type_normal_char(raw, wrong, cfg)
+9 -1
View File
@@ -22,7 +22,15 @@ class AsyncRawKeyboard(Protocol):
async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig) -> None: async def async_human_type(page: Any, raw: AsyncRawKeyboard, text: str, cfg: HumanConfig) -> None:
for i, ch in enumerate(text): for i, ch in enumerate(text):
# Mistype chance — press wrong key, notice, backspace, then correct # 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(): if random.random() < cfg.mistype_chance and ch.isalnum():
wrong = _get_nearby_key(ch) wrong = _get_nearby_key(ch)
await _type_normal_char(raw, wrong, cfg) await _type_normal_char(raw, wrong, cfg)
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# agent-browser + CloakBrowser: AI browser agent with stealth fingerprints.
#
# agent-browser is a Node.js CLI for browser automation with session management.
# CloakBrowser provides the stealth Chromium binary.
#
# Requires: npm install -g agent-browser
# pip install cloakbrowser (to auto-download the binary)
#
# Note: agent-browser launches Chrome itself via env vars — it can't connect
# to an existing browser via CDP. So we pass the binary path and stealth args directly.
# Get CloakBrowser binary path (auto-downloads if needed)
BINARY_PATH=$(python3 -c "from cloakbrowser.download import ensure_binary; print(ensure_binary())")
# Get stealth args from our wrapper (comma-separated for agent-browser)
STEALTH_ARGS=$(python3 -c "from cloakbrowser.config import get_default_stealth_args; print(','.join(get_default_stealth_args()))")
# Point agent-browser at CloakBrowser
export AGENT_BROWSER_EXECUTABLE_PATH="$BINARY_PATH"
export AGENT_BROWSER_ARGS="$STEALTH_ARGS"
# Open a page
agent-browser --session stealth-test open "https://example.com"
# Get page title
agent-browser --session stealth-test eval "document.title"
# Check stealth
agent-browser --session stealth-test eval "JSON.stringify({webdriver: navigator.webdriver, plugins: navigator.plugins.length, platform: navigator.platform})"
@@ -0,0 +1,41 @@
"""browser-use + CloakBrowser: AI agent with stealth fingerprints.
browser-use handles AI agent logic, CloakBrowser handles bot detection.
Your agent can now browse sites behind Cloudflare, reCAPTCHA, DataDome.
Requires: pip install browser-use cloakbrowser
Set OPENAI_API_KEY (or swap for another LLM provider).
"""
import asyncio
from browser_use import Agent, BrowserSession, ChatOpenAI
from cloakbrowser import launch_async
async def main():
# Step 1: Launch CloakBrowser (handles binary, stealth args, fingerprints)
cb_browser = await launch_async(
headless=True,
args=["--remote-debugging-port=9242", "--remote-debugging-address=127.0.0.1"],
)
# Step 2: Connect browser-use to the stealth browser via CDP
session = BrowserSession(cdp_url="http://127.0.0.1:9242")
# Step 3: Run your AI agent — it browses through CloakBrowser
agent = Agent(
task="Go to https://www.google.com and search for 'browser automation'",
llm=ChatOpenAI(model="gpt-4o-mini"),
browser_session=session,
)
result = await agent.run()
print(result)
await cb_browser.close()
if __name__ == "__main__":
asyncio.run(main())
+39
View File
@@ -0,0 +1,39 @@
"""Crawl4AI + CloakBrowser: LLM-ready web crawling with stealth fingerprints.
Crawl4AI handles extraction and markdown conversion,
CloakBrowser handles bot detection.
Requires: pip install crawl4ai cloakbrowser
"""
import asyncio
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
from cloakbrowser import launch_async
async def main():
# Step 1: Launch CloakBrowser with remote debugging
cb_browser = await launch_async(
headless=True,
args=["--remote-debugging-port=9243", "--remote-debugging-address=127.0.0.1"],
)
# Step 2: Connect Crawl4AI to the stealth browser via CDP
browser_config = BrowserConfig(browser_mode="cdp", cdp_url="http://127.0.0.1:9243")
run_config = CrawlerRunConfig()
async with AsyncWebCrawler(config=browser_config) as crawler:
result = await crawler.arun(
"https://example.com",
config=run_config,
)
print(f"Extracted {len(result.markdown)} chars of markdown")
print(result.markdown[:500])
await cb_browser.close()
if __name__ == "__main__":
asyncio.run(main())
+72
View File
@@ -0,0 +1,72 @@
"""Crawlee + CloakBrowser: stealth web crawling with PlaywrightCrawler.
Uses a custom BrowserPlugin to swap Crawlee's default Chromium
for CloakBrowser's patched binary with source-level fingerprint patches.
Requires: pip install cloakbrowser "crawlee[playwright]"
"""
import asyncio
from cloakbrowser.config import IGNORE_DEFAULT_ARGS, get_default_stealth_args
from cloakbrowser.download import ensure_binary
from typing_extensions import override
from crawlee.browsers import (
BrowserPool,
PlaywrightBrowserController,
PlaywrightBrowserPlugin,
)
from crawlee.crawlers import PlaywrightCrawler, PlaywrightCrawlingContext
class CloakBrowserPlugin(PlaywrightBrowserPlugin):
"""Browser plugin that uses CloakBrowser's patched Chromium,
but otherwise keeps the functionality of PlaywrightBrowserPlugin.
"""
@override
async def new_browser(self) -> PlaywrightBrowserController:
if not self._playwright:
raise RuntimeError('Playwright browser plugin is not initialized.')
binary_path = ensure_binary()
stealth_args = get_default_stealth_args()
# Merge CloakBrowser stealth args with any user-provided launch options.
launch_options = dict(self._browser_launch_options)
launch_options.pop('executable_path', None)
launch_options.pop('chromium_sandbox', None)
existing_args = list(launch_options.pop('args', []))
launch_options['args'] = [*existing_args, *stealth_args]
return PlaywrightBrowserController(
browser=await self._playwright.chromium.launch(
executable_path=binary_path,
ignore_default_args=IGNORE_DEFAULT_ARGS,
**launch_options,
),
max_open_pages_per_browser=1,
# CloakBrowser handles fingerprints at the binary level.
header_generator=None,
)
async def main() -> None:
crawler = PlaywrightCrawler(
max_requests_per_crawl=10,
browser_pool=BrowserPool(plugins=[CloakBrowserPlugin()]),
)
@crawler.router.default_handler
async def request_handler(context: PlaywrightCrawlingContext) -> None:
context.log.info(f'Processing {context.request.url} ...')
title = await context.page.title()
await context.push_data({'url': context.request.url, 'title': title})
await context.enqueue_links()
await crawler.run(['https://example.com'])
if __name__ == '__main__':
asyncio.run(main())
+51
View File
@@ -0,0 +1,51 @@
"""LangChain + CloakBrowser: load web pages behind bot detection into LangChain Documents.
LangChain's PlaywrightURLLoader hardcodes chromium.launch() with no way to pass
a custom binary. This example uses CloakBrowser directly as a stealth document loader
that produces LangChain Document objects.
Requires: pip install langchain-core cloakbrowser
"""
import asyncio
from langchain_core.documents import Document
from cloakbrowser import launch_async
async def load_urls_stealth(urls: list[str], **launch_kwargs) -> list[Document]:
"""Load URLs using CloakBrowser stealth browser, return LangChain Documents."""
browser = await launch_async(headless=True, **launch_kwargs)
page = await browser.new_page()
docs = []
for url in urls:
await page.goto(url, wait_until="domcontentloaded")
text = await page.evaluate("document.body.innerText")
title = await page.title()
docs.append(Document(
page_content=text,
metadata={"source": url, "title": title},
))
await browser.close()
return docs
async def main():
urls = [
"https://example.com",
"https://httpbin.org/html",
]
docs = await load_urls_stealth(urls)
for doc in docs:
print(f"--- {doc.metadata['title']} ({doc.metadata['source']}) ---")
print(doc.page_content[:300])
print()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,42 @@
"""Scrapling + CloakBrowser: adaptive web scraping with stealth fingerprints.
Scrapling handles parsing and element tracking,
CloakBrowser handles bot detection.
Requires: pip install scrapling[all] cloakbrowser
"""
import asyncio
import json
from urllib.request import urlopen
from scrapling.fetchers import StealthyFetcher
from cloakbrowser import launch_async
async def main():
# Launch CloakBrowser with remote debugging
cb_browser = await launch_async(
headless=True,
args=["--remote-debugging-port=9245", "--remote-debugging-address=127.0.0.1"],
)
# Get the WebSocket URL from Chrome (Scrapling requires ws:// scheme)
info = json.loads(urlopen("http://127.0.0.1:9245/json/version").read())
ws_url = info["webSocketDebuggerUrl"]
# Connect Scrapling to the stealth browser via CDP
page = await StealthyFetcher.async_fetch(
"https://example.com",
cdp_url=ws_url,
)
print(f"Title: {page.css('title::text').get()}")
print(f"Text: {page.css('p::text').getall()}")
await cb_browser.close()
if __name__ == "__main__":
asyncio.run(main())
+41
View File
@@ -0,0 +1,41 @@
"""Selenium + CloakBrowser: use stealth Chromium with Selenium WebDriver.
CloakBrowser provides the binary and stealth args.
Selenium drives it via ChromeDriver.
Requires: pip install selenium cloakbrowser
Note: ChromeDriver version must match Chromium 145.
pip install chromedriver-autoinstaller or download manually.
"""
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from cloakbrowser.config import get_default_stealth_args
from cloakbrowser.download import ensure_binary
binary_path = ensure_binary()
stealth_args = get_default_stealth_args()
options = Options()
options.binary_location = binary_path
options.add_argument("--headless")
for arg in stealth_args:
options.add_argument(arg)
driver = webdriver.Chrome(options=options)
driver.get("https://example.com")
print(f"Selenium + CloakBrowser: {driver.title}")
# Verify stealth
result = driver.execute_script("""
return {
webdriver: navigator.webdriver,
plugins: navigator.plugins.length,
platform: navigator.platform,
}
""")
print(f"Stealth checks: {result}")
driver.quit()
@@ -0,0 +1,40 @@
"""undetected-chromedriver + CloakBrowser: double stealth layer.
undetected-chromedriver patches ChromeDriver detection signals,
CloakBrowser patches the browser fingerprints at the C++ level.
Requires: pip install undetected-chromedriver cloakbrowser
"""
import undetected_chromedriver as uc
from cloakbrowser.config import get_chromium_version, get_default_stealth_args
from cloakbrowser.download import ensure_binary
binary_path = ensure_binary()
stealth_args = get_default_stealth_args()
chromium_major = int(get_chromium_version().split(".")[0])
options = uc.ChromeOptions()
options.binary_location = binary_path
options.add_argument("--headless")
for arg in stealth_args:
options.add_argument(arg)
driver = uc.Chrome(options=options, version_main=chromium_major)
driver.get("https://example.com")
print(f"undetected-chromedriver + CloakBrowser: {driver.title}")
# Verify stealth
result = driver.execute_script("""
return {
webdriver: navigator.webdriver,
plugins: navigator.plugins.length,
platform: navigator.platform,
hardwareConcurrency: navigator.hardwareConcurrency,
}
""")
print(f"Stealth checks: {result}")
driver.quit()
+6 -5
View File
@@ -11,12 +11,12 @@
Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.** Drop-in Playwright/Puppeteer replacement. Same API, same code — just swap the import. **3 lines of code, 30 seconds to unblock.**
- **32 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals - **42 source-level C++ patches** — canvas, WebGL, audio, fonts, GPU, screen, automation signals
- **0.9 reCAPTCHA v3 score** — human-level, server-verified - **0.9 reCAPTCHA v3 score** — human-level, server-verified
- **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites - **Passes Cloudflare Turnstile**, FingerprintJS, BrowserScan — tested against 30+ detection sites
- **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config - **`npm install cloakbrowser`** — binary auto-downloads, auto-updates, zero config
- **Free and open source** — no subscriptions, no usage limits - **Free and open source** — no subscriptions, no usage limits
- **Works with any framework** also tested with Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser - **Works with any framework** — tested with browser-use, Crawl4AI, Scrapling, Stagehand ([example](examples/stagehand.ts)), LangChain, Selenium, and more
## Install ## Install
@@ -203,10 +203,11 @@ const page = await browser.newPage();
| Platform | Chromium | Patches | Status | | Platform | Chromium | Patches | Status |
|---|---|---|---| |---|---|---|---|
| Linux x86_64 | 145 | 26 | ✅ Latest | | Linux x86_64 | 145 | 33 | ✅ Latest |
| Linux arm64 (RPi, Graviton) | 145 | 33 | ✅ Latest |
| macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest | | macOS arm64 (Apple Silicon) | 145 | 26 | ✅ Latest |
| macOS x86_64 (Intel) | 145 | 26 | ✅ Latest | | macOS x86_64 (Intel) | 145 | 26 | ✅ Latest |
| Windows x86_64 | 145 | 26 | ✅ Latest | | Windows x86_64 | 145 | 33 | ✅ Latest |
## Requirements ## Requirements
@@ -264,7 +265,7 @@ export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159.2/chrome
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.109.2/Chromium.app/Contents/MacOS/Chromium
# Windows # Windows
set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.109.2\chrome.exe set CLOAKBROWSER_BINARY_PATH=%USERPROFILE%\.cloakbrowser\chromium-145.0.7632.159.7\chrome.exe
``` ```
## Links ## Links
+36
View File
@@ -0,0 +1,36 @@
/**
* Stagehand + CloakBrowser: AI browser automation with stealth fingerprints.
*
* Stagehand handles AI-powered navigation and actions,
* CloakBrowser handles bot detection.
*
* Requires: npm install @browserbasehq/stagehand cloakbrowser
* Set OPENAI_API_KEY for the AI model.
*
* Usage:
* CLOAKBROWSER_BINARY_PATH=/path/to/chrome npx tsx examples/stagehand.ts
*/
import { Stagehand } from "@browserbasehq/stagehand";
import { ensureBinary } from "../src/download.js";
import { getDefaultStealthArgs } from "../src/config.js";
const binaryPath = await ensureBinary();
const stealthArgs = getDefaultStealthArgs();
const stagehand = new Stagehand({
env: "LOCAL",
localBrowserLaunchOptions: {
executablePath: binaryPath,
args: stealthArgs,
headless: true,
},
});
await stagehand.init();
const page = stagehand.context.pages()[0];
await page.goto("https://example.com");
console.log(`Stagehand + CloakBrowser: ${await page.title()}`);
await stagehand.close();
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.3.14", "version": "0.3.19",
"description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.", "description": "Stealth Chromium that passes every bot detection test. Drop-in Playwright/Puppeteer replacement with source-level fingerprint patches.",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+9
View File
@@ -20,6 +20,15 @@ export function buildArgs(options: LaunchOptions): string[] {
seen.set(arg.split("=")[0], arg); seen.set(arg.split("=")[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. Linux doesn't need it.
if (options.headless === false || process.platform === "win32") {
seen.set("--ignore-gpu-blocklist", "--ignore-gpu-blocklist");
}
if (options.args) { if (options.args) {
for (const arg of options.args) { for (const arg of options.args) {
const key = arg.split("=")[0]; const key = arg.split("=")[0];
+4 -3
View File
@@ -27,13 +27,14 @@ export { WRAPPER_VERSION };
// CHROMIUM_VERSION is the latest across all platforms (for display/reference). // CHROMIUM_VERSION is the latest across all platforms (for display/reference).
// Use getChromiumVersion() for the current platform's actual version. // Use getChromiumVersion() for the current platform's actual version.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const CHROMIUM_VERSION = "145.0.7632.159.6"; export const CHROMIUM_VERSION = "145.0.7632.159.8";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = { export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.159.6", "linux-x64": "145.0.7632.159.8",
"linux-arm64": "145.0.7632.159.7",
"darwin-arm64": "145.0.7632.109.2", "darwin-arm64": "145.0.7632.109.2",
"darwin-x64": "145.0.7632.109.2", "darwin-x64": "145.0.7632.109.2",
"windows-x64": "145.0.7632.109.2", "windows-x64": "145.0.7632.159.7",
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+10 -8
View File
@@ -146,13 +146,14 @@ export async function checkForUpdate(): Promise<string | null> {
function showWelcome(): void { function showWelcome(): void {
const marker = path.join(getCacheDir(), ".welcome_shown"); const marker = path.join(getCacheDir(), ".welcome_shown");
if (fs.existsSync(marker)) return; if (fs.existsSync(marker)) return;
console.log(); console.error();
console.log(" CloakBrowser — stealth Chromium for automation"); console.error(" CloakBrowser — stealth Chromium for automation");
console.log(" https://github.com/CloakHQ/CloakBrowser"); console.error(" https://github.com/CloakHQ/CloakBrowser");
console.log(); console.error();
console.log(" Issues? https://github.com/CloakHQ/CloakBrowser/issues"); console.error(" Issues? https://github.com/CloakHQ/CloakBrowser/issues");
console.log(" Star us if CloakBrowser helps your project!"); console.error(" Donate? https://ko-fi.com/cloakhq");
console.log(); console.error(" Star us if CloakBrowser helps your project!");
console.error();
try { try {
fs.mkdirSync(getCacheDir(), { recursive: true }); fs.mkdirSync(getCacheDir(), { recursive: true });
fs.writeFileSync(marker, ""); fs.writeFileSync(marker, "");
@@ -227,7 +228,8 @@ async function verifyDownloadChecksum(filePath: string, version?: string): Promi
await verifyChecksum(filePath, expected); await verifyChecksum(filePath, expected);
} }
async function fetchChecksums(version?: string): Promise<Map<string, string> | null> { /** @internal Exported for testing only. */
export async function fetchChecksums(version?: string): Promise<Map<string, string> | null> {
const v = version || getChromiumVersion(); const v = version || getChromiumVersion();
const hasCustomUrl = !!process.env.CLOAKBROWSER_DOWNLOAD_URL; const hasCustomUrl = !!process.env.CLOAKBROWSER_DOWNLOAD_URL;
+22 -5
View File
@@ -22,6 +22,11 @@ const NEARBY_KEYS: Record<string, string> = {
'6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p', '6': '57ty', '7': '68yu', '8': '79ui', '9': '80io', '0': '9p',
}; };
function isAscii(ch: string): boolean {
const code = ch.codePointAt(0);
return code !== undefined && code < 128;
}
function getNearbyKey(ch: string): string { function getNearbyKey(ch: string): string {
const lower = ch.toLowerCase(); const lower = ch.toLowerCase();
if (lower in NEARBY_KEYS) { if (lower in NEARBY_KEYS) {
@@ -38,11 +43,23 @@ export async function humanType(
text: string, text: string,
cfg: HumanConfig, cfg: HumanConfig,
): Promise<void> { ): Promise<void> {
for (let i = 0; i < text.length; i++) { const chars = [...text]; // Handle emoji surrogate pairs correctly
const ch = text[i];
// Mistype chance — press wrong key, notice, backspace, then correct for (let i = 0; i < chars.length; i++) {
if (Math.random() < cfg.mistype_chance && /[a-zA-Z0-9]/.test(ch)) { const ch = chars[i];
// Non-ASCII characters (Cyrillic, CJK, emoji) — use insertText
if (!isAscii(ch)) {
await sleep(randRange(cfg.key_hold));
await raw.insertText(ch);
if (i < chars.length - 1) {
await interCharDelay(cfg);
}
continue;
}
// Mistype chance — only for ASCII alphanumeric
if (Math.random() < cfg.mistype_chance && /^[a-zA-Z0-9]$/.test(ch)) {
const wrong = getNearbyKey(ch); const wrong = getNearbyKey(ch);
await typeNormalChar(raw, wrong, cfg); await typeNormalChar(raw, wrong, cfg);
await sleep(randRange(cfg.mistype_delay_notice)); await sleep(randRange(cfg.mistype_delay_notice));
@@ -60,7 +77,7 @@ export async function humanType(
await typeNormalChar(raw, ch, cfg); await typeNormalChar(raw, ch, cfg);
} }
if (i < text.length - 1) { if (i < chars.length - 1) {
await interCharDelay(cfg); await interCharDelay(cfg);
} }
} }
+75
View File
@@ -530,6 +530,81 @@ function buildMockPage(overrides: Record<string, any> = {}): any {
return page; return page;
} }
// =========================================================================
// humanType non-ASCII
// =========================================================================
describe("humanType non-ASCII", () => {
function makeRawKeyboardMock() {
const downKeys: string[] = [];
const insertedChars: string[] = [];
const raw = {
down: vi.fn(async (k: string) => { downKeys.push(k); }),
up: vi.fn(async () => {}),
type: vi.fn(async () => {}),
insertText: vi.fn(async (t: string) => { insertedChars.push(t); }),
};
return { raw, downKeys, insertedChars };
}
it("types Cyrillic via insertText, not down", async () => {
const { humanType } = await import("../src/human/keyboard.js");
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, downKeys, insertedChars } = makeRawKeyboardMock();
await humanType({} as any, raw, "Привет", cfg);
expect(insertedChars.join("")).toBe("Привет");
for (const k of downKeys) {
expect(k.charCodeAt(0)).toBeLessThan(128);
}
});
it("types mixed ASCII + Cyrillic correctly", async () => {
const { humanType } = await import("../src/human/keyboard.js");
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, downKeys, insertedChars } = makeRawKeyboardMock();
await humanType({} as any, raw, "Hi Мир", cfg);
expect(downKeys).toContain("H");
expect(downKeys).toContain("i");
expect(insertedChars.join("")).toContain("М");
expect(insertedChars.join("")).toContain("и");
expect(insertedChars.join("")).toContain("р");
});
it("types CJK via insertText", async () => {
const { humanType } = await import("../src/human/keyboard.js");
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, insertedChars } = makeRawKeyboardMock();
await humanType({} as any, raw, "你好", cfg);
expect(insertedChars.join("")).toBe("你好");
});
it("types emoji via insertText", async () => {
const { humanType } = await import("../src/human/keyboard.js");
const cfg = resolveConfig("default", { mistype_chance: 0 });
const { raw, insertedChars } = makeRawKeyboardMock();
await humanType({} as any, raw, "Hi 👋", cfg);
expect(insertedChars.join("")).toContain("👋");
});
it("mistype only triggers for ASCII, not Cyrillic", async () => {
const { humanType } = await import("../src/human/keyboard.js");
const cfg = resolveConfig("default", { mistype_chance: 1.0 });
const { raw, downKeys } = makeRawKeyboardMock();
await humanType({} as any, raw, "AБ", cfg);
expect(downKeys).toContain("Backspace");
});
});
function buildMockFrame(): any { function buildMockFrame(): any {
return { return {
+49
View File
@@ -14,6 +14,7 @@ import {
checkWrapperUpdate, checkWrapperUpdate,
clearCache, clearCache,
ensureBinary, ensureBinary,
fetchChecksums,
getLatestChromiumVersion, getLatestChromiumVersion,
parseChecksums, parseChecksums,
resetWrapperUpdateChecked, resetWrapperUpdateChecked,
@@ -269,6 +270,54 @@ describe("parseChecksums", () => {
}); });
}); });
describe("download fallback", () => {
afterEach(() => {
vi.restoreAllMocks();
delete process.env.CLOAKBROWSER_DOWNLOAD_URL;
});
it("checksum fetch falls back to GitHub on primary 429", async () => {
const HASH =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const checksumText = `${HASH} cloakbrowser-${getPlatformTag()}.tar.gz`;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: (input as Request).url;
if (url.includes("cloakbrowser.dev")) {
return {
ok: false,
status: 429,
statusText: "Too Many Requests",
} as Response;
}
// GitHub fallback
return { ok: true, text: async () => checksumText } as Response;
});
const result = await fetchChecksums();
expect(result).not.toBeNull();
expect(
result!.has(`cloakbrowser-${getPlatformTag()}.tar.gz`)
).toBe(true);
});
it("checksum fetch returns null when both sources fail", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 429,
statusText: "Too Many Requests",
} as Response);
const result = await fetchChecksums();
expect(result).toBeNull();
});
});
describe("effective version", () => { describe("effective version", () => {
it("returns platform version when no marker exists", () => { it("returns platform version when no marker exists", () => {
// Default behavior — no marker file in test environment // Default behavior — no marker file in test environment
+1
View File
@@ -56,6 +56,7 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
geoip = ["geoip2>=4.0"] geoip = ["geoip2>=4.0"]
patchright = ["patchright>=1.40"] patchright = ["patchright>=1.40"]
serve = ["aiohttp>=3.9", "websockets>=12.0"]
dev = ["pytest>=7.0", "pytest-asyncio>=0.23"] dev = ["pytest>=7.0", "pytest-asyncio>=0.23"]
[project.scripts] [project.scripts]
+15 -15
View File
@@ -1,24 +1,24 @@
"""Unit tests for _build_args timezone/locale injection and timezone alias.""" """Unit tests for build_args timezone/locale injection and timezone alias."""
from cloakbrowser.browser import _build_args, _resolve_timezone from cloakbrowser.browser import build_args, _resolve_timezone
def test_timezone_injected(): def test_timezone_injected():
"""--fingerprint-timezone flag should appear when timezone is set.""" """--fingerprint-timezone flag should appear when timezone is set."""
args = _build_args(stealth_args=True, extra_args=None, timezone="America/New_York") args = build_args(stealth_args=True, extra_args=None, timezone="America/New_York")
assert "--fingerprint-timezone=America/New_York" in args assert "--fingerprint-timezone=America/New_York" in args
def test_locale_injected(): def test_locale_injected():
"""--lang and --fingerprint-locale flags should appear when locale is set.""" """--lang and --fingerprint-locale flags should appear when locale is set."""
args = _build_args(stealth_args=True, extra_args=None, locale="en-US") args = build_args(stealth_args=True, extra_args=None, locale="en-US")
assert "--lang=en-US" in args assert "--lang=en-US" in args
assert "--fingerprint-locale=en-US" in args assert "--fingerprint-locale=en-US" in args
def test_both_injected(): def test_both_injected():
"""Both flags should appear when both are set.""" """Both flags should appear when both are set."""
args = _build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE") args = build_args(stealth_args=True, extra_args=None, timezone="Europe/Berlin", locale="de-DE")
assert "--fingerprint-timezone=Europe/Berlin" in args assert "--fingerprint-timezone=Europe/Berlin" in args
assert "--lang=de-DE" in args assert "--lang=de-DE" in args
assert "--fingerprint-locale=de-DE" in args assert "--fingerprint-locale=de-DE" in args
@@ -26,7 +26,7 @@ def test_both_injected():
def test_timezone_independent_of_stealth_args(): def test_timezone_independent_of_stealth_args():
"""--fingerprint-timezone should be injected even when stealth_args=False.""" """--fingerprint-timezone should be injected even when stealth_args=False."""
args = _build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US") args = build_args(stealth_args=False, extra_args=None, timezone="America/New_York", locale="en-US")
assert "--fingerprint-timezone=America/New_York" in args assert "--fingerprint-timezone=America/New_York" in args
assert "--lang=en-US" in args assert "--lang=en-US" in args
assert "--fingerprint-locale=en-US" in args assert "--fingerprint-locale=en-US" in args
@@ -36,7 +36,7 @@ def test_timezone_independent_of_stealth_args():
def test_no_flags_when_not_set(): def test_no_flags_when_not_set():
"""No timezone/lang/fingerprint-locale flags when params are None.""" """No timezone/lang/fingerprint-locale flags when params are None."""
args = _build_args(stealth_args=True, extra_args=None) args = build_args(stealth_args=True, extra_args=None)
assert not any(a.startswith("--fingerprint-timezone=") for a in args) assert not any(a.startswith("--fingerprint-timezone=") for a in args)
assert not any(a.startswith("--lang=") for a in args) assert not any(a.startswith("--lang=") for a in args)
assert not any(a.startswith("--fingerprint-locale=") for a in args) assert not any(a.startswith("--fingerprint-locale=") for a in args)
@@ -44,7 +44,7 @@ def test_no_flags_when_not_set():
def test_extra_args_preserved(): def test_extra_args_preserved():
"""Extra args should still be included alongside timezone/locale.""" """Extra args should still be included alongside timezone/locale."""
args = _build_args(stealth_args=True, extra_args=["--disable-gpu"], timezone="Asia/Tokyo", locale="ja-JP") args = build_args(stealth_args=True, extra_args=["--disable-gpu"], timezone="Asia/Tokyo", locale="ja-JP")
assert "--disable-gpu" in args assert "--disable-gpu" in args
assert "--fingerprint-timezone=Asia/Tokyo" in args assert "--fingerprint-timezone=Asia/Tokyo" in args
assert "--lang=ja-JP" in args assert "--lang=ja-JP" in args
@@ -90,7 +90,7 @@ def test_resolve_both_none():
def test_user_fingerprint_overrides_default(): def test_user_fingerprint_overrides_default():
"""User --fingerprint should override the random default seed.""" """User --fingerprint should override the random default seed."""
args = _build_args(stealth_args=True, extra_args=["--fingerprint=99887"]) args = build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
fingerprint_args = [a for a in args if a.startswith("--fingerprint=")] fingerprint_args = [a for a in args if a.startswith("--fingerprint=")]
assert len(fingerprint_args) == 1 assert len(fingerprint_args) == 1
assert fingerprint_args[0] == "--fingerprint=99887" assert fingerprint_args[0] == "--fingerprint=99887"
@@ -98,7 +98,7 @@ def test_user_fingerprint_overrides_default():
def test_user_platform_overrides_default(): def test_user_platform_overrides_default():
"""User --fingerprint-platform should override the default.""" """User --fingerprint-platform should override the default."""
args = _build_args(stealth_args=True, extra_args=["--fingerprint-platform=linux"]) args = build_args(stealth_args=True, extra_args=["--fingerprint-platform=linux"])
platform_args = [a for a in args if a.startswith("--fingerprint-platform=")] platform_args = [a for a in args if a.startswith("--fingerprint-platform=")]
assert len(platform_args) == 1 assert len(platform_args) == 1
assert platform_args[0] == "--fingerprint-platform=linux" assert platform_args[0] == "--fingerprint-platform=linux"
@@ -106,7 +106,7 @@ def test_user_platform_overrides_default():
def test_timezone_param_overrides_user_arg(): def test_timezone_param_overrides_user_arg():
"""Dedicated timezone param should override user arg.""" """Dedicated timezone param should override user arg."""
args = _build_args( args = build_args(
stealth_args=True, stealth_args=True,
extra_args=["--fingerprint-timezone=Europe/London"], extra_args=["--fingerprint-timezone=Europe/London"],
timezone="America/New_York", timezone="America/New_York",
@@ -118,7 +118,7 @@ def test_timezone_param_overrides_user_arg():
def test_locale_param_overrides_user_arg(): def test_locale_param_overrides_user_arg():
"""Dedicated locale param should override user --lang and --fingerprint-locale args.""" """Dedicated locale param should override user --lang and --fingerprint-locale args."""
args = _build_args( args = build_args(
stealth_args=True, stealth_args=True,
extra_args=["--lang=de-DE", "--fingerprint-locale=de-DE"], extra_args=["--lang=de-DE", "--fingerprint-locale=de-DE"],
locale="en-US", locale="en-US",
@@ -133,7 +133,7 @@ def test_locale_param_overrides_user_arg():
def test_no_duplicate_flags(): def test_no_duplicate_flags():
"""No flag key should appear more than once in the output.""" """No flag key should appear more than once in the output."""
args = _build_args( args = build_args(
stealth_args=True, stealth_args=True,
extra_args=["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"], extra_args=["--fingerprint=99887", "--fingerprint-timezone=UTC", "--lang=fr-FR"],
timezone="Europe/Berlin", timezone="Europe/Berlin",
@@ -145,7 +145,7 @@ def test_no_duplicate_flags():
def test_non_value_flags_preserved(): def test_non_value_flags_preserved():
"""Flags without = should be preserved without dedup issues.""" """Flags without = should be preserved without dedup issues."""
args = _build_args(stealth_args=True, extra_args=["--disable-gpu", "--no-zygote"]) args = build_args(stealth_args=True, extra_args=["--disable-gpu", "--no-zygote"])
assert "--disable-gpu" in args assert "--disable-gpu" in args
assert "--no-zygote" in args assert "--no-zygote" in args
assert "--no-sandbox" in args assert "--no-sandbox" in args
@@ -156,5 +156,5 @@ def test_override_logs_debug(caplog):
import logging import logging
with caplog.at_level(logging.DEBUG, logger="cloakbrowser"): with caplog.at_level(logging.DEBUG, logger="cloakbrowser"):
_build_args(stealth_args=True, extra_args=["--fingerprint=99887"]) build_args(stealth_args=True, extra_args=["--fingerprint=99887"])
assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records) assert any("--fingerprint=" in r.message and "99887" in r.message for r in caplog.records)
+244
View File
@@ -0,0 +1,244 @@
"""Unit tests for cloakserve — parse_connection_params, parse_cli_args, URL rewriting, connection tracking."""
import importlib.machinery
import importlib.util
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
aiohttp = pytest.importorskip("aiohttp", reason="cloakserve requires aiohttp (install with .[serve])")
# Load cloakserve as a module from bin/ (no .py extension).
_bin_path = str(Path(__file__).resolve().parents[1] / "bin" / "cloakserve")
_loader = importlib.machinery.SourceFileLoader("cloakserve", _bin_path)
_spec = importlib.util.spec_from_file_location("cloakserve", _bin_path, loader=_loader)
_mod = importlib.util.module_from_spec(_spec)
sys.modules["cloakserve"] = _mod
_loader.exec_module(_mod)
parse_connection_params = _mod.parse_connection_params
parse_cli_args = _mod.parse_cli_args
ChromePool = _mod.ChromePool
_default_data_dir = _mod._default_data_dir
# ---------------------------------------------------------------------------
# parse_connection_params
# ---------------------------------------------------------------------------
class TestParseConnectionParams:
def test_empty_query(self):
result = parse_connection_params("")
assert result["seed"] is None
assert result["extra_args"] == []
def test_fingerprint_seed(self):
result = parse_connection_params("fingerprint=12345")
assert result["seed"] == "12345"
def test_timezone_and_locale(self):
result = parse_connection_params("fingerprint=1&timezone=Asia/Tokyo&locale=ja-JP")
assert result["timezone"] == "Asia/Tokyo"
assert result["locale"] == "ja-JP"
def test_proxy(self):
result = parse_connection_params("proxy=http://proxy:8080")
assert result["proxy"] == "http://proxy:8080"
def test_geoip_true_variants(self):
for val in ("true", "1", "yes", "True", "YES"):
result = parse_connection_params(f"geoip={val}")
assert result["geoip"] is True, f"geoip={val} should be True"
def test_geoip_false(self):
for val in ("false", "0", "no", "anything"):
result = parse_connection_params(f"geoip={val}")
assert result["geoip"] is False, f"geoip={val} should be False"
def test_generic_fingerprint_params(self):
qs = "fingerprint=1&platform=windows&hardware-concurrency=8&gpu-vendor=NVIDIA"
result = parse_connection_params(qs)
assert "--fingerprint-platform=windows" in result["extra_args"]
assert "--fingerprint-hardware-concurrency=8" in result["extra_args"]
assert "--fingerprint-gpu-vendor=NVIDIA" in result["extra_args"]
def test_special_params_not_in_extra_args(self):
qs = "fingerprint=1&timezone=UTC&locale=en-US&proxy=http://x:1&geoip=true"
result = parse_connection_params(qs)
assert result["extra_args"] == []
def test_multiple_values_takes_first(self):
result = parse_connection_params("fingerprint=111&fingerprint=222")
assert result["seed"] == "111"
# ---------------------------------------------------------------------------
# parse_cli_args
# ---------------------------------------------------------------------------
class TestParseCliArgs:
def test_defaults(self):
config, passthrough = parse_cli_args([])
assert config["port"] == 9222
assert config["headless"] is True
assert config["data_dir"] is not None
assert passthrough == []
def test_custom_port(self):
config, _ = parse_cli_args(["--port=8080"])
assert config["port"] == 8080
def test_headless_false(self):
config, passthrough = parse_cli_args(["--headless=false"])
assert config["headless"] is False
# headless flag still passed through to Chrome
assert "--headless=false" in passthrough
def test_strips_remote_debugging_flags(self):
args = ["--remote-debugging-port=9999", "--remote-debugging-address=0.0.0.0", "--no-sandbox"]
config, passthrough = parse_cli_args(args)
assert passthrough == ["--no-sandbox"]
def test_passthrough_args(self):
args = ["--no-sandbox", "--disable-gpu", "--fingerprint=999"]
_, passthrough = parse_cli_args(args)
assert passthrough == args
def test_port_not_in_passthrough(self):
_, passthrough = parse_cli_args(["--port=9222", "--no-sandbox"])
assert "--port=9222" not in passthrough
assert "--no-sandbox" in passthrough
def test_custom_data_dir(self):
config, passthrough = parse_cli_args(["--data-dir=/custom/path", "--no-sandbox"])
assert config["data_dir"] == "/custom/path"
assert "--data-dir=/custom/path" not in passthrough
def test_data_dir_not_in_passthrough(self):
_, passthrough = parse_cli_args(["--data-dir=/tmp/test"])
assert not any(a.startswith("--data-dir=") for a in passthrough)
@patch("os.path.exists", return_value=True)
def test_default_data_dir_docker(self, _mock):
assert _default_data_dir() == "/tmp/cloakserve"
@patch("os.path.exists", return_value=False)
def test_default_data_dir_bare_metal(self, _mock):
result = _default_data_dir()
assert result.endswith(".cloakbrowser/cloakserve")
# ---------------------------------------------------------------------------
# URL rewriting logic (pure string manipulation, extracted from handlers)
# ---------------------------------------------------------------------------
class TestURLRewriting:
"""Test the URL rewriting logic used by /json/version and /json/list."""
def _rewrite_version(self, orig_ws: str, host: str, seed: str | None, scheme: str = "ws") -> str:
"""Replicate the URL rewrite logic from handle_json_version."""
if seed:
ws_path = f"fingerprint/{seed}/devtools/browser"
else:
ws_path = "devtools/browser"
guid = orig_ws.rsplit("/", 1)[-1] if "/devtools/" in orig_ws else ""
return f"{scheme}://{host}/{ws_path}/{guid}"
def _rewrite_list_entry(self, orig_ws: str, host: str, seed: str | None, scheme: str = "ws") -> str:
"""Replicate the URL rewrite logic from handle_json_list."""
ws_tail = orig_ws.split("/devtools/")[-1]
if seed:
return f"{scheme}://{host}/fingerprint/{seed}/devtools/{ws_tail}"
else:
return f"{scheme}://{host}/devtools/{ws_tail}"
def test_version_rewrite_with_seed(self):
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
result = self._rewrite_version(orig, "container:9222", "12345")
assert result == "ws://container:9222/fingerprint/12345/devtools/browser/abc-123"
def test_version_rewrite_no_seed(self):
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
result = self._rewrite_version(orig, "container:9222", None)
assert result == "ws://container:9222/devtools/browser/abc-123"
def test_list_rewrite_page_with_seed(self):
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
result = self._rewrite_list_entry(orig, "host:9222", "99")
assert result == "ws://host:9222/fingerprint/99/devtools/page/DEF-456"
def test_list_rewrite_page_no_seed(self):
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
result = self._rewrite_list_entry(orig, "host:9222", None)
assert result == "ws://host:9222/devtools/page/DEF-456"
def test_list_rewrite_browser(self):
orig = "ws://127.0.0.1:5100/devtools/browser/XYZ"
result = self._rewrite_list_entry(orig, "host:9222", "seed1")
assert result == "ws://host:9222/fingerprint/seed1/devtools/browser/XYZ"
def test_wss_scheme_version(self):
orig = "ws://127.0.0.1:5100/devtools/browser/abc-123"
result = self._rewrite_version(orig, "host:443", "seed1", scheme="wss")
assert result == "wss://host:443/fingerprint/seed1/devtools/browser/abc-123"
def test_wss_scheme_list(self):
orig = "ws://127.0.0.1:5100/devtools/page/DEF-456"
result = self._rewrite_list_entry(orig, "host:443", "seed1", scheme="wss")
assert result == "wss://host:443/fingerprint/seed1/devtools/page/DEF-456"
# ---------------------------------------------------------------------------
# Connection refcounting
# ---------------------------------------------------------------------------
class TestConnectionTracking:
"""Test ChromePool.connect() / disconnect() without real Chrome."""
def _make_pool(self):
return ChromePool(
binary="/fake/chrome",
global_args=[],
headless=True,
data_dir="/tmp/test-cloakserve",
)
def test_connect_increments(self):
pool = self._make_pool()
pool.connect("seed1")
assert pool._connections["seed1"] == 1
pool.connect("seed1")
assert pool._connections["seed1"] == 2
def test_disconnect_decrements(self):
pool = self._make_pool()
pool.connect("seed1")
pool.connect("seed1")
pool.disconnect("seed1")
assert pool._connections["seed1"] == 1
def test_disconnect_to_zero_removes_key(self):
pool = self._make_pool()
pool.connect("seed1")
pool.disconnect("seed1")
assert "seed1" not in pool._connections
def test_disconnect_below_zero_safe(self):
pool = self._make_pool()
pool.disconnect("nonexistent")
assert "nonexistent" not in pool._connections
def test_multiple_seeds_independent(self):
pool = self._make_pool()
pool.connect("a")
pool.connect("b")
pool.connect("a")
pool.disconnect("a")
assert pool._connections["a"] == 1
assert pool._connections["b"] == 1
+8 -8
View File
@@ -4,7 +4,7 @@ from unittest.mock import patch
import pytest import pytest
from cloakbrowser.browser import _maybe_resolve_geoip from cloakbrowser.browser import maybe_resolve_geoip
from cloakbrowser.geoip import ( from cloakbrowser.geoip import (
COUNTRY_LOCALE_MAP, COUNTRY_LOCALE_MAP,
_is_private_ip, _is_private_ip,
@@ -92,25 +92,25 @@ def test_resolve_geo_returns_none_when_db_missing():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _maybe_resolve_geoip (browser.py helper) # maybe_resolve_geoip (browser.py helper)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_maybe_resolve_skips_when_geoip_false(): def test_maybe_resolve_skips_when_geoip_false():
tz, loc = _maybe_resolve_geoip(False, "http://proxy:8080", None, None) tz, loc = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None assert tz is None
assert loc is None assert loc is None
def test_maybe_resolve_skips_when_no_proxy(): def test_maybe_resolve_skips_when_no_proxy():
tz, loc = _maybe_resolve_geoip(True, None, None, None) tz, loc = maybe_resolve_geoip(True, None, None, None)
assert tz is None assert tz is None
assert loc is None assert loc is None
def test_maybe_resolve_skips_when_both_explicit(): def test_maybe_resolve_skips_when_both_explicit():
"""Explicit values should not trigger geoip resolution.""" """Explicit values should not trigger geoip resolution."""
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE") tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", "de-DE")
assert tz == "Europe/Berlin" assert tz == "Europe/Berlin"
assert loc == "de-DE" assert loc == "de-DE"
@@ -118,7 +118,7 @@ def test_maybe_resolve_skips_when_both_explicit():
def test_maybe_resolve_fills_missing_timezone(): def test_maybe_resolve_fills_missing_timezone():
"""When only locale is explicit, geoip should fill timezone.""" """When only locale is explicit, geoip should fill timezone."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")): with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR") tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", None, "fr-FR")
assert tz == "America/New_York" assert tz == "America/New_York"
assert loc == "fr-FR" # Explicit wins assert loc == "fr-FR" # Explicit wins
@@ -126,7 +126,7 @@ def test_maybe_resolve_fills_missing_timezone():
def test_maybe_resolve_fills_missing_locale(): def test_maybe_resolve_fills_missing_locale():
"""When only timezone is explicit, geoip should fill locale.""" """When only timezone is explicit, geoip should fill locale."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")): with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")):
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None) tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", "Asia/Tokyo", None)
assert tz == "Asia/Tokyo" # Explicit wins assert tz == "Asia/Tokyo" # Explicit wins
assert loc == "en-US" assert loc == "en-US"
@@ -134,7 +134,7 @@ def test_maybe_resolve_fills_missing_locale():
def test_maybe_resolve_fills_both(): def test_maybe_resolve_fills_both():
"""When neither is set, geoip should fill both.""" """When neither is set, geoip should fill both."""
with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/Berlin", "de-DE")): with patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/Berlin", "de-DE")):
tz, loc = _maybe_resolve_geoip(True, "http://proxy:8080", None, None) tz, loc = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
assert tz == "Europe/Berlin" assert tz == "Europe/Berlin"
assert loc == "de-DE" assert loc == "de-DE"
+121
View File
@@ -406,6 +406,127 @@ class TestSelectAllPlatform:
assert _SELECT_ALL == "Control+a" assert _SELECT_ALL == "Control+a"
# =========================================================================
# 11. Non-ASCII keyboard input
# =========================================================================
class TestNonAsciiKeyboard:
def test_cyrillic_uses_insert_text(self):
from cloakbrowser.human.keyboard import human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"mistype_chance": 0})
page = MagicMock()
raw = MagicMock()
down_keys = []
inserted = []
raw.down = MagicMock(side_effect=lambda k: down_keys.append(k))
raw.up = MagicMock()
raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t))
human_type(page, raw, "Привет", cfg)
assert "".join(inserted) == "Привет"
for k in down_keys:
assert ord(k[0]) < 128 or k in ("Shift", "Backspace")
def test_mixed_ascii_cyrillic(self):
from cloakbrowser.human.keyboard import human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"mistype_chance": 0})
page = MagicMock()
raw = MagicMock()
down_keys = []
inserted = []
raw.down = MagicMock(side_effect=lambda k: down_keys.append(k))
raw.up = MagicMock()
raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t))
human_type(page, raw, "Hi Мир", cfg)
assert "H" in down_keys
assert "i" in down_keys
assert "М" in "".join(inserted)
def test_cjk_uses_insert_text(self):
from cloakbrowser.human.keyboard import human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"mistype_chance": 0})
page = MagicMock()
raw = MagicMock()
inserted = []
raw.down = MagicMock()
raw.up = MagicMock()
raw.insert_text = MagicMock(side_effect=lambda t: inserted.append(t))
human_type(page, raw, "你好", cfg)
assert "".join(inserted) == "你好"
def test_mistype_only_ascii(self):
from cloakbrowser.human.keyboard import human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"mistype_chance": 1.0})
page = MagicMock()
raw = MagicMock()
down_keys = []
raw.down = MagicMock(side_effect=lambda k: down_keys.append(k))
raw.up = MagicMock()
raw.insert_text = MagicMock()
human_type(page, raw, "", cfg)
assert "Backspace" in down_keys
def test_no_error_on_cyrillic(self):
from cloakbrowser.human.keyboard import human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock
cfg = resolve_config("default", {"mistype_chance": 0})
page = MagicMock()
raw = MagicMock()
raw.down = MagicMock()
raw.up = MagicMock()
raw.insert_text = MagicMock()
# Should not raise
human_type(page, raw, "Тест кириллицы", cfg)
class TestNonAsciiKeyboardAsync:
@pytest.mark.asyncio
async def test_async_cyrillic_uses_insert_text(self):
from cloakbrowser.human.keyboard_async import async_human_type
from cloakbrowser.human.config import resolve_config
from unittest.mock import MagicMock, AsyncMock
cfg = resolve_config("default", {"mistype_chance": 0})
page = MagicMock()
raw = MagicMock()
inserted = []
raw.down = AsyncMock()
raw.up = AsyncMock()
raw.insert_text = AsyncMock(side_effect=lambda t: inserted.append(t))
await async_human_type(page, raw, "Привет", cfg)
assert "".join(inserted) == "Привет"
# ========================================================================= # =========================================================================
# SLOW TESTS — require browser (skipped in CI unless pytest -m slow) # SLOW TESTS — require browser (skipped in CI unless pytest -m slow)
# ========================================================================= # =========================================================================
+1 -1
View File
@@ -114,7 +114,7 @@ def test_color_scheme(mock_launch, _mock_bin):
assert ctx_kwargs[1]["color_scheme"] == "dark" assert ctx_kwargs[1]["color_scheme"] == "dark"
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE")) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser.launch") @patch("cloakbrowser.browser.launch")
def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip): def test_geoip_resolution(mock_launch, _mock_bin, _mock_geoip):
+11 -11
View File
@@ -26,7 +26,7 @@ def _make_mock_pw_and_context():
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_args_built(_mock_geoip, _mock_bin): def test_persistent_context_args_built(_mock_geoip, _mock_bin):
"""Stealth args + extra args combined correctly.""" """Stealth args + extra args combined correctly."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -42,7 +42,7 @@ def test_persistent_context_args_built(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_default_viewport(_mock_geoip, _mock_bin): def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
"""DEFAULT_VIEWPORT applied when no viewport given.""" """DEFAULT_VIEWPORT applied when no viewport given."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -56,7 +56,7 @@ def test_persistent_context_default_viewport(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin): def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
"""Custom viewport overrides DEFAULT_VIEWPORT.""" """Custom viewport overrides DEFAULT_VIEWPORT."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -71,7 +71,7 @@ def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_user_agent(_mock_geoip, _mock_bin): def test_persistent_context_user_agent(_mock_geoip, _mock_bin):
"""user_agent forwarded to launch_persistent_context().""" """user_agent forwarded to launch_persistent_context()."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -103,7 +103,7 @@ def test_persistent_context_locale_and_timezone(_mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_color_scheme(_mock_geoip, _mock_bin): def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
"""color_scheme forwarded correctly.""" """color_scheme forwarded correctly."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -116,7 +116,7 @@ def test_persistent_context_color_scheme(_mock_geoip, _mock_bin):
assert call_kwargs["color_scheme"] == "dark" assert call_kwargs["color_scheme"] == "dark"
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE")) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=("Europe/Berlin", "de-DE"))
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
def test_persistent_context_geoip(_mock_bin, _mock_geoip): def test_persistent_context_geoip(_mock_bin, _mock_geoip):
"""geoip fills missing tz/locale — flows to binary args, not CDP context.""" """geoip fills missing tz/locale — flows to binary args, not CDP context."""
@@ -150,7 +150,7 @@ def test_persistent_context_timezone_id_alias(_mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin): def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
"""context.close() also calls pw.stop().""" """context.close() also calls pw.stop()."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -166,7 +166,7 @@ def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_proxy_string(_mock_geoip, _mock_bin): def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
"""Proxy string parsed and passed.""" """Proxy string parsed and passed."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -182,7 +182,7 @@ def test_persistent_context_proxy_string(_mock_geoip, _mock_bin):
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin): def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin):
"""Proxy dict passed through.""" """Proxy dict passed through."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
@@ -213,7 +213,7 @@ def _make_mock_async_pw_and_context():
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin): async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
"""Async launch builds args correctly.""" """Async launch builds args correctly."""
pw_cm, pw, context = _make_mock_async_pw_and_context() pw_cm, pw, context = _make_mock_async_pw_and_context()
@@ -229,7 +229,7 @@ async def test_persistent_context_async_args_built(_mock_geoip, _mock_bin):
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome") @patch("cloakbrowser.browser.ensure_binary", return_value="/fake/chrome")
@patch("cloakbrowser.browser._maybe_resolve_geoip", return_value=(None, None)) @patch("cloakbrowser.browser.maybe_resolve_geoip", return_value=(None, None))
async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin): async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
"""await context.close() calls await pw.stop().""" """await context.close() calls await pw.stop()."""
pw_cm, pw, context = _make_mock_async_pw_and_context() pw_cm, pw, context = _make_mock_async_pw_and_context()
+8 -8
View File
@@ -2,7 +2,7 @@
from unittest.mock import patch from unittest.mock import patch
from cloakbrowser.browser import _build_proxy_kwargs, _maybe_resolve_geoip, _parse_proxy_url from cloakbrowser.browser import _build_proxy_kwargs, maybe_resolve_geoip, _parse_proxy_url
class TestParseProxyUrl: class TestParseProxyUrl:
@@ -70,7 +70,7 @@ class TestBuildProxyKwargs:
class TestMaybeResolveGeoip: class TestMaybeResolveGeoip:
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")) @patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
def test_geoip_with_string_proxy(self, mock_geo): def test_geoip_with_string_proxy(self, mock_geo):
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", None, None) tz, locale = maybe_resolve_geoip(True, "http://proxy:8080", None, None)
mock_geo.assert_called_once_with("http://proxy:8080") mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "America/New_York" assert tz == "America/New_York"
assert locale == "en-US" assert locale == "en-US"
@@ -78,31 +78,31 @@ class TestMaybeResolveGeoip:
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/London", "en-GB")) @patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Europe/London", "en-GB"))
def test_geoip_with_dict_proxy_extracts_server(self, mock_geo): def test_geoip_with_dict_proxy_extracts_server(self, mock_geo):
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"} proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
tz, locale = _maybe_resolve_geoip(True, proxy_dict, None, None) tz, locale = maybe_resolve_geoip(True, proxy_dict, None, None)
mock_geo.assert_called_once_with("http://proxy:8080") mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "Europe/London" assert tz == "Europe/London"
assert locale == "en-GB" assert locale == "en-GB"
def test_geoip_disabled_skips_resolution(self): def test_geoip_disabled_skips_resolution(self):
tz, locale = _maybe_resolve_geoip(False, "http://proxy:8080", None, None) tz, locale = maybe_resolve_geoip(False, "http://proxy:8080", None, None)
assert tz is None assert tz is None
assert locale is None assert locale is None
def test_geoip_no_proxy_skips_resolution(self): def test_geoip_no_proxy_skips_resolution(self):
tz, locale = _maybe_resolve_geoip(True, None, None, None) tz, locale = maybe_resolve_geoip(True, None, None, None)
assert tz is None assert tz is None
assert locale is None assert locale is None
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Asia/Tokyo", "ja-JP")) @patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("Asia/Tokyo", "ja-JP"))
def test_geoip_preserves_explicit_timezone(self, mock_geo): def test_geoip_preserves_explicit_timezone(self, mock_geo):
tz, locale = _maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None) tz, locale = maybe_resolve_geoip(True, "http://proxy:8080", "Europe/Berlin", None)
assert tz == "Europe/Berlin" assert tz == "Europe/Berlin"
assert locale == "ja-JP" assert locale == "ja-JP"
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")) @patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
def test_geoip_normalizes_bare_proxy_with_creds(self, mock_geo): def test_geoip_normalizes_bare_proxy_with_creds(self, mock_geo):
# "user:pass@host:port" must be normalized to http:// before geoip lookup. # "user:pass@host:port" must be normalized to http:// before geoip lookup.
tz, locale = _maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None) tz, locale = maybe_resolve_geoip(True, "user:pass@proxy:8080", None, None)
mock_geo.assert_called_once_with("http://user:pass@proxy:8080") mock_geo.assert_called_once_with("http://user:pass@proxy:8080")
assert tz == "America/New_York" assert tz == "America/New_York"
assert locale == "en-US" assert locale == "en-US"
@@ -110,7 +110,7 @@ class TestMaybeResolveGeoip:
@patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US")) @patch("cloakbrowser.geoip.resolve_proxy_geo", return_value=("America/New_York", "en-US"))
def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo): def test_geoip_normalizes_schemeless_proxy_no_creds(self, mock_geo):
# "host:port" (no @ and no scheme) must also be normalized. # "host:port" (no @ and no scheme) must also be normalized.
tz, locale = _maybe_resolve_geoip(True, "proxy:8080", None, None) tz, locale = maybe_resolve_geoip(True, "proxy:8080", None, None)
mock_geo.assert_called_once_with("http://proxy:8080") mock_geo.assert_called_once_with("http://proxy:8080")
assert tz == "America/New_York" assert tz == "America/New_York"
+74
View File
@@ -20,6 +20,8 @@ from cloakbrowser.config import (
) )
from cloakbrowser.download import ( from cloakbrowser.download import (
_check_wrapper_update, _check_wrapper_update,
_download_and_extract,
_fetch_checksums,
_get_latest_chromium_version, _get_latest_chromium_version,
_parse_checksums, _parse_checksums,
_should_check_for_update, _should_check_for_update,
@@ -474,3 +476,75 @@ class TestWriteVersionMarker:
marker = tmp_path / f"latest_version_{get_platform_tag()}" marker = tmp_path / f"latest_version_{get_platform_tag()}"
assert marker.exists() assert marker.exists()
assert marker.read_text() == "999.0.0.0" 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": "",
"CLOAKBROWSER_SKIP_CHECKSUM": "true",
}):
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")
with patch("cloakbrowser.download._download_file", side_effect=mock_download_file), \
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