muse-chat-api.py: approval handling (auto-approve trusted IPs)
This commit is contained in:
+106
-18
@@ -1,33 +1,33 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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:
|
Usage:
|
||||||
muse-chat-api.py --account <agent> send "message"
|
muse-chat-api.py --account <agent> send "message"
|
||||||
muse-chat-api.py --account <agent> messages [n]
|
muse-chat-api.py --account <agent> messages [n]
|
||||||
muse-chat-api.py --account <agent> wait [timeout]
|
muse-chat-api.py --account <agent> wait [timeout]
|
||||||
|
muse-chat-api.py --account <agent> approvals # check pending approvals
|
||||||
The <agent> 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.
|
|
||||||
"""
|
"""
|
||||||
import json, urllib.request, websocket, time, sys, argparse
|
import json, urllib.request, websocket, time, sys, argparse
|
||||||
|
|
||||||
# Maps agent name -> (node, CDP URL). Agent == node == profile.
|
|
||||||
ACCOUNTS = {
|
ACCOUNTS = {
|
||||||
"muse": ("muse", "http://127.0.0.1:9410/json/list"),
|
"muse": ("muse", "http://127.0.0.1:9410/json/list"),
|
||||||
"pip": ("pip", "http://127.0.0.1:9420/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):
|
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:
|
with urllib.request.urlopen(cdp_url, timeout=5) as r:
|
||||||
ts = json.load(r)
|
ts = json.load(r)
|
||||||
pages = [t for t in ts if t.get('type') == 'page']
|
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())
|
resp = json.loads(ws.recv())
|
||||||
return resp.get('result', {}).get('result', {}).get('value')
|
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):
|
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('$', '\\$')
|
msg_esc = message.replace('\\', '\\\\').replace('`', '\\`').replace('$', '\\$')
|
||||||
result = ev(ws, f"""(async()=>{{
|
result = ev(ws, f"""(async()=>{{
|
||||||
const input = document.querySelector('[contenteditable="true"]') ||
|
const input = document.querySelector('[contenteditable="true"]') ||
|
||||||
@@ -65,6 +144,8 @@ def cmd_send(ws, message):
|
|||||||
print(result)
|
print(result)
|
||||||
|
|
||||||
def cmd_messages(ws, n=5):
|
def cmd_messages(ws, n=5):
|
||||||
|
# Check approvals first (non-blocking)
|
||||||
|
check_approvals(ws)
|
||||||
result = ev(ws, f"""(() => {{
|
result = ev(ws, f"""(() => {{
|
||||||
const ps = [...document.querySelectorAll('p')].slice(-{n*2}).map(p=>p.innerText.slice(0,200));
|
const ps = [...document.querySelectorAll('p')].slice(-{n*2}).map(p=>p.innerText.slice(0,200));
|
||||||
return ps.join('\\n---\\n');
|
return ps.join('\\n---\\n');
|
||||||
@@ -73,20 +154,25 @@ def cmd_messages(ws, n=5):
|
|||||||
|
|
||||||
def cmd_wait(ws, timeout=30):
|
def cmd_wait(ws, timeout=30):
|
||||||
print(f"Waiting {timeout}s for response...")
|
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)
|
cmd_messages(ws, 2)
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
p.add_argument('--account', required=True, choices=list(ACCOUNTS.keys()),
|
p.add_argument('--account', required=True, choices=list(ACCOUNTS.keys()),
|
||||||
help='Agent name (matches node, profile, ACCOUNTS.md)')
|
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)
|
p.add_argument('arg', nargs='?', default=None)
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
node, cdp_url = ACCOUNTS[args.account]
|
node, cdp_url = ACCOUNTS[args.account]
|
||||||
# Note: Must be run via: netvm-exec.sh <node> -- python3 muse-chat-api.py ...
|
|
||||||
# The node name matches the account name.
|
|
||||||
page = get_page(node, cdp_url)
|
page = get_page(node, cdp_url)
|
||||||
ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=15)
|
ws = websocket.create_connection(page['webSocketDebuggerUrl'], timeout=15)
|
||||||
|
|
||||||
@@ -102,6 +188,8 @@ def main():
|
|||||||
elif args.command == 'wait':
|
elif args.command == 'wait':
|
||||||
t = int(args.arg) if args.arg else 30
|
t = int(args.arg) if args.arg else 30
|
||||||
cmd_wait(ws, t)
|
cmd_wait(ws, t)
|
||||||
|
elif args.command == 'approvals':
|
||||||
|
cmd_approvals(ws)
|
||||||
finally:
|
finally:
|
||||||
ws.close()
|
ws.close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user