返回 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 * **``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
444 lines PYTHON