Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi

## [Unreleased]

- **[wrapper]** Add `connect()` / `connect_async()` — attach to an already-running CloakBrowser instance over CDP (e.g. one started via `cloakserve` in Docker) and keep the wrapper conveniences that stock `connect_over_cdp()` drops: `humanize=True` and the no-viewport default. Select a fingerprint/timezone/locale via the endpoint URL's query string (forwarded verbatim); closing the returned browser detaches from the remote instance without terminating it (#435). Python (sync + async), JavaScript (Playwright + Puppeteer), and .NET (`CloakLauncher.ConnectAsync`).
- **[wrapper]** Fix `geoip=True` overriding a timezone or locale that was passed explicitly as a raw flag via `args=` (`--fingerprint-timezone`, `--lang`, `--fingerprint-locale`). A raw flag now counts as explicit and is preserved, matching the behavior of the `timezone=`/`locale=` parameters; GeoIP only fills the values you did not set. Python, JavaScript, and .NET.

## [0.4.13] — 2026-07-22
Expand Down
31 changes: 25 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -818,7 +818,14 @@ browser = await launch_async(args=["--remote-debugging-port=9242"])
# Note: humanize requires the wrapper (see below)
```

> **Humanize over CDP**: Stealth fingerprint patches work automatically over CDP, but `humanize=True` is a wrapper-level feature. If you connect to CloakBrowser via CDP from a separate script, import the patching functions to add humanization:
> **Humanize over CDP**: Stealth fingerprint patches work automatically over CDP, but `humanize=True` is a wrapper-level feature. The simplest way to keep it when attaching to a running instance is `connect()` (see [CDP server mode](#cdp-server-mode)):
>
> ```python
> from cloakbrowser import connect
> browser = connect("http://127.0.0.1:9242", humanize=True)
> ```
>
> If you already hold a Playwright/Puppeteer browser from your own code, apply the layer directly instead:
>
> ```js
> import { patchBrowser, resolveConfig } from 'cloakbrowser/human';
Expand Down Expand Up @@ -904,19 +911,31 @@ Start a persistent stealth browser and connect to it remotely via Chrome DevTool
docker run -d --name cloak -p 127.0.0.1:9222:9222 cloakhq/cloakbrowser cloakserve
```

Then connect from your host machine:
Then connect from your host machine with `connect()` — same API as `launch()`, but it attaches to the running instance and keeps `humanize` and the no-viewport default working:

```python
from playwright.sync_api import sync_playwright
from cloakbrowser import connect

pw = sync_playwright().start()
browser = pw.chromium.connect_over_cdp("http://localhost:9222")
browser = connect("http://localhost:9222", humanize=True)
page = browser.new_page()
page.goto("https://example.com")
print(page.title())
browser.close()
browser.close() # detaches; the remote instance keeps running
```

```javascript
import { connect } from 'cloakbrowser'; // or 'cloakbrowser/puppeteer'

const browser = await connect('http://localhost:9222', { humanize: true });
const page = await browser.newPage();
await page.goto('https://example.com');
await browser.close();
```

Select a fingerprint/timezone/locale via the endpoint URL's query string — it's forwarded verbatim: `connect("http://localhost:9222?fingerprint=11111&timezone=America/New_York")`. Async: `connect_async(...)` (Python); .NET: `await CloakLauncher.ConnectAsync("http://localhost:9222")`.

Prefer plain Playwright? `pw.chromium.connect_over_cdp("http://localhost:9222")` still works — you just don't get `humanize` or the no-viewport default.

If your framework needs a direct WebSocket endpoint, fetch Chrome's discovery document and use the rewritten `webSocketDebuggerUrl`. The URL points back through `cloakserve` so the CDP proxy can keep per-seed routing intact:

```bash
Expand Down
4 changes: 3 additions & 1 deletion cloakbrowser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
browser.close()
"""

from .browser import launch, launch_async, launch_context, launch_context_async, launch_persistent_context, launch_persistent_context_async, ProxySettings, build_args, maybe_resolve_geoip
from .browser import launch, launch_async, launch_context, launch_context_async, launch_persistent_context, launch_persistent_context_async, connect, connect_async, ProxySettings, build_args, maybe_resolve_geoip
from .config import CHROMIUM_VERSION, get_default_stealth_args
from .download import binary_info, check_for_update, clear_cache, ensure_binary
from .license import CloakBrowserLicenseError, LicenseInfo, validate_license
Expand All @@ -36,6 +36,8 @@ def __getattr__(name):
"launch_context_async",
"launch_persistent_context",
"launch_persistent_context_async",
"connect",
"connect_async",
"ensure_binary",
"clear_cache",
"binary_info",
Expand Down
155 changes: 155 additions & 0 deletions cloakbrowser/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,161 @@ async def _close_with_cleanup() -> None:
return browser


def _finalize_connected(
browser: Any,
pw: Any,
humanize: bool,
human_preset: HumanPreset,
human_config: HumanConfigOverrides | None,
default_no_viewport: bool,
) -> Any:
"""Shared tail for connect(): wrap close() for driver cleanup, default the
viewport, and apply humanize. Mirrors the tail of launch(). ``browser.close()``
detaches from the remote instance (it does not terminate it — connect_over_cdp
semantics) and stops our local Playwright driver.
"""
_original_close = browser.close

def _close_with_cleanup() -> None:
try:
_original_close()
finally:
pw.stop()

browser.close = _close_with_cleanup

if default_no_viewport:
_default_no_viewport(browser)

if humanize:
from .human import patch_browser
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_browser(browser, cfg)

return browser


async def _finalize_connected_async(
browser: Any,
pw: Any,
humanize: bool,
human_preset: HumanPreset,
human_config: HumanConfigOverrides | None,
default_no_viewport: bool,
) -> Any:
"""Async variant of :func:`_finalize_connected`."""
_original_close = browser.close

async def _close_with_cleanup() -> None:
try:
await _original_close()
finally:
await pw.stop()

browser.close = _close_with_cleanup

if default_no_viewport:
_default_no_viewport_async(browser)

if humanize:
from .human import patch_browser_async
from .human.config import resolve_config
cfg = resolve_config(human_preset, human_config)
patch_browser_async(browser, cfg)

return browser


def connect(
endpoint_url: str,
*,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
default_no_viewport: bool = True,
**kwargs: Any,
) -> Any:
"""Connect to an already-running CloakBrowser instance over CDP.

Unlike ``launch()``, this does not start a browser — it attaches to one that is
already running (e.g. started via ``cloakserve`` in Docker) and layers on the
same conveniences ``launch()`` provides: the no-viewport default and, optionally,
the humanize behavioral layer. The C++ fingerprint patches live in the running
binary and persist across the connection, so nothing fingerprint-related happens
here — select a fingerprint/timezone/locale via the endpoint URL's query string
(e.g. ``http://host:9222?fingerprint=12345&timezone=America/New_York``), which is
forwarded verbatim.

Args:
endpoint_url: CDP endpoint of the running instance, e.g.
``"http://localhost:9222"`` (optionally with a ``?fingerprint=...`` query).
humanize: Enable human-like mouse, keyboard, scroll behavior (default False).
human_preset: Humanize preset — 'default' or 'careful' (default 'default').
human_config: Custom humanize config mapping to override preset values.
default_no_viewport: Default ``new_page()``/``new_context()`` to
``no_viewport=True`` (default True). Set False to keep Playwright's
emulated viewport.
**kwargs: Passed directly to ``playwright.chromium.connect_over_cdp()``
(e.g. ``headers``, ``timeout``, ``slow_mo``).

Returns:
Playwright Browser object. ``browser.close()`` detaches from the remote
instance (it does not terminate it) and stops the local driver.

Example:
>>> from cloakbrowser import connect
>>> browser = connect("http://localhost:9222", humanize=True)
>>> page = browser.new_page()
>>> page.goto("https://bot.incolumitas.com")
>>> browser.close()
"""
from playwright.sync_api import sync_playwright

pw = sync_playwright().start()
try:
browser = pw.chromium.connect_over_cdp(endpoint_url, **kwargs)
except Exception:
try:
pw.stop()
except Exception as stop_exc:
logger.warning("Playwright cleanup after connect failure failed: %s", stop_exc)
raise

logger.debug("Connected to CloakBrowser over CDP (%s)", endpoint_url)
return _finalize_connected(
browser, pw, humanize, human_preset, human_config, default_no_viewport
)


async def connect_async(
endpoint_url: str,
*,
humanize: bool = False,
human_preset: HumanPreset = "default",
human_config: HumanConfigOverrides | None = None,
default_no_viewport: bool = True,
**kwargs: Any,
) -> Any:
"""Async variant of :func:`connect`."""
from playwright.async_api import async_playwright

pw = await async_playwright().start()
try:
browser = await pw.chromium.connect_over_cdp(endpoint_url, **kwargs)
except Exception:
try:
await pw.stop()
except Exception as stop_exc:
logger.warning("Playwright cleanup after connect failure failed: %s", stop_exc)
raise

logger.debug("Connected to CloakBrowser over CDP async (%s)", endpoint_url)
return await _finalize_connected_async(
browser, pw, humanize, human_preset, human_config, default_no_viewport
)


def launch_persistent_context(
user_data_dir: str | os.PathLike,
headless: bool = True,
Expand Down
54 changes: 54 additions & 0 deletions dotnet/src/CloakBrowser/CloakLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,60 @@ public static async Task<CloakBrowserHandle> LaunchAsync(LaunchOptions? options
}
}

// -----------------------------------------------------------------------
// connect - attach to an already-running instance over CDP
// -----------------------------------------------------------------------

/// <summary>
/// Connect to an already-running CloakBrowser instance over CDP (e.g. one started
/// via cloakserve in Docker) and apply the same conveniences <see cref="LaunchAsync"/>
/// provides — the no-viewport default and, optionally, the humanize behavioral layer.
/// The C++ fingerprint patches live in the running binary and persist across the
/// connection, so select a fingerprint/timezone/locale via the endpoint URL's query
/// string (e.g. <c>http://host:9222?fingerprint=12345</c>), which is forwarded verbatim.
/// <see cref="CloakBrowserHandle.CloseAsync"/> detaches from the remote instance; it
/// does not terminate it.
/// </summary>
public static async Task<CloakBrowserHandle> ConnectAsync(string endpointUrl, ConnectOptions? options = null)
{
options ??= new ConnectOptions();

CloakLog.Debug($"Connecting to CloakBrowser over CDP ({endpointUrl})");

var playwright = await Playwright.CreateAsync().ConfigureAwait(false);
IBrowser browser;
try
{
browser = await playwright.Chromium.ConnectOverCDPAsync(endpointUrl).ConfigureAwait(false);
}
catch
{
playwright.Dispose();
throw;
}

try
{
var humanCfg = options.Humanize
? HumanConfigFactory.Resolve(options.HumanPreset, options.HumanConfig)
: null;
// Map DefaultNoViewport onto the handle's headed/headlessNoViewport gate:
// ApplyHeadedNoViewport applies NoViewport unless (headless && !headlessNoViewport),
// so headless=false forces the no-viewport default; headless=true keeps the
// emulated viewport. (We can't know the remote's real headless state.)
return new CloakBrowserHandle(
playwright, browser, options.Humanize, humanCfg,
headless: !options.DefaultNoViewport, headlessNoViewport: false);
}
catch
{
try { await browser.CloseAsync().ConfigureAwait(false); }
catch (Exception closeEx) { CloakLog.Warning($"browser cleanup after connect failure failed: {closeEx.Message}"); }
playwright.Dispose();
throw;
}
}

// -----------------------------------------------------------------------
// launch_context - returns a Context handle (browser owned)
// -----------------------------------------------------------------------
Expand Down
22 changes: 22 additions & 0 deletions dotnet/src/CloakBrowser/LaunchOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,28 @@ public class LaunchOptions
internal bool SuppressMaximize { get; set; }
}

/// <summary>
/// Options for <see cref="CloakLauncher.ConnectAsync(string, ConnectOptions)"/>.
/// Mirrors the keyword arguments of the Python <c>connect()</c>.
/// </summary>
public class ConnectOptions
{
/// <summary>Enable the human-like behavior layer when creating pages via <see cref="CloakBrowserHandle"/>.</summary>
public bool Humanize { get; set; }

/// <summary>Humanize preset (default <see cref="HumanPreset.Default"/>).</summary>
public HumanPreset HumanPreset { get; set; } = HumanPreset.Default;

/// <summary>Custom humanize config overrides (snake_case or PascalCase keys).</summary>
public IReadOnlyDictionary<string, object>? HumanConfig { get; set; }

/// <summary>
/// Default <c>NewPageAsync</c>/<c>NewContextAsync</c> to <c>NoViewport</c> (default true).
/// Set false to keep Playwright's emulated viewport.
/// </summary>
public bool DefaultNoViewport { get; set; } = true;
}

/// <summary>Options for context-producing launchers (adds context-level emulation settings).</summary>
public class LaunchContextOptions : LaunchOptions
{
Expand Down
29 changes: 29 additions & 0 deletions dotnet/tests/CloakBrowser.Tests/ConnectTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System;
using System.Threading.Tasks;
using CloakBrowser;
using CloakBrowser.Human;
using Xunit;

namespace CloakBrowser.Tests;

public class ConnectTests
{
[Fact]
public void ConnectOptions_Defaults()
{
var o = new ConnectOptions();
Assert.False(o.Humanize);
Assert.Equal(HumanPreset.Default, o.HumanPreset);
Assert.True(o.DefaultNoViewport); // no-viewport on by default (parity with Python/JS)
Assert.Null(o.HumanConfig);
}

[Fact]
public async Task ConnectAsync_UnreachableEndpoint_Throws()
{
// No browser is listening on this port; ConnectOverCDPAsync fails, and
// ConnectAsync disposes Playwright and rethrows rather than leaking the driver.
await Assert.ThrowsAnyAsync<Exception>(
() => CloakLauncher.ConnectAsync("http://127.0.0.1:1"));
}
}
16 changes: 16 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,22 @@ await page.goto('https://example.com');
await ctx.close(); // profile saved — reuse same path to restore state
```

### Connect to a running instance

Attach to an already-running CloakBrowser (e.g. one started via `cloakserve` in Docker) over CDP, keeping `humanize` and the no-viewport default — unlike stock `connectOverCDP`:

```javascript
import { connect } from 'cloakbrowser'; // Playwright
// import { connect } from 'cloakbrowser/puppeteer'; // Puppeteer

const browser = await connect('http://localhost:9222', { humanize: true });
const page = await browser.newPage();
await page.goto('https://example.com');
await browser.close(); // detaches; the remote instance keeps running
```

Select a fingerprint via the endpoint URL's query string — it's forwarded verbatim: `connect('http://localhost:9222?fingerprint=11111')`. Pass a `ws(s)://` URL directly, or an `http(s)://` one (the ws endpoint is resolved for you).

### Auto Timezone/Locale from Proxy IP

When using a proxy, antibot systems check that your browser's timezone and locale match the proxy's location. Install `mmdb-lib` to enable auto-detection from an offline GeoIP database (~70 MB, downloaded on first use):
Expand Down
4 changes: 2 additions & 2 deletions js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*/

// Launch functions (Playwright API)
export { launch, launchContext, launchPersistentContext, buildLaunchOptions, buildContextOptions, humanizeBrowser } from "./playwright.js";
export { launch, launchContext, launchPersistentContext, connect, buildLaunchOptions, buildContextOptions, humanizeBrowser } from "./playwright.js";

// Binary management
export { ensureBinary, clearCache, binaryInfo, checkForUpdate } from "./download.js";
Expand All @@ -28,5 +28,5 @@ export { CHROMIUM_VERSION, getDefaultStealthArgs } from "./config.js";
export { validateLicense, CloakBrowserLicenseError } from "./license.js";

// Types
export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, BinaryInfo } from "./types.js";
export type { LaunchOptions, LaunchContextOptions, LaunchPersistentContextOptions, ConnectOptions, BinaryInfo } from "./types.js";
export type { LicenseInfo } from "./license.js";
Loading