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