Metadata-Version: 2.5
Name: clearcote
Version: 0.50.1
Summary: Playwright drop-in for the Clearcote anti-fingerprint Chromium build. launch() returns a standard Playwright Browser backed by the verified Clearcote binary, which is auto-downloaded and SHA-256 checked.
Project-URL: Homepage, https://github.com/clearcotelabs/clearcote-browser
Project-URL: Repository, https://github.com/clearcotelabs/clearcote-browser
Project-URL: Issues, https://github.com/clearcotelabs/clearcote-browser/issues
License: BSD-3-Clause
License-File: LICENSE
Keywords: anti-detect,anti-fingerprint,automation,chromium,clearcote,farbling,fingerprint,playwright,stealth,ungoogled
Requires-Python: >=3.8
Requires-Dist: maxminddb>=2.2
Requires-Dist: playwright>=1.40
Provides-Extra: test
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
Requires-Dist: pytest>=7.3; extra == 'test'
Description-Content-Type: text/markdown

# clearcote (Python SDK)

A **Playwright drop-in** for [Clearcote](https://github.com/clearcotelabs/clearcote-browser) — the
open, reproducible, anti-fingerprint Chromium build. `launch()` returns a standard Playwright
`Browser`, so migrating is a one-line import change.

The verified Clearcote binary is **auto-downloaded and SHA-256 checked** on first use, then cached —
no zips or paths to manage. Or run the same code on a hosted Clearcote browser with
`launch(cloud=True)`: see [Local or cloud](#local-or-cloud).

> **Platform:** Clearcote ships **Windows x64** and **Linux x64** binaries; `launch()` runs on both
> and the SDK auto-downloads the right one for your OS. On Linux the persona is Linux-native (Linux
> GPU/voices/audio-device values) and DRM uses the Linux CDM. There is no native macOS build yet
> ([roadmap](../../ROADMAP.md)): on macOS, `launch()` runs the [Docker image](../../docker/README.md) through
> Docker Desktop instead (SDK 0.40.0+; `docker=True` or `CLEARCOTE_DOCKER=1` does the same on any OS). On a minimal Linux host, install the browser's runtime libs (e.g.
> `apt-get install -y libnss3 libnspr4 libgbm1 libasound2 libatk1.0-0 libatk-bridge2.0-0 libcups2
> libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libxfixes3 libxext6 libpango-1.0-0
> libcairo2 libx11-6 libxcb1 libexpat1 libdbus-1-3`) and pass `args=["--no-sandbox"]` (or
> `chown root:root chrome-sandbox && chmod 4755 chrome-sandbox`) in containers.
> Running as **root** in a container? Run as a normal user (the official Docker image does) or add
> `--cap-add=SYS_NICE`: the open build is compiled with debug assertions, and a root container that
> refuses Chromium's process-priority call stops it at start.

## Install

```bash
pip install clearcote
```

`playwright` is pulled in as a dependency. You do **not** need to run `playwright install`
(Clearcote uses its own browser binary, not Playwright's bundled Chromium).

## Usage

```python
from clearcote import launch

browser = launch(
    fingerprint="user-7423",        # per-eTLD+1 seed: same seed => same identity, different => unlinkable
    platform="windows",
    timezone="America/New_York",
    headless=False,
)
page = browser.new_page()
page.goto("https://abrahamjuliot.github.io/creepjs/")
# ... standard Playwright (sync API) from here ...
browser.close()
```

Already using Playwright? Swap `p.chromium.launch(...)` for `launch(...)` from `clearcote` — the
returned object is a normal Playwright `Browser`. (One shared Playwright driver is started lazily
and stopped at interpreter exit.)

### Async API (`clearcote.async_api`)

Inside an asyncio event loop, use the async API — it mirrors the sync one and returns Playwright
**async** objects (the sync API raises "Sync API inside the asyncio loop"):

```python
import asyncio
from clearcote.async_api import launch

async def main():
    browser = await launch(
        fingerprint="user-7423",
        platform="windows",
        timezone="America/New_York",
    )
    page = await browser.new_page()
    await page.goto("https://abrahamjuliot.github.io/creepjs/")
    # ... standard Playwright (async API) from here ...
    await browser.close()

asyncio.run(main())
```

Same options as the sync `launch` (fingerprint/persona/proxy/`geoip`/`profile`/`canvas_bridge`/
`humanize`/…). `clearcote.async_api` exposes `launch`, `launch_persistent_context`, `launch_agent`,
`run_agent_task`, `executable_path`, `download`, plus `Profile` (use `launch(profile="name")`). Each
launched browser owns its Playwright driver and stops it on `await browser.close()`.

### Light stealth (`light_stealth=True`)

When the full seed-derived persona is more than a target needs, `light_stealth` spoofs only a
coherent, seed-derived bundle of the *safe* metadata axes — `hardware_concurrency`, `device_memory`,
`color_depth`, `device_pixel_ratio`, `max_touch_points` — via native override switches, leaving
rendering (canvas/WebGL/audio/fonts), TLS and the real browser version untouched. Screen dimensions
stay real by default (opt-in), since a faked screen that can't be reconciled with the real render
surface is a reliable block trigger. It never engages the `--fingerprint` persona machinery.

```python
browser = launch(light_stealth=True, fingerprint="my-seed")   # coherent metadata, rendering left real
```

Or set any native override directly (no seed needed) — an explicit value always wins over the persona:

```python
browser = launch(hardware_concurrency=8, device_memory=8, device_pixel_ratio=1.25, max_touch_points=0)
```

Native overrides: `hardware_concurrency`, `device_memory`, `color_depth`, `device_pixel_ratio`,
`max_touch_points`, and (opt-in) `screen_width` / `screen_height` / `avail_width` / `avail_height`.
Needs the Clearcote 149.0.7827.114 (v0.1.0-pre.22) build or newer.

### Standing CDP endpoint (`serve()`)

Run Clearcote as a **stealthy CDP endpoint** that any existing automation attaches to unchanged —
Playwright's `connect_over_cdp`, `puppeteer.connect`, or browser-use / Crawl4AI / Stagehand. Where
`launch()` spawns a Playwright-owned browser, `serve()` launches the binary **directly** — so
`--enable-automation` is never added and `navigator.webdriver` stays `false`; the port binds to
loopback (`127.0.0.1`) behind an origin allowlist.

```python
from clearcote import serve

srv = serve(fingerprint="seed-123", platform="windows")   # same persona/proxy/geoip options as launch()
print(srv.cdp_url)                                        # http://127.0.0.1:<port>

from playwright.sync_api import sync_playwright
browser = sync_playwright().start().chromium.connect_over_cdp(srv.cdp_url)   # your code, unchanged
# ... or puppeteer.connect / browser-use / Crawl4AI / Stagehand, pointed at srv.cdp_url ...
srv.close()                                               # or: `with serve(...) as srv:`
```

Or from the shell:

```bash
clearcote-serve --port 9222 --fingerprint seed-123 --platform windows   # prints http://127.0.0.1:9222
```

The returned `Server` exposes `.cdp_url`, `.ws_url()`, and `.close()`, and works as a context manager.

Headless, `serve()` gives the browser a real-size display (the persona's, or one drawn from real
desktops) and maximizes its window onto the work area before any client attaches, so every page,
tab and popup reports a window that fits its screen. Pass `window_size={"width": ..., "height": ...}`
for a smaller window (clamped to the work area), or your own `--window-size` / `--screen-info` in
`args` to opt out.

**Drive it from an AI agent (MCP).** Point Claude Desktop / Cursor / Cline at the
[`clearcote-mcp`](https://github.com/clearcotelabs/clearcote-browser/tree/main/mcp) server
(`pip install clearcote-mcp` or `npx -y clearcote-mcp`) — ~20 tools over one shared stealth browser,
persona set via `CLEARCOTE_*` env.

### Many identities on one endpoint (`serve_multiplex`, `clearcote serve`)

One port, one browser per identity, chosen by the connection URL:

```python
# in another shell:  clearcote serve --port 9222 --idle-timeout 300
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
    a = p.chromium.connect_over_cdp("http://127.0.0.1:9222?fingerprint=acct-1&timezone=Europe/Berlin")
    b = p.chromium.connect_over_cdp("http://127.0.0.1:9222?fingerprint=acct-2&proxy=socks5%3A%2F%2Fu%3Ap%40host%3A1080&geoip=true")
```

- Query options: `fingerprint`, `timezone`, `locale`, `proxy`, `geoip`, plus any fingerprint option in kebab-case (`platform`, `hardware-concurrency`, `allow-third-party-cookies`, ...). Unknown options are rejected (400).
- The same identity reuses its browser. Asking for a running identity with *different* options returns 409 — close it first.
- `GET /` lists running browsers; `POST /fingerprint/<id>/close` stops one; `--idle-timeout <s>` closes a browser after its last client disconnects; `--max-browsers` caps them (default 16).
- Behind a reverse proxy, WebSocket URLs honour `X-Forwarded-Host` / `X-Forwarded-Proto`; add the public name with `--allow-host`.
- Binds `127.0.0.1` by default. Requests a web page could make (cross-site fetches, foreign `Origin`, a `Host` that isn't an IP / `localhost` / allowed) are refused.

In code: `clearcote.serve_multiplex(port=9222, idle_timeout=300, data_dir="./profiles")`.

### `clearcote` command line

```bash
clearcote install [--version 152] [--channel preview]
clearcote info [--quick] [--json] [--proxy <url>]   # alias: doctor
clearcote login [key]      # validates the key, saves it to ~/.clearcote/license.key
clearcote logout
clearcote clear-cache
clearcote serve [--port 9222] ...
clearcote cloud run|sessions|stop|events|recording|profile sync|webhooks ...   # the hosted API, see Local or cloud
```

`info` never downloads: it reports the SDK, the cached builds, which engine features the binary supports, a launch test (skipped with `--quick`), licence seats, fonts and missing system libraries.

Windows: when a cached build cannot start from the cache (`spawn UNKNOWN`, "the side-by-side configuration is incorrect"; this happens when the SDK runs inside an MSIX-packaged app, whose writes to `%LOCALAPPDATA%` Windows redirects), launches use one copy of that build in `~/.clearcote/recovered/` instead, made once and reused by every later launch from Python or Node. `clear-cache` removes those copies too.

### Engine options (PRO 152 r22+)

| Option | Effect |
|---|---|
| `fingerprint="off"` | No persona at all — for telling whether a problem comes from the spoofing or from your environment. |
| `fingerprint_voices=False` | Keep the host's own `speechSynthesis` voices under a persona. |
| `allow_third_party_cookies=False` | Block third-party cookies. Since 0.45.0 they are allowed by default, as in Chrome; embedded sign-in, payment and captcha frames rely on them. |
| `transparent_proxy=True` | With a proxy: send the headers a direct connection sends, and report connection timing as a reused connection. |

On an older engine each of these is skipped with a warning; the launch still works.

`stock_runtime=True` (or `CLEARCOTE_STOCK_RUNTIME=1`) gives Playwright the browser's stock DevTools behaviour back: `page.on("console")` and `page.on("pageerror")` receive events, `page.set_content()` works, and `expose_function()` / `expose_binding()` keep working after a navigation. It is off by default because pages can observe some of what it restores. It needs an engine from r32 on; an older engine launches without it and the SDK warns once. It applies to local and Docker launches, not to cloud browsers.

### On engines from r32 (`persona_env`, light or dark)

Two more things change on an engine that supports them (the licensed build from r32). The SDK checks the binary
for each one first, so an older engine launches exactly as before.

- **The persona stays off the command line** (`persona_env`, on by default). The seed, every persona override,
  proxy credentials and the canvas-bridge token reach the browser in the `CLEARCOTE_PERSONA_ARGS` environment
  variable instead of on its command line, which any local user can read (`/proc/<pid>/cmdline` on Linux, the
  process details on Windows). `persona_env=False` or `CLEARCOTE_PERSONA_ENV=0` keeps them on the command line. A
  Docker launch hands the choice to the container.
- **Light or dark comes from the persona.** The engine picks the persona's colour scheme (about one persona in
  three is dark; `--fingerprint-color-scheme=light|dark` in `args` forces one), and CSS, `matchMedia`, the
  `Sec-CH-Prefers-Color-Scheme` header and system colours follow it. Playwright emulates light in every context it
  creates, which a dark persona would contradict, so on such an engine `launch()` and
  `launch_persistent_context()` leave the colour scheme unemulated (`color_scheme="null"`). Pass `color_scheme=`
  yourself to emulate one anyway. Docker and cloud launches are unchanged.

Also: `license_through_proxy=True` (or `CLEARCOTE_LICENSE_THROUGH_PROXY=1`) sends the licence calls through the launch proxy; `release_channel="preview"` (or `CLEARCOTE_RELEASE_CHANNEL`) picks up PRO preview builds; `get_session_seats()` reports seats in use.

### Through a proxy (report the proxy's IP, not your host's)

```python
browser = launch(
    fingerprint="user-7423",
    proxy={"server": "http://host:8080", "username": "u", "password": "p"},  # standard Playwright option
    timezone="America/New_York",
    webrtc_ip="203.0.113.10",       # make WebRTC report the proxy egress IP, not your host's
)
```

**WebRTC won't leak your real IP.** The engine *fabricates* the WebRTC server-reflexive (`srflx`) candidate at `webrtc_ip` and sends **no real STUN** from your host — so WebRTC reports the proxy IP and your real IP never leaks at the packet level. A plain candidate "relabel" doesn't stop the leak (the real STUN packet still goes out from your host); Clearcote sends none. Raw host candidates are suppressed, and the candidate set stays coherent (not empty/disabled).

### Auto geo-match (`geoip`)

Set `geoip=True` and Clearcote resolves the **proxy's exit IP** (looked up *through* the proxy) and auto-fills any unset `timezone`, `accept_language`, `location`, **and `webrtc_ip`** so the whole identity — clock, language, geo, and WebRTC IP — matches the proxy's region:

```python
browser = launch(
    fingerprint="user-7423",
    proxy={"server": "http://host:8080", "username": "u", "password": "p"},
    geoip=True,              # timezone, languages, location, AND WebRTC IP all auto-set to the proxy's geo
)
```

Anything you set explicitly wins over `geoip`. With no proxy it uses your direct connection's IP. The lookup goes through the proxy — http(s) and SOCKS5 (with username/password) alike.

The whole lookup has one deadline, `CLEARCOTE_GEOIP_TIMEOUT_SECONDS` (default 20). If the region can't be resolved in time, `launch()` raises `GeoipError` (code `GEOIP_UNRESOLVED`) **before** any browser starts, instead of launching with this machine's clock and language. If you set both `timezone` and `accept_language` yourself, it warns and launches anyway.

Geo data comes from the offline [geoip-all-in-one](https://github.com/daijro/geoip-all-in-one) MaxMind database (downloaded + cached on first use, ~52 MB; GPL-3.0 data, the same source Camoufox uses) — more accurate than a single online API. The exit IP comes from a small IP echo through the proxy (`api.ipify.org`, then `icanhazip.com`).

- `CLEARCOTE_GEOIP_ECHO_URL`: your own IP-echo endpoint(s), comma-separated, used instead of the public ones, so no third party learns that the proxy is in use. It should answer with the IP as plain text, or JSON with an `ip` field.
- `CLEARCOTE_GEOIP_IPAPI=1`: also ask `ip-api.com` when the database has no answer, e.g. while it is still downloading on first use. Off by default: ip-api.com's free endpoint is for non-commercial use only and allows 45 requests a minute per IP.

### Humanized input (`humanize`, `show_cursor`)

```python
browser = launch(fingerprint="user-7423", humanize=True)
page = browser.new_page()
page.goto("https://example.com")

page.click("#login")                       # eased bezier glide, then a trusted click
page.fill("#user", "alice")                # focus + key-by-key typing with human timing
page.locator("#pwd").type("s3cr3t")        # locators are humanized too
page.mouse.wheel(0, 800)                   # whole wheel notches, in finger-flick bursts
# a held-button drag (slider captchas): the button stays pressed across the move
page.mouse.move(x0, y0); page.mouse.down(); page.mouse.move(x1, y0); page.mouse.up()

page.select_option("#country", "Germany")  # click it open, type "ger", Enter: one trusted change
page.fill("#volume", "70")                 # a range slider is dragged, then nudged to exactly 70
page.fill("#birthday", "1990-04-12")       # a date field is typed segment by segment
page.click("#row", button="right")         # click options are honoured (button, modifiers, ...)
```

`humanize=True` installs **one consistent human-input standard** covering moving, clicking,
dragging, scrolling and typing — all dispatched as **native trusted input** (`isTrusted === true`,
`navigator.webdriver` stays `false`), at both the page level (`page.click`/`hover`/`dblclick`/
`type`/`fill`/`press`, `page.mouse.*`, `page.keyboard.type`) and the locator level
(`locator.click`/`type`/`fill`/`hover`/`press_sequentially`/`drag_to`/`check`/…). Mouse paths start
from the *last* cursor position (no snap back to the corner) and are walked as a **sum of sub-movements
with a min-jerk velocity profile**: an arcing ballistic primary that reaches its top speed before the
halfway point and either stops a little short of the target or, in a share of moves fixed per identity
(15–38 %), passes it and comes back, then a short corrective move. Because they use native input, the
button held by `mouse.down()` stays held across the move, so `down → move → up` is a real drag (slider
captchas work); a move with the button held is planned as a drag, a flat leg that stays within a few
pixels of the line from grab to drop (a slider handle stays on its track), with the occasional small
overshoot and pull-back. `page.mouse.click(x, y)` is walked by the engine's own pointer path when the
engine has one and knows where the pointer is (nothing else moved it since its last walk);
`CLEARCOTE_HUMANIZE_ENGINE_CLICKS=off` keeps every click on the SDK's path, and `=all` also sends
`page.click(selector)` / `locator.click()` there (plain left single clicks on a point the hit test proved). Clicks get an
actionability pre-flight (visible + enabled + stable + not covered) and fall back to the native click
if it fails. The "not covered" check is the browser's own hit test, read in an isolated world: the
pointer does not move before the glide and nothing is pressed. A layer with `pointer-events: none` over
the target is skipped, as a real press skips it, and a click point inside a child frame is checked
against the `<iframe>` element that holds it (where a press there lands as far as the page is
concerned); no Playwright `trial` runs at all. With a CSS selector the element is also found and
scrolled into view there, so the page's own scripts see no read at all; a `get_by_role` / `get_by_label` /
text locator costs one Playwright `bounding_box`, which reads the element's `textContent` in the page. Typing goes key-by-key with **gaussian inter-key timing** + word-boundary pauses, the
occasional fat-finger correction and **key rollover** (on some fast pairs the next key goes down
before the previous one comes up, at a rate fixed per identity); `page.fill` over 200 chars glides to
the field and clicks into it like any fill, then inserts the value in one go (no per-key typing, which
would crawl). Every press the SDK makes itself (a selector click, the click into a field, `mouse.down()`
after a move, a drag's grab, a drop-down or list-box click) comes after a short pause once the pointer
has arrived (40-250 ms, right-skewed, typical length fixed per identity), the way a hand confirms its
target. A `page.mouse.click(x, y)` that the engine's pointer path walks is pressed by the engine, not
the SDK: engine builds up to r39 press about 10 ms after their last move, with no such pause
(`CLEARCOTE_HUMANIZE_ENGINE_CLICKS=off` keeps those clicks on the SDK's path, pause included). Scrolling moves in **whole wheel notches** (100 px on
a Windows identity, 120 on Linux, 40 on macOS), because a browser reports every wheel event as one
full notch: the requested distance is rounded to the nearest notch (at least one). Notches come in
short flicks with the occasional reading pause.

**Click options** are honoured with humanize on: `button`, `click_count`, `delay` (the hold),
`modifiers` (held around the press), `position` and `force`; `trial=True` performs no action: the SDK
runs Playwright's checks itself (visible, enabled, stable, the click point receives the press) and
raises Playwright's `TimeoutError` when they never pass within the timeout, without moving the pointer
or pressing (Playwright's own trial does both, and listeners a page registered early see it). Locator actions stay strict, and every Playwright locator works (`get_by_role`,
`get_by_label`, `.filter()`, `.nth()`). Errors are Playwright's own class, worded for the method you
called (`Page.click: Timeout 800ms exceeded.`, `Locator.check: Error: Not a checkbox or radio button`),
and `locator.tap()` in a context without `has_touch` raises Playwright's own error instead of clicking.

**`fill` follows the field.** Text is typed (cleared with held keys only when it has a value, then
checked through Playwright; a field whose value cannot be confirmed, or one the click could not reach, is
filled by Playwright instead). A field with a `maxlength` gets only the characters it can hold, the same
value Playwright's own `fill` leaves there; a field whose input mask re-punctuates what is typed (an
`MM/YY` expiry, a dashed phone number) is typed once, and a mask that then stops taking keys leaves what
it would leave a person. A range slider is dragged (or its track clicked) and nudged with the arrow
keys to the exact value; a value the slider cannot hold (off its step, out of range, not a number) raises
Playwright's own "Malformed value", as before. A date / time / month / week / datetime-local field is typed
segment by segment, in the order the browser shows them, with the same key timing as text. Colour pickers, non-text inputs and fields inside
frames keep Playwright's own behaviour.

**Waits and errors are Playwright's.** Humanized actions wait with your timeout (or the page's default),
`force=True` skips the waits exactly as in Playwright, and a timeout raises once — it is never retried
natively. `type` / `press` click into a text field first; a button, link or hidden helper element is
focused by Playwright without a click, so it is activated once.

**`select_option`** opens a drop-down with a click, finds the option by typing the start of its label
(spaces included: `Item 371` of 400 `Item NNN` rows takes a few seconds) and commits with Enter, so the
browser fires **one trusted `change`** — not one per row. Rows that cannot be typed to are reached with a
held arrow key. A list box is
clicked row by row (scrolled with the wheel when needed, notch by notch until the row shows), with Ctrl (Cmd on a Mac engine) held for the
extra rows of a multi-select. `check` / `uncheck` / `set_checked` click only when the state must change.

**Typos** are never made in password, e-mail, number, phone, URL or one-time-code fields (nor in a field
inside a frame, which cannot be checked), and only what actually landed is deleted (an input mask or
`maxlength` can swallow a slip). Set `CLEARCOTE_HUMANIZE_TYPO_RATE` to change the per-key rate (default
`0.02`; `0` turns typos off). Since 0.48.0 the asyncio API (`clearcote.async_api`) makes the same slips as
the sync API, and humanizes `page.press` / `page.dblclick` too.

**Press and hold** (`press_and_hold`) is one long press for controls that act only while the button
stays down: hold-to-confirm buttons, long-press menus, press-and-hold verification buttons.

```python
page.press_and_hold("#hold", 3000)                       # hold for 3 s
page.press_and_hold("#hold", until=lambda: page.locator("#hold[data-state=done]").count() > 0)
page.frame_locator("iframe").locator("button").press_and_hold()   # frames work too

from clearcote import press_and_hold                     # any page, humanize on or off
press_and_hold(page.locator("#hold"), 2500, position={"x": 40, "y": 20})
```

It waits for the element and scrolls it into view, glides to a point inside it, pauses, presses and
holds: `duration` ms (a fresh 2.8–3.8 s when not given), or until `until()` returns true (asked every
100 ms; `duration` is then the longest it may last, 15 s by default) — and lets go 160–350 ms after
that, as a hand reacts. While the button is down the pointer drifts by a pixel now and then, never more
than 2 px from the press, every move reporting the button held (`tremor=False` keeps it still). It
returns `True`, or `False` when `until` never became true, and the button is always released, also when
`until` raises or (async) the task is cancelled. Options: `position`, `button`, `timeout` (for the
element to show). In `clearcote.async_api` it is awaited and `until` may be async.

`show_cursor=True` injects a red cursor dot that follows the real mouse, handy for watching a
headed run. Both default to off for a local launch (a cloud launch defaults `humanize` to on); everything
stays standard Playwright when `humanize=False`.

### Render-backend coherence check (`check_render_coherence`)

A persona can claim a GPU, but if the page is actually painted by a software rasterizer
(SwiftShader/llvmpipe — common headless with no GPU) a strict detector can tell. Probe a live page:

```python
from clearcote import launch, check_render_coherence

br = launch(fingerprint="user-7423")
page = br.new_page(); page.goto("about:blank")
verdict = check_render_coherence(page)        # {'renderer', 'software_suspected', 'coherent', 'warnings', ...}
if not verdict["coherent"]:
    print(verdict["warnings"])                 # e.g. software rasterizer / incoherent GPU family
```

It reads the (unmasked) WebGL vendor/renderer the page actually sees, flags a software rasterizer (a
fatal headless tell — enable the canvas bridge or run headed on a real GPU) and an incoherent
vendor/renderer pair. Pass `claimed_gpu=...` to also assert the rendered family. The async API
exposes the same as `await clearcote.async_api.check_render_coherence(page)`.

The renderer *string* alone is not enough: a persona renames the backend, so a SwiftShader fallback
stops looking like one. The check therefore also measures limits the string cannot move —
`MAX_TEXTURE_SIZE`, the vertex/fragment uniform-vector pair, and an actual 16384-wide texture
allocation — and reports them as `max_texture_size`, `max_vertex_uniform_vectors`,
`max_fragment_uniform_vectors` and `can_allocate_16k_texture`. A renderer naming a desktop GPU while
`MAX_TEXTURE_SIZE` is below 16384 is a spoof over a software rasterizer and comes back
`coherent: False`. **Headless on Linux with no GPU hits exactly this** when Chromium renders through
SwiftShader (8192): a Windows persona always does, and a Linux persona does on a host without Mesa's
EGL (`libegl1`). Run headed under Xvfb, or use the canvas bridge.

On a Linux host the SDK picks the software backend that matches the GPU the persona names: a Linux
persona (Mesa/OpenGL) renders through Mesa's own GL — over the X display when one is reachable
(headed, or headless with `DISPLAY` pointing at an Xvfb), otherwise over EGL when the host has Mesa's
EGL (`libegl1` on Debian/Ubuntu) — and a Windows persona (Direct3D11) renders through SwiftShader,
whose limits match Direct3D11. Your own `--use-angle=` / `--use-gl=` in `args` wins.

### Hardened launch defaults

Every `launch()` already does, with no extra options:

- **drops Playwright's `--enable-automation`** so the engine's `AutomationControlled` feature stays
  off (it otherwise flips `navigator.webdriver`-adjacent tells). Pass your own `ignore_default_args`
  to override.
- **disables QUIC/HTTP-3 when a proxy is set**, so no UDP egresses around the proxy (a SOCKS5/HTTP
  proxy carries only TCP) — coherent with proxied Chrome.
- prints a one-line **coherence warning** to stderr for incoherent option combos it can't auto-fix
  (silence with `quiet=True` or `CLEARCOTE_NO_WARN=1`). That includes a one-time warning when a launch claims
  Linux on a Windows host: Direct3D caps the WebGL and WebGPU limits there, and a persona can lower a limit but never
  raise one past the driver's, so a Linux persona reports limits no Linux machine has. Claim `platform="windows"` on
  Windows, or run Linux personas on Linux or in Docker (`docker=True`).
- on Linux, sizes a **headless display to the persona's screen** (`--screen-info`), so full-screen, pop-ups and
  moved windows stay inside the screen the page is told about. Your own `--screen-info` wins.

### Your own fonts on Linux (`font_dirs`)

On Linux the browser sees only the fonts the SDK points it at: the release's bundle of Windows metric-compatible
lookalikes (Arial→Arimo, Segoe UI→Selawik, Times New Roman→Tinos, …). Fonts installed on the host never reach it,
so nothing on the machine changes what a page measures. Two settings add directories (`launch()`, persistent
contexts and `serve()`):

- `font_dirs=[...]`, or `CLEARCOTE_FONT_DIRS` (directories separated by `:`): your own fonts, typically a copy of a
  Windows machine's `C:\Windows\Fonts`, listed ahead of the bundle. On an engine that lets a genuine face win over
  its lookalike (the licensed build from r32), every family they provide renders as itself, and the names that
  stand for it on Windows follow: Helvetica, Times and Courier, and the CSS generics (`sans-serif` → Arial, `serif` →
  Times New Roman, `monospace` → Consolas, `system-ui` → Segoe UI). An older engine keeps its lookalikes for the
  families a persona lists and uses the directories only for characters nothing else covers. A path that is not a
  directory raises before the licence lease.
- `CLEARCOTE_FALLBACK_FONT_DIRS`: fonts used only for characters nothing else covers (CJK, Indic scripts, emoji,
  …), listed after the bundle; they take no family over. The Docker image sets it to the script fonts it ships.
  A persona hides a family's name from CSS, but the browser can still draw characters that only that font covers,
  so put only fonts in these directories that you are happy for a page to see.

The font configuration is written next to the browser binary. When that directory is not writable (a binary
unpacked by another user, a read-only image layer), it goes to `CLEARCOTE_CACHE/fontconfig` or
`~/.cache/clearcote/fontconfig` instead, then to a private temp directory; if none can be written, the SDK warns
once, because the browser would then see this machine's own fonts.

```python
from clearcote import launch

# a copy of a Windows machine's C:\Windows\Fonts on the Linux host
browser = launch(fingerprint="acct-1", platform="windows", font_dirs=["/srv/windows-fonts"])
```

`clearcote info` reports which of 29 writing systems the fonts a launch would see can draw, your font directories,
and which of the key Windows families they provide for real. Never point either setting at all of
`/usr/share/fonts`: the host's Latin families then compete with the bundle and change the widths a Windows persona
reports. `--disable-genuine-font-faces` in `args` (r32+) keeps the lookalikes even with your fonts listed, and the
SDK then leaves its font rules alone. Ignored on Windows and macOS, which use their own fonts; refused for cloud and
Docker launches (in your own container, set `CLEARCOTE_FONT_DIRS` there). Only use fonts you are licensed to use.

### Persistent profile

```python
from clearcote import launch_persistent_context

context = launch_persistent_context(
    "./profile-7423",
    fingerprint="user-7423",
    platform="windows",
)
```

### Widevine / DRM (`widevine`)

clearcote ships the **EME/Widevine plumbing** compiled in, but — being 100% open source — it does
**not** bundle Google's proprietary CDM. The SDK fetches that CDM from Google's own component server
(same as a real Chrome receives it), caches it, and enables it — so
`navigator.requestMediaKeySystemAccess('com.widevine.alpha')` resolves and DRM streams play, instead of
EME being a "no-Widevine" tell.

**On by default for PRO launches (since 0.48.0).** A licensed launch (a licence key set: the PRO build,
which claims the Google Chrome brand) on an engine from r32 loads the CDM by default, in `launch()`,
`launch_persistent_context()`, `serve()` and the async API alike:

- The first such launch **downloads Google's CDM from Google** (`update.googleapis.com`), checks its
  SHA-256 and caches it under `~/.clearcote/WidevineCdm` (`CLEARCOTE_WIDEVINE_DIR` overrides). Later launches
  use the cache **at once, with no network**; once a day a background check asks Google for a newer CDM
  (used from the next launch on). A failed check counts as that day's check: the cached CDM keeps working.
- **Fail-soft**: only a launch with nothing cached waits for the download, at most
  `CLEARCOTE_WIDEVINE_TIMEOUT_SECONDS` (default 15, clamped to 1-600). If it fails — offline, a firewall,
  Google refusing the request — the launch goes on without Widevine and prints one warning per process on
  stderr (`quiet=True` or `CLEARCOTE_NO_WARN=1` silences it); that process retries the download after 10
  minutes. The default path prints nothing on stdout.
- **Opt out** with `CLEARCOTE_WIDEVINE=0` (every launch), or `widevine=False`. An explicit `widevine=` wins
  over the environment; `widevine=False` is also accepted (and ignored) by `cloud=True` and Docker launches.
- **Free (open) builds stay opt-in**: pass `widevine=True` (or set `CLEARCOTE_WIDEVINE=1`). On a
  persistent context that also seeds the CDM into the profile, so an engine before r32 loads it too.

```python
from clearcote import launch_persistent_context

ctx = launch_persistent_context("./profile-drm", widevine=True)   # free build: fetch + seed + enable the CDM
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto("https://example.com")
ok = page.evaluate("""async () => {
  const a = await navigator.requestMediaKeySystemAccess('com.widevine.alpha',
    [{initDataTypes:['cenc'], videoCapabilities:[{contentType:'video/mp4;codecs="avc1.42E01E"',
      robustness:'SW_SECURE_DECODE'}]}]);
  await a.createMediaKeys(); return true;
}""")
print("Widevine:", ok)            # True
ctx.close()
```

- On an engine from r32 the SDK passes `--widevine-cdm-path`, pointing at the cached CDM, so the browser
  registers it at startup in every profile — incognito (`ephemeral_profile=False`) included — without waiting
  for the component updater. Older engines need `widevine=True` on a **persistent** context (the seeded-profile
  route). Your own `--widevine-cdm-path=<dir>` in `args` is left as it is.
- Fetch the CDM ahead of time (e.g. in a Docker build step) with `fetch_widevine()`.
- The clearcote package never distributes Google's CDM: it is downloaded from Google, on your machine.
- Cloud launches (`cloud=True`) are unchanged: hosted browsers load Widevine on the server.
- Software-secure (L3) playback. Hardware-secure (L1) paths are out of scope.

### AI agent (OpenRouter)

Drive a page with an **in-browser AI agent** — it perceives the live page, asks an LLM what to do,
and executes the steps as real, trusted input through Chrome's Actor framework. Defaults to
[OpenRouter](https://openrouter.ai); switch models with a single slug.

```python
from clearcote import launch_agent, run_agent_task

ctx = launch_agent(
    agent_llm_key=OPENROUTER_API_KEY,        # turns the agent on
    agent_model="openai/gpt-4o-mini",        # any provider/model slug
)
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto("https://example.com")

result = run_agent_task(page, "Click the 'More information...' link.", max_steps=8)
print(result["success"], result["finalText"], result["steps"])
ctx.close()
```

- `agent_llm_key` is all you need — the engine auto-enables Chrome's Actor framework (no extra flags).
- `agent_llm_url` points at any OpenAI-compatible endpoint (default OpenRouter); `agent_tool_mode` is `"tools"` (function-calling) or `"json"`.
- Override the model per task: `run_agent_task(page, goal, model="anthropic/claude-3.5-sonnet")`.
- The agent needs a **regular profile** — use `launch_agent` / `launch_persistent_context`, not the incognito `launch()`.
- Without `user_data_dir`, `launch_agent` uses a temp profile and deletes it when the context closes; pass `user_data_dir` to keep logins between runs.

### Capture or import a profile

Instead of the synthetic seed-derived identity, you can have Clearcote present a **real machine's
fingerprint**. Pass it to `launch()` via `fingerprint_profile` — fields present in the profile
**override** the seed-derived persona; **absent fields fall back** to the `fingerprint` seed, so
partial profiles stay coherent.

**1. Capture from a donor Chrome** — open `tools/fingerprint-collect/collect.html` and click
**Capture** (downloads a JSON), or paste the collector script in DevTools. It records an exhaustive
profile (navigator, screen, WebGL, audio, speech voices, fonts, codecs, CSS media, WebGPU, WebRTC).
See the [collector README](../../tools/fingerprint-collect/README.md).

**2. Or convert from the open-source 10k dataset** —
[`chrome-fingerprints`](https://github.com/Vinyzu/chrome-fingerprints):

```bash
pip install chrome-fingerprints
python tools/fingerprint-collect/convert_dataset.py --out ./profiles --count 100
```

**3. Launch with the profile:**

```python
browser = launch(
    fingerprint="seed-1",                 # seeds any field the profile doesn't specify
    fingerprint_profile="profile.json",   # path / dict / JSON string — SDK gzip+base64-encodes it
)
```

## Local or cloud

The same `launch()` runs the browser on this machine or on Clearcote's servers. One flag picks which,
and both return the same Playwright `Browser`:

```python
from clearcote import launch

browser = launch(cloud=True, country="us", identity="acct-1")
page = browser.new_page()
page.goto("https://example.com")
browser.close()                      # disconnects and ends the hosted session
```

Leave `cloud` unset and set `CLEARCOTE_CLOUD=1` to move existing code to the cloud without editing
it. The API key comes from `api_key=` or `CLEARCOTE_API_KEY`, and `CLEARCOTE_API_URL` points the SDK
at another server (https only: plain `http://` is accepted for `127.0.0.1`, `::1` and `localhost`
alone, so the key never crosses a network unencrypted). `clearcote.async_api.launch(cloud=True)` is
the asyncio twin.

What changes in the cloud:

- **Options.** `fingerprint`, `identity`, `platform`, `brand`, `timezone`, `locale` (or
  `accept_language`), `geoip`, `headless`, `light_stealth`, `proxy` (`"managed"`, a URL, or
  `{server, username, password}`), `country`/`state`/`city`, `proxy_session`, `timeout_sec`,
  `idle_timeout_sec`, `max_gb`, `version`, `profile`, `url`, `adblock`, `solve_sliders`,
  `solve_checkboxes`, `solve_press_hold`, `challenge_service`, `keep_alive`, `record`, `note`, `worker`. `humanize` and `show_cursor` run in the SDK, exactly as for
  a local browser. `timeout` and `slow_mo` go to the CDP connect, where `timeout` defaults to 120 s (the
  browser starts as you connect).
- **Input is humanized by default.** A cloud launch types, clicks and scrolls the way
  `humanize=True` does locally ([Humanized input](#humanized-input-humanize-show_cursor)), and switches
  the hosted browser's own mouse humanization off so the two never fight over the cursor.
  `humanize=False` turns humanizing off altogether.
- **Slider challenges are solved for you.** A cloud browser drags slide-to-verify challenges (a handle
  to the end of a bar, or a puzzle piece into its gap) by itself, in any tab or frame. Pass
  `solve_sliders=False` when your script handles them.
- **Human-check checkboxes are clicked for you.** A separate option: a cloud browser clicks "verify you
  are human" boxes by itself, in any tab or frame. Pass `solve_checkboxes=False` when your script
  handles them.
- **Press-and-hold buttons are held for you.** A third option: a cloud browser presses and holds a
  "press and hold" verification button by itself, in any tab or frame, until it reports done. Pass
  `solve_press_hold=False` when your script handles them (for example with
  [`press_and_hold`](#humanized-input-humanize-show_cursor)). Local browsers never do this on their own.
- **Harder challenges can go to a solving service (off by default).** `challenge_service=True`, or a
  dict of `categories`, `sites`, `key`, `api_key`, `mode`, `max_solves`, `max_spend_eur` (the API's
  camelCase names work too): what the free actions cannot clear is solved with your own solving-service key
  (`"key": "own"`, stored in the dashboard or passed as `api_key`) or ours (`"key": "managed"`, billed per
  solve). `"mode": "report"` only reports what the pages show. See the
  [hosted browsers docs](https://www.clearcotelabs.com/docs/hosted-browsers#challenge-service).
- **Local-only options are refused, by name.** `executable_path`, `args`, `user_data_dir`,
  `extensions`, `ignore_default_args`, the finer persona switches (`gpu_vendor`, `webrtc_ip`, ...)
  and the licence options raise `ValueError("<name> is not available for cloud browsers")` before
  anything starts.
- **Profiles are cloud profiles.** `profile="acct-1"` loads the cookies of a named cloud profile, and
  `launch_persistent_context(cloud=True, profile="acct-1")` returns a context that also saves them
  back when it closes. Fill one from a browser you are logged in to with `profiles.sync` (below).
- **The session.** `browser.cloud_session` holds its `id`, `worker` and `expiresAt`. `close()`
  disconnects and ends the session; a `keep_alive=True` session is left running (stop it with
  `Cloud().browsers.stop(id)`).

### The Cloud client

Everything else the hosted API does is on `Cloud` (and `AsyncCloud`, the same methods awaitable):

```python
from clearcote.cloud import Cloud

cloud = Cloud()                                   # CLEARCOTE_API_KEY

# An agent run: a task in, JSON out
run = cloud.runs.create(
    "Log in with {{password}} and read the current plan's price",
    url="https://example.com/login",
    schema={"type": "object", "properties": {"price": {"type": "string"}}},
    secrets={"password": {"value": "s3cret", "domains": ["example.com"]}},  # the model never sees it
)
print(run["status"], run["result"]["output"], run["costEur"]["total"])

# Copy a logged-in state into a cloud profile: only the cookies a browser would use on the domains
# you name (theirs, their subdomains', and parent-domain ones such as .example.com for www.example.com)
cloud.profiles.sync("acct-1", login_url="https://example.com/login", domains=["example.com"])

# Recordings, the event timeline, hand-off to a person
s = cloud.browsers.create(record=True)
cloud.browsers.events(s["id"])                    # {"events": [...], "next": ...}
cloud.browsers.download_recording(s["id"], "session.mp4")
```

- `browsers`: `create(**options)`, `get(id)`, `list(status=, note=, limit=, before=)`, `stop(id)`,
  `live(id, control=False)`, `share(id, control=, minutes=, recording=)`,
  `handoff(id, reason=, timeout_sec=)`, `handoff_done(id)`, `wait_handoff(id, timeout=, poll=2.0)`,
  `events(id, after=0, limit=)`, `recording_url(id)`, `download_recording(id, path)`.
  `wait_handoff` on a session with no hand-off raises `CloudError` (code `NO_HANDOFF`).
- `runs`: `create(task, url=, schema=, secrets=, wait=True, timeout=, poll=1.5, on_update=, **options)`
  (the browser options above plus `max_steps`, `handoff`, `handoff_timeout_sec`), `get(id)`,
  `list(limit=, before=)`, `cancel(id)`, `wait(id, timeout=, poll=1.5, on_update=)`. A run that
  pauses for a person (`waiting_for_human`) is not finished: `on_update` hears about it, and without
  one the live link is printed to stderr.
- `profiles`: `list()`, `get(name)`, `delete(name)`, `import_cookies(name, cookies, mode="merge")`,
  `sync(name, from_profile=DIR | from_cdp=URL | from_file=PATH | login_url=URL, domains=[...] |
  all_domains=True, replace=False)`. `sync` refuses to run without `domains` or `all_domains=True`.
- `webhooks`: `create(url, events=, description=)` (the answer carries the `secret`, shown once),
  `list()`, `delete(id)`, `test(id)`

Every method returns the parsed JSON of its endpoint. An API refusal raises
`CloudError(status, code, message)` with the server's own message (`e.code` is e.g.
`"PROFILE_IN_USE"`); a wait that runs out raises `CloudTimeoutError`, whose `.last` holds the last
view (the run carries on on the server).

Check a webhook delivery with the raw request body and the `Clearcote-Signature` header:

```python
from clearcote import verify_webhook

event = verify_webhook(request_body, headers["Clearcote-Signature"], "whsec_...")  # ValueError if forged or stale
```

The same from the command line (`--json` for machine-readable output):

```bash
clearcote cloud run "Read the price of the Pro plan" --url https://example.com/pricing --schema price.json
clearcote cloud run "Open my account page" --country us --profile acct-1 --persist-profile --max-steps 20
clearcote cloud sessions
clearcote cloud events bs_123
clearcote cloud recording bs_123 -o session.mp4
clearcote cloud profile sync acct-1 --login https://example.com/login --domain example.com
clearcote cloud webhooks add https://hooks.example.com/clearcote --event run.finished
```

## Fingerprint options

All optional. Anything not listed here is passed straight through to Playwright
(`headless`, `proxy`, `args`, `timeout`, `slow_mo`, …).

| Kwarg | Switch | Meaning |
|---|---|---|
| `fingerprint` | `--fingerprint` | Master seed (per-eTLD+1 farbling root). `str` or `int`. |
| `platform` | `--fingerprint-platform` | `"windows"` \| `"linux"` \| `"macos"` \| `"android"` (best-effort mobile persona). |
| `platform_version` | `--fingerprint-platform-version` | UA-CH platform version. |
| `brand` | `--fingerprint-brand` | `"Chrome"` \| `"Edge"` \| `"Opera"` \| `"Vivaldi"`. |
| `brand_version` | `--fingerprint-brand-version` | Brand version. |
| `gpu_vendor` | `--fingerprint-gpu-vendor` | WebGL UNMASKED vendor. |
| `gpu_renderer` | `--fingerprint-gpu-renderer` | WebGL UNMASKED renderer. |
| `hardware_concurrency` | `--fingerprint-hardware-concurrency` | `navigator.hardwareConcurrency`. |
| `location` | `--fingerprint-location` | `"lat,lng"` (only when geo permission is granted). |
| `timezone` | `--timezone` | IANA timezone, e.g. `"America/New_York"`. |
| `accept_language` | `--accept-lang` | `navigator.languages` + `Accept-Language` header, e.g. `"en-US,en"`. |
| `webrtc_ip` | `--webrtc-ip` | WebRTC IP to report. The engine **fabricates** the `srflx` candidate at this IP and sends **no real STUN** from the host, so the real IP never leaks (not merely relabeled). |
| `disable_gpu_fingerprint` | `--disable-gpu-fingerprint` | Turn off GPU/WebGL spoofing. |
| `geoip` | _(directive)_ | `True` → resolve the proxy's exit-IP geo and auto-fill timezone/accept_language/location/**webrtc_ip**. |
| `fingerprint_profile` | _(directive → `--fingerprint-profile`)_ | A real captured machine profile (file path / dict / JSON string); the SDK gzip+base64-encodes it. Fields present **override** the seed-derived persona; absent fields fall back to `fingerprint`. Also derives `accept_language` from the profile's `navigator.languages` when none is set. |
| `canvas_bridge` | _(→ `--canvas-bridge-*`)_ | Forward canvas/WebGL readbacks to a remote real-GPU host so the pixels a page hashes match the GPU your persona claims. `{"url", "auth", "mode", "allow", "deny", "fallback"}`; setting `url` auto-adds `--no-sandbox`. See [docs/CANVAS-BRIDGE.md](../../docs/CANVAS-BRIDGE.md). |
| `extensions` | _(→ `--load-extension` + `--disable-extensions-except`)_ | List of unpacked-extension directory paths to load (Chromium forces headed when extensions are present). |
| `humanize` | _(directive)_ | `True` → humanize all input (move/click/drag/scroll/type) as native trusted events, at the page and locator level. See [Humanized input](#humanized-input-humanize-show_cursor). |
| `show_cursor` | _(directive)_ | `True` → inject a red cursor dot that follows the real mouse (handy for watching a headed run). |

> **Headed launches** default to `no_viewport=True` so `window.innerWidth` tracks the real OS window — an emulated `1280×720` on a real window is an impossible-window tell. Pass an explicit `viewport` to override.

> **Headless launches** (0.24.0+) get a coherent window geometry by default, so `screen`, `availWidth/Height`,
> `innerWidth/Height` and `outerWidth/Height` agree with each other the way a real window's do. With a
> `fingerprint` seed the engine's own screen and work area are used and the window is sized to them; without a
> seed the SDK sets the headless display to a screen size drawn from real captured desktops (with a
> taskbar on Windows) and sizes the window to its work area the same way. It is applied at launch,
> before your first navigation. Pass an explicit `viewport`, `screen` or `no_viewport` to opt out
> entirely.
>
> **Proxies:** a SOCKS5 proxy with credentials — written in the URL (`socks5://user:pass@host:port`) or passed as `username`/`password` — is routed via `--proxy-server` (Playwright rejects credentials in its SOCKS descriptor), and the credentials are passed to the engine, which implements SOCKS5 username/password authentication (RFC 1929). Stock Chromium does not, so no local relay is needed.

## Personas & Client Hints coherence

A persona's Client Hints are coherent across **JavaScript and HTTP by construction** — clearcote rewrites a single `blink::UserAgentMetadata` (brand list, full-version list, platform, mobile, arch/bitness) that feeds *both* the browser path that attaches the `Sec-CH-UA*` request headers **and** the renderer path that builds `navigator.userAgentData`. There aren't two sides to keep in sync; they read the same source. So `getHighEntropyValues(['fullVersionList', …])` in JS matches `Sec-CH-UA-Full-Version-List` on the wire, `userAgentData.platform` matches `Sec-CH-UA-Platform`, and so on.

**Chrome — the default and the recommendation** (most coherent: clearcote *is* Chromium, so the claim matches the engine's real behavior):

```python
browser = launch(fingerprint="p1")   # brand defaults to Chrome; platform defaults to the host OS
# HTTP:  sec-ch-ua: "Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="…"
#        sec-ch-ua-mobile: ?0    sec-ch-ua-platform: "Windows"
# JS:    navigator.userAgentData.brands == that same list; mobile=False; platform="Windows"
```

**Edge** — a coherent Edge string surface (UA + UA-CH), for targets that specifically expect the Edge brand:

```python
browser = launch(fingerprint="p1", brand="Edge")
# UA:    …Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0
# HTTP:  sec-ch-ua: "Microsoft Edge";v="149", "Chromium";v="149", "Not)A;Brand";v="…"
#        sec-ch-ua-full-version-list: "Microsoft Edge";v="149.0.3650.65", "Chromium";v="149.0.7827.114", …
# JS:    userAgentData.brands include "Microsoft Edge"; getHighEntropyValues(['fullVersionList'])
#        carries that same distinct Edge build — JS and HTTP identical, from one metadata.
```

`brand` (`"Chrome"` \| `"Edge"` \| `"Opera"` \| `"Vivaldi"`) is a **string-level** persona: it changes the UA + UA-CH brand, but the engine still behaves like Chromium. Chrome is the most coherent default (nothing to contradict); reach for `brand="Edge"` only when a target expects it. If you also set `brand_version` to an older major, the network `tls_profile` (default `match-persona`) shifts the TLS shape to that major while the JS engine stays at the build version — so **Chrome ≈ the build version, everything aligned** is the strongest persona.

**Android** — a best-effort mobile persona (seed-selected Pixel/Galaxy):

```python
browser = launch(fingerprint="p1", platform="android")   # auto-sets a phone --window-size
# UA:    …(Linux; Android 10; K) … Chrome/149.0.0.0 Mobile Safari/537.36
# JS/HTTP: sec-ch-ua-mobile: ?1, platform="Android", model + platformVersion; maxTouchPoints=5,
#          pointer:coarse / hover:none, mobile screen + DPR, Mali/Adreno WebGL, plugins=0.
```

Android is **best-effort on a desktop engine**: the JS/header surface is coherent, but the GPU *render* and fine page geometry (`innerWidth` floors at ~500px) stay desktop — documented residual tells. Pair with `canvas_bridge` for render coherence.

## Saved profiles (`Profile`)

A `Profile` bundles a persona (seed, GPU, brand, …) **and** its `canvas_bridge` config under one
name you can persist and re-launch — the claimed GPU, the bridge endpoint, and the bridge's
GPU-keyed cache stay coherent because they travel together.

```python
from clearcote import Profile, launch

# save once
Profile("acct-1", {
    "fingerprint": "acct-1",
    "gpu_vendor": "Google Inc. (Intel)",
    "gpu_renderer": "ANGLE (Intel, Intel(R) UHD Graphics ... D3D11)",
    "canvas_bridge": {"url": "ws://127.0.0.1:9099", "auth": "user:secret"},
}).save()

# re-launch anywhere (explicit kwargs override the saved options)
browser = Profile.load("acct-1").launch(headless=False)
# equivalently: launch(profile="acct-1")
```

Profiles are JSON at `~/.clearcote/profiles/<name>.json` (set `CLEARCOTE_PROFILE_DIR` to relocate).

## API

- `launch(**options)` → Playwright `Browser`. Pass `profile=` (a name, path, or `Profile`) to launch a saved persona; `cloud=True` for a hosted browser ([Local or cloud](#local-or-cloud)).
- `launch_persistent_context(user_data_dir, **options)` → Playwright `BrowserContext`; `launch_persistent_context(cloud=True, profile="name")` for a cloud profile.
- `Cloud(api_key=None, base_url=None)` / `AsyncCloud` — the hosted API (`browsers`, `runs`, `profiles`, `webhooks`); `CloudError`, `CloudTimeoutError`, `verify_webhook(raw_body, signature_header, secret, tolerance_sec=300)`.
- `serve(**options)` → `Server` — a standing, stealthy CDP endpoint (`.cdp_url` / `.ws_url()` / `.close()`; context manager) any Playwright/Puppeteer/CDP client attaches to. Also exposed as the `clearcote-serve` CLI.
- `executable_path(executable_path=None, cache_dir=None, quiet=False)` → `str` — resolve (download/verify if needed) the chrome.exe path.
- `download(cache_dir=None, quiet=False)` → `str` — pre-fetch + verify without launching.
- `Profile` — `Profile(name, options)`, `.save(path=None)`, `Profile.load(name)`, `.launch(**overrides)`, `.launch_persistent_context(dir, **overrides)`; plus `list_profiles()`, `load_profile(name)`.
- `RELEASE` — the pinned release metadata (tag, version, sha256).

## Binary resolution & verification

`launch()` resolves the browser in this order:

1. `executable_path=` argument, if given;
2. `CLEARCOTE_BINARY` environment variable, if set;
3. otherwise **download** the pinned release, **verify its SHA-256** (the hash is baked into this
   package — it's the trust anchor), extract to a per-version cache, and verify the extracted
   `chrome.exe` hash too.

Cache location (override with `CLEARCOTE_CACHE`):
- Windows: `%LOCALAPPDATA%\clearcote\Cache\<tag>`
- macOS: `~/Library/Caches/clearcote/<tag>`
- Linux: `${XDG_CACHE_HOME:-~/.cache}/clearcote/<tag>`

Processes that share this cache take turns installing a build: the first one downloads it, the others
wait and then use it, whichever SDK (Python, Node or .NET) each one runs. That needs every process
sharing the cache to run an SDK release with this install lock (any release after 0.40.1); earlier
releases do not wait for it.

A SHA-256 mismatch is a hard error — the SDK refuses to run an unverified binary. You can
independently confirm the published checksums and GPG signatures on the
[release page](https://github.com/clearcotelabs/clearcote-browser/releases).

### Stay on the latest build (`auto_update`)

By default the SDK installs the **exact browser build pinned into this package** — reproducible,
and the baked-in SHA-256 is the trust anchor. To follow new browser releases **without upgrading
the package every time**, opt in:

```python
browser = launch(fingerprint="seed-123", auto_update=True)
```

or set the environment variable globally:

```bash
CLEARCOTE_AUTO_UPDATE=1
```

With `auto_update`, the SDK resolves the **newest GitHub release**, downloads its zip, and verifies
it against that release's published `SHA256SUMS.txt`. When a **`gpg`** binary is available it
additionally imports the release's public key, confirms its fingerprint equals the pinned
`CA96F185 F96A693A EDB3AC1F CB00D851 B7A86B0F`, and verifies the signed checksum — so an
auto-resolved build is cryptographically authenticated, not just downloaded. If GitHub is
unreachable it falls back to the pinned release; if the latest release *is* the pinned one, the
audited baked-in hashes are used. Each build is cached per tag, so this only downloads when a new
version actually ships. (For locked-down/reproducible deployments, leave `auto_update` off and bump
the package deliberately.)

## Choosing a browser version

By default the SDK downloads the exact build pinned into this package. To pick a specific browser
major/version instead, pass `version=` (or set `CLEARCOTE_BROWSER_VERSION`):

```python
launch(fingerprint="seed-1", version="150")      # newest 150.x (free)
launch(version="150.0.7871.114")                 # an exact build
launch(version="latest")                         # newest you can access
launch(version="150", license_key="cc_lic_...")  # a PRO-tier version (see below)
```

The request is resolved against a public catalog (`GET /api/v1/versions`) and **validated to exist
before anything downloads**, so a bad request fails fast instead of getting stuck:

- unknown version → `No Clearcote build matches version '151'. Available: 149.0.7827.114 (free).`
- a PRO-tier version without a key → `Clearcote 150… is a PRO build … set a license key (CLEARCOTE_LICENSE_KEY).`

Free versions download from GitHub (no key needed); PRO versions download via the authenticated route.
Each build is cached per version, so different majors coexist. Binary resolution order:
`executable_path` > `CLEARCOTE_BINARY` > `version` > pinned default. Works the same in the async API,
`executable_path()`, and `download()`.

## PRO tier (license key)

Everything above is the **free** build. A PRO license adds **floating-concurrency licensing** and
pulls a separate, license-gated browser build. It's opt-in and entirely additive — **with no
license key the SDK is byte-for-byte the free client** (free binary from GitHub, and it never
contacts the license backend).

### What's in each tier

<!-- The rows below mirror site/lib/tiers.ts, which is the single source of truth for the
     free/PRO split. If you change one, change the other (and the sibling SDK README). -->

The identity surface is **free in full**. A licence does not unlock "more spoofing" — every fingerprint
control below is in the open build. The licensed build adds behavioural realism that needs recorded
data or engine work held out of the public tree. **It is free with GitHub for one browser at a time**
(sign in at [clearcotelabs.com](https://clearcotelabs.com/pricing#free), needs SDK 0.29.0+); **PRO**
runs up to 250 browsers at once and adds email support.

| Capability | Open source | Free with GitHub | PRO |
|---|:---:|:---:|:---:|
| Seeded personas (`fingerprint`), per-site farbling | ✅ | ✅ | ✅ |
| Canvas / WebGL / audio / font identity controls | ✅ | ✅ | ✅ |
| All 18 metadata overrides (`screen_width`, `device_memory`, `gpu_vendor`, …) | ✅ | ✅ | ✅ |
| `light_stealth` preset | ✅ | ✅ | ✅ |
| TLS ClientHello profile (`tls_profile`) | ✅ | ✅ | ✅ |
| Proxy + `geoip` locale/timezone coherence | ✅ | ✅ | ✅ |
| Humanized input (`humanize`) — synthetic bézier paths | ✅ | ✅ | ✅ |
| **Humanized input — real recorded human trajectories** | — | ✅ | ✅ |
| **Coalesced pointer samples** (`getCoalescedEvents` realism) | — | ✅ | ✅ |
| **Coherent WebRTC srflx fabrication** (`webrtc_ip`) | — | ✅ | ✅ |
| **WebRTC host-candidate concealment** (`.local` names) | — | ✅ | ✅ |
| **Request-header hygiene** on revalidation | — | ✅ | ✅ |
| Floating-concurrency licensing + run-token gate | — | 1 browser | ✅ up to 250 |
| Email support from the owner | — | — | ✅ |

The mouse tier is decided **at runtime** from a signed claim in the run-token — same SDK call,
same `humanize=True`. With a valid PRO lease the motion comes from recorded human trajectories;
without one it falls back to the synthetic bézier path. Nothing in your code changes.

Pass a `license_key` (or set `CLEARCOTE_LICENSE_KEY`, or drop it in `~/.clearcote/license.key`):

```python
browser = launch(fingerprint="seed-123", license_key="cc_lic_...")
```

When a key is present the SDK:

1. **downloads the PRO binary** via the site's authenticated `GET /api/v1/download/pro` route
   (short-lived signed URL), verified against its SHA-256 exactly like the free pin, then cached;
2. **checks out one concurrency slot** — a background heartbeat keeps it alive and rotates a
   short-lived run-token, and the slot is released when the browser closes.

The PRO engine refuses to launch without a valid run-token, so a copied binary alone won't run.
Resolution order for the binary is **`executable_path` → `CLEARCOTE_BINARY` → PRO (licensed) →
free** — an explicit binary always wins. A revoked/expired key raises (`ConcurrencyLimitError` /
`LicenseRevokedError` / `LicenseError`); it never silently downgrades to the free binary. Override
the backend with `license_api_base` or `CLEARCOTE_LICENSE_API`. Works the same in the async API.

## License

BSD-3-Clause. See [LICENSE](../../LICENSE).
