306 lines
12 KiB
Python
306 lines
12 KiB
Python
|
|
#!/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 <agent> threads
|
||
|
|
box-chat-cdp.py <agent> messages <thread-id>
|
||
|
|
|
||
|
|
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},
|
||
|
|
}))
|
||
|
|
resp = json.loads(ws.recv())
|
||
|
|
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()) {
|
||
|
|
const sw = document.querySelector('[data-testid="hatch-chat-switcher-trigger"]');
|
||
|
|
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 <agent> threads|messages [thread-id]"}))
|
||
|
|
return 1
|
||
|
|
agent, op = argv[1], argv[2]
|
||
|
|
ws = None
|
||
|
|
try:
|
||
|
|
ws = connect(agent)
|
||
|
|
# CDP evaluate can rarely resolve null when the page is mid-navigation;
|
||
|
|
# one retry covers the flake (observed 2026-10-04).
|
||
|
|
data, last_err = None, None
|
||
|
|
for attempt in range(2):
|
||
|
|
try:
|
||
|
|
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))
|