feat(box): add invite code handler, Settings RPA, and agent onboarding pipeline
- Support invite code discovery in main chat and redemption in settings menu - Add Settings RPA primitives with Radix UI mouse dispatch and retry polling for async DOM - Attribute 'has_redeemed' binary from the Additional tokens ticker / entrypoint visibility - Unblock agent @646 by repairing warp-def/dev tunnels and redeeming REDCJ7 via dev (+1B tokens) - Implement 'box onboard' pipeline to provision infra, authenticate, and auto-redeem queued codes - Add 'box onboard feed-matrix' ranking all agents by work done over time, job count, and role - Register 'InputType.SALVAGE' in loop modulation (CRITICAL priority, 600s timeout, 3 nudges to opm) - Ingest recurring balance audits into canonical HEARTBEAT.md and TOOLS.md
This commit is contained in:
Executable
+443
@@ -0,0 +1,443 @@
|
||||
#!/usr/bin/env python3
|
||||
"""settings_rpa.py: RPA module for Muse.ai Settings menu, dock rail, and dialogs.
|
||||
|
||||
Provides headless browser automation primitives for:
|
||||
- Dock rail settings menu toggle via CDP mouse events
|
||||
- Settings dialog navigation (General, Connectors, Wallet, etc.)
|
||||
- Token and weekly quota usage inspection
|
||||
- Settings-based invite code redemption entry point
|
||||
|
||||
Compatible with NetVM's unified box CLI and approvals system.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
try:
|
||||
from approvals import cdp_evaluate, get_cdp_ws, get_node_pages
|
||||
except ImportError:
|
||||
# Allow running when approvals is in current dir or NetVM bin
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from approvals import cdp_evaluate, get_cdp_ws, get_node_pages
|
||||
|
||||
|
||||
_REQ_COUNTER = itertools.count(10000)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsageBar:
|
||||
label: str
|
||||
percent: int
|
||||
raw_text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeUsage:
|
||||
node: str
|
||||
plan: str
|
||||
weekly_reset_text: str
|
||||
weekly_percent_used: int
|
||||
extra_tokens_status: str
|
||||
extra_percent_used: int
|
||||
extra_tokens_remaining: str
|
||||
is_blocked: bool
|
||||
bars: List[Dict[str, Any]]
|
||||
dialog_text: str
|
||||
has_redeemed: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def cdp_send_command(ws: Any, method: str, params: Dict[str, Any], timeout: float = 3.0) -> Optional[Dict[str, Any]]:
|
||||
"""Send a raw CDP command and await response with a unique request ID."""
|
||||
req_id = next(_REQ_COUNTER)
|
||||
msg = {"id": req_id, "method": method, "params": params}
|
||||
ws.send(json.dumps(msg))
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
raw = ws.recv()
|
||||
except Exception:
|
||||
break
|
||||
resp = json.loads(raw)
|
||||
if resp.get("id") == req_id:
|
||||
return resp.get("result", {})
|
||||
return None
|
||||
|
||||
|
||||
def cdp_dispatch_mouse_click(ws: Any, x: float, y: float, timeout: float = 3.0) -> bool:
|
||||
"""Simulate real mousePressed and mouseReleased events at (x, y)."""
|
||||
p1 = cdp_send_command(
|
||||
ws,
|
||||
"Input.dispatchMouseEvent",
|
||||
{"type": "mousePressed", "x": x, "y": y, "button": "left", "clickCount": 1},
|
||||
timeout=timeout,
|
||||
)
|
||||
p2 = cdp_send_command(
|
||||
ws,
|
||||
"Input.dispatchMouseEvent",
|
||||
{"type": "mouseReleased", "x": x, "y": y, "button": "left", "clickCount": 1},
|
||||
timeout=timeout,
|
||||
)
|
||||
return p1 is not None and p2 is not None
|
||||
|
||||
|
||||
def cdp_click_element_by_selector(ws: Any, selector: str, timeout: float = 3.0) -> bool:
|
||||
"""Find element by selector, calculate bounding box center, and dispatch real mouse click."""
|
||||
js_rect = f"""(() => {{
|
||||
const el = document.querySelector({json.dumps(selector)});
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 || r.height === 0) return null;
|
||||
return {{x: r.x + r.width / 2, y: r.y + r.height / 2}};
|
||||
}})()"""
|
||||
rect = cdp_evaluate(ws, js_rect, timeout=timeout)
|
||||
if not rect or "x" not in rect:
|
||||
return False
|
||||
return cdp_dispatch_mouse_click(ws, float(rect["x"]), float(rect["y"]), timeout=timeout)
|
||||
|
||||
|
||||
def cdp_send_escape(ws: Any) -> None:
|
||||
"""Send an Escape keydown event to dismiss popovers and modals."""
|
||||
js_escape = """(() => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
keyCode: 27,
|
||||
which: 27,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
})()"""
|
||||
cdp_evaluate(ws, js_escape)
|
||||
|
||||
|
||||
class SettingsRPA:
|
||||
"""Automates Settings menu, dialogs, and usage inspection for a NetVM node."""
|
||||
|
||||
def __init__(self, node: str, timeout: float = 4.0):
|
||||
self.node = node
|
||||
self.timeout = timeout
|
||||
self.ws = None
|
||||
self.page = None
|
||||
|
||||
def connect(self) -> SettingsRPA:
|
||||
if self.ws is None:
|
||||
self.ws, self.page = get_cdp_ws(self.node, timeout=self.timeout)
|
||||
return self
|
||||
|
||||
def close(self) -> None:
|
||||
if self.ws is not None:
|
||||
try:
|
||||
self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
|
||||
def __enter__(self) -> SettingsRPA:
|
||||
return self.connect()
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
self.close()
|
||||
|
||||
def ensure_main_chat(self) -> bool:
|
||||
"""Ensure agent is in main chat home view so dock and main toolbar are active."""
|
||||
self.connect()
|
||||
js_is_main = """(() => {
|
||||
return Boolean(document.querySelector('[data-testid="hatch-invite-friends-button"]'));
|
||||
})()"""
|
||||
if cdp_evaluate(self.ws, js_is_main):
|
||||
return True
|
||||
|
||||
# Click the chat dock item to return to main
|
||||
cdp_evaluate(
|
||||
self.ws,
|
||||
"""(() => {
|
||||
const chatLink = document.querySelector('[data-testid="hatch-nav-chat"]');
|
||||
if (chatLink) { chatLink.click(); return true; }
|
||||
return false;
|
||||
})()""",
|
||||
)
|
||||
time.sleep(1.0)
|
||||
return bool(cdp_evaluate(self.ws, js_is_main))
|
||||
|
||||
def is_menu_open(self) -> bool:
|
||||
"""Check if dock radix menu is currently open."""
|
||||
self.connect()
|
||||
js_check = """(() => {
|
||||
return document.querySelectorAll('[role="menuitem"]').length > 0;
|
||||
})()"""
|
||||
return bool(cdp_evaluate(self.ws, js_check))
|
||||
|
||||
def is_settings_dialog_open(self) -> bool:
|
||||
"""Check if Settings dialog is currently open."""
|
||||
self.connect()
|
||||
js_check = """(() => {
|
||||
const d = document.querySelector('[role="dialog"]');
|
||||
if (!d) return false;
|
||||
const h = d.querySelector('h2');
|
||||
return h && h.innerText.trim().toLowerCase() === 'settings';
|
||||
})()"""
|
||||
return bool(cdp_evaluate(self.ws, js_check))
|
||||
|
||||
def open_dock_menu(self) -> bool:
|
||||
"""Open the dock settings menu via real mouse clicks on hatch-dock-more."""
|
||||
self.connect()
|
||||
if self.is_menu_open():
|
||||
return True
|
||||
|
||||
# Dispatch real mouse click on dock button
|
||||
clicked = cdp_click_element_by_selector(self.ws, '[data-testid="hatch-dock-more"]', timeout=self.timeout)
|
||||
if not clicked:
|
||||
return False
|
||||
|
||||
deadline = time.time() + 2.0
|
||||
while time.time() < deadline:
|
||||
if self.is_menu_open():
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
def close_dock_menu(self) -> None:
|
||||
"""Dismiss dock menu."""
|
||||
if self.is_menu_open():
|
||||
cdp_send_escape(self.ws)
|
||||
time.sleep(0.2)
|
||||
|
||||
def open_settings_dialog(self) -> bool:
|
||||
"""Open the Settings dialog through the dock settings menu."""
|
||||
self.connect()
|
||||
if self.is_settings_dialog_open():
|
||||
return True
|
||||
|
||||
# Ensure menu is open
|
||||
if not self.open_dock_menu():
|
||||
return False
|
||||
|
||||
time.sleep(0.3)
|
||||
# Click "Settings" menu item
|
||||
js_click_item = """(() => {
|
||||
const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
|
||||
const target = items.find(el => el.getAttribute('data-pel-click') === 'settings_nav_click') ||
|
||||
items.find(el => (el.innerText || '').trim() === 'Settings');
|
||||
if (target) {
|
||||
target.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})()"""
|
||||
clicked = cdp_evaluate(self.ws, js_click_item)
|
||||
if not clicked:
|
||||
return False
|
||||
|
||||
deadline = time.time() + 3.0
|
||||
while time.time() < deadline:
|
||||
if self.is_settings_dialog_open():
|
||||
return True
|
||||
time.sleep(0.15)
|
||||
return False
|
||||
|
||||
def close_settings_dialog(self) -> None:
|
||||
"""Dismiss Settings dialog with Escape or Close button."""
|
||||
if self.is_settings_dialog_open():
|
||||
cdp_send_escape(self.ws)
|
||||
time.sleep(0.3)
|
||||
# If still open, try close button
|
||||
if self.is_settings_dialog_open():
|
||||
cdp_evaluate(
|
||||
self.ws,
|
||||
"""(() => {
|
||||
const btn = Array.from(document.querySelectorAll('[role="dialog"] button')).find(b => (b.innerText||'').trim() === 'Close');
|
||||
if (btn) btn.click();
|
||||
})()""",
|
||||
)
|
||||
time.sleep(0.2)
|
||||
|
||||
def select_tab(self, tab_name: str) -> bool:
|
||||
"""Select a section tab inside the Settings dialog."""
|
||||
self.connect()
|
||||
if not self.open_settings_dialog():
|
||||
return False
|
||||
|
||||
js_tab = f"""(() => {{
|
||||
const btns = Array.from(document.querySelectorAll('[role="dialog"] button'));
|
||||
const tab = btns.find(b => (b.innerText||'').trim().toLowerCase() === {json.dumps(tab_name.lower())});
|
||||
if (tab) {{
|
||||
tab.click();
|
||||
return true;
|
||||
}}
|
||||
return false;
|
||||
}})()"""
|
||||
res = cdp_evaluate(self.ws, js_tab)
|
||||
if res:
|
||||
time.sleep(0.4)
|
||||
return True
|
||||
return False
|
||||
|
||||
def read_usage(self, keep_dialog_open: bool = False) -> NodeUsage:
|
||||
"""Open settings dialog, read token and weekly limit usage, and optionally close dialog."""
|
||||
self.connect()
|
||||
opened = self.open_settings_dialog()
|
||||
if not opened:
|
||||
raise RuntimeError(f"Could not open Settings dialog for node {self.node}")
|
||||
|
||||
# Ensure General tab is active
|
||||
self.select_tab("General")
|
||||
time.sleep(0.4)
|
||||
|
||||
js_usage = """(() => {
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
if (!dialog) return null;
|
||||
const fullText = dialog.innerText || '';
|
||||
const bars = Array.from(dialog.querySelectorAll('[role="progressbar"]')).map(b => ({
|
||||
label: b.getAttribute('aria-label') || '',
|
||||
percent: parseInt(b.getAttribute('aria-valuenow') || '0', 10),
|
||||
raw_text: b.parentElement ? b.parentElement.innerText.trim() : ''
|
||||
}));
|
||||
|
||||
// Extract plan
|
||||
let plan = 'Free plan';
|
||||
if (fullText.includes('Free plan')) plan = 'Free plan';
|
||||
else if (fullText.includes('Pro plan')) plan = 'Pro plan';
|
||||
|
||||
// Extract reset text
|
||||
let resetText = '';
|
||||
const mReset = fullText.match(/Weekly limit resets on [^\\n]+/);
|
||||
if (mReset) resetText = mReset[0];
|
||||
|
||||
// Extract tokens left
|
||||
let tokensLeft = '';
|
||||
const mLeft = fullText.match(/\\(([^\\)]+tokens left)\\)/);
|
||||
if (mLeft) tokensLeft = mLeft[1];
|
||||
else if (fullText.includes('0 tokens left')) tokensLeft = '0 tokens left';
|
||||
|
||||
return {
|
||||
dialogText: fullText,
|
||||
bars: bars,
|
||||
plan: plan,
|
||||
resetText: resetText,
|
||||
tokensLeft: tokensLeft
|
||||
};
|
||||
})()"""
|
||||
# Poll up to 4.0s for usage elements to render in React
|
||||
deadline = time.time() + 4.0
|
||||
raw = None
|
||||
while time.time() < deadline:
|
||||
res = cdp_evaluate(self.ws, js_usage)
|
||||
if res and (res.get("bars") or "Weekly limit" in res.get("dialogText", "") or "Additional tokens" in res.get("dialogText", "")):
|
||||
raw = res
|
||||
break
|
||||
time.sleep(0.3)
|
||||
if not raw:
|
||||
raw = cdp_evaluate(self.ws, js_usage)
|
||||
|
||||
if not keep_dialog_open:
|
||||
self.close_settings_dialog()
|
||||
|
||||
if not raw:
|
||||
raise RuntimeError(f"Failed to read usage data from Settings dialog on {self.node}")
|
||||
|
||||
bars = raw.get("bars", [])
|
||||
weekly_pct = 0
|
||||
extra_pct = 0
|
||||
extra_status = "Never expires"
|
||||
|
||||
for b in bars:
|
||||
label = b.get("label", "").lower()
|
||||
detail = b.get("raw_text", "").lower()
|
||||
if "free plan" in label or "weekly" in detail:
|
||||
weekly_pct = b.get("percent", 0)
|
||||
elif "additional tokens" in label or "additional" in detail:
|
||||
extra_pct = b.get("percent", 0)
|
||||
|
||||
# Blocked condition: weekly limit 100% and additional tokens 100% or 0 tokens left
|
||||
tokens_left = raw.get("tokensLeft", "")
|
||||
is_blocked = (weekly_pct >= 100 and extra_pct >= 100) or ("0 tokens left" in tokens_left)
|
||||
|
||||
# has_redeemed binary: If the "Additional tokens" ticker is present in Settings (or "Redeem invite code" entry is absent),
|
||||
# the agent has already redeemed an invite code.
|
||||
has_extra_ticker = any("additional tokens" in b.get("label", "").lower() or "additional" in b.get("raw_text", "").lower() for b in bars)
|
||||
has_redeemed = has_extra_ticker or ("Additional tokens" in raw.get("dialogText", ""))
|
||||
|
||||
return NodeUsage(
|
||||
node=self.node,
|
||||
plan=raw.get("plan", "Free plan"),
|
||||
weekly_reset_text=raw.get("resetText", ""),
|
||||
weekly_percent_used=weekly_pct,
|
||||
extra_tokens_status=extra_status,
|
||||
extra_percent_used=extra_pct,
|
||||
extra_tokens_remaining=tokens_left or ("0 tokens left" if extra_pct >= 100 else "Unknown"),
|
||||
is_blocked=is_blocked,
|
||||
bars=bars,
|
||||
dialog_text=raw.get("dialogText", ""),
|
||||
has_redeemed=has_redeemed,
|
||||
)
|
||||
|
||||
def check_redeem_entrypoint(self) -> Dict[str, Any]:
|
||||
"""Check if 'Redeem invite code' option is available in Settings -> General."""
|
||||
self.connect()
|
||||
if not self.open_settings_dialog():
|
||||
return {"available": False, "reason": "could_not_open_settings"}
|
||||
|
||||
self.select_tab("General")
|
||||
time.sleep(0.3)
|
||||
|
||||
js_check = """(() => {
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
if (!dialog) return {available: false, reason: 'no_dialog'};
|
||||
const items = Array.from(dialog.querySelectorAll('button, div, span'));
|
||||
const redeemItem = items.find(el => (el.innerText || '').trim() === 'Redeem invite code');
|
||||
return {
|
||||
available: Boolean(redeemItem),
|
||||
has_text: dialog.innerText.includes('Redeem invite code')
|
||||
};
|
||||
})()"""
|
||||
res = cdp_evaluate(self.ws, js_check)
|
||||
self.close_settings_dialog()
|
||||
return res or {"available": False, "reason": "evaluation_failed"}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Settings menu RPA for NetVM agents")
|
||||
parser.add_argument("node", help="Node name (e.g. 646, pip, muse, opm)")
|
||||
parser.add_argument("--usage", action="store_true", help="Read token and weekly quota usage")
|
||||
parser.add_argument("--json", action="store_true", help="Emit JSON output")
|
||||
parser.add_argument("--check-redeem", action="store_true", help="Check if Redeem Invite Code is available")
|
||||
args = parser.parse_args()
|
||||
|
||||
rpa = SettingsRPA(args.node)
|
||||
try:
|
||||
rpa.connect()
|
||||
if args.usage:
|
||||
usage = rpa.read_usage()
|
||||
if args.json:
|
||||
print(json.dumps(usage.to_dict(), indent=2))
|
||||
else:
|
||||
status_str = "BLOCKED (LIMIT REACHED)" if usage.is_blocked else "ACTIVE"
|
||||
print(f"=== Node {usage.node} Usage ===")
|
||||
print(f" Plan: {usage.plan}")
|
||||
print(f" Weekly Reset: {usage.weekly_reset_text} ({usage.weekly_percent_used}% used)")
|
||||
print(f" Extra Tokens: {usage.extra_tokens_remaining} ({usage.extra_percent_used}% used)")
|
||||
print(f" Status: {status_str}")
|
||||
elif args.check_redeem:
|
||||
info = rpa.check_redeem_entrypoint()
|
||||
print(json.dumps(info, indent=2))
|
||||
else:
|
||||
usage = rpa.read_usage()
|
||||
print(json.dumps(usage.to_dict(), indent=2))
|
||||
finally:
|
||||
rpa.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user