diff --git a/bin/muse-chat-api.py b/bin/muse-chat-api.py index af0e6d2..b93abcf 100755 --- a/bin/muse-chat-api.py +++ b/bin/muse-chat-api.py @@ -1,33 +1,33 @@ #!/usr/bin/env python3 """ -Multi-account muse.ai chat API. Unified naming. +Multi-account muse.ai chat API with approval handling. + +Approvals: The browser may show permission dialogs (e.g., "Allow pip to share +information with 34.139.37.135?"). The API detects these and handles them: +- Known-safe (our infrastructure IPs): auto-approve +- Unknown: raise APPROVAL_NEEDED, operator decides via chat Usage: muse-chat-api.py --account send "message" muse-chat-api.py --account messages [n] muse-chat-api.py --account wait [timeout] - -The name matches the NetVM node, chrome-box profile, and -the `agent` column in ACCOUNTS.md. No translation. - -Accounts: - muse : CDP 9410 - ltd.pixels.ltd@gmail.com (email_otp) - ACTIVE - pip : CDP 9420 - piparada (phone_otp) - PENDING (needs re-auth) - 646 : (not set up yet) - -See ACCOUNTS.md for the full registry. + muse-chat-api.py --account approvals # check pending approvals """ import json, urllib.request, websocket, time, sys, argparse -# Maps agent name -> (node, CDP URL). Agent == node == profile. ACCOUNTS = { "muse": ("muse", "http://127.0.0.1:9410/json/list"), "pip": ("pip", "http://127.0.0.1:9420/json/list"), } +# IPs we trust for auto-approval (our infrastructure) +TRUSTED_IPS = { + "34.139.37.135", # VM (gateway) + "100.123.153.75", # bl (main compute) + "100.81.31.9", # VM tailnet +} + def get_page(node, cdp_url): - # Run from within the node's netns via netvm-exec - # For now, assume we're already in the netns (called via netvm-exec.sh) with urllib.request.urlopen(cdp_url, timeout=5) as r: ts = json.load(r) pages = [t for t in ts if t.get('type') == 'page'] @@ -44,7 +44,86 @@ def ev(ws, expr, await_p=False): resp = json.loads(ws.recv()) return resp.get('result', {}).get('result', {}).get('value') +def check_approvals(ws): + """ + Check for browser permission dialogs. + Returns list of (dialog_text, is_trusted, action_taken). + """ + result = ev(ws, """(() => { + const dialogs = []; + // Look for permission prompts (common patterns) + const body = document.body.innerText; + // Check for "Allow ... to share" pattern + if (body.includes('Allow') && body.includes('to share')) { + // Find the dialog + const els = [...document.querySelectorAll('*')].filter(el => { + const t = el.innerText || ''; + return t.includes('Allow') && t.includes('to share') && t.length < 500; + }); + for (const el of els.slice(0,3)) { + dialogs.push(el.innerText.slice(0,200)); + } + } + // Check for other permission patterns + const perm_btns = [...document.querySelectorAll('button')].filter(b => { + const t = (b.innerText||'').toLowerCase(); + return t.includes('allow') || t.includes('deny') || t.includes('block'); + }); + if (perm_btns.length >= 2 && dialogs.length === 0) { + // Might be a permission dialog + const parent = perm_btns[0].closest('div'); + if (parent) dialogs.push(parent.innerText.slice(0,200)); + } + return JSON.stringify(dialogs); + })()""") + try: + dialogs = json.loads(result) if result else [] + except: + dialogs = [] + + actions = [] + for d in dialogs: + # Extract IP if present + import re + ips = re.findall(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', d) + is_trusted = any(ip in TRUSTED_IPS for ip in ips) + if is_trusted: + # Auto-approve: click "Allow once" or "Allow" + clicked = ev(ws, """(async()=>{ + const b = [...document.querySelectorAll('button')].find(x=>{ + const t = (x.innerText||'').toLowerCase(); + return t.includes('allow once') || t === 'allow'; + }); + if (b) { b.click(); return 'clicked:'+b.innerText.slice(0,20); } + return 'NOTFOUND'; + })()""", True) + actions.append((d[:80], True, clicked)) + else: + actions.append((d[:80], False, "APPROVAL_NEEDED")) + + return actions + +def cmd_approvals(ws): + """Check and handle pending approvals.""" + actions = check_approvals(ws) + if not actions: + print("No pending approvals") + return + for dialog, trusted, action in actions: + print(f"Dialog: {dialog}") + print(f" Trusted: {trusted}, Action: {action}") + if not trusted: + print(" APPROVAL_NEEDED: Manual review required") + sys.exit(2) + def cmd_send(ws, message): + # Check approvals first + actions = check_approvals(ws) + for dialog, trusted, action in actions: + if not trusted: + print(f"APPROVAL_NEEDED: {dialog[:80]}", file=sys.stderr) + sys.exit(2) + msg_esc = message.replace('\\', '\\\\').replace('`', '\\`').replace('$', '\\$') result = ev(ws, f"""(async()=>{{ const input = document.querySelector('[contenteditable="true"]') || @@ -65,6 +144,8 @@ def cmd_send(ws, message): print(result) def cmd_messages(ws, n=5): + # Check approvals first (non-blocking) + check_approvals(ws) result = ev(ws, f"""(() => {{ const ps = [...document.querySelectorAll('p')].slice(-{n*2}).map(p=>p.innerText.slice(0,200)); return ps.join('\\n---\\n'); @@ -73,20 +154,25 @@ def cmd_messages(ws, n=5): def cmd_wait(ws, timeout=30): print(f"Waiting {timeout}s for response...") - time.sleep(timeout) + # Check approvals periodically during wait + for i in range(timeout // 5): + actions = check_approvals(ws) + for dialog, trusted, action in actions: + if not trusted: + print(f"APPROVAL_NEEDED: {dialog[:80]}", file=sys.stderr) + sys.exit(2) + time.sleep(5) cmd_messages(ws, 2) def main(): p = argparse.ArgumentParser() p.add_argument('--account', required=True, choices=list(ACCOUNTS.keys()), help='Agent name (matches node, profile, ACCOUNTS.md)') - p.add_argument('command', choices=['send', 'messages', 'wait']) + p.add_argument('command', choices=['send', 'messages', 'wait', 'approvals']) p.add_argument('arg', nargs='?', default=None) args = p.parse_args() node, cdp_url = ACCOUNTS[args.account] - # Note: Must be run via: netvm-exec.sh -- python3 muse-chat-api.py ... - # The node name matches the account name. page = get_page(node, cdp_url) ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=15) @@ -102,6 +188,8 @@ def main(): elif args.command == 'wait': t = int(args.arg) if args.arg else 30 cmd_wait(ws, t) + elif args.command == 'approvals': + cmd_approvals(ws) finally: ws.close()