| 1 | """Live Chrome cookie reader over the DevTools Protocol (CDP). |
| 2 | |
| 3 | An EXTRA-host cookie lookup for the bird backend: when a Chrome/Chromium |
| 4 | instance is running with a remote-debugging endpoint and the user is signed |
| 5 | into x.com in it, that live session holds the ``auth_token`` + ``ct0`` cookies |
| 6 | bird needs — even on Linux, where the on-disk cookie store cannot be decrypted |
| 7 | here. This module talks to that endpoint and pulls the pair via |
| 8 | ``Network.getAllCookies``. |
| 9 | |
| 10 | Deliberate constraints (see docs/plans/2026-08-31 X plan): |
| 11 | |
| 12 | * **Extras only.** The engine only calls this on extra hosts (Linux, Mac mini, |
| 13 | Darwin agentcookie sink, or ``AGENTCOOKIE=on``); the gating lives in |
| 14 | ``env.x_extras_enabled``. On a plain MacBook this is never called, so no |
| 15 | socket is opened (AE8). |
| 16 | * **No port scan.** Endpoint resolution is: ``BROWSER_CDP_URL`` if set, else |
| 17 | port ``18800`` if it answers as Chrome, else ``9222`` + the X display number. |
| 18 | No 9222..9232 sweep. ``18800`` is NOT box-chrome's built-in default (that is |
| 19 | ``9222`` + the display number); it is the last30days extras NUX convention — |
| 20 | the agent launches the throwaway login Chrome with |
| 21 | ``SAND_CHROME_REMOTE_DEBUG_PORT=18800`` (see SKILL.md), so a leftover daily |
| 22 | profile on ``9222``+display is not mistaken for the login session. If ``18800`` |
| 23 | answers with no complete pair we fall through; if it answers with a stale or |
| 24 | wrong pair, pin ``BROWSER_CDP_URL`` after the NUX rather than scanning. |
| 25 | * **Require a Chrome page target.** ``/json/version`` must report a Chrome / |
| 26 | Chromium browser (a Node inspector is rejected) and ``/json`` must expose a |
| 27 | ``page`` target. |
| 28 | * **``FROM_BROWSER=off`` skips CDP.** |
| 29 | * Stdlib only: a tiny RFC 6455 websocket client, no third-party dependency. |
| 30 | * Cookie **values are never logged** — only counts and endpoints. |
| 31 | * First complete pair wins: both ``auth_token`` and ``ct0`` must be present. |
| 32 | """ |
| 33 | |
| 34 | from __future__ import annotations |
| 35 | |
| 36 | import base64 |
| 37 | import json |
| 38 | import os |
| 39 | import re |
| 40 | import socket |
| 41 | import struct |
| 42 | import urllib.request |
| 43 | from typing import Any, Dict, List, Optional |
| 44 | |
| 45 | from . import log |
| 46 | |
| 47 | X_COOKIE_NAMES = ("auth_token", "ct0") |
| 48 | _BASE_DEBUG_PORT = 9222 |
| 49 | # The last30days extras NUX convention port: the agent launches the throwaway |
| 50 | # login Chrome with SAND_CHROME_REMOTE_DEBUG_PORT=18800 so this lookup finds it. |
| 51 | # NOT box-chrome's built-in default (which is 9222 + the X display number). |
| 52 | _BOX_CHROME_PORT = 18800 |
| 53 | |
| 54 | _HTTP_TIMEOUT = 1.5 # /json and /json/version fetches |
| 55 | _WS_TIMEOUT = 3.0 # websocket exchange |
| 56 | |
| 57 | |
| 58 | def _log(msg: str) -> None: |
| 59 | log.source_log("chrome-cdp", msg, tty_only=False) |
| 60 | |
| 61 | |
| 62 | def _display_number() -> Optional[int]: |
| 63 | """Parse the X display number from ``$DISPLAY`` (e.g. ``:99`` -> 99).""" |
| 64 | disp = os.environ.get("DISPLAY") or "" |
| 65 | match = re.search(r":(\d+)", disp) |
| 66 | if not match: |
| 67 | return None |
| 68 | try: |
| 69 | return int(match.group(1)) |
| 70 | except ValueError: |
| 71 | return None |
| 72 | |
| 73 | |
| 74 | def _normalize_base(url: str) -> str: |
| 75 | """Return an ``http://host:port`` base for a user-supplied endpoint.""" |
| 76 | url = url.strip().rstrip("/") |
| 77 | if url.startswith(("http://", "https://", "ws://", "wss://")): |
| 78 | return url |
| 79 | return f"http://{url}" |
| 80 | |
| 81 | |
| 82 | def candidate_endpoints(config: Optional[Dict[str, Any]] = None) -> List[str]: |
| 83 | """Debug endpoints to try, most-specific first (no port scan). |
| 84 | |
| 85 | Order: an explicit ``BROWSER_CDP_URL`` (used exclusively when set), else the |
| 86 | last30days extras NUX port ``18800`` (where the agent launches the throwaway |
| 87 | login Chrome via ``SAND_CHROME_REMOTE_DEBUG_PORT=18800``), then ``9222`` + |
| 88 | the X display number (box-chrome's own built-in default). ``18800`` is tried |
| 89 | first but read_x_cookies falls through when it yields no complete pair, so a |
| 90 | logged-out Chrome there never shadows a logged-in daily profile. |
| 91 | """ |
| 92 | explicit = "" |
| 93 | if config is not None: |
| 94 | explicit = (config.get("BROWSER_CDP_URL") or "").strip() |
| 95 | explicit = explicit or (os.environ.get("BROWSER_CDP_URL") or "").strip() |
| 96 | if explicit: |
| 97 | return [_normalize_base(explicit)] |
| 98 | |
| 99 | endpoints = [f"http://127.0.0.1:{_BOX_CHROME_PORT}"] |
| 100 | display = _display_number() |
| 101 | endpoints.append(f"http://127.0.0.1:{_BASE_DEBUG_PORT + (display or 0)}") |
| 102 | return endpoints |
| 103 | |
| 104 | |
| 105 | def _http_get_json(url: str) -> Optional[Any]: |
| 106 | """GET ``url`` and parse JSON, or None (unreachable/non-JSON).""" |
| 107 | try: |
| 108 | with urllib.request.urlopen(url, timeout=_HTTP_TIMEOUT) as resp: |
| 109 | body = resp.read() |
| 110 | except (OSError, ValueError): |
| 111 | return None |
| 112 | try: |
| 113 | return json.loads(body) |
| 114 | except (json.JSONDecodeError, TypeError): |
| 115 | return None |
| 116 | |
| 117 | |
| 118 | def _is_chrome_endpoint(base: str) -> bool: |
| 119 | """True when ``base``/json/version reports a Chrome/Chromium browser. |
| 120 | |
| 121 | Rejects a Node ``--inspect`` endpoint (whose ``Browser`` is ``node.js/...``) |
| 122 | so we never mistake an inspector for a browser. |
| 123 | """ |
| 124 | version = _http_get_json(f"{base}/json/version") |
| 125 | if not isinstance(version, dict): |
| 126 | return False |
| 127 | browser = str(version.get("Browser") or "").lower() |
| 128 | return "chrome" in browser or "chromium" in browser |
| 129 | |
| 130 | |
| 131 | def _page_ws_url(base: str) -> Optional[str]: |
| 132 | """Find a Chrome PAGE target's webSocketDebuggerUrl on ``base``.""" |
| 133 | targets = _http_get_json(f"{base}/json") |
| 134 | if not isinstance(targets, list): |
| 135 | return None |
| 136 | for target in targets: |
| 137 | if not isinstance(target, dict): |
| 138 | continue |
| 139 | if target.get("type") != "page": |
| 140 | continue |
| 141 | ws_url = target.get("webSocketDebuggerUrl") |
| 142 | if isinstance(ws_url, str) and ws_url.startswith("ws://"): |
| 143 | return ws_url |
| 144 | return None |
| 145 | |
| 146 | |
| 147 | class _WSConn: |
| 148 | """Minimal RFC 6455 websocket client (text frames only) over a TCP socket.""" |
| 149 | |
| 150 | def __init__(self, sock: socket.socket) -> None: |
| 151 | self._sock = sock |
| 152 | self._buf = b"" |
| 153 | |
| 154 | def _fill(self, n: int) -> Optional[bytes]: |
| 155 | while len(self._buf) < n: |
| 156 | try: |
| 157 | chunk = self._sock.recv(65536) |
| 158 | except OSError: |
| 159 | return None |
| 160 | if not chunk: |
| 161 | return None |
| 162 | self._buf += chunk |
| 163 | out, self._buf = self._buf[:n], self._buf[n:] |
| 164 | return out |
| 165 | |
| 166 | @classmethod |
| 167 | def connect(cls, ws_url: str, timeout: float) -> Optional["_WSConn"]: |
| 168 | # Plaintext ws:// only. A wss:// URL would need real TLS |
| 169 | # (ssl.wrap_socket); this client does not, so refuse it rather than |
| 170 | # open a plaintext socket to a TLS endpoint. Defense in depth alongside |
| 171 | # the scheme gate in read_x_cookies. |
| 172 | match = re.match(r"ws://([^:/]+):(\d+)(/.*)$", ws_url) |
| 173 | if not match: |
| 174 | return None |
| 175 | host, port, path = match.group(1), int(match.group(2)), match.group(3) |
| 176 | try: |
| 177 | sock = socket.create_connection((host, port), timeout=timeout) |
| 178 | except OSError: |
| 179 | return None |
| 180 | sock.settimeout(timeout) |
| 181 | key = base64.b64encode(os.urandom(16)).decode("ascii") |
| 182 | handshake = ( |
| 183 | f"GET {path} HTTP/1.1\r\n" |
| 184 | f"Host: {host}:{port}\r\n" |
| 185 | "Upgrade: websocket\r\n" |
| 186 | "Connection: Upgrade\r\n" |
| 187 | f"Sec-WebSocket-Key: {key}\r\n" |
| 188 | "Sec-WebSocket-Version: 13\r\n\r\n" |
| 189 | ) |
| 190 | try: |
| 191 | sock.sendall(handshake.encode("ascii")) |
| 192 | except OSError: |
| 193 | sock.close() |
| 194 | return None |
| 195 | conn = cls(sock) |
| 196 | header = conn._read_http_headers() |
| 197 | if header is None or b" 101 " not in header.split(b"\r\n", 1)[0]: |
| 198 | sock.close() |
| 199 | return None |
| 200 | return conn |
| 201 | |
| 202 | def _read_http_headers(self) -> Optional[bytes]: |
| 203 | while b"\r\n\r\n" not in self._buf: |
| 204 | try: |
| 205 | chunk = self._sock.recv(65536) |
| 206 | except OSError: |
| 207 | return None |
| 208 | if not chunk: |
| 209 | return None |
| 210 | self._buf += chunk |
| 211 | head, _, rest = self._buf.partition(b"\r\n\r\n") |
| 212 | self._buf = rest # any bytes after the header belong to the frame stream |
| 213 | return head |
| 214 | |
| 215 | def send_text(self, payload: bytes) -> bool: |
| 216 | header = bytearray([0x81]) # FIN + text opcode |
| 217 | mask = os.urandom(4) |
| 218 | length = len(payload) |
| 219 | if length < 126: |
| 220 | header.append(0x80 | length) |
| 221 | elif length < 65536: |
| 222 | header.append(0x80 | 126) |
| 223 | header += struct.pack(">H", length) |
| 224 | else: |
| 225 | header.append(0x80 | 127) |
| 226 | header += struct.pack(">Q", length) |
| 227 | header += mask |
| 228 | masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) |
| 229 | try: |
| 230 | self._sock.sendall(bytes(header) + masked) |
| 231 | return True |
| 232 | except OSError: |
| 233 | return False |
| 234 | |
| 235 | def recv_message(self) -> Optional[bytes]: |
| 236 | """Read one (possibly fragmented) data message; skip control frames.""" |
| 237 | message = b"" |
| 238 | while True: |
| 239 | first = self._fill(2) |
| 240 | if first is None: |
| 241 | return None |
| 242 | fin = first[0] & 0x80 |
| 243 | opcode = first[0] & 0x0F |
| 244 | length = first[1] & 0x7F |
| 245 | masked = first[1] & 0x80 |
| 246 | if length == 126: |
| 247 | ext = self._fill(2) |
| 248 | if ext is None: |
| 249 | return None |
| 250 | length = struct.unpack(">H", ext)[0] |
| 251 | elif length == 127: |
| 252 | ext = self._fill(8) |
| 253 | if ext is None: |
| 254 | return None |
| 255 | length = struct.unpack(">Q", ext)[0] |
| 256 | mask = self._fill(4) if masked else b"" |
| 257 | payload = self._fill(length) if length else b"" |
| 258 | if length and payload is None: |
| 259 | return None |
| 260 | if masked and payload: |
| 261 | payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) |
| 262 | if opcode == 0x8: # close |
| 263 | return None |
| 264 | if opcode in (0x9, 0xA): # ping / pong — ignore |
| 265 | continue |
| 266 | message += payload or b"" |
| 267 | if fin: |
| 268 | return message |
| 269 | |
| 270 | def close(self) -> None: |
| 271 | try: |
| 272 | self._sock.close() |
| 273 | except OSError: |
| 274 | pass |
| 275 | |
| 276 | |
| 277 | def _get_all_cookies(ws_url: str) -> Optional[List[Dict[str, Any]]]: |
| 278 | """Run Network.enable then Network.getAllCookies over one CDP websocket.""" |
| 279 | conn = _WSConn.connect(ws_url, _WS_TIMEOUT) |
| 280 | if conn is None: |
| 281 | return None |
| 282 | try: |
| 283 | if not conn.send_text(json.dumps({"id": 1, "method": "Network.enable"}).encode("utf-8")): |
| 284 | return None |
| 285 | if not conn.send_text(json.dumps({"id": 2, "method": "Network.getAllCookies"}).encode("utf-8")): |
| 286 | return None |
| 287 | # Read frames until the id=2 response arrives (skipping enable's ack and |
| 288 | # any Network.* events the browser pushes after enable). |
| 289 | for _ in range(200): |
| 290 | raw = conn.recv_message() |
| 291 | if raw is None: |
| 292 | return None |
| 293 | try: |
| 294 | msg = json.loads(raw) |
| 295 | except (json.JSONDecodeError, UnicodeDecodeError): |
| 296 | continue |
| 297 | if isinstance(msg, dict) and msg.get("id") == 2: |
| 298 | result = msg.get("result") |
| 299 | if isinstance(result, dict) and isinstance(result.get("cookies"), list): |
| 300 | return result["cookies"] |
| 301 | return None |
| 302 | return None |
| 303 | finally: |
| 304 | conn.close() |
| 305 | |
| 306 | |
| 307 | # Registrable X hosts we accept cookies from, in preference order. Matched |
| 308 | # EXACTLY after stripping a single leading dot (never endswith), so a lookalike |
| 309 | # like ``notx.com`` is not treated as x.com and cannot contribute a cookie. |
| 310 | _ALLOWED_X_HOSTS = ("x.com", "twitter.com") |
| 311 | |
| 312 | |
| 313 | def _canonical_partition(raw: Any) -> Optional[str]: |
| 314 | """Canonicalize a CDP ``partitionKey`` to a hashable scope tag. |
| 315 | |
| 316 | CDP may send ``partitionKey`` as a string, as an object |
| 317 | (``{"topLevelSite": ..., "hasCrossSiteAncestor": ...}``), or omit it for an |
| 318 | unpartitioned cookie. Returns None for "unpartitioned" (so all unpartitioned |
| 319 | cookies share one scope) and a stable string otherwise (so a partitioned |
| 320 | cookie never shares a scope with an unpartitioned one, nor with a different |
| 321 | partition). |
| 322 | """ |
| 323 | if raw is None: |
| 324 | return None |
| 325 | if isinstance(raw, str): |
| 326 | return raw.strip() or None |
| 327 | if isinstance(raw, dict): |
| 328 | try: |
| 329 | return json.dumps(raw, sort_keys=True, separators=(",", ":")) |
| 330 | except (TypeError, ValueError): |
| 331 | return repr(sorted((str(k), str(v)) for k, v in raw.items())) |
| 332 | return str(raw) |
| 333 | |
| 334 | |
| 335 | def _pair_from_cookies(cookies: List[Dict[str, Any]]) -> Dict[str, str]: |
| 336 | """Extract a complete X cookie pair from ONE cookie scope. |
| 337 | |
| 338 | ``auth_token`` and ``ct0`` are only a usable pair when they share the SAME |
| 339 | cookie scope — same registrable host AND same ``path`` AND same partition. |
| 340 | Chrome can hold duplicate names across scopes (different ``path`` or |
| 341 | ``partitionKey``), so pairing across the whole host jar could hand Bird a |
| 342 | token from one session scope and a ct0 from another, and a valid login would |
| 343 | look unauthorized. We therefore group by the full scope key |
| 344 | ``(host, path, partition)`` and only ever pair WITHIN one scope. |
| 345 | |
| 346 | Host is matched EXACTLY against ``_ALLOWED_X_HOSTS`` after stripping one |
| 347 | leading dot (never ``endswith``, so ``notx.com`` never counts). Missing |
| 348 | ``path`` is treated as ``/``; ``partitionKey`` is canonicalized so |
| 349 | unpartitioned cookies stay together. Preference order for the returned pair: |
| 350 | host ``x.com`` before ``twitter.com``; unpartitioned before partitioned; |
| 351 | path ``/`` before other paths. A scope with only one of the two cookies is |
| 352 | skipped so a later complete scope still wins. When no scope has a complete |
| 353 | pair, a single scope's partial is returned for the caller's incomplete-pair |
| 354 | log — never a cross-scope mix. |
| 355 | """ |
| 356 | # scopes[host][(path, partition)] -> {name: value} |
| 357 | scopes: Dict[str, Dict[tuple, Dict[str, str]]] = {host: {} for host in _ALLOWED_X_HOSTS} |
| 358 | for cookie in cookies: |
| 359 | if not isinstance(cookie, dict): |
| 360 | continue |
| 361 | name = cookie.get("name") |
| 362 | value = cookie.get("value") |
| 363 | if name not in X_COOKIE_NAMES or not (isinstance(value, str) and value): |
| 364 | continue |
| 365 | host = str(cookie.get("domain") or "").lstrip(".").lower() |
| 366 | if host not in scopes: |
| 367 | continue |
| 368 | path = cookie.get("path") |
| 369 | if not isinstance(path, str) or not path: |
| 370 | path = "/" |
| 371 | scope_key = (path, _canonical_partition(cookie.get("partitionKey"))) |
| 372 | jar = scopes[host].setdefault(scope_key, {}) |
| 373 | # First value WITHIN this scope only — never across scopes. |
| 374 | jar.setdefault(name, value) |
| 375 | |
| 376 | def _scope_rank(item: tuple) -> tuple: |
| 377 | (path, partition), _jar = item |
| 378 | # unpartitioned (None) before partitioned; path "/" before others. |
| 379 | return (partition is not None, path != "/", path) |
| 380 | |
| 381 | for host in _ALLOWED_X_HOSTS: |
| 382 | for _key, jar in sorted(scopes[host].items(), key=_scope_rank): |
| 383 | if all(name in jar for name in X_COOKIE_NAMES): |
| 384 | return {name: jar[name] for name in X_COOKIE_NAMES} |
| 385 | |
| 386 | for host in _ALLOWED_X_HOSTS: |
| 387 | for _key, jar in sorted(scopes[host].items(), key=_scope_rank): |
| 388 | if jar: |
| 389 | return dict(jar) |
| 390 | return {} |
| 391 | |
| 392 | |
| 393 | def read_x_cookies(config: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, str]]: |
| 394 | """Return the complete X cookie pair from a live Chrome session, or None. |
| 395 | |
| 396 | Resolves the debug endpoint (BROWSER_CDP_URL, else 18800 if Chrome, else |
| 397 | 9222+$DISPLAY), requires a Chrome page target, and calls |
| 398 | ``Network.getAllCookies``. Returns ``{"auth_token", "ct0"}`` only when BOTH |
| 399 | cookies are found (no half-pair). ``FROM_BROWSER=off`` returns None without |
| 400 | opening a socket. Any failure returns None so the caller falls through. |
| 401 | Never raises. |
| 402 | |
| 403 | Host gating (extras-only) lives in the caller (``env.x_extras_enabled``); |
| 404 | on a plain MacBook this function is never invoked, so no socket is opened. |
| 405 | """ |
| 406 | from_browser = "" |
| 407 | if config is not None: |
| 408 | from_browser = (config.get("FROM_BROWSER") or "").strip().lower() |
| 409 | if from_browser == "off": |
| 410 | return None |
| 411 | |
| 412 | for base in candidate_endpoints(config): |
| 413 | # TLS CDP is NOT supported: the websocket client speaks plaintext only, |
| 414 | # so a wss:// endpoint (or an https:// base, which would yield a wss:// |
| 415 | # page URL) must fail closed rather than be downgraded to a plaintext |
| 416 | # connect. Local Chrome CDP is ws://http://. |
| 417 | scheme = base.split("://", 1)[0].lower() if "://" in base else "http" |
| 418 | if scheme in ("wss", "https"): |
| 419 | _log(f"refusing TLS CDP endpoint {base!r}: only ws://http:// is supported (no TLS)") |
| 420 | continue |
| 421 | # ws:// endpoints (rare, explicit) connect directly; http bases are |
| 422 | # validated as Chrome and asked for a page target. |
| 423 | if base.startswith("ws://"): |
| 424 | ws_url = base |
| 425 | else: |
| 426 | if not _is_chrome_endpoint(base): |
| 427 | continue |
| 428 | ws_url = _page_ws_url(base) |
| 429 | if not ws_url: |
| 430 | continue |
| 431 | cookies = _get_all_cookies(ws_url) |
| 432 | if not cookies: |
| 433 | continue |
| 434 | found = _pair_from_cookies(cookies) |
| 435 | if all(name in found for name in X_COOKIE_NAMES): |
| 436 | _log(f"read a complete X cookie pair from a live Chrome session at {base}") |
| 437 | return {name: found[name] for name in X_COOKIE_NAMES} |
| 438 | if found: |
| 439 | _log( |
| 440 | f"live Chrome at {base} had an incomplete pair " |
| 441 | f"({sorted(found)}); ignoring per no-half-pair rule" |
| 442 | ) |
| 443 | return None |
| 444 |