#!/usr/bin/env python3 """box-chat-cdp.py — READ-ONLY CDP scraper for box-chat.py. Runs INSIDE the agent's netns (via netvm-exec.sh), where the agent's headless Chromium CDP port is reachable on 127.0.0.1. Performs only Runtime.evaluate reads plus benign navigation clicks (panel open, thread switch, restore-to-main). Never sends messages, never touches the composer, never clicks send. Usage: box-chat-cdp.py threads box-chat-cdp.py messages Prints exactly one JSON object to stdout. Exit 0 on success, 1 on failure (stdout still carries {"ok": false, ...}). """ import importlib.util import json import sys import time import urllib.request def load_accounts(): path = "/home/super/Projects/NetVM/bin/netvm-registry.py" spec = importlib.util.spec_from_file_location("netvm_registry", path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) accounts = {} for node, rec in mod.load().items(): accounts[node] = (node, "http://127.0.0.1:%d/json/list" % rec["cdp_port"]) return accounts def connect(agent): accounts = load_accounts() if agent not in accounts: raise RuntimeError("unknown agent in registry: %s" % agent) _node, cdp_url = accounts[agent] with urllib.request.urlopen(cdp_url, timeout=10) as r: targets = json.load(r) pages = [t for t in targets if t.get("type") == "page"] if not pages: raise RuntimeError("no page target on CDP") import websocket ws = websocket.create_connection( pages[0]["webSocketDebuggerUrl"], timeout=60) return ws def ev(ws, expr, await_p=False): ws.send(json.dumps({ "id": 1, "method": "Runtime.evaluate", "params": {"expression": expr, "returnByValue": True, "awaitPromise": await_p}, })) # Drain CDP events until we get our command response (id 1). # The browser can emit events (Runtime.executionContextCreated, etc.) # at any time; taking the first recv() blindly returns None on a # busy page (observed as transient NO_SWITCHER / "unexpected # messages payload" on pip/opm 2026-10-04). resp = None drained = 0 for _ in range(50): raw = ws.recv() resp = json.loads(raw) if resp.get("id") == 1: break drained += 1 else: raise RuntimeError("CDP: no response to Runtime.evaluate (drained %d)" % drained) pass # drained count available in `drained` if needed res = resp.get("result", {}) if res.get("subtype") == "error": raise RuntimeError("JS error: %s" % str(res.get("description"))[:200]) return res.get("result", {}).get("value") TITLE_OF = """const titleOf = r => { const s = r.querySelector('span[title]'); return s ? s.getAttribute('title').trim() : ((r.innerText||'').split('\\n')[0]||'').trim(); };""" ENSURE = """(async () => { const sleep = ms => new Promise(r => setTimeout(r, ms)); %s if (window.location.pathname === '/thread/new') { window.location.href = '/'; await sleep(4000); } const nav = document.querySelector('[data-testid="hatch-nav-chat"]'); if (!nav) return 'NO_CHAT_NAV'; if (nav.getAttribute('aria-current') !== 'page') { nav.click(); await sleep(3000); } const panelOpen = () => !!document.querySelector('[data-testid="hatch-chat-compose"]'); if (!panelOpen()) { // Retry: the switcher may not be rendered yet if the SPA is still // settling (observed transient NO_SWITCHER on pip/opm 2026-10-04). let sw = null; for (let k = 0; k < 4 && !sw; k++) { sw = document.querySelector('[data-testid="hatch-chat-switcher-trigger"]'); if (!sw) await sleep(2000); } if (!sw) return 'NO_SWITCHER'; sw.click(); await sleep(2500); if (!panelOpen()) return 'PANEL_CLOSED'; } return 'OK'; })()""" % TITLE_OF def op_threads(ws): st = ev(ws, ENSURE, await_p=True) if st != "OK": raise RuntimeError("could not reach chat panel: %s" % st) js = """(async () => { const sleep = ms => new Promise(r => setTimeout(r, ms)); %s const snap = [...document.querySelectorAll('[data-testid="hatch-thread-row"]')] .map(r => { const spans = [...r.querySelectorAll('span')]; return {title: titleOf(r), rel: spans.length ? (spans[spans.length-1].innerText||'').trim() : ''}; }); const ensurePanel = async () => { if (document.querySelector('[data-testid="hatch-chat-compose"]')) return true; for (let k = 0; k < 3; k++) { const sw = document.querySelector('[data-testid="hatch-chat-switcher-trigger"]'); if (!sw) return false; sw.click(); await sleep(3000); if (document.querySelector('[data-testid="hatch-chat-compose"]')) return true; } return false; }; const out = []; for (const s of snap) { const isMain = /^main chat$/i.test(s.title); if (isMain) { out.push({id: 'main', kind: 'main', title: s.title, rel: s.rel}); continue; } const panelOk = await ensurePanel(); if (!panelOk) { out.push({id: null, kind: 'sidechat', title: s.title, rel: s.rel, error: 'PANEL_CLOSED'}); continue; } const row = [...document.querySelectorAll('[data-testid="hatch-thread-row"]')] .find(r => titleOf(r) === s.title); if (!row) { out.push({id: null, kind: 'sidechat', title: s.title, rel: s.rel, error: 'ROW_GONE'}); continue; } row.click(); let id = null; for (let t = 0; t < 20; t++) { await sleep(500); const m = window.location.href.match(/\\/thread\\/([0-9a-fA-F-]{36})/); if (m) { id = m[1]; break; } } if (!id) { const row2 = [...document.querySelectorAll('[data-testid="hatch-thread-row"]')] .find(r => titleOf(r) === s.title); if (row2) { row2.click(); for (let t = 0; t < 12; t++) { await sleep(500); const m = window.location.href.match(/\\/thread\\/([0-9a-fA-F-]{36})/); if (m) { id = m[1]; break; } } } } out.push({id, kind: 'sidechat', title: s.title, rel: s.rel}); } return {threads: out}; })()""" % TITLE_OF return ev(ws, js, await_p=True) def op_messages(ws, thread_id): st = ev(ws, ENSURE, await_p=True) if st != "OK": raise RuntimeError("could not reach chat panel: %s" % st) js = """(async () => { const sleep = ms => new Promise(r => setTimeout(r, ms)); %s const THREAD = %s; const ensurePanel = async () => { if (document.querySelector('[data-testid="hatch-chat-compose"]')) return true; for (let k = 0; k < 3; k++) { const sw = document.querySelector('[data-testid="hatch-chat-switcher-trigger"]'); if (!sw) return false; sw.click(); await sleep(3000); if (document.querySelector('[data-testid="hatch-chat-compose"]')) return true; } return false; }; const uuidOf = () => { const m = window.location.href.match(/\\/thread\\/([0-9a-fA-F-]{36})/); return m ? m[1].toLowerCase() : null; }; let landed = false; if (THREAD === 'main') { await ensurePanel(); const row = [...document.querySelectorAll('[data-testid="hatch-thread-row"]')] .find(r => /^main chat$/i.test(titleOf(r))); if (row) { row.click(); await sleep(2500); } landed = /muse\\.ai\\/?$/.test(window.location.href) && !uuidOf(); } else { // already there? if (uuidOf() === THREAD.toLowerCase()) landed = true; // click rows until the URL carries our thread uuid (SPA navigation, // keeps the JS context alive unlike location.href assignment) for (let i = 0; i < 12 && !landed; i++) { await ensurePanel(); const rows = [...document.querySelectorAll('[data-testid="hatch-thread-row"]')]; if (!rows.length) break; const row = rows[i %% rows.length]; row.click(); for (let t = 0; t < 14; t++) { await sleep(500); if (uuidOf() === THREAD.toLowerCase()) { landed = true; break; } if (uuidOf()) break; // navigated somewhere else; try next row } } if (!landed) { // fallback: SPA history navigation (no full page load) window.history.pushState({}, '', '/thread/' + THREAD); window.dispatchEvent(new PopStateEvent('popstate')); await sleep(5000); landed = uuidOf() === THREAD.toLowerCase(); } } if (!landed) return {error: 'THREAD_NOT_FOUND'}; await sleep(2500); const sc = document.getElementById('hatch-chat-scroll'); if (sc) { for (let i = 0; i < 3; i++) { sc.scrollTop = 0; await sleep(1500); } sc.scrollTop = sc.scrollHeight; await sleep(800); } const els = [...document.querySelectorAll('[data-message-id]')]; const messages = els.map(m => { const id = m.getAttribute('data-message-id'); const ps = [...m.querySelectorAll('p')].map(p => (p.innerText||'').trim()).filter(Boolean); let text = ps.join('\\n'); if (!text) text = (m.innerText||'').replace(/^(Assistant message:|User message:)\\s*/, '').trim(); const t = m.querySelector('time'); return {id, author: id.indexOf('assistant-msg') === 0 ? 'assistant' : 'user', text, ts: t ? (t.getAttribute('datetime') || t.innerText || null) : null}; }); return {url: window.location.href, messages}; })()""" % (TITLE_OF, json.dumps(thread_id)) data = ev(ws, js, await_p=True) if not isinstance(data, dict) or "messages" not in data: if isinstance(data, dict) and data.get("error") == "THREAD_NOT_FOUND": raise RuntimeError("THREAD_NOT_FOUND: no such thread for this agent") raise RuntimeError("unexpected messages payload") return data def restore_main(ws): try: ev(ws, """(() => { %s const row = [...document.querySelectorAll('[data-testid="hatch-thread-row"]')] .find(r => /^main chat$/i.test(titleOf(r))); if (row) row.click(); return 'ok'; })()""" % TITLE_OF) except Exception: pass def main(argv): if len(argv) < 3: print(json.dumps({"ok": False, "code": "BAD_ARGS", "error": "usage: box-chat-cdp.py threads|messages [thread-id]"})) return 1 agent, op = argv[1], argv[2] ws = None try: # CDP evaluate can resolve null when the page is mid-navigation # (agent actively using browser). Reconnect fresh on each retry # so we attach to the current page, not a stale JS context. # Observed 2026-10-04: opm's browser navigates during reads. data, last_err = None, None for attempt in range(3): try: if ws is not None: try: ws.close() except Exception: pass ws = connect(agent) if op == "threads": data = op_threads(ws) ok = isinstance(data, dict) and isinstance( data.get("threads"), list) elif op == "messages": if len(argv) < 4: raise RuntimeError("messages requires thread-id") data = op_messages(ws, argv[3]) ok = isinstance(data, dict) and isinstance( data.get("messages"), list) else: raise RuntimeError("unknown op: %s" % op) if ok: break last_err = "empty CDP result" data = None except RuntimeError as e: last_err = str(e) data = None time.sleep(3) if data is None: raise RuntimeError(last_err or "CDP returned no usable data") if op == "threads": print(json.dumps({"ok": True, "agent": agent, "threads": data["threads"]})) else: print(json.dumps({"ok": True, "agent": agent, "url": data["url"], "messages": data["messages"]})) return 0 except Exception as e: print(json.dumps({"ok": False, "code": "CDP_ERROR", "error": str(e)[:300]})) return 1 finally: if ws is not None: try: restore_main(ws) except Exception: pass try: ws.close() except Exception: pass if __name__ == "__main__": sys.exit(main(sys.argv))