The CDP Plumbing Behind `chrome-sync`
Companion to the chrome-sync how-to: DevToolsActivePort discovery, cookie extraction via CDP, OAuth loopback, and batched Network.setCookies injection.
Dzianis Vashchuk
5 min read
The CDP plumbing behind chrome-sync
Companion to Bypassing Chrome's App-Bound Encryption to Sync Cookies via CDP — that post tells you what to run. This one walks the wire.
@vibetechnologies/chrome-sync v0.6.0 has two halves: a CLI that talks to local Chrome over CDP, and a server handler in OpenClaw that talks to cloud Chrome over CDP. The middle is one HTTPS POST. Everything interesting lives at the edges.
Why not just read the cookie DB
Chrome v127+ wraps the SQLite cookie store with App-Bound Encryption: the key is bound to the OS user (Keychain / libsecret / DPAPI) and a per-binary entitlement check, so processes outside Chrome get ciphertext garbage. The fix: skip the DB, ask the live browser via CDP.
Finding the local Chrome's CDP socket
When Chrome runs with --remote-debugging-port=<n> (including 0, "pick one"), it writes the bound port and browser-level WebSocket path into DevToolsActivePort in the user-data directory. chrome-sync reads it directly (src/extract.ts:96-121):
// src/extract.ts
const portFile = join(dir, "DevToolsActivePort");
const content = readFileSync(portFile, "utf-8");
const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
const port = parseInt(lines[0], 10);
const wsPath = lines[1]; // e.g. /devtools/browser/<guid>
return { port, wsUrl: `ws://127.0.0.1:${port}${wsPath}` };
Line 2 is the browser-level WebSocket path (something like /devtools/browser/4f9c…), not a per-tab one — which is what Storage.getCookies needs.
The user-data directory is OS-specific (src/extract.ts:69-84): ~/Library/Application Support/Google/Chrome on macOS, ~/.config/google-chrome on Linux, %LOCALAPPDATA%\Google\Chrome\User Data on Windows.
push defaults to --autoConnect. With --remote-debugging-port 9222 it still tries DevToolsActivePort first, then falls back to http://127.0.0.1:9222/json/version for the webSocketDebuggerUrl (src/extract.ts:144-181). If neither works — Chrome launched without remote debugging — the CLI prints the chrome://inspect/#remote-debugging walkthrough and exits (src/extract.ts:58-66). It does not relaunch Chrome.
Pulling cookies out
One WebSocket, two commands tried in order (src/extract.ts:293-297):
let result: { cookies?: Array<Record<string, any>> };
try {
result = await sendCdpCommand(wsUrl, "Storage.getCookies");
} catch {
result = await sendCdpCommand(wsUrl, "Network.getAllCookies");
}
Storage.getCookies is the newer browser-level method. It returns every cookie Chrome has, including partitioned cookies (CHIPS) and HTTP-only entries that never appear in any page's document.cookie. It runs on the browser endpoint, so we don't attach to a tab and don't trigger per-target permission prompts.
Network.getAllCookies is the fallback for older CDP builds that don't ship the Storage domain. Same shape of result, but it predates partition keys, so CHIPS-using sites may return fewer entries.
Out of scope either way: partitioned cookies whose partition has no live frame, plus anything Chrome itself can't read — HSTS state, IndexedDB, sessionStorage. chrome-sync is cookies + (placeholder for) localStorage only; see the StorageState shape (src/extract.ts:28-31).
The single connection is held open for the whole push (src/extract.ts:183-236) so Chrome doesn't fire its "Allow remote debugging?" toast on each short-lived socket. 15s timeout per command. Locally, expired cookies are dropped (src/extract.ts:320-321) and sameSite=None gets secure=true forced (Chrome rejects the inverse downstream).
The auth flow
chrome-sync login (no --token) goes through loginViaBrowser (src/api.ts:60-136):
- Open a
node:httpserver on127.0.0.1:0(kernel-assigned port). - Open the user's default browser to
https://console.openclaw.vibebrowser.app/auth/cli?callback_port=<port>viaopen/xdg-open/start. - The console authenticates the user (Telegram OAuth or existing session) and redirects to
http://localhost:<port>/callback?token=…&refresh_token=…&username=…&subdomain=…. - The local server saves
~/.config/chrome-sync/auth.json(mode: 0o600), serves a small HTML success page, and resolves.
Two-minute hard timeout (src/api.ts:130-134). No PKCE — this is a localhost loopback with a short-lived listener, so there's no third party to intercept the redirect.
For CI, chrome-sync login --token <jwt> skips all of that and validates by calling GET /admin/api/users with the bearer (src/api.ts:141-159).
Refresh is lazy: pushCookies only calls POST /api/v1/auth/refresh when the push returns 401, then retries once (src/api.ts:165-237). logout truncates auth.json to empty and leaves the file in place with the same perms (src/api.ts:45-49).
The push
// src/api.ts
await fetch(`${auth.apiUrl}/admin/api/browser-sync/cookies`, {
method: "POST",
headers: { Authorization: `Bearer ${auth.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ storageState, subdomain: subdomain || undefined }),
});
That's the whole client. Every cookie in one JSON body — no streaming, no chunking. 3,000+ cookies is a few hundred KB. subdomain lets a multi-tenant account pick a target; otherwise the server uses the token's default tenant.
Server side: injectCookiesViaCDP
The handler at POST /admin/api/browser-sync/cookies resolves the tenant's Chrome CDP URL and calls injectCookiesViaCDP (src/server.ts:94-128). Inside:
Sanitize (src/server.ts:25-86). For each cookie: drop empty name/domain; force secure=true on sameSite="None"; strip unknown sameSite values; convert Chrome's microsecond-since-1601 timestamps (>10_000_000_000_000) to Unix seconds by subtracting the 11_644_473_600 Windows epoch offset and dividing by 1e6; drop already-expired cookies (CDP errors on them and fails the batch); drop cookies whose value contains \x00-range control bytes or � — that's the "App-Bound Encryption decrypted with the wrong key" signature, binary garbage that would otherwise ship.
Connect (src/server.ts:131-161). Prefer a page-level target because Network.setCookies needs one. Hit /json/list, pick the first type: "page", fall back to the browser-level webSocketDebuggerUrl from /json/version if no page is open.
Batched inject (src/server.ts:109-122). 100 cookies per Network.setCookies call:
const BATCH_SIZE = 100;
for (let i = 0; i < cdpCookies.length; i += BATCH_SIZE) {
const batch = cdpCookies.slice(i, i + BATCH_SIZE);
const result = await sendCDPCommand(ws, "Network.setCookies", { cookies: batch });
if (result.error) errors.push(`batch ${...}: ${result.error.message}`);
else totalInjected += batch.length;
}
Without batching, one cookie CDP doesn't like takes the whole call with it. 100 is a tradeoff: small enough that a poison batch loses ~3% of a typical session, large enough that 3,000 cookies finishes in tens of CDP roundtrips.
Respond with { injected, errors }. The CLI prints injected, warns on errors. The server does not validate cookies against domain ownership, does not merge with existing cookies (Network.setCookies overwrites by (name, domain, path)), and does not retry batches.
Where this fits
The companion post pitches the experience: "I logged in once on my laptop, the cloud agent has my session." This is the plumbing. Two CDP connections — one local, one remote — bridged by a single authenticated HTTPS POST. No extensions, no profile-dir blobs, no decryption keys. Source: packages/browser-sync/src/{extract,api,server}.ts.