Compare commits

..
2 Commits
Author SHA1 Message Date
Cloak-HQ 9b0446e142 fix: deduplicate CLI flags when user args overlap with stealth defaults
Bump version to 0.3.9. Extract shared buildArgs into js/src/args.ts (DRY),
guard console.debug behind DEBUG=cloakbrowser env var, strengthen caplog assertion.
2026-03-05 08:00:10 +01:00
Cloak-HQandDurafen 0578fdc5e1 feat: upgrade Chromium base to 145.0.7632.159 (Linux x64)
- Bump linux-x64 binary to 145.0.7632.159 (macOS/Windows stay at 145.0.7632.109.2)
- Wrapper version 0.3.8
- Fix rollback path examples to use correct per-platform versions
2026-03-05 07:53:52 +01:00
18 changed files with 43 additions and 422 deletions
-34
View File
@@ -1,34 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -e ".[dev]" pytest pytest-asyncio
- name: Run tests
run: pytest tests/ -v -m "not slow"
javascript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install and build
run: cd js && npm install && npm run build
- name: Typecheck
run: cd js && npm run typecheck
- name: Run tests
run: cd js && npm test
-9
View File
@@ -6,19 +6,10 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
--- ---
## [0.3.10] — 2026-03-07
- **[binary]** Upgrade Linux build to 145.0.7632.159.2
- **[binary]** Fix detection regression caused by unnecessary browser flag (fixes #16)
- **[binary]** Fix fingerprint consistency in offline audio rendering
- **[wrapper]** Add `cloakserve` CDP server mode for Docker — exposes Chrome DevTools Protocol on `0.0.0.0:9222` for external tool integration
- **[wrapper]** Add wrapper regression tests: page.goto timing with stealth init (#9), add_init_script compatibility with proxy auth (#27)
## [0.3.9] — 2026-03-05 ## [0.3.9] — 2026-03-05
- **[binary]** Upgrade Chromium base to 145.0.7632.159 (Linux x64). macOS and Windows remain on 145.0.7632.109.2 - **[binary]** Upgrade Chromium base to 145.0.7632.159 (Linux x64). macOS and Windows remain on 145.0.7632.109.2
- **[binary]** WebGPU adapter spoofing for headless/Docker, timezone multi-context fix, stealth audit phase 2 (6 detection vector fixes), font auto-hide for cross-platform fingerprints - **[binary]** WebGPU adapter spoofing for headless/Docker, timezone multi-context fix, stealth audit phase 2 (6 detection vector fixes), font auto-hide for cross-platform fingerprints
- **[wrapper]** Default Playwright backend switched from `patchright` to stock `playwright`. Patchright broke proxy auth and `add_init_script` (#27) and is redundant since the binary handles stealth at C++ level. Opt in with `launch(backend="patchright")` or `CLOAKBROWSER_BACKEND=patchright` env var. Install: `pip install cloakbrowser[patchright]`
- **[wrapper]** Deduplicate CLI flags when user args overlap with stealth defaults — user values win cleanly instead of passing both to Chromium - **[wrapper]** Deduplicate CLI flags when user args overlap with stealth defaults — user values win cleanly instead of passing both to Chromium
- **[wrapper]** Extract shared `buildArgs` into `js/src/args.ts` (JS DRY fix), guard debug logging behind `DEBUG=cloakbrowser` env var - **[wrapper]** Extract shared `buildArgs` into `js/src/args.ts` (JS DRY fix), guard debug logging behind `DEBUG=cloakbrowser` env var
+2 -5
View File
@@ -10,7 +10,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \ libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \
libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation \ libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation \
xvfb xdotool \ xvfb xdotool \
curl ca-certificates socat \ curl ca-certificates \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -36,10 +36,7 @@ RUN python -c "from cloakbrowser import ensure_binary; ensure_binary()" \
# CLI shortcuts # CLI shortcuts
COPY bin/cloaktest /usr/local/bin/cloaktest COPY bin/cloaktest /usr/local/bin/cloaktest
COPY bin/cloakserve /usr/local/bin/cloakserve RUN chmod +x /usr/local/bin/cloaktest
RUN chmod +x /usr/local/bin/cloaktest /usr/local/bin/cloakserve
EXPOSE 9222
# Xvfb entrypoint for headed mode support # Xvfb entrypoint for headed mode support
COPY bin/docker-entrypoint.sh /entrypoint.sh COPY bin/docker-entrypoint.sh /entrypoint.sh
+10 -98
View File
@@ -9,7 +9,7 @@
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/last-commit/cloakhq/cloakbrowser" alt="Last Commit"></a> <a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/last-commit/cloakhq/cloakbrowser" alt="Last Commit"></a>
<br> <br>
<a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/cloakhq/cloakbrowser" alt="Stars"></a> <a href="https://github.com/CloakHQ/CloakBrowser"><img src="https://img.shields.io/github/stars/cloakhq/cloakbrowser" alt="Stars"></a>
<a href="https://pypi.org/project/cloakbrowser/"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a> <a href="https://pepy.tech/projects/cloakbrowser"><img src="https://img.shields.io/pepy/dt/cloakbrowser?label=pypi&logo=pypi&logoColor=white" alt="PyPI Downloads"></a>
<a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm Downloads"></a> <a href="https://www.npmjs.com/package/cloakbrowser"><img src="https://img.shields.io/npm/dt/cloakbrowser?label=npm&logo=npm&logoColor=white" alt="npm Downloads"></a>
</p> </p>
@@ -108,7 +108,7 @@ 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.10 (Chromium 145.0.7632.159.2) ## Latest: v0.3.8 (Chromium 145.0.7632.159)
- **All 4 platforms** — Linux x64, macOS arm64, macOS x64, and Windows x64 all on Chromium 145 - **All 4 platforms** — Linux x64, macOS arm64, macOS x64, and Windows x64 all on Chromium 145
- **26 fingerprint patches** — 10 new patches since v142 (screen, device memory, audio, WebGL, auto-spoof, and more) - **26 fingerprint patches** — 10 new patches since v142 (screen, device memory, audio, WebGL, auto-spoof, and more)
@@ -125,7 +125,7 @@ See the full [CHANGELOG.md](CHANGELOG.md) for details.
- **Config-level patches break**`playwright-stealth`, `undetected-chromedriver`, and `puppeteer-extra` inject JavaScript or tweak flags. Every Chrome update breaks them. Antibot systems detect the patches themselves. - **Config-level patches break**`playwright-stealth`, `undetected-chromedriver`, and `puppeteer-extra` inject JavaScript or tweak flags. Every Chrome update breaks them. Antibot systems detect the patches themselves.
- **CloakBrowser patches Chromium source code** — fingerprints are modified at the C++ level, compiled into the binary. Detection sites see a real browser because it *is* a real browser. - **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. - **Two layers of stealth** — C++ patches handle fingerprints (GPU, screen, UA, hardware reporting), while the Patchright driver defers Playwright's binding registration and randomizes internal world names. Most stealth tools only do one or the other.
- **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 any browser automation framework** — tested and passing stealth checks with Playwright, Puppeteer, Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser. Just point any Chromium-based framework at the binary path. - **Works with any browser automation framework** — tested and passing stealth checks with Playwright, Puppeteer, Selenium, undetected-chromedriver, browser-use, Crawl4AI, and agent-browser. Just point any Chromium-based framework at the binary path.
@@ -307,7 +307,7 @@ from cloakbrowser import binary_info, clear_cache, ensure_binary
# Check binary installation status # Check binary installation status
print(binary_info()) print(binary_info())
# {'version': '145.0.7632.159.2', 'platform': 'linux-x64', 'installed': True, ...} # {'version': '145.0.7632.159', 'platform': 'linux-x64', 'installed': True, ...}
# Force re-download # Force re-download
clear_cache() clear_cache()
@@ -506,18 +506,13 @@ The wrapper auto-downloads the correct binary for your platform.
## Docker ## Docker
Pre-built image on Docker Hub — no install, no setup. Pre-built image on Docker Hub — no install, no setup:
### Quick test
```bash ```bash
# Run the stealth test suite
docker run --rm cloakhq/cloakbrowser cloaktest docker run --rm cloakhq/cloakbrowser cloaktest
```
### Run a script # Run your own script
```bash
# Inline script
docker run --rm cloakhq/cloakbrowser python -c " docker run --rm cloakhq/cloakbrowser python -c "
from cloakbrowser import launch from cloakbrowser import launch
browser = launch() browser = launch()
@@ -527,9 +522,6 @@ print(page.title())
browser.close() browser.close()
" "
# Mount your own script
docker run --rm -v ./my_script.py:/app/my_script.py cloakhq/cloakbrowser python my_script.py
# With a proxy # With a proxy
docker run --rm cloakhq/cloakbrowser python -c " docker run --rm cloakhq/cloakbrowser python -c "
from cloakbrowser import launch from cloakbrowser import launch
@@ -541,86 +533,7 @@ browser.close()
" "
``` ```
### CDP server mode To extend with your own script:
Start a persistent stealth browser and connect to it remotely via Chrome DevTools Protocol:
```bash
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser cloakserve
```
Then connect from your host machine:
```python
from playwright.sync_api import sync_playwright
pw = sync_playwright().start()
browser = pw.chromium.connect_over_cdp("http://localhost:9222")
page = browser.new_page()
page.goto("https://example.com")
print(page.title())
browser.close()
```
Pass extra flags to the browser:
```bash
# With proxy
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
cloakserve --proxy-server=http://proxy:8080
# Headed mode (renders to Xvfb inside container)
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser \
cloakserve --headless=false
```
Stop the server:
```bash
docker stop cloak && docker rm cloak
```
> **Security:** CDP gives full control over the browser (execute JS, read pages, access files).
> The examples bind to `127.0.0.1` so only your machine can connect. Never expose port 9222
> to the public internet without additional authentication.
### Docker Compose
```yaml
services:
cloakbrowser:
image: cloakhq/cloakbrowser
command: cloakserve
restart: unless-stopped
ports:
- "127.0.0.1:9222:9222"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9222/json/version"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
```
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`).
**Persistent profiles** — mount a volume to keep cookies and sessions across container restarts:
```bash
docker run --rm -v ./my-profile:/profile cloakhq/cloakbrowser python -c "
from cloakbrowser import launch_persistent_context
ctx = launch_persistent_context('/profile')
page = ctx.new_page()
page.goto('https://example.com')
ctx.close()
"
```
Run again with the same volume — cookies, localStorage, and cache are restored automatically.
**Resource usage:** ~190MB RAM idle, ~280MB with 3 tabs. ~30MB per additional tab.
### Extend with your own image
```dockerfile ```dockerfile
FROM cloakhq/cloakbrowser FROM cloakhq/cloakbrowser
@@ -717,7 +630,7 @@ export CLOAKBROWSER_BINARY_PATH=/path/to/your/chrome
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary: When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
```bash ```bash
# Linux # Linux
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159.2/chrome export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159/chrome
# macOS # macOS
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
@@ -735,7 +648,7 @@ xattr -cr ~/.cloakbrowser/chromium-*/Chromium.app
**"playwright install" vs CloakBrowser binary** **"playwright install" vs CloakBrowser binary**
You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps: You do NOT need `playwright install chromium`. CloakBrowser downloads its own binary. You only need Playwright's system deps:
```bash ```bash
playwright install-deps chromium patchright install-deps chromium
``` ```
**macOS: Blocked on some sites that pass on Linux** **macOS: Blocked on some sites that pass on Linux**
@@ -786,7 +699,6 @@ await new Promise(r => setTimeout(r, 3000));
``` ```
Other tips for maximizing reCAPTCHA scores: Other tips for maximizing reCAPTCHA scores:
- **Try the Patchright backend** — suppresses CDP automation signals that reCAPTCHA Enterprise detects. Install with `pip install cloakbrowser[patchright]`, then use `launch(backend="patchright")` or set `CLOAKBROWSER_BACKEND=patchright` globally. Note: Patchright breaks proxy auth and `add_init_script` — only use it when you need the extra CDP stealth
- **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer)) - **Use Playwright, not Puppeteer** — Puppeteer sends more CDP protocol traffic that reCAPTCHA detects ([details](#puppeteer))
- **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint - **Use residential proxies** — datacenter IPs are flagged by IP reputation, not browser fingerprint
- **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower - **Spend 15+ seconds on the page** before triggering reCAPTCHA — short visits score lower
-69
View File
@@ -1,69 +0,0 @@
#!/usr/bin/env python3
"""Launch stealth Chromium as a CDP server for remote connections.
Usage:
cloakserve # headless on port 9222
cloakserve --headless=false # headed (uses Xvfb in Docker)
cloakserve --proxy-server=host:port # with proxy
Connect from host:
playwright.chromium.connect_over_cdp("http://localhost:9222")
"""
import os
import signal
import subprocess
import sys
import time
from cloakbrowser.config import get_default_stealth_args
from cloakbrowser.download import ensure_binary
EXPOSE_PORT = 9222 # External, 0.0.0.0 (user connects here)
CHROME_PORT = 9223 # Internal, 127.0.0.1 (Chrome binds here)
binary = ensure_binary()
chrome_args = [
binary,
f"--remote-debugging-port={CHROME_PORT}",
# "--remote-debugging-address=0.0.0.0", # TODO: enable after binary patch 032
# Sane defaults for running Chrome directly (outside Playwright)
"--no-first-run",
"--no-default-browser-check",
"--disable-dev-shm-usage",
"--disable-extensions",
"--disable-popup-blocking",
"--disable-background-networking",
"--metrics-recording-only",
] + get_default_stealth_args() + sys.argv[1:]
# Launch Chrome
chrome = subprocess.Popen(chrome_args)
# Wait for Chrome to start listening
time.sleep(2)
# Forward 0.0.0.0:9222 -> 127.0.0.1:9223
# Chrome hardcodes 127.0.0.1 for security; socat exposes it to Docker network.
# TODO: Remove socat after patching binary to support --remote-debugging-address
socat = subprocess.Popen([
"socat",
f"TCP-LISTEN:{EXPOSE_PORT},fork,reuseaddr,bind=0.0.0.0",
f"TCP:127.0.0.1:{CHROME_PORT}",
])
print(f"CloakBrowser CDP server ready on port {EXPOSE_PORT}", flush=True)
def cleanup(sig, frame):
chrome.terminate()
socat.terminate()
sys.exit(0)
signal.signal(signal.SIGTERM, cleanup)
signal.signal(signal.SIGINT, cleanup)
# Wait for Chrome to exit, then clean up socat
chrome.wait()
socat.terminate()
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.3.10" __version__ = "0.3.9"
+6 -59
View File
@@ -57,7 +57,6 @@ def launch(
timezone: str | None = None, timezone: str | None = None,
locale: str | None = None, locale: str | None = None,
geoip: bool = False, geoip: bool = False,
backend: str | None = None,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Launch stealth Chromium browser. Returns a Playwright Browser object. """Launch stealth Chromium browser. Returns a Playwright Browser object.
@@ -77,10 +76,6 @@ def launch(
Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB Requires ``pip install cloakbrowser[geoip]``. Downloads ~70 MB
GeoLite2-City database on first use. Explicit timezone/locale GeoLite2-City database on first use. Explicit timezone/locale
always override geoip results. always override geoip results.
backend: Playwright backend 'playwright' (default) or 'patchright'.
Patchright suppresses CDP signals (helps reCAPTCHA v3 Enterprise)
but breaks proxy auth and add_init_script.
Override globally with CLOAKBROWSER_BACKEND env var.
**kwargs: Passed directly to playwright.chromium.launch(). **kwargs: Passed directly to playwright.chromium.launch().
Returns: Returns:
@@ -94,7 +89,7 @@ def launch(
>>> print(page.title()) >>> print(page.title())
>>> browser.close() >>> browser.close()
""" """
sync_playwright = _import_sync_playwright(_resolve_backend(backend)) from patchright.sync_api import sync_playwright
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)
@@ -132,7 +127,6 @@ async def launch_async(
timezone: str | None = None, timezone: str | None = None,
locale: str | None = None, locale: str | None = None,
geoip: bool = False, geoip: bool = False,
backend: str | None = None,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Async version of launch(). Returns a Playwright Browser object. """Async version of launch(). Returns a Playwright Browser object.
@@ -145,7 +139,6 @@ async def launch_async(
timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag. timezone: IANA timezone (e.g. 'America/New_York'). Sets --fingerprint-timezone binary flag.
locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag. locale: BCP 47 locale (e.g. 'en-US'). Sets --lang binary flag.
geoip: Auto-detect timezone/locale from proxy IP (default False). geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend 'playwright' (default) or 'patchright'.
**kwargs: Passed directly to playwright.chromium.launch(). **kwargs: Passed directly to playwright.chromium.launch().
Returns: Returns:
@@ -164,7 +157,7 @@ async def launch_async(
>>> >>>
>>> asyncio.run(main()) >>> asyncio.run(main())
""" """
async_playwright = _import_async_playwright(_resolve_backend(backend)) from patchright.async_api import async_playwright
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)
@@ -206,7 +199,6 @@ def launch_persistent_context(
timezone: str | None = None, timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None, color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False, geoip: bool = False,
backend: str | None = None,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Launch stealth browser with a persistent profile and return a BrowserContext. """Launch stealth browser with a persistent profile and return a BrowserContext.
@@ -231,7 +223,6 @@ def launch_persistent_context(
Default: None (uses Chromium default, which is 'light'). Default: None (uses Chromium default, which is 'light').
geoip: Auto-detect timezone/locale from proxy IP (default False). geoip: Auto-detect timezone/locale from proxy IP (default False).
Requires ``pip install cloakbrowser[geoip]``. Requires ``pip install cloakbrowser[geoip]``.
backend: Playwright backend 'playwright' (default) or 'patchright'.
**kwargs: Passed directly to playwright.chromium.launch_persistent_context(). **kwargs: Passed directly to playwright.chromium.launch_persistent_context().
Returns: Returns:
@@ -245,7 +236,7 @@ def launch_persistent_context(
>>> page.goto("https://protected-site.com") >>> page.goto("https://protected-site.com")
>>> ctx.close() # Profile is saved; re-use path next run to restore state. >>> ctx.close() # Profile is saved; re-use path next run to restore state.
""" """
sync_playwright = _import_sync_playwright(_resolve_backend(backend)) from patchright.sync_api import sync_playwright
timezone = _migrate_timezone_id(timezone, kwargs) timezone = _migrate_timezone_id(timezone, kwargs)
@@ -306,7 +297,6 @@ async def launch_persistent_context_async(
timezone: str | None = None, timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None, color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False, geoip: bool = False,
backend: str | None = None,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Async version of launch_persistent_context(). """Async version of launch_persistent_context().
@@ -328,7 +318,6 @@ async def launch_persistent_context_async(
timezone: IANA timezone (e.g. 'America/New_York'). timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference 'light', 'dark', or 'no-preference'. color_scheme: Color scheme preference 'light', 'dark', or 'no-preference'.
geoip: Auto-detect timezone/locale from proxy IP (default False). geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend 'playwright' (default) or 'patchright'.
**kwargs: Passed directly to playwright.chromium.launch_persistent_context(). **kwargs: Passed directly to playwright.chromium.launch_persistent_context().
Returns: Returns:
@@ -347,7 +336,7 @@ async def launch_persistent_context_async(
>>> >>>
>>> asyncio.run(main()) >>> asyncio.run(main())
""" """
async_playwright = _import_async_playwright(_resolve_backend(backend)) from patchright.async_api import async_playwright
timezone = _migrate_timezone_id(timezone, kwargs) timezone = _migrate_timezone_id(timezone, kwargs)
@@ -407,7 +396,6 @@ def launch_context(
timezone: str | None = None, timezone: str | None = None,
color_scheme: Literal["light", "dark", "no-preference"] | None = None, color_scheme: Literal["light", "dark", "no-preference"] | None = None,
geoip: bool = False, geoip: bool = False,
backend: str | None = None,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
"""Launch stealth browser and return a BrowserContext with common options pre-set. """Launch stealth browser and return a BrowserContext with common options pre-set.
@@ -426,8 +414,8 @@ def launch_context(
timezone: IANA timezone (e.g. 'America/New_York'). timezone: IANA timezone (e.g. 'America/New_York').
color_scheme: Color scheme preference 'light', 'dark', or 'no-preference'. color_scheme: Color scheme preference 'light', 'dark', or 'no-preference'.
Default: None (uses Chromium default, which is 'light'). Default: None (uses Chromium default, which is 'light').
Note: 'no-preference' doesn't work in Patchright (falls back to 'light').
geoip: Auto-detect timezone/locale from proxy IP (default False). geoip: Auto-detect timezone/locale from proxy IP (default False).
backend: Playwright backend 'playwright' (default) or 'patchright'.
**kwargs: Passed to browser.new_context(). **kwargs: Passed to browser.new_context().
Returns: Returns:
@@ -442,7 +430,7 @@ def launch_context(
# context and interferes with Playwright's timezone_id on new contexts. # context and interferes with Playwright's timezone_id on new contexts.
# Timezone is set via browser.new_context(timezone_id=...) below instead. # Timezone is set via browser.new_context(timezone_id=...) below instead.
browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args, browser = launch(headless=headless, proxy=proxy, args=args, stealth_args=stealth_args,
timezone=None, locale=locale, backend=backend) timezone=None, locale=locale)
context_kwargs: dict[str, Any] = {} context_kwargs: dict[str, Any] = {}
if user_agent: if user_agent:
@@ -474,47 +462,6 @@ def launch_context(
return context return context
# ---------------------------------------------------------------------------
# Backend resolution
# ---------------------------------------------------------------------------
def _resolve_backend(backend: str | None) -> str:
"""Resolve backend: param > env var > default ('playwright')."""
b = backend or os.environ.get("CLOAKBROWSER_BACKEND", "playwright")
if b not in ("playwright", "patchright"):
raise ValueError(f"Unknown backend '{b}'. Use 'playwright' or 'patchright'.")
return b
def _import_sync_playwright(backend: str):
"""Import sync_playwright from the resolved backend."""
if backend == "patchright":
try:
from patchright.sync_api import sync_playwright
except ModuleNotFoundError:
raise ModuleNotFoundError(
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
) from None
return sync_playwright
from playwright.sync_api import sync_playwright
return sync_playwright
def _import_async_playwright(backend: str):
"""Import async_playwright from the resolved backend."""
if backend == "patchright":
try:
from patchright.async_api import async_playwright
except ModuleNotFoundError:
raise ModuleNotFoundError(
"patchright is not installed. Install it with: pip install cloakbrowser[patchright]"
) from None
return async_playwright
from playwright.async_api import async_playwright
return async_playwright
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Internal helpers # Internal helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+2 -2
View File
@@ -15,10 +15,10 @@ 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.2" CHROMIUM_VERSION = "145.0.7632.159"
PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = { PLATFORM_CHROMIUM_VERSIONS: dict[str, str] = {
"linux-x64": "145.0.7632.159.2", "linux-x64": "145.0.7632.159",
"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.109.2",
+1 -1
View File
@@ -44,7 +44,7 @@ from .config import (
logger = logging.getLogger("cloakbrowser") logger = logging.getLogger("cloakbrowser")
# Timeout for download (large binary, allow 10 min) # Timeout for download (large binary, allow 10 min)
DOWNLOAD_TIMEOUT = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0) DOWNLOAD_TIMEOUT = 600.0
# Auto-update check interval (1 hour) # Auto-update check interval (1 hour)
UPDATE_CHECK_INTERVAL = 3600 UPDATE_CHECK_INTERVAL = 3600
+1 -1
View File
@@ -247,7 +247,7 @@ Other tips for maximizing reCAPTCHA scores:
When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary: When auto-update downloads a newer binary, the previous version stays in `~/.cloakbrowser/`. Point `CLOAKBROWSER_BINARY_PATH` to the older cached binary:
```bash ```bash
# Linux # Linux
export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159.2/chrome export CLOAKBROWSER_BINARY_PATH=~/.cloakbrowser/chromium-145.0.7632.159/chrome
# macOS # macOS
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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "cloakbrowser", "name": "cloakbrowser",
"version": "0.3.10", "version": "0.3.9",
"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",
+2 -2
View File
@@ -27,10 +27,10 @@ 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.2"; export const CHROMIUM_VERSION = "145.0.7632.159";
export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = { export const PLATFORM_CHROMIUM_VERSIONS: Record<string, string> = {
"linux-x64": "145.0.7632.159.2", "linux-x64": "145.0.7632.159",
"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.109.2",
+1
View File
@@ -29,6 +29,7 @@ describe("config", () => {
expect(args.some((a) => a.includes("hardware-concurrency"))).toBe(false); expect(args.some((a) => a.includes("hardware-concurrency"))).toBe(false);
} else { } else {
expect(args).toContain("--fingerprint-platform=windows"); expect(args).toContain("--fingerprint-platform=windows");
expect(args).toContain("--fingerprint-hardware-concurrency=8");
} }
// Should have a random fingerprint seed // Should have a random fingerprint seed
+1 -2
View File
@@ -49,13 +49,12 @@ classifiers = [
"Topic :: Software Development :: Testing", "Topic :: Software Development :: Testing",
] ]
dependencies = [ dependencies = [
"playwright>=1.40", "patchright>=1.40",
"httpx>=0.24", "httpx>=0.24",
] ]
[project.optional-dependencies] [project.optional-dependencies]
geoip = ["geoip2>=4.0"] geoip = ["geoip2>=4.0"]
patchright = ["patchright>=1.40"]
[project.urls] [project.urls]
Homepage = "https://github.com/CloakHQ/CloakBrowser" Homepage = "https://github.com/CloakHQ/CloakBrowser"
-11
View File
@@ -1,11 +0,0 @@
"""Shared test fixtures."""
import os
import pytest
@pytest.fixture(autouse=True)
def _clean_backend_env(monkeypatch):
"""Ensure CLOAKBROWSER_BACKEND doesn't leak into tests from the host environment."""
monkeypatch.delenv("CLOAKBROWSER_BACKEND", raising=False)
-45
View File
@@ -1,45 +0,0 @@
"""Unit tests for backend resolution (_resolve_backend)."""
import os
from unittest.mock import patch
import pytest
from cloakbrowser.browser import _resolve_backend
def test_resolve_backend_default():
"""No param, no env var → 'playwright'."""
with patch.dict(os.environ, {}, clear=True):
assert _resolve_backend(None) == "playwright"
def test_resolve_backend_explicit_playwright():
assert _resolve_backend("playwright") == "playwright"
def test_resolve_backend_explicit_patchright():
assert _resolve_backend("patchright") == "patchright"
def test_resolve_backend_env_var():
"""CLOAKBROWSER_BACKEND env var used when no param."""
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
assert _resolve_backend(None) == "patchright"
def test_resolve_backend_param_beats_env():
"""Explicit param overrides env var."""
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "patchright"}):
assert _resolve_backend("playwright") == "playwright"
def test_resolve_backend_invalid_raises():
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
_resolve_backend("bogus")
def test_resolve_backend_invalid_env_raises():
with patch.dict(os.environ, {"CLOAKBROWSER_BACKEND": "bogus"}):
with pytest.raises(ValueError, match="Unknown backend 'bogus'"):
_resolve_backend(None)
+15 -15
View File
@@ -1,6 +1,6 @@
"""Unit tests for launch_persistent_context() and launch_persistent_context_async(). """Unit tests for launch_persistent_context() and launch_persistent_context_async().
All tests mock playwright to avoid needing a binary. All tests mock patchright to avoid needing a binary.
""" """
import warnings import warnings
@@ -32,7 +32,7 @@ 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()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("patchright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", args=["--disable-gpu"]) launch_persistent_context("/tmp/profile", args=["--disable-gpu"])
@@ -48,7 +48,7 @@ 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()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("patchright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile") launch_persistent_context("/tmp/profile")
@@ -63,7 +63,7 @@ def test_persistent_context_custom_viewport(_mock_geoip, _mock_bin):
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
custom = {"width": 1280, "height": 720} custom = {"width": 1280, "height": 720}
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("patchright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", viewport=custom) launch_persistent_context("/tmp/profile", viewport=custom)
@@ -77,7 +77,7 @@ 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()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("patchright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", user_agent="Custom/1.0") launch_persistent_context("/tmp/profile", user_agent="Custom/1.0")
@@ -90,7 +90,7 @@ def test_persistent_context_locale_and_timezone(_mock_bin):
"""Both timezone and locale flow to context kwargs and binary args.""" """Both timezone and locale flow to context kwargs and binary args."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("patchright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP") launch_persistent_context("/tmp/profile", timezone="Asia/Tokyo", locale="ja-JP")
@@ -109,7 +109,7 @@ 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()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("patchright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", color_scheme="dark") launch_persistent_context("/tmp/profile", color_scheme="dark")
@@ -123,7 +123,7 @@ def test_persistent_context_geoip(_mock_bin, _mock_geoip):
"""geoip fills missing tz/locale.""" """geoip fills missing tz/locale."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("patchright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True) launch_persistent_context("/tmp/profile", proxy="http://proxy:8080", geoip=True)
@@ -137,7 +137,7 @@ def test_persistent_context_timezone_id_deprecation(_mock_bin):
"""Old timezone_id kwarg migrated with warning.""" """Old timezone_id kwarg migrated with warning."""
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("patchright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
with warnings.catch_warnings(record=True) as w: with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always") warnings.simplefilter("always")
@@ -156,7 +156,7 @@ def test_persistent_context_close_stops_pw(_mock_geoip, _mock_bin):
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
original_close = context.close original_close = context.close
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("patchright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
ctx = launch_persistent_context("/tmp/profile") ctx = launch_persistent_context("/tmp/profile")
@@ -171,7 +171,7 @@ 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()
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("patchright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", proxy="http://user:pass@proxy:8080") launch_persistent_context("/tmp/profile", proxy="http://user:pass@proxy:8080")
@@ -188,7 +188,7 @@ def test_persistent_context_proxy_dict(_mock_geoip, _mock_bin):
pw_cm, pw, context = _make_mock_pw_and_context() pw_cm, pw, context = _make_mock_pw_and_context()
proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"} proxy_dict = {"server": "http://proxy:8080", "bypass": ".google.com"}
with patch("playwright.sync_api.sync_playwright", return_value=pw_cm): with patch("patchright.sync_api.sync_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context from cloakbrowser.browser import launch_persistent_context
launch_persistent_context("/tmp/profile", proxy=proxy_dict) launch_persistent_context("/tmp/profile", proxy=proxy_dict)
@@ -218,7 +218,7 @@ 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()
with patch("playwright.async_api.async_playwright", return_value=pw_cm): with patch("patchright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async from cloakbrowser.browser import launch_persistent_context_async
await launch_persistent_context_async("/tmp/profile", args=["--disable-gpu"]) await launch_persistent_context_async("/tmp/profile", args=["--disable-gpu"])
@@ -235,7 +235,7 @@ async def test_persistent_context_async_close_stops_pw(_mock_geoip, _mock_bin):
pw_cm, pw, context = _make_mock_async_pw_and_context() pw_cm, pw, context = _make_mock_async_pw_and_context()
original_close = context.close original_close = context.close
with patch("playwright.async_api.async_playwright", return_value=pw_cm): with patch("patchright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async from cloakbrowser.browser import launch_persistent_context_async
ctx = await launch_persistent_context_async("/tmp/profile") ctx = await launch_persistent_context_async("/tmp/profile")
@@ -250,7 +250,7 @@ async def test_persistent_context_async_timezone_id_deprecation(_mock_bin):
"""Deprecated timezone_id kwarg migrated with warning in async path.""" """Deprecated timezone_id kwarg migrated with warning in async path."""
pw_cm, pw, context = _make_mock_async_pw_and_context() pw_cm, pw, context = _make_mock_async_pw_and_context()
with patch("playwright.async_api.async_playwright", return_value=pw_cm): with patch("patchright.async_api.async_playwright", return_value=pw_cm):
from cloakbrowser.browser import launch_persistent_context_async from cloakbrowser.browser import launch_persistent_context_async
with warnings.catch_warnings(record=True) as w: with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always") warnings.simplefilter("always")
-67
View File
@@ -217,70 +217,3 @@ class TestBotDetectionSites:
score = results["score"] score = results["score"]
assert score is not None, "Could not extract reCAPTCHA score" assert score is not None, "Could not extract reCAPTCHA score"
assert score >= 0.7, f"reCAPTCHA score too low: {score}" assert score >= 0.7, f"reCAPTCHA score too low: {score}"
class TestIssueRegressions:
"""Regression tests for specific GitHub issues.
Uses the shared browser fixture to avoid "Sync API inside asyncio loop"
errors when pytest-asyncio is active.
"""
@pytest.mark.slow
def test_immediate_goto_works(self, browser):
"""Issue #9: page.goto() immediately after launch must not fail.
User reported reCAPTCHA fails if goto is called too quickly after
launch. This test verifies that immediate navigation works without
needing an artificial delay.
"""
page = browser.new_page()
# No delay — goto immediately
page.goto("https://example.com", timeout=30000)
title = page.title()
page.close()
assert "Example Domain" in title, f"Immediate goto failed, title={title}"
@pytest.mark.slow
def test_add_init_script_without_proxy(self, browser):
"""Issue #27: add_init_script must work (baseline without proxy).
The bug is proxy + add_init_script, but we first verify init_script
alone works so we have a baseline.
"""
page = browser.new_page()
page.add_init_script("window.__cloaktest = 42;")
page.goto("https://example.com", timeout=30000)
val = page.evaluate("window.__cloaktest")
page.close()
assert val == 42, f"add_init_script failed, got {val}"
@pytest.mark.slow
def test_add_init_script_with_proxy(self, browser):
"""Issue #27: add_init_script + proxy must not cause ERR_TUNNEL_CONNECTION_FAILED.
Patchright bug: add_init_script breaks proxy auth. This test guards
against regression if/when the upstream fix lands. Uses context-level
proxy to avoid launching a separate browser (event loop conflict).
"""
proxy = os.environ.get("CLOAKBROWSER_TEST_PROXY")
if not proxy:
pytest.skip("CLOAKBROWSER_TEST_PROXY not set")
ctx = browser.new_context(proxy={"server": proxy})
page = ctx.new_page()
page.add_init_script("window.__cloaktest = 99;")
try:
page.goto("https://httpbin.org/ip", timeout=30000)
body = page.evaluate("document.body.innerText")
val = page.evaluate("window.__cloaktest")
assert val == 99, f"init_script value wrong: {val}"
assert "origin" in body, f"Page didn't load through proxy: {body[:100]}"
except Exception as e:
err = str(e)
if "ERR_TUNNEL_CONNECTION_FAILED" in err:
pytest.xfail("Known patchright bug: add_init_script + proxy auth (issue #27)")
raise
finally:
page.close()
ctx.close()