Canvas bridge
Render canvas and WebGL on a real remote GPU so the pixels a page reads back are coherent with the GPU the render host really has — set your profile's GPU strings to match it. Experimental and opt-in — with no --canvas-bridge-url, Clearcote renders entirely locally, exactly as before.
Why it exists
Clearcote renders canvas/WebGL on whatever GPU the host machine actually has. If a profile claims a different GPU than the host, strict anti-detect / browser-tampering checks that compare the rendered pixels against the claimed hardware can notice the mismatch — you cannot make one GPU emit another GPU's exact pixels in software. The canvas bridge removes the mismatch: instead of rendering locally and only spoofing the GPU string, it forwards the canvas/WebGL operations to a remote host that has the GPU you want to present, and returns that host's real pixels. Because it forwards the operations (not a fixed library of pre-recorded images), it handles most generated canvases, not just known probes.
How it works
The readback APIs — getImageData, toDataURL, readPixels and measureText — return the bridge host's authentic pixels; the local farbling noise is bypassed on the bridge path (the bridge pixels are ground truth). Transport is a WebSocket carrying a compact binary message stream. The pixels come from whatever GPU the render browser has.
clearcote (your automation host) bridge host (real GPU)
+-----------------------------+ +-------------------------+
| page: getImageData / | ops --> | render browser |
| toDataURL / readPixels | | renders on the real GPU,|
| CanvasBridgeClient <-- pixels ---------| reads back the pixels |
+-----------------------------+ +-------------------------+What you need
- A render host — any machine whose GPU you want to present (a Windows GPU for a Windows persona). Its GPU becomes the canvas identity your profiles present, so pick hardware matching the persona you want to show (an NVIDIA box to present NVIDIA, and so on). Point the server's
--backend cdpat a regular Chrome you run on that machine (recommended), or use--backend localwith a Clearcote binary. - A private network path between your automation host and the bridge host. The bridge speaks plaintext WebSocket (
ws://) — always run it over a private network or an encrypted tunnel (Tailscale, WireGuard, or SSH port forwarding). Never expose the bridge port on the public internet.
Setup
1. Start the render server on the real-GPU host. It's a small Python coordinator that drives a headless browser and replays the forwarded ops on its real canvas, so the pixels it returns are exactly what that GPU produces. The render host's real GPU becomes the canvas/WebGL identity. With --backend cdp it prints the render GPU's renderer string on start (render GPU='ANGLE (…)') — note it for step 3. With --backend local, read the strings from a regular Chrome on the same machine instead.
pip install playwright # one-time (no browser download needed for CDP)
# a regular Chrome on the GPU host, started with --remote-debugging-port=9222
# --user-data-dir=<a separate folder> (Chrome ignores the port on its default profile);
# its CDP URL is webSocketDebuggerUrl from http://127.0.0.1:9222/json/version
python tools/canvas-bridge-server/server.py \
--backend cdp \
--cdp-url ws://127.0.0.1:9222/devtools/browser/... \
--port 8443
# Or let the server launch a Clearcote binary itself:
# python tools/canvas-bridge-server/server.py \
# --backend local \
# --chrome /path/to/clearcote/chrome.exe \
# --port 84432. Tunnel it. ws:// is unencrypted — put both hosts on the same Tailscale/WireGuard network, or forward the port over SSH. The server binds to localhost by default, so on Tailscale/WireGuard start it with --host <that interface's IP>; the SSH route below works as is:
ssh -N -L 8443:localhost:8443 user@bridge-host
# the bridge is now reachable at ws://127.0.0.1:84433. Launch the client (your automation Clearcote) pointing at the bridge, with the render GPU's strings — the renderer exactly as the server printed it, and the vendor as Google Inc. (<first name in the renderer>), e.g. Google Inc. (Intel). The bridge makes the pixels match the render GPU, and these make the reported strings match it too. Without them the persona's GPU name and the bridged pixels disagree:
--canvas-bridge-url=ws://127.0.0.1:8443 \
--no-sandbox \
--fingerprint=<seed> \
--fingerprint-gpu-vendor='Google Inc. (Intel)' \
--fingerprint-gpu-renderer='ANGLE (Intel, Intel(R) UHD Graphics ... D3D11)'| Flag | Meaning |
|---|---|
| --canvas-bridge-url | Bridge endpoint ws://host:port. Required to enable the bridge. |
| --canvas-bridge-auth | Optional user:secret HTTP Basic credentials, for a deployment that puts authentication in front of the server. The reference server does not check them, so rely on the private network or tunnel for access control. |
| --no-sandbox | Required — the client opens the bridge socket from the renderer process, which the sandbox blocks. |
| --fingerprint | Your persona seed. |
| --fingerprint-gpu-vendor / --fingerprint-gpu-renderer | The render GPU's strings: the renderer exactly as the server printed it, the vendor as Google Inc. (<first name in the renderer>). |
| --canvas-bridge-mode | Per-origin policy: off, all (default), allow, or deny. |
| --canvas-bridge-allow / --canvas-bridge-deny | Comma-separated eTLD+1 lists used by mode=allow or mode=deny. |
| --canvas-bridge-fallback | Cold cache-miss behavior: block (the default) waits for the bridge; local serves local pixels instead of stalling. |
From the SDK
The SDK (Node, Python and .NET; current 0.43.0) exposes a first-class canvasBridge / canvas_bridge / CanvasBridge option. Setting a bridge URL emits the switches and auto-adds --no-sandbox. For the same allow-list policy in a full launch script, see Examples.
const browser = await clearcote.launch({
fingerprint: "user-1",
gpuVendor: "Google Inc. (Intel)", // "Google Inc. (<first name in the renderer>)"
gpuRenderer: "ANGLE (Intel, Intel(R) UHD Graphics ... D3D11)", // exactly as the server printed it
canvasBridge: {
url: "ws://127.0.0.1:8443",
auth: "user:secret",
mode: "allow",
allow: ["example.com"],
fallback: "local",
},
});Verify it's working
- The client log prints
canvas-bridge: connected to <host>:<port>on a successful connection (run with--enable-logging=stderr --v=1to see it). - Load a page that hashes a canvas/WebGL surface — with the bridge connected the hashes match the bridge host's GPU, not your automation host's. Quick check: run the same
canvas.toDataURL()with and without the bridge; the results differ. - If the bridge is unreachable, Clearcote logs a warning and falls back to local rendering — a misconfigured bridge degrades gracefully, it never breaks the page.
Caveats & limits
- Canvas identity = the bridge host, not the seed. Every profile sharing one bridge host shares that host's canvas/WebGL hash, so they are linkable by canvas hash. For many unlinkable identities, run one bridge host (GPU) per identity group.
- Latency. A pixel readback that misses the bridge's cache is a blocking network round-trip (5s timeout, then local fallback) unless
fallbackislocal; the engine prefetches after each draw, so repeated pixel reads of an unchanged canvas don't wait. EachmeasureTextcall is still a round trip. Keep the bridge host on the same LAN/datacenter; avoid the bridge for latency-sensitive, canvas-heavy pages. - Procedural WebGL textures are bridged. Image, 2D-canvas, video,
ImageBitmapand 3D texture sources fall back to local rendering for that canvas, so they remain correct but un-bridged. --no-sandboxis required on the client, and transport is plaintext — always tunnel; never expose the bridge port publicly.
Experimental. In the open build since v0.1.0-pre.12, and in the licensed build. For the canonical reference (with the full troubleshooting table), see the canvas-bridge guide on GitHub. Most setups don't need the bridge — see Fingerprint flags for the standard engine-level controls.