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:
operator
2026-10-06 19:33:17 +00:00
parent 4998ffddb6
commit b824f6d405
30 changed files with 4516 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
"""hatch_menu — modular muse.ai settings-menu navigation + toggles.
One module per part so a site change means patching one file:
mouse.py trusted input primitives (real click, escape, close)
dialog.py settings dialog open / tab nav / rows / back / text
controls.py generic radio/switch primitives (verify-then-fallback)
toggles.py toggle registry + sessions over controls and tabs
tabs/ one module per settings tab (uniform describe())
invite.py consumes this package; `box chromebox` exposes toggles.
"""
from hatch_menu.dialog import (
TAB_NAMES,
TABS,
click_row,
close_settings,
describe_rows,
dialog_present,
dialog_text,
go_back,
goto_tab,
open_settings,
)
from hatch_menu.mouse import MouseError, close, escape, real_click
from hatch_menu.toggles import (
MenuError,
describe_tab,
get_toggle,
list_toggles,
set_toggle,
)
__all__ = [
"TAB_NAMES",
"TABS",
"MenuError",
"MouseError",
"click_row",
"close",
"close_settings",
"describe_rows",
"describe_tab",
"dialog_present",
"dialog_text",
"escape",
"get_toggle",
"go_back",
"goto_tab",
"list_toggles",
"open_settings",
"real_click",
]
+260
View File
@@ -0,0 +1,260 @@
"""Generic control primitives (ws-level, no tab knowledge).
Radio/switch list + set with verify-then-fallback: synthetic click,
verify state flipped, else trusted real click, verify again. Tab
modules build their flows on these; toggles.py adds addressing.
"""
import time
from approvals import cdp_evaluate
from hatch_menu.mouse import real_click
JS_LIST_RADIOS = """(() => {
const d = document.querySelector('[role="dialog"]');
if (!d) return null;
return Array.from(d.querySelectorAll('input[type="radio"]')).map(r => {
let head = '', el = r.parentElement, depth = 0;
while (el && el !== d && depth < 6) {
const h = el.querySelector('h1,h2,h3,h4');
if (h && (h.innerText || '').trim()) {
head = h.innerText.trim().slice(0, 60);
break;
}
el = el.parentElement;
depth += 1;
}
const lab = r.closest('label');
return {value: r.value, checked: !!r.checked, heading: head,
aria: r.getAttribute('aria-label') || '',
label: lab ? (lab.innerText || '').trim().slice(0, 80) : ''};
});
})()"""
JS_CLICK_RADIO = """((heading, value) => {
const d = document.querySelector('[role="dialog"]');
if (!d) return 'NO_DIALOG';
const radios = Array.from(d.querySelectorAll('input[type="radio"]'));
const headOf = (r) => {
let el = r.parentElement, depth = 0;
while (el && el !== d && depth < 6) {
const h = el.querySelector('h1,h2,h3,h4');
if (h && (h.innerText || '').trim())
return h.innerText.trim().toLowerCase();
el = el.parentElement;
depth += 1;
}
return '';
};
const t = radios.find(r => headOf(r) === heading.toLowerCase()
&& r.value === value);
if (!t) return 'NO_MATCH';
t.click();
return 'CLICKED';
})('%s', '%s')"""
JS_RADIO_RECT = """((heading, value) => {
const d = document.querySelector('[role="dialog"]');
if (!d) return null;
const radios = Array.from(d.querySelectorAll('input[type="radio"]'));
const headOf = (r) => {
let el = r.parentElement, depth = 0;
while (el && el !== d && depth < 6) {
const h = el.querySelector('h1,h2,h3,h4');
if (h && (h.innerText || '').trim())
return h.innerText.trim().toLowerCase();
el = el.parentElement;
depth += 1;
}
return '';
};
const t = radios.find(r => headOf(r) === heading.toLowerCase()
&& r.value === value);
if (!t) return null;
const r = t.getBoundingClientRect();
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
})('%s', '%s')"""
JS_CLICK_RADIO_ARIA = """((name) => {
const d = document.querySelector('[role="dialog"]');
if (!d) return 'NO_DIALOG';
const n = name.toLowerCase();
const t = Array.from(d.querySelectorAll('input[type="radio"]'))
.find(r => (r.getAttribute('aria-label') || '').toLowerCase() === n
|| (r.value || '').toLowerCase() === n);
if (!t) return 'NO_MATCH';
t.click();
return 'CLICKED';
})('%s')"""
JS_RADIO_ARIA_RECT = """((name) => {
const d = document.querySelector('[role="dialog"]');
if (!d) return null;
const n = name.toLowerCase();
const t = Array.from(d.querySelectorAll('input[type="radio"]'))
.find(r => (r.getAttribute('aria-label') || '').toLowerCase() === n
|| (r.value || '').toLowerCase() === n);
if (!t) return null;
const r = t.getBoundingClientRect();
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
})('%s')"""
JS_LIST_SWITCHES = """(() => {
const d = document.querySelector('[role="dialog"]');
if (!d) return null;
return Array.from(d.querySelectorAll('[role="switch"]')).map(s => {
let el = s.parentElement, label = '', depth = 0;
while (el && el !== d && depth < 6) {
const t = (el.innerText || '').trim().replace(/\\s+/g, ' ');
if (t && t.length < 250) { label = t; break; }
el = el.parentElement;
depth += 1;
}
return {label: label.slice(0, 200),
aria: s.getAttribute('aria-label') || '',
checked: s.getAttribute('aria-checked') === 'true'};
});
})()"""
JS_CLICK_SWITCH = """((label) => {
const d = document.querySelector('[role="dialog"]');
if (!d) return 'NO_DIALOG';
const rowOf = (s) => {
let el = s.parentElement, depth = 0;
while (el && el !== d && depth < 6) {
const t = (el.innerText || '').trim();
if (t && t.length < 250) return t.toLowerCase();
el = el.parentElement;
depth += 1;
}
return '';
};
const hits = Array.from(d.querySelectorAll('[role="switch"]'))
.filter(s => rowOf(s).includes(label.toLowerCase())
|| (s.getAttribute('aria-label') || '').toLowerCase()
.includes(label.toLowerCase()));
if (!hits.length) return 'NO_MATCH';
if (hits.length > 1) return 'AMBIGUOUS';
hits[0].click();
return 'CLICKED';
})('%s')"""
JS_SWITCH_RECT = """((label) => {
const d = document.querySelector('[role="dialog"]');
if (!d) return null;
const rowOf = (s) => {
let el = s.parentElement, depth = 0;
while (el && el !== d && depth < 6) {
const t = (el.innerText || '').trim();
if (t && t.length < 250) return t.toLowerCase();
el = el.parentElement;
depth += 1;
}
return '';
};
const hits = Array.from(d.querySelectorAll('[role="switch"]'))
.filter(s => rowOf(s).includes(label.toLowerCase())
|| (s.getAttribute('aria-label') || '').toLowerCase()
.includes(label.toLowerCase()));
if (hits.length !== 1) return null;
const r = hits[0].getBoundingClientRect();
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
})('%s')"""
def _eval(ws, js, timeout=8.0):
try:
return cdp_evaluate(ws, js, timeout=timeout)
except Exception:
return None
def list_radios(ws):
"""All dialog radios with heading/label/value/checked (or [])."""
return _eval(ws, JS_LIST_RADIOS) or []
def list_switches(ws):
"""All dialog switches with row label + checked (or [])."""
return _eval(ws, JS_LIST_SWITCHES) or []
def radio_state(ws, heading, value):
"""Checked state of one heading-grouped radio, None if absent."""
for r in list_radios(ws):
if (r.get("heading") or "").lower() == heading.lower() \
and r.get("value") == value:
return bool(r.get("checked"))
return None
def set_radio_by_heading(ws, heading, value):
"""Set a heading-grouped radio; verify, else trusted click, verify."""
if _eval(ws, JS_CLICK_RADIO % (heading, value)) == "CLICKED" \
and radio_state(ws, heading, value) is True:
return True
rect = _eval(ws, JS_RADIO_RECT % (heading, value))
if not rect or "x" not in rect:
return False
try:
real_click(ws, rect["x"], rect["y"])
except Exception:
return False
time.sleep(0.6)
return radio_state(ws, heading, value) is True
def radio_aria_state(ws, name):
"""Checked state of one aria-labeled radio, None if absent."""
n = name.lower()
for r in list_radios(ws):
if (r.get("aria") or "").lower() == n \
or (r.get("value") or "").lower() == n:
return bool(r.get("checked"))
return None
def set_radio_by_aria(ws, name):
"""Set an aria-labeled radio; verify, else trusted click, verify."""
if _eval(ws, JS_CLICK_RADIO_ARIA % name) == "CLICKED" \
and radio_aria_state(ws, name) is True:
return True
rect = _eval(ws, JS_RADIO_ARIA_RECT % name)
if not rect or "x" not in rect:
return False
try:
real_click(ws, rect["x"], rect["y"])
except Exception:
return False
time.sleep(0.6)
return radio_aria_state(ws, name) is True
def switch_state(ws, label):
"""Checked state of one label-matched switch, None if not unique."""
matches = [s for s in list_switches(ws)
if label.lower() in (s.get("label") or "").lower()
or label.lower() in (s.get("aria") or "").lower()]
if len(matches) != 1:
return None
return bool(matches[0].get("checked"))
def set_switch(ws, label, on):
"""Set a switch by row-label/aria match; no-op when already there."""
state = switch_state(ws, label)
if state is None:
return False
if state == bool(on):
return True
if _eval(ws, JS_CLICK_SWITCH % label) == "CLICKED" \
and switch_state(ws, label) is bool(on):
return True
rect = _eval(ws, JS_SWITCH_RECT % label)
if not rect or "x" not in rect:
return False
try:
real_click(ws, rect["x"], rect["y"])
except Exception:
return False
time.sleep(0.6)
return switch_state(ws, label) is bool(on)
+151
View File
@@ -0,0 +1,151 @@
"""Settings dialog navigation: open, tabs, rows, back, text.
Open is retried (single-shot opens flake ~1/4 live): each attempt
re-Escapes and re-drives the dock menu from scratch. Tab clicks are
idempotent (re-clicking the active tab is a harmless no-op), so goto
always clicks and reports the click result instead of guessing which
tab is active.
"""
import time
from approvals import cdp_evaluate
from hatch_menu.mouse import escape, real_click
TAB_NAMES = ["General", "Connectors", "Wallet", "Secure store",
"Permissions", "Messaging channels", "Devices",
"Data controls", "Help & support", "Legal info"]
TABS = TAB_NAMES # legacy alias
DOCK_MORE_TESTID = "hatch-dock-more"
JS_DOCK_RECT = ("(() => { const b = document.querySelector("
"'[data-testid=\"hatch-dock-more\"]'); if (!b) return null;"
" const r = b.getBoundingClientRect();"
" return {x: r.x + r.width/2, y: r.y + r.height/2}; })()")
JS_CLICK_SETTINGS_ITEM = ("(() => { const it = Array.from(document."
"querySelectorAll('[role=\"menuitem\"]')).find(el => el.getAttribute("
"'data-pel-click') === 'settings_nav_click');"
" if (!it) return 'NO_ITEM'; it.click(); return 'CLICKED'; })()")
JS_DIALOG_PRESENT = ("(() => !!document.querySelector('[role=\"dialog\"]'))()")
JS_DIALOG_TEXT = ("(() => { const d = document.querySelector("
"'[role=\"dialog\"]'); return d ? d.innerText : null; })()")
JS_GOTO_TAB_TMPL = ("(() => { const b = Array.from(document."
"querySelectorAll('[role=\"dialog\"] button')).find(x => "
"(x.innerText||'').trim() === '%s');"
" if (!b) return 'NO_TAB'; b.click(); return 'CLICKED'; })()")
JS_CLICK_ROW_TMPL = ("((name) => {"
" const d = document.querySelector('[role=\"dialog\"]');"
" if (!d) return 'NO_DIALOG';"
" const inNav = (el) => !!el.closest("
"'nav, [role=\"tablist\"], [role=\"navigation\"]');"
" const els = Array.from(d.querySelectorAll("
"'button, [role=\"button\"], a')).filter(e => !inNav(e));"
" const t = els.find(e => (e.innerText || '').trim()"
".toLowerCase().startsWith(name.toLowerCase()));"
" if (!t) return 'NO_ROW'; t.click(); return 'CLICKED'; })('%s')")
JS_DESCRIBE_ROWS = ("(() => {"
" const d = document.querySelector('[role=\"dialog\"]');"
" if (!d) return null;"
" const inNav = (el) => !!el.closest("
"'nav, [role=\"tablist\"], [role=\"navigation\"]');"
" return Array.from(d.querySelectorAll("
"'button, [role=\"button\"], a')).filter(e => !inNav(e))"
".map(e => { const lines = (e.innerText || '').trim().split('\\n');"
" return {name: (lines[0] || '').slice(0, 80),"
" detail: lines.slice(1).join(' / ').slice(0, 120)}; }); })()")
JS_GO_BACK = ("(() => { const d = document.querySelector("
"'[role=\"dialog\"]'); if (!d) return 'NO_DIALOG';"
" const b = Array.from(d.querySelectorAll('button')).find("
"x => (x.getAttribute('aria-label') || '') === 'Go back');"
" if (!b) return 'NO_BACK'; b.click(); return 'CLICKED'; })()")
def _eval(ws, js, timeout=8.0):
try:
return cdp_evaluate(ws, js, timeout=timeout)
except Exception:
return None
def dialog_present(ws):
"""True when a dialog is currently open."""
return bool(_eval(ws, JS_DIALOG_PRESENT, timeout=5.0))
def dialog_text(ws, timeout=8.0, limit=4000):
"""Inner text of the open dialog, or None."""
try:
text = cdp_evaluate(ws, JS_DIALOG_TEXT, timeout=timeout)
except Exception:
return None
if not text:
return None
return text[:limit]
def open_settings(ws, tries=3):
"""Open the Settings dialog via the dock menu. True when open."""
for _ in range(tries):
escape(ws)
time.sleep(0.5)
rect = _eval(ws, JS_DOCK_RECT, timeout=5.0)
if not rect or "x" not in rect:
continue
try:
real_click(ws, rect["x"], rect["y"])
except Exception:
continue
time.sleep(1.0)
if _eval(ws, JS_CLICK_SETTINGS_ITEM,
timeout=5.0) != "CLICKED":
continue
time.sleep(1.5)
if dialog_present(ws):
return True
return False
def goto_tab(ws, name, timeout=5.0):
"""Click a Settings tab by visible name. True when clicked."""
if _eval(ws, JS_GOTO_TAB_TMPL % name, timeout=timeout) != "CLICKED":
return False
time.sleep(0.8)
return True
def click_row(ws, name, tab=None, timeout=8.0):
"""Click a content row; verify the drill/expand opened. Bool."""
if tab is not None and not goto_tab(ws, tab, timeout=timeout):
return False
if _eval(ws, JS_CLICK_ROW_TMPL % name, timeout=timeout) != "CLICKED":
return False
time.sleep(1.0)
text = dialog_text(ws, timeout=timeout) or ""
return name.lower() in text.lower()
def describe_rows(ws, tab=None, timeout=8.0):
"""Inventory rows (name/detail) on a tab. [] when unreadable."""
if tab is not None and not goto_tab(ws, tab, timeout=timeout):
return []
return _eval(ws, JS_DESCRIBE_ROWS, timeout=timeout) or []
def go_back(ws):
"""Click the sub-page Go back button. True when clicked."""
if _eval(ws, JS_GO_BACK, timeout=5.0) != "CLICKED":
return False
time.sleep(0.8)
return True
def close_settings(ws):
"""Dismiss settings/popovers. Never raises."""
escape(ws)
+62
View File
@@ -0,0 +1,62 @@
"""Trusted input primitives for menu automation.
Radix triggers (dock menu, permission-mode choosers) ignore synthetic
JS clicks: they need real CDP Input.dispatchMouseEvent press+release.
"""
import json
import time
from approvals import _cdp_req_ids as _shared_cdp_ids, cdp_evaluate
ESCAPE_JS = ("(() => { document.dispatchEvent(new KeyboardEvent("
"'keydown', {key: 'Escape', code: 'Escape',"
" bubbles: true})); return 'ESC'; })()")
class MouseError(RuntimeError):
"""Trusted click failed (CDP transport or echo timeout)."""
def escape(ws):
"""Dismiss topmost popover/menu/dialog. Never raises."""
try:
cdp_evaluate(ws, ESCAPE_JS, timeout=3.0)
except Exception:
pass
def close(ws):
"""Close a CDP websocket. Never raises."""
try:
ws.close()
except Exception:
pass
def real_click(ws, x, y, timeout=5.0):
"""Trusted press+release at page coordinates.
Shares approvals' monotonic CDP id counter so ids stay unique on
the connection; matches responses by id like cdp_evaluate.
Raises MouseError on transport or echo-timeout failure.
"""
try:
for typ in ("mousePressed", "mouseReleased"):
req_id = next(_shared_cdp_ids)
ws.send(json.dumps({"id": req_id,
"method": "Input.dispatchMouseEvent",
"params": {"type": typ, "x": x, "y": y,
"button": "left",
"clickCount": 1}}))
deadline = time.time() + timeout
while time.time() < deadline:
resp = json.loads(ws.recv())
if resp.get("id") == req_id:
break
else:
raise MouseError("mouse echo timeout for %s" % typ)
except MouseError:
raise
except Exception as e:
raise MouseError("real click failed: %s: %s"
% (type(e).__name__, e))
+30
View File
@@ -0,0 +1,30 @@
"""One module per Settings tab. Uniform: TAB, describe(ws)."""
from hatch_menu.tabs import (
connectors,
data_controls,
devices,
general,
help_support,
legal,
messaging,
permissions,
secure_store,
wallet,
)
TAB_MODULES = {
"General": general,
"Connectors": connectors,
"Wallet": wallet,
"Secure store": secure_store,
"Permissions": permissions,
"Messaging channels": messaging,
"Devices": devices,
"Data controls": data_controls,
"Help & support": help_support,
"Legal info": legal,
}
__all__ = ["TAB_MODULES", "connectors", "data_controls", "devices",
"general", "help_support", "legal", "messaging",
"permissions", "secure_store", "wallet"]
+10
View File
@@ -0,0 +1,10 @@
"""Connectors tab: read-only inventory (search + per-app Connect/View)."""
from hatch_menu import dialog
TAB = "Connectors"
def describe(ws):
"""Row inventory + text excerpt (describe-only for now)."""
return {"rows": dialog.describe_rows(ws, TAB),
"text": (dialog.dialog_text(ws) or "")[:400]}
+52
View File
@@ -0,0 +1,52 @@
"""Data controls tab: model-improvement switch (read-only otherwise).
The switch row label is not yet pinned from recon, so resolution
prefers the single switch on the tab and falls back to keyword
match. Import/Delete rows are inventoried, never touched.
"""
from hatch_menu import controls, dialog
TAB = "Data controls"
_KEYWORDS = ("improv", "train", "model", "data", "usage")
def _resolve(ws):
"""The improvement switch dict, or None when not resolvable."""
if not dialog.goto_tab(ws, TAB):
return None
switches = controls.list_switches(ws)
if len(switches) == 1:
return switches[0]
for kw in _KEYWORDS:
for s in switches:
blob = ((s.get("label") or "") + " "
+ (s.get("aria") or "")).lower()
if kw in blob:
return s
return None
def ai_improvement(ws):
"""Improvement-switch state: True/False, None when unreadable."""
sw = _resolve(ws)
return None if sw is None else bool(sw.get("checked"))
def set_ai_improvement(ws, on):
"""Set the improvement switch; verify-then-fallback. Bool."""
sw = _resolve(ws)
if sw is None:
return False
label = (sw.get("aria") or "").strip() \
or (sw.get("label") or "")[:40].strip()
if not label:
return False
return controls.set_switch(ws, label, on)
def describe(ws):
"""Inventory: switch state + row names (import/delete read-only)."""
state = ai_improvement(ws)
return {"ai_improvement":
("on" if state else "off") if state is not None else None,
"rows": dialog.describe_rows(ws, TAB)}
+13
View File
@@ -0,0 +1,13 @@
"""Devices tab: read-only inventory (paired devices or empty state)."""
from hatch_menu import dialog
TAB = "Devices"
def describe(ws):
"""Row inventory + empty flag (describe-only for now)."""
text = dialog.dialog_text(ws) or ""
low = text.lower()
return {"rows": dialog.describe_rows(ws, TAB),
"empty": ("no devices" in low or "don't have" in low),
"text": text[:400]}
+95
View File
@@ -0,0 +1,95 @@
"""General tab: usage balances, theme picker, redeem entrypoint.
Usage parsing moved here from invite.py (single copy). All flows are
ws-level: sessions and error shaping live in toggles.py.
"""
import re
from approvals import cdp_evaluate
from hatch_menu import controls, dialog
TAB = "General"
THEME_VALUES = ("match", "default", "blue", "purple", "pink",
"orange", "green", "beige", "monochrome")
_FREE_RE = re.compile(r"\bfree plan\b", re.IGNORECASE)
_PCT_RE = re.compile(r"(\d+)%\s*used")
_RESET_RE = re.compile(r"resets?\s+on\s+([A-Z][a-z]+\s+\d{1,2})",
re.IGNORECASE)
_TOK_RE = re.compile(r"\(([0-9.,]+\s*[BMK]?)\s*tokens?\s+left\)",
re.IGNORECASE)
def parse_usage_text(text):
"""Parse a General-tab usage block into a balance dict.
Returns None for empty/unreadable text. Weekly fields stay None
when the block only carries additional-tokens rows.
"""
if not text or not text.strip():
return None
out = {"plan": None, "weekly": {"pct_used": None, "resets_on": None},
"additional": {"pct_used": None, "tokens_left": None,
"never_expires": False}}
if _FREE_RE.search(text):
out["plan"] = "free"
pcts = _PCT_RE.findall(text)
if pcts:
out["weekly"]["pct_used"] = int(pcts[0])
if len(pcts) > 1:
out["additional"]["pct_used"] = int(pcts[1])
m = _RESET_RE.search(text)
if m:
out["weekly"]["resets_on"] = m.group(1)
m = _TOK_RE.search(text)
if m:
out["additional"]["tokens_left"] = m.group(1).strip()
if "never expires" in text.lower():
out["additional"]["never_expires"] = True
return out
def _eval(ws, js, timeout=8.0):
try:
return cdp_evaluate(ws, js, timeout=timeout)
except Exception:
return None
def usage(ws):
"""Usage balances from General tab. Dict, or None when unreadable."""
if not dialog.goto_tab(ws, TAB):
return None
return parse_usage_text(dialog.dialog_text(ws))
def theme(ws):
"""Current theme radio value, or None when unreadable."""
if not dialog.goto_tab(ws, TAB):
return None
for r in controls.list_radios(ws):
if r.get("checked"):
return r.get("value")
return None
def set_theme(ws, value):
"""Set theme by aria-label; verify-then-fallback. Bool."""
if not dialog.goto_tab(ws, TAB):
return False
return controls.set_radio_by_aria(ws, value)
def redeem_row(ws):
"""True when the 'Redeem invite code' row is present (eligible)."""
if not dialog.goto_tab(ws, TAB):
return False
text = (dialog.dialog_text(ws) or "").lower()
return "redeem" in text and "invite code" in text
def describe(ws):
"""Full General inventory: usage, theme, redeem entrypoint."""
return {"usage": usage(ws), "theme": theme(ws),
"redeem_row": redeem_row(ws)}
+10
View File
@@ -0,0 +1,10 @@
"""Help & support tab: read-only inventory (help links)."""
from hatch_menu import dialog
TAB = "Help & support"
def describe(ws):
"""Row inventory + text excerpt (describe-only for now)."""
return {"rows": dialog.describe_rows(ws, TAB),
"text": (dialog.dialog_text(ws) or "")[:400]}
+10
View File
@@ -0,0 +1,10 @@
"""Legal info tab: read-only inventory (legal links)."""
from hatch_menu import dialog
TAB = "Legal info"
def describe(ws):
"""Row inventory + text excerpt (describe-only for now)."""
return {"rows": dialog.describe_rows(ws, TAB),
"text": (dialog.dialog_text(ws) or "")[:400]}
+10
View File
@@ -0,0 +1,10 @@
"""Messaging channels tab: read-only inventory (channel rows)."""
from hatch_menu import dialog
TAB = "Messaging channels"
def describe(ws):
"""Row inventory + text excerpt (describe-only for now)."""
return {"rows": dialog.describe_rows(ws, TAB),
"text": (dialog.dialog_text(ws) or "")[:400]}
+383
View File
@@ -0,0 +1,383 @@
"""Permissions tab: defaults radios, website modes, protocols, advanced.
Contracts live here (single copy): toggles.py references these
constants for addressing. Drills open sub-pages, read, and come back
via the back affordance with tab re-entry fallback. All flows are
ws-level; sessions and error shaping live in toggles.py.
"""
import re
import time
from approvals import cdp_evaluate
from hatch_menu import controls, dialog
from hatch_menu.mouse import escape, real_click
TAB = "Permissions"
ROOT_MARK = "Manage permissions"
CONNECTOR_HEADING = "Connector defaults"
WEB_HEADING = "Web access defaults"
DEFAULT_VALUES = ("auto_allow", "always_ask")
WEBSITE_MODES = ("Allow", "Ask", "Deny")
ADV_LABELS = {"transparent_proxy": "Transparent proxy",
"tls_interception": "TLS interception",
"sni_mismatch_rejection": "SNI mismatch rejection"}
PROTOCOL_SLUGS = {"Model Context Protocol servers (SSE)": "mcp-sse",
"Model Context Protocol servers (Streamable HTTP)":
"mcp-streamable",
"Agent Skills endpoints": "agent-skills",
"MCP Apps (UI extensions)": "mcp-apps",
"MCP remote OAuth": "mcp-oauth"}
JS_WEBSITES = """(() => {
const d = document.querySelector('[role="dialog"]');
if (!d) return null;
return Array.from(d.querySelectorAll('button')).filter(b =>
['allow', 'ask', 'deny'].includes((b.getAttribute('aria-label') || '')
.trim().toLowerCase())).map(b => {
let el = b.parentElement, host = '', depth = 0;
while (el && el !== d && depth < 6) {
const t = (el.innerText || '').trim().split('\\n')[0] || '';
if (t && t.includes('.') && t.length < 120) { host = t; break; }
el = el.parentElement;
depth += 1;
}
return {host: host, mode: (b.getAttribute('aria-label') || '').trim(),
x: b.getBoundingClientRect().x + b.getBoundingClientRect().width / 2,
y: b.getBoundingClientRect().y + b.getBoundingClientRect().height / 2};
});
})()"""
JS_MODE_MENU = """(() => {
return Array.from(document.querySelectorAll('[role="menuitem"]'))
.map(m => ({text: (m.innerText || '').trim()}));
})()"""
JS_CLICK_MODE = """((mode) => {
const m = Array.from(document.querySelectorAll('[role="menuitem"]'))
.find(el => (el.innerText || '').trim() === mode);
if (!m) return 'NO_MATCH';
m.click();
return 'CLICKED';
})('%s')"""
JS_MODE_RECT = """((mode) => {
const m = Array.from(document.querySelectorAll('[role="menuitem"]'))
.find(el => (el.innerText || '').trim() === mode);
if (!m) return null;
const r = m.getBoundingClientRect();
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
})('%s')"""
JS_PROTO_ROWS = """(() => {
const d = document.querySelector('[role="dialog"]');
if (!d) return null;
return Array.from(d.querySelectorAll('[role="switch"]')).map(s => {
let el = s.parentElement, label = '', depth = 0;
while (el && el !== d && depth < 6) {
const t = (el.innerText || '').trim().replace(/\\s+/g, ' ');
if (t && t.length < 250) { label = t; break; }
el = el.parentElement;
depth += 1;
}
const r = s.getBoundingClientRect();
return {label: label.slice(0, 120),
checked: s.getAttribute('aria-checked') === 'true',
x: r.x + r.width / 2, y: r.y + r.height / 2};
});
})()"""
def _eval(ws, js, timeout=8.0):
try:
return cdp_evaluate(ws, js, timeout=timeout)
except Exception:
return None
def _slug(label):
"""Protocol slug: registry hit, else slugified, else None."""
if label in PROTOCOL_SLUGS:
return PROTOCOL_SLUGS[label]
clean = re.sub(r"[^a-z0-9]+", "-",
label.strip().lower()).strip("-")
return clean or None
def resolve_protocol(name):
"""Slug or label fragment -> row label, None when unresolvable."""
if not isinstance(name, str) or not name.strip():
return None
want = name.strip().lower()
for label, slug in PROTOCOL_SLUGS.items():
if want == slug or want == label.lower():
return label
return None
def _back_to_root(ws):
"""Back affordance, else tab re-entry; verify root text."""
dialog.go_back(ws)
if ROOT_MARK in (dialog.dialog_text(ws) or ""):
return True
if not dialog.goto_tab(ws, TAB):
return False
return ROOT_MARK in (dialog.dialog_text(ws) or "")
def defaults(ws):
"""Connector + web default values (each value or None)."""
if not dialog.goto_tab(ws, TAB):
return {"connector_defaults": None, "web_access": None}
heads = {CONNECTOR_HEADING.lower(): "connector_defaults",
WEB_HEADING.lower(): "web_access"}
vals = {CONNECTOR_HEADING.lower(): [], WEB_HEADING.lower(): []}
for r in controls.list_radios(ws):
h = (r.get("heading") or "").lower()
if h in vals and r.get("checked"):
vals[h].append(r.get("value"))
out = {}
for h, key in heads.items():
out[key] = vals[h][0] if len(vals[h]) == 1 else None
return out
def set_default(ws, which, value):
"""Set one defaults radio. Bool."""
if not dialog.goto_tab(ws, TAB):
return False
heading = CONNECTOR_HEADING if which == "connector_defaults" \
else WEB_HEADING
return controls.set_radio_by_heading(ws, heading, value)
def ensure_advanced(ws):
"""Expand Advanced network settings when collapsed. Bool."""
if not dialog.goto_tab(ws, TAB):
return False
labels = [s.get("label", "") for s in controls.list_switches(ws)]
if any("Transparent proxy" in lab for lab in labels):
return True
if not dialog.click_row(ws, "Advanced network settings", TAB):
return False
time.sleep(0.6)
labels = [s.get("label", "") for s in controls.list_switches(ws)]
return any("Transparent proxy" in lab for lab in labels)
def advanced(ws):
"""Advanced switch states {key: on/off/None}."""
if not ensure_advanced(ws):
return {k: None for k in ADV_LABELS}
out = {}
for key, label in ADV_LABELS.items():
state = None
for s in controls.list_switches(ws):
if label.lower() in (s.get("label") or "").lower():
state = bool(s.get("checked"))
break
out[key] = ("on" if state else "off") if state is not None \
else None
return out
def set_advanced(ws, key, on):
"""Set one advanced switch. Bool."""
if key not in ADV_LABELS or not ensure_advanced(ws):
return False
return controls.set_switch(ws, ADV_LABELS[key], on)
def _websites_raw(ws):
"""Drill into Websites; rows or None (stays on sub-page)."""
if not dialog.click_row(ws, "Websites", TAB):
return None
time.sleep(0.6)
return _eval(ws, JS_WEBSITES)
def websites(ws):
"""[{host, mode}] (back at root afterwards)."""
rows = _websites_raw(ws)
if rows is None:
return []
out = [{"host": r.get("host"), "mode": r.get("mode")} for r in rows]
_back_to_root(ws)
return out
def website_mode(ws, host):
"""Mode for one host, or None when absent/unreadable."""
for row in websites(ws):
if (row.get("host") or "").lower() == host.lower():
return row.get("mode")
return None
def set_website_mode(ws, host, mode):
"""Set one host mode via the mode chooser. Verify + readback. Bool."""
if mode not in WEBSITE_MODES:
return False
rows = _websites_raw(ws)
if rows is None:
return False
target = next((r for r in rows
if (r.get("host") or "").lower() == host.lower()),
None)
if target is None:
_back_to_root(ws)
return False
try:
real_click(ws, target["x"], target["y"])
except Exception:
_back_to_root(ws)
return False
time.sleep(0.8)
items = _eval(ws, JS_MODE_MENU) or []
texts = [(i.get("text") or "") for i in items]
if mode not in texts:
escape(ws)
_back_to_root(ws)
return False
if _eval(ws, JS_CLICK_MODE % mode) != "CLICKED":
escape(ws)
_back_to_root(ws)
return False
time.sleep(0.6)
rows = _eval(ws, JS_WEBSITES) or []
cur = next(((r.get("mode")) for r in rows
if (r.get("host") or "").lower() == host.lower()),
None)
if cur == mode:
_back_to_root(ws)
return True
rect = _eval(ws, JS_MODE_RECT % mode)
if rect and "x" in rect:
try:
real_click(ws, rect["x"], rect["y"])
except Exception:
pass
time.sleep(0.6)
rows = _eval(ws, JS_WEBSITES) or []
cur = next(((r.get("mode")) for r in rows
if (r.get("host") or "").lower() == host.lower()),
None)
if cur == mode:
_back_to_root(ws)
return True
escape(ws)
_back_to_root(ws)
return False
def _protocols_raw(ws):
"""Drill into protocols; rows or None (stays on sub-page)."""
if not dialog.click_row(ws, "Direct network protocols", TAB):
return None
time.sleep(0.6)
return _eval(ws, JS_PROTO_ROWS)
def protocols(ws):
"""[{slug, label, on}] (back at root afterwards)."""
rows = _protocols_raw(ws)
if rows is None:
return []
out = [{"slug": _slug(r.get("label", "")),
"label": r.get("label", ""),
"on": "on" if r.get("checked") else "off"} for r in rows]
_back_to_root(ws)
return out
def protocol_state(ws, label):
"""on/off for one protocol row label, None when absent."""
for row in protocols(ws):
if row.get("label") == label:
return row.get("on")
return None
def set_protocol(ws, label, on):
"""Set one protocol switch in place; readback before returning."""
rows = _protocols_raw(ws)
if rows is None:
return False
target = next((r for r in rows if r.get("label") == label), None)
if target is None:
_back_to_root(ws)
return False
want = bool(on)
if bool(target.get("checked")) == want:
_back_to_root(ws)
return True
try:
real_click(ws, target["x"], target["y"])
except Exception:
_back_to_root(ws)
return False
time.sleep(0.6)
rows = _eval(ws, JS_PROTO_ROWS) or []
cur = next((r for r in rows if r.get("label") == label), None)
ok = cur is not None and bool(cur.get("checked")) == want
_back_to_root(ws)
return ok
def manage_counts(ws):
"""Manageable-row summary counts (name -> count|None)."""
if not dialog.goto_tab(ws, TAB):
return {}
text = dialog.dialog_text(ws) or ""
out = {}
for name in ("Websites", "Connectors", "Scheduled tasks",
"Direct network protocols"):
m = re.search(re.escape(name) + r"\s*(\d+)", text)
out[name] = int(m.group(1)) if m else None
return out
def scheduled_tasks(ws):
"""[{name, cadence}] (back at root afterwards; empty when none)."""
if not dialog.click_row(ws, "Scheduled tasks", TAB):
return []
time.sleep(0.6)
text = dialog.dialog_text(ws) or ""
_back_to_root(ws)
rows = []
lines = [line.strip() for line in text.splitlines()
if line.strip()]
for i, line in enumerate(lines):
if re.search(r"\b(daily|weekly|hourly|every|min)\b", line,
re.IGNORECASE) and i > 0:
rows.append({"name": lines[i - 1], "cadence": line})
return rows
def all_toggles(ws):
"""Flat map of every settable Permissions toggle (for list)."""
out = {}
defs = defaults(ws)
out["permissions.connector_defaults"] = defs.get("connector_defaults")
out["permissions.web_access"] = defs.get("web_access")
adv = advanced(ws)
for key, val in adv.items():
out["permissions.advanced." + key] = val
for row in protocols(ws):
if row.get("slug"):
out["permissions.protocols:" + row["slug"]] = row.get("on")
for row in websites(ws):
if row.get("host"):
out["permissions.websites:" + row["host"]] = row.get("mode")
return out
def describe(ws):
"""Full Permissions inventory: defaults, counts, adv, rows."""
return {"defaults": defaults(ws), "counts": manage_counts(ws),
"advanced": advanced(ws), "websites": websites(ws),
"protocols": protocols(ws),
"scheduled_tasks": scheduled_tasks(ws)}
+10
View File
@@ -0,0 +1,10 @@
"""Secure store tab: read-only inventory (secret entries + add)."""
from hatch_menu import dialog
TAB = "Secure store"
def describe(ws):
"""Row inventory + text excerpt (describe-only for now)."""
return {"rows": dialog.describe_rows(ws, TAB),
"text": (dialog.dialog_text(ws) or "")[:400]}
+10
View File
@@ -0,0 +1,10 @@
"""Wallet tab: read-only inventory (payment methods + add row)."""
from hatch_menu import dialog
TAB = "Wallet"
def describe(ws):
"""Row inventory + text excerpt (describe-only for now)."""
return {"rows": dialog.describe_rows(ws, TAB),
"text": (dialog.dialog_text(ws) or "")[:400]}
+305
View File
@@ -0,0 +1,305 @@
"""Toggle registry + sessions over controls.py and tabs/*.
Every settable toggle has ONE address; contracts live in the tab
modules (single-copy per part), addressing here. Set flows read back
through a fresh session and never partially report success.
Caller errors (unknown node/toggle/value) raise MenuError before any
CDP traffic. Transport failures return {"ok": False, ...}.
"""
from approvals import VALID_NODES, get_cdp_ws
from hatch_menu import controls, dialog
from hatch_menu.mouse import close, escape
from hatch_menu.tabs import TAB_MODULES
_PERM = TAB_MODULES["Permissions"]
_DC = TAB_MODULES["Data controls"]
_GEN = TAB_MODULES["General"]
class MenuError(ValueError):
"""Caller error: unknown node, toggle, or value. Raised before CDP."""
TOGGLES = {
"permissions.connector_defaults": {
"tab": "Permissions", "kind": "radio-heading",
"heading": _PERM.CONNECTOR_HEADING,
"values": _PERM.DEFAULT_VALUES},
"permissions.web_access": {
"tab": "Permissions", "kind": "radio-heading",
"heading": _PERM.WEB_HEADING,
"values": _PERM.DEFAULT_VALUES},
"permissions.advanced.transparent_proxy": {
"tab": "Permissions", "kind": "adv-switch",
"label": _PERM.ADV_LABELS["transparent_proxy"],
"values": ("on", "off")},
"permissions.advanced.tls_interception": {
"tab": "Permissions", "kind": "adv-switch",
"label": _PERM.ADV_LABELS["tls_interception"],
"values": ("on", "off")},
"permissions.advanced.sni_mismatch_rejection": {
"tab": "Permissions", "kind": "adv-switch",
"label": _PERM.ADV_LABELS["sni_mismatch_rejection"],
"values": ("on", "off")},
"data_controls.ai_improvement": {
"tab": "Data controls", "kind": "switch",
"values": ("on", "off")},
"general.theme": {
"tab": "General", "kind": "radio-aria",
"values": _GEN.THEME_VALUES},
}
WEBSITE_PREFIX = "permissions.websites:"
PROTOCOL_PREFIX = "permissions.protocols:"
def _check_node(node):
if node not in VALID_NODES:
raise MenuError("unknown node: %r (valid: %s)"
% (node, ", ".join(VALID_NODES)))
def normalize_onoff(value):
"""on/off/true/false/1/0/yes/no -> bool. None when invalid."""
if isinstance(value, bool):
return value
if not isinstance(value, str):
return None
v = value.strip().lower()
if v in ("on", "true", "1", "yes"):
return True
if v in ("off", "false", "0", "no"):
return False
return None
def resolve_toggle(name):
"""Resolve a toggle address to its spec. Raises MenuError."""
if not isinstance(name, str) or not name:
raise MenuError("toggle name must be a non-empty string")
if name in TOGGLES:
spec = dict(TOGGLES[name])
spec["name"] = name
return spec
if name.startswith(WEBSITE_PREFIX) and len(name) > len(WEBSITE_PREFIX):
return {"name": name, "tab": "Permissions", "kind": "website",
"host": name[len(WEBSITE_PREFIX):],
"values": _PERM.WEBSITE_MODES}
if name.startswith(PROTOCOL_PREFIX) and len(name) > len(PROTOCOL_PREFIX):
label = _PERM.resolve_protocol(name[len(PROTOCOL_PREFIX):])
if label is None:
raise MenuError("unknown protocol: %r (see list_toggles)"
% (name,))
return {"name": name, "tab": "Permissions", "kind": "protocol",
"label": label, "values": ("on", "off")}
raise MenuError("unknown toggle: %r (see list_toggles)" % (name,))
def _session(node):
_check_node(node)
try:
ws, _ = get_cdp_ws(node)
except Exception as e:
raise MenuError("CDP unreachable for %s: %s" % (node, e))
return ws
def _normalize_value(spec, value):
kind = spec["kind"]
if kind == "website":
if not isinstance(value, str):
raise MenuError("mode must be one of %s"
% (spec["values"],))
for m in spec["values"]:
if value.strip().lower() == m.lower():
return m
raise MenuError("mode must be one of %s (got %r)"
% (spec["values"], value))
if kind in ("switch", "adv-switch", "protocol"):
b = normalize_onoff(value)
if b is None:
raise MenuError("value must be on/off (got %r)" % (value,))
return "on" if b else "off"
if kind == "radio-heading":
if value not in spec["values"]:
raise MenuError("%s must be one of %s (got %r)"
% (spec["name"], spec["values"], value))
return value
if kind == "radio-aria":
if not isinstance(value, str):
raise MenuError("theme must be one of %s" % (spec["values"],))
v = value.strip().lower()
if v in spec["values"]:
return v
raise MenuError("theme must be one of %s (got %r)"
% (spec["values"], value))
raise MenuError("cannot set kind %r" % (kind,))
def get_toggle(node, name):
"""Read one toggle. Returns {"ok", "node", "toggle", "value"}."""
spec = resolve_toggle(name)
_check_node(node)
try:
ws = _session(node)
except MenuError as e:
return {"ok": False, "node": node, "toggle": name,
"error": str(e)}
try:
if not dialog.open_settings(ws):
return {"ok": False, "node": node, "toggle": name,
"error": "settings dialog did not open"}
if not dialog.goto_tab(ws, spec["tab"]):
return {"ok": False, "node": node, "toggle": name,
"error": "tab did not open: %s" % spec["tab"]}
kind = spec["kind"]
if kind == "radio-heading":
vals = {r["value"]: r["checked"]
for r in controls.list_radios(ws)
if (r.get("heading") or "").lower()
== spec["heading"].lower()}
on = [v for v, c in vals.items() if c]
value = on[0] if len(on) == 1 else None
elif kind == "radio-aria":
vals = [(r.get("value"), r.get("checked"))
for r in controls.list_radios(ws)]
on = [v for v, c in vals if c]
value = on[0] if len(on) == 1 else None
elif kind == "switch":
state = _DC.ai_improvement(ws)
value = ("on" if state else "off") if state is not None \
else None
elif kind == "adv-switch":
_PERM.ensure_advanced(ws)
state = controls.switch_state(ws, spec["label"])
value = ("on" if state else "off") if state is not None \
else None
elif kind == "website":
value = _PERM.website_mode(ws, spec["host"])
elif kind == "protocol":
value = _PERM.protocol_state(ws, spec["label"])
else:
value = None
if value is None:
return {"ok": False, "node": node, "toggle": name,
"error": "toggle not readable (site changed?)"}
return {"ok": True, "node": node, "toggle": name, "value": value}
except Exception as e:
return {"ok": False, "node": node, "toggle": name,
"error": "%s: %s" % (type(e).__name__, e)}
finally:
escape(ws)
close(ws)
def set_toggle(node, name, value):
"""Set one toggle with readback. Never partially reports success."""
spec = resolve_toggle(name)
want = _normalize_value(spec, value)
_check_node(node)
try:
ws = _session(node)
except MenuError as e:
return {"ok": False, "node": node, "toggle": name,
"error": str(e)}
try:
if not dialog.open_settings(ws):
return {"ok": False, "node": node, "toggle": name,
"error": "settings dialog did not open"}
if not dialog.goto_tab(ws, spec["tab"]):
return {"ok": False, "node": node, "toggle": name,
"error": "tab did not open: %s" % spec["tab"]}
kind = spec["kind"]
if kind == "radio-heading":
ok = controls.set_radio_by_heading(ws, spec["heading"], want)
elif kind == "radio-aria":
ok = controls.set_radio_by_aria(ws, want)
elif kind == "switch":
ok = _DC.set_ai_improvement(ws, want == "on")
elif kind == "adv-switch":
_PERM.ensure_advanced(ws)
ok = controls.set_switch(ws, spec["label"], want == "on")
elif kind == "website":
ok = _PERM.set_website_mode(ws, spec["host"], want)
elif kind == "protocol":
ok = _PERM.set_protocol(ws, spec["label"], want == "on")
else:
ok = False
if not ok:
return {"ok": False, "node": node, "toggle": name,
"error": "set failed verification (site changed?)"}
readback = get_toggle(node, name)
if not readback.get("ok") or readback.get("value") != want:
return {"ok": False, "node": node, "toggle": name,
"error": "readback mismatch (want %r, got %r)"
% (want, readback.get("value"))}
return {"ok": True, "node": node, "toggle": name, "value": want}
except Exception as e:
return {"ok": False, "node": node, "toggle": name,
"error": "%s: %s" % (type(e).__name__, e)}
finally:
escape(ws)
close(ws)
def list_toggles(node, tab=None):
"""All toggle states, optionally filtered to one tab."""
_check_node(node)
if tab is not None and tab not in TAB_MODULES:
raise MenuError("unknown tab: %r (valid: %s)"
% (tab, sorted(TAB_MODULES)))
want = [tab] if tab else ["Permissions", "Data controls", "General"]
try:
ws = _session(node)
except MenuError as e:
return {"ok": False, "node": node, "error": str(e)}
try:
if not dialog.open_settings(ws):
return {"ok": False, "node": node,
"error": "settings dialog did not open"}
out = {}
if "Permissions" in want:
dialog.goto_tab(ws, "Permissions")
out.update(_PERM.all_toggles(ws))
if "Data controls" in want:
state = _DC.ai_improvement(ws)
out["data_controls.ai_improvement"] = \
("on" if state else "off") if state is not None else None
if "General" in want:
dialog.goto_tab(ws, "General")
out["general.theme"] = _GEN.theme(ws)
return {"ok": True, "node": node, "toggles": out}
except Exception as e:
return {"ok": False, "node": node,
"error": "%s: %s" % (type(e).__name__, e)}
finally:
escape(ws)
close(ws)
def describe_tab(node, tab):
"""Full inventory of one tab (debugging/patching aid)."""
_check_node(node)
if tab not in TAB_MODULES:
raise MenuError("unknown tab: %r (valid: %s)"
% (tab, sorted(TAB_MODULES)))
try:
ws = _session(node)
except MenuError as e:
return {"ok": False, "node": node, "tab": tab, "error": str(e)}
try:
if not dialog.open_settings(ws):
return {"ok": False, "node": node, "tab": tab,
"error": "settings dialog did not open"}
if not dialog.goto_tab(ws, tab):
return {"ok": False, "node": node, "tab": tab,
"error": "tab did not open"}
return {"ok": True, "node": node, "tab": tab,
"inventory": TAB_MODULES[tab].describe(ws)}
except Exception as e:
return {"ok": False, "node": node, "tab": tab,
"error": "%s: %s" % (type(e).__name__, e)}
finally:
escape(ws)
close(ws)
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""invite.py — Muse.ai invite codes + usage via the agent browsers.
Find: GET /api/hatch/invite via in-page fetch (primary; proven live),
Invite-button popover parse (DOM fallback).
Redeem: POST /api/hatch/invite-code/redeem via in-page fetch.
Usage: Settings > General DOM read (weekly reset, % used, additional
tokens) through the hatch_menu tree (dialog/mouse/tabs);
settings-nav primitives live there, this module keeps the
invite API + popover flows and the flat CLI parse.
Server eligibility (observed live): one redemption per account
(already_redeemed); 48h window from joining (window_expired); codes
carry limited uses (used_up) and can be revoked. Inviter rewards
accrue regardless of the inviter's own redemption state.
Caller errors (unknown node, malformed code) raise InviteError before
any CDP traffic. Transport/CDP failures return {"ok": False, ...}.
"""
import re
import time
from approvals import VALID_NODES, cdp_evaluate, get_cdp_ws
from hatch_menu import dialog as menu_dialog
from hatch_menu.mouse import close as _close
from hatch_menu.mouse import escape as _escape
from hatch_menu.tabs import general as general_tab
class InviteError(ValueError):
"""Caller error: unknown node or malformed code. Raised before CDP."""
CODE_RE = re.compile(r"^[A-Z0-9]{6}$")
CODE_REVEALED_RE = re.compile(r"Invite code revealed:\s*([A-Z0-9]{6})")
JS_INVITE_GET = """(async () => {
try {
const r = await fetch('/api/hatch/invite',
{method: 'GET', cache: 'no-store'});
return {status: r.status, data: await r.json()};
} catch (e) { return {error: String(e).slice(0, 200)}; }
})()"""
# %s is a validated [A-Z0-9]{6} code: injection-safe by construction.
JS_REDEEM_TMPL = """(async () => {
try {
const r = await fetch('/api/hatch/invite-code/redeem', {method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code: '%s', supportsRedemptionStatus: true})});
return {status: r.status, ok: r.ok, data: await r.json()};
} catch (e) { return {error: String(e).slice(0, 200)}; }
})()"""
JS_INVITE_CLICK = ("(() => { const b = document.querySelector("
"'[data-testid=\"hatch-invite-friends-button\"]');"
" if (!b) return 'NO_BUTTON'; b.click();"
" return 'CLICKED'; })()")
JS_POPOVER_TEXT = ("(() => { const d = document.querySelector("
"'[data-slot=\"popover-content\"]');"
" return d ? d.innerText : null; })()")
def normalize_code(code):
"""Uppercase/strip a code; None unless 6-char A-Z0-9."""
if not isinstance(code, str):
return None
c = code.strip().upper()
return c if CODE_RE.fullmatch(c) else None
def _check_node(node):
if node not in VALID_NODES:
raise InviteError("unknown node: %r (valid: %s)"
% (node, ", ".join(VALID_NODES)))
def open_settings(ws):
"""Open the Settings dialog via the dock menu. True when open.
Public shim over hatch_menu.dialog.open_settings (single copy).
"""
return menu_dialog.open_settings(ws)
def click_settings_tab(ws, name):
"""Open a Settings dialog tab by visible name. True when open.
Public shim over hatch_menu.dialog.goto_tab (single copy).
"""
return menu_dialog.goto_tab(ws, name)
def _popover_code(ws):
"""Invite code via the main-chat popover (DOM fallback). None if absent."""
try:
if cdp_evaluate(ws, JS_INVITE_CLICK, timeout=5.0) != "CLICKED":
return None
time.sleep(1.0)
text = cdp_evaluate(ws, JS_POPOVER_TEXT, timeout=5.0) or ""
except Exception:
return None
finally:
_escape(ws)
m = CODE_REVEALED_RE.search(text)
return m.group(1) if m else None
def get_invite(node, timeout=8.0):
"""Per-agent invite state. API first, popover fallback for the code."""
_check_node(node)
try:
ws, _ = get_cdp_ws(node)
except Exception as e:
return {"ok": False, "node": node,
"error": "CDP unreachable: %s" % e}
try:
try:
res = cdp_evaluate(ws, JS_INVITE_GET, await_promise=True,
timeout=timeout)
except Exception as e:
res = {"error": "%s: %s" % (type(e).__name__, e)}
if isinstance(res, dict) and res.get("status") == 200 \
and isinstance(res.get("data"), dict) \
and res["data"].get("code"):
d = res["data"]
return {"ok": True, "node": node, "source": "api",
"code": d.get("code"),
"has_redeemed": d.get("has_redeemed_invite_code"),
"uses_remaining": d.get("uses_remaining"),
"use_count": d.get("use_count"),
"reward": d.get("reward")}
code = _popover_code(ws)
if code:
return {"ok": True, "node": node, "source": "dom",
"code": code, "has_redeemed": None,
"uses_remaining": None, "use_count": None,
"reward": None}
if isinstance(res, dict):
detail = res.get("error", "invite API failed")
else:
detail = "invite API failed"
return {"ok": False, "node": node,
"error": "invite lookup failed (%s); popover has no code"
% detail}
finally:
_close(ws)
def redeem_invite(node, code, timeout=12.0):
"""Redeem CODE on node. Returns ok / server reason + detail."""
c = normalize_code(code)
if c is None:
raise InviteError("malformed invite code: %r (want 6 chars A-Z0-9)"
% (code,))
_check_node(node)
js = JS_REDEEM_TMPL % c
try:
ws, _ = get_cdp_ws(node)
except Exception as e:
return {"ok": False, "node": node, "code": c,
"error": "CDP unreachable: %s" % e}
try:
try:
res = cdp_evaluate(ws, js, await_promise=True, timeout=timeout)
except Exception as e:
return {"ok": False, "node": node, "code": c,
"error": "%s: %s" % (type(e).__name__, e)}
if not isinstance(res, dict) or "data" not in res:
if isinstance(res, dict):
detail = res.get("error", "empty redeem response")
else:
detail = "empty redeem response"
return {"ok": False, "node": node, "code": c,
"reason": "unknown", "detail": detail}
data = res.get("data") or {}
if res.get("ok") and data.get("success"):
return {"ok": True, "node": node, "code": c,
"redemption_status": data.get("redemptionStatus"),
"detail": data.get("detail")}
return {"ok": False, "node": node, "code": c,
"reason": data.get("reason", "unknown"),
"detail": data.get("detail")}
finally:
_close(ws)
def parse_usage_text(text):
"""Flat usage parse (CLI contract) over the tree's General parser."""
nested = general_tab.parse_usage_text(text)
wk = (nested.get("weekly") if nested else None) or {}
add = (nested.get("additional") if nested else None) or {}
tokens = add.get("tokens_left")
return {
"weekly_reset": wk.get("resets_on"),
"weekly_used_pct": wk.get("pct_used"),
"additional_expires": "Never expires"
if add.get("never_expires") else None,
"additional_used_pct": add.get("pct_used"),
"additional_left": ("%s tokens left" % tokens)
if tokens else None,
"has_redeemed": bool(add.get("never_expires") or tokens
or "additional tokens" in (text or "").lower()),
}
def get_usage(node, timeout=8.0):
"""Usage limits for one agent via Settings General (DOM read)."""
_check_node(node)
try:
ws, _ = get_cdp_ws(node)
except Exception as e:
return {"ok": False, "node": node,
"error": "CDP unreachable: %s" % e}
try:
if not open_settings(ws):
return {"ok": False, "node": node,
"error": "settings dialog did not open"}
if not menu_dialog.goto_tab(ws, "General"):
return {"ok": False, "node": node,
"error": "General tab did not open"}
# Note: Usage stats and redeem field can load asynchronously in the React/Radix tree.
# Poll up to timeout seconds for usage or redeem markers.
text = None
deadline = time.time() + timeout
while time.time() < deadline:
t = menu_dialog.dialog_text(ws)
if t and ("Weekly limit" in t or "Additional tokens" in t or "Redeem invite code" in t or "tokens left" in t or "% used" in t):
text = t
break
time.sleep(0.4)
if not text:
text = menu_dialog.dialog_text(ws)
if not text:
return {"ok": False, "node": node,
"error": "empty settings dialog"}
out = {"ok": True, "node": node}
out.update(parse_usage_text(text))
return out
finally:
_escape(ws)
_close(ws)
def fleet_invite_status(nodes=None):
"""Invite state per node. Never raises; per-node error dicts."""
out = {}
for n in (nodes or list(VALID_NODES)):
try:
out[n] = get_invite(n)
except InviteError as e:
out[n] = {"ok": False, "node": n, "error": str(e)}
return out
def fleet_usage(nodes=None):
"""Usage limits per node. Never raises; per-node error dicts."""
out = {}
for n in (nodes or list(VALID_NODES)):
try:
out[n] = get_usage(n)
except InviteError as e:
out[n] = {"ok": False, "node": n, "error": str(e)}
return out
+597
View File
@@ -0,0 +1,597 @@
#!/usr/bin/env python3
"""invite_handler.py: Invite code discovery, inspection, and redemption handler.
Supports:
- Extracting agent invite codes via Main Chat DOM RPA & in-session API
- Redeeming invite codes via Settings Menu RPA & redemption endpoint
- Fleet-wide invite inventory, usage tracking, and agent salvage flows
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from dataclasses import asdict, dataclass
from typing import Any, Dict, List, Optional
try:
from approvals import cdp_evaluate, get_cdp_ws, get_node_pages
from settings_rpa import SettingsRPA, cdp_click_element_by_selector, cdp_send_escape
except ImportError:
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from approvals import cdp_evaluate, get_cdp_ws, get_node_pages
from settings_rpa import SettingsRPA, cdp_click_element_by_selector, cdp_send_escape
VALID_NODES = ["muse", "pip", "646", "opm", "def", "dev"]
@dataclass
class InviteCodeInfo:
node: str
code: str
uses_remaining: int
use_count: int
max_uses: int
has_redeemed: bool
invite_state: str
reward: Optional[Dict[str, Any]]
method_used: str
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@dataclass
class RedemptionResult:
target_node: str
code: str
success: bool
status: str
reason: Optional[str]
detail: Optional[str]
method_used: str
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
class InviteHandler:
"""Handles invite codes discovery, inspection, and redemption for NetVM nodes."""
def __init__(self, node: str, timeout: float = 4.0):
self.node = node
self.timeout = timeout
self.ws = None
def connect(self) -> InviteHandler:
if self.ws is None:
self.ws, _ = 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) -> InviteHandler:
return self.connect()
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.close()
def find_code_api(self) -> InviteCodeInfo:
"""Fetch invite code and metadata directly via in-session /api/hatch/invite query."""
self.connect()
js_query = """(async () => {
try {
const resp = await fetch('/api/hatch/invite', {method: 'GET', cache: 'no-store'});
if (!resp.ok) return {error: `HTTP ${resp.status}`};
return await resp.json();
} catch(e) {
return {error: e.toString()};
}
})()"""
res = cdp_evaluate(self.ws, js_query, await_promise=True, timeout=self.timeout)
if not res or "error" in res or "code" not in res:
err = res.get("error", "Invalid response") if res else "No response"
raise RuntimeError(f"Failed to fetch invite info for {self.node}: {err}")
return InviteCodeInfo(
node=self.node,
code=res.get("code", ""),
uses_remaining=res.get("uses_remaining", 0),
use_count=res.get("use_count", 0),
max_uses=res.get("max_uses", 30),
has_redeemed=bool(res.get("has_redeemed_invite_code", False)),
invite_state=res.get("invite_state", "UNKNOWN"),
reward=res.get("reward"),
method_used="api",
)
def find_code_dom(self) -> InviteCodeInfo:
"""Find invite code by navigating to Main Chat and opening the Invite popover."""
self.connect()
# 1. Ensure main chat home is active
with SettingsRPA(self.node, timeout=self.timeout) as rpa:
rpa.ensure_main_chat()
time.sleep(0.3)
# Dismiss any open popovers first
cdp_send_escape(self.ws)
time.sleep(0.2)
# 2. Click Invite button in main chat
clicked = cdp_click_element_by_selector(self.ws, '[data-testid="hatch-invite-friends-button"]', timeout=self.timeout)
if not clicked:
# Fallback to button by text
js_click = """(() => {
const btn = Array.from(document.querySelectorAll('button')).find(b => (b.innerText||'').trim() === 'Invite');
if (btn) { btn.click(); return true; }
return false;
})()"""
clicked = cdp_evaluate(self.ws, js_click)
if not clicked:
raise RuntimeError(f"Could not click Invite button on {self.node}")
time.sleep(0.8)
# 3. Read popover contents
js_read_popover = """(() => {
const pop = document.querySelector('[data-slot="popover-content"]');
if (!pop) return {found: false};
// Look for revealed code span
const codeSpan = pop.querySelector('[data-pel-impression="invite_code_revealed_impression"]');
let code = codeSpan ? codeSpan.innerText.trim() : null;
// Regex fallback on text
const fullText = pop.innerText || '';
if (!code) {
const m = fullText.match(/Invite code revealed:\\s*([A-Z0-9]{6})/);
if (m) code = m[1];
}
if (!code) {
const m2 = fullText.match(/\\b([A-Z0-9]{6})\\b/);
if (m2) code = m2[1];
}
let usesLeft = 30;
const mUses = fullText.match(/(\\d+)\\s+uses\\s+left/);
if (mUses) usesLeft = parseInt(mUses[1], 10);
return {
found: true,
code: code,
uses_left: usesLeft,
text: fullText
};
})()"""
pop_res = cdp_evaluate(self.ws, js_read_popover)
cdp_send_escape(self.ws)
if not pop_res or not pop_res.get("found") or not pop_res.get("code"):
# Fall back to API if popover extraction failed
return self.find_code_api()
code = pop_res.get("code")
uses_left = pop_res.get("uses_left", 30)
# Query API for additional metadata
try:
api_info = self.find_code_api()
api_info.method_used = "dom+api"
return api_info
except Exception:
return InviteCodeInfo(
node=self.node,
code=code,
uses_remaining=uses_left,
use_count=30 - uses_left,
max_uses=30,
has_redeemed=True,
invite_state="ELIGIBLE",
reward=None,
method_used="dom",
)
def find_code(self, method: str = "auto") -> InviteCodeInfo:
"""Find invite code using specified method ('auto', 'api', or 'dom')."""
if method == "api":
return self.find_code_api()
elif method == "dom":
return self.find_code_dom()
else:
# Auto: Try API first, fallback to DOM
try:
return self.find_code_api()
except Exception:
return self.find_code_dom()
def redeem_code_api(self, code: str) -> RedemptionResult:
"""Redeem invite code using in-session /api/hatch/invite-code/redeem endpoint."""
self.connect()
clean_code = code.strip().upper()
js_redeem = f"""(async () => {{
try {{
const resp = await fetch('/api/hatch/invite-code/redeem', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{code: {json.dumps(clean_code)}, supportsRedemptionStatus: true}})
}});
const data = await resp.json();
return {{status_code: resp.status, ok: resp.ok, data: data}};
}} catch(e) {{
return {{error: e.toString()}};
}}
}})()"""
res = cdp_evaluate(self.ws, js_redeem, await_promise=True, timeout=self.timeout)
if not res or "error" in res:
err = res.get("error", "No response") if res else "Timeout"
return RedemptionResult(
target_node=self.node,
code=clean_code,
success=False,
status="error",
reason="transport_failure",
detail=err,
method_used="api",
)
data = res.get("data", {})
ok = res.get("ok", False) and data.get("success", False)
status = data.get("redemptionStatus") or ("redeemed" if ok else "failed")
reason = data.get("reason")
detail = data.get("detail")
return RedemptionResult(
target_node=self.node,
code=clean_code,
success=ok,
status=status,
reason=reason,
detail=detail,
method_used="api",
)
def redeem_code_dom(self, code: str) -> RedemptionResult:
"""Redeem invite code using Settings Menu RPA -> Settings -> Redeem Invite Code dialog."""
self.connect()
clean_code = code.strip().upper()
with SettingsRPA(self.node, timeout=self.timeout) as rpa:
# 1. Open Settings dialog
opened = rpa.open_settings_dialog()
if not opened:
return RedemptionResult(
target_node=self.node,
code=clean_code,
success=False,
status="error",
reason="settings_open_failed",
detail="Could not open Settings dialog via RPA",
method_used="dom",
)
rpa.select_tab("General")
time.sleep(0.3)
# 2. Check for "Redeem invite code" entry
js_find_and_click = """(() => {
const dialog = document.querySelector('[role="dialog"]');
if (!dialog) return {found: false};
const items = Array.from(dialog.querySelectorAll('button, div, span'));
const redeemItem = items.find(el => (el.innerText || '').trim() === 'Redeem invite code');
if (redeemItem) {
redeemItem.click();
return {found: true};
}
return {found: false, text: dialog.innerText};
})()"""
click_res = cdp_evaluate(self.ws, js_find_and_click)
if not click_res or not click_res.get("found"):
rpa.close_settings_dialog()
# If entrypoint missing, likely already redeemed
api_check = self.find_code_api()
if api_check.has_redeemed:
return RedemptionResult(
target_node=self.node,
code=clean_code,
success=False,
status="already_redeemed",
reason="already_redeemed",
detail="Node has already redeemed an invite code (entrypoint hidden)",
method_used="dom",
)
return RedemptionResult(
target_node=self.node,
code=clean_code,
success=False,
status="entrypoint_not_found",
reason="not_eligible",
detail="Redeem invite code item not present in General settings",
method_used="dom",
)
time.sleep(0.8)
# 3. Handle HatchInviteRedemptionDialog
js_input_code = f"""(() => {{
// Look for dialog titled "Redeem a code"
const input = document.querySelector('input[aria-label="Invite code"]') ||
document.querySelector('[role="dialog"] input[type="text"]');
if (!input) return {{found_input: false}};
// Type code into input
input.focus();
input.value = {json.dumps(clean_code)};
input.dispatchEvent(new Event('input', {{bubbles: true}}));
input.dispatchEvent(new Event('change', {{bubbles: true}}));
// Submit via Enter key
input.dispatchEvent(new KeyboardEvent('keydown', {{key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true}}));
return {{found_input: true}};
}})()"""
input_res = cdp_evaluate(self.ws, js_input_code)
time.sleep(1.5)
# 4. Check outcome in dialog
js_check_outcome = """(() => {
const dialog = document.querySelector('[role="dialog"]');
if (!dialog) return {found: false};
const text = dialog.innerText || '';
if (text.includes('Congratulations') || text.includes('successful')) {
return {success: true, status: 'redeemed', detail: text};
}
if (text.includes('already redeemed')) {
return {success: false, status: 'already_redeemed', detail: text};
}
if (text.includes('no longer valid') || text.includes('invalid')) {
return {success: false, status: 'invalid', detail: text};
}
if (text.includes('used up')) {
return {success: false, status: 'used_up', detail: text};
}
if (text.includes('passed') || text.includes('expired')) {
return {success: false, status: 'window_expired', detail: text};
}
return {status: 'unknown', detail: text};
})()"""
outcome = cdp_evaluate(self.ws, js_check_outcome)
rpa.close_settings_dialog()
if outcome and outcome.get("success"):
return RedemptionResult(
target_node=self.node,
code=clean_code,
success=True,
status="redeemed",
reason=None,
detail="Redemption successful via Settings RPA",
method_used="dom",
)
elif outcome and outcome.get("status") in ["already_redeemed", "invalid", "used_up", "window_expired"]:
return RedemptionResult(
target_node=self.node,
code=clean_code,
success=False,
status=outcome.get("status"),
reason=outcome.get("status"),
detail=outcome.get("detail"),
method_used="dom",
)
# Fallback to API check if DOM did not confirm status
return self.redeem_code_api(clean_code)
def redeem_code(self, code: str, method: str = "auto") -> RedemptionResult:
"""Redeem invite code using specified method ('auto', 'dom', or 'api')."""
if method == "dom":
return self.redeem_code_dom(code)
elif method == "api":
return self.redeem_code_api(code)
else:
# Auto: Validate and attempt via API for reliability
return self.redeem_code_api(code)
def scan_fleet_invites(nodes: Optional[List[str]] = None) -> List[Dict[str, Any]]:
"""Scan fleet nodes and return invite code details for each active node."""
target_nodes = nodes or ["muse", "pip", "646", "opm"]
results = []
for node in target_nodes:
try:
handler = InviteHandler(node)
with handler:
info = handler.find_code(method="auto")
results.append(info.to_dict())
except Exception as e:
results.append({
"node": node,
"code": None,
"uses_remaining": 0,
"use_count": 0,
"max_uses": 30,
"has_redeemed": None,
"invite_state": "UNREACHABLE",
"reward": None,
"method_used": "error",
"error": str(e),
})
return results
def scan_fleet_usage(nodes: Optional[List[str]] = None) -> List[Dict[str, Any]]:
"""Scan fleet nodes and return token usage for each active node."""
target_nodes = nodes or ["muse", "pip", "646", "opm"]
results = []
for node in target_nodes:
try:
with SettingsRPA(node) as rpa:
usage = rpa.read_usage()
results.append(usage.to_dict())
except Exception as e:
results.append({
"node": node,
"plan": "Unknown",
"weekly_reset_text": "Unreachable",
"weekly_percent_used": 0,
"extra_tokens_status": "Unknown",
"extra_percent_used": 0,
"extra_tokens_remaining": "Unknown",
"is_blocked": False,
"bars": [],
"error": str(e),
})
return results
def salvage_blocked_node(blocked_node: str = "646", helper_node: Optional[str] = None) -> Dict[str, Any]:
"""Salvage an out-of-tokens node by identifying its code and redeeming it on an eligible peer."""
# 1. Fetch blocked node invite code
with InviteHandler(blocked_node) as h_blocked:
blocked_info = h_blocked.find_code()
code_to_redeem = blocked_info.code
if not code_to_redeem:
return {
"success": False,
"blocked_node": blocked_node,
"error": f"Could not find invite code for blocked node {blocked_node}",
}
# 2. Check candidate helper nodes
candidates = [helper_node] if helper_node else [n for n in VALID_NODES if n != blocked_node]
eligible_peer = None
for peer in candidates:
try:
with InviteHandler(peer) as h_peer:
peer_info = h_peer.find_code()
if not peer_info.has_redeemed:
eligible_peer = peer
break
except Exception:
continue
if not eligible_peer:
return {
"success": False,
"blocked_node": blocked_node,
"invite_code": code_to_redeem,
"error": "No existing fleet peer is currently eligible (all active peers have already redeemed an invite code). An onboarding agent or fresh client profile must redeem this code.",
"code_to_redeem": code_to_redeem,
"share_instruction": f"Redeem code '{code_to_redeem}' on a newly provisioned agent to credit 1 billion tokens to {blocked_node}."
}
# 3. Redeem on eligible peer
with InviteHandler(eligible_peer) as h_peer:
redemption = h_peer.redeem_code(code_to_redeem)
return {
"success": redemption.success,
"blocked_node": blocked_node,
"helper_node": eligible_peer,
"code_redeemed": code_to_redeem,
"redemption_result": redemption.to_dict(),
}
def main() -> None:
parser = argparse.ArgumentParser(description="NetVM Invite Code Handler")
subparsers = parser.add_subparsers(dest="command")
p_find = subparsers.add_parser("find", help="Find invite code for an agent")
p_find.add_argument("node", help="Node name (e.g. 646, pip, muse, opm)")
p_find.add_argument("--method", choices=["auto", "api", "dom"], default="auto")
p_find.add_argument("--json", action="store_true")
p_redeem = subparsers.add_parser("redeem", help="Redeem an invite code on a target agent")
p_redeem.add_argument("node", help="Target node to redeem the code on")
p_redeem.add_argument("code", help="6-character invite code")
p_redeem.add_argument("--method", choices=["auto", "api", "dom"], default="auto")
p_redeem.add_argument("--json", action="store_true")
p_list = subparsers.add_parser("list", help="List invite codes across fleet")
p_list.add_argument("--json", action="store_true")
p_salvage = subparsers.add_parser("salvage", help="Salvage a blocked agent (e.g. 646)")
p_salvage.add_argument("node", default="646", nargs="?", help="Blocked node (default: 646)")
p_salvage.add_argument("--helper", help="Specific helper node to redeem code")
p_salvage.add_argument("--json", action="store_true")
args = parser.parse_args()
if args.command == "find":
with InviteHandler(args.node) as h:
info = h.find_code(method=args.method)
if args.json:
print(json.dumps(info.to_dict(), indent=2))
else:
print(f"=== Agent {info.node} Invite Code ===")
print(f" Code: {info.code}")
print(f" Uses Remaining: {info.uses_remaining} / {info.max_uses}")
print(f" Has Redeemed?: {'Yes' if info.has_redeemed else 'No'}")
print(f" Method: {info.method_used}")
if info.reward:
print(f" Reward: {info.reward.get('title')}")
elif args.command == "redeem":
with InviteHandler(args.node) as h:
res = h.redeem_code(args.code, method=args.method)
if args.json:
print(json.dumps(res.to_dict(), indent=2))
else:
status_icon = "✔" if res.success else "✖"
print(f"[{status_icon}] Redemption on {res.target_node} for code {res.code}:")
print(f" Success: {res.success}")
print(f" Status: {res.status}")
if res.detail:
print(f" Detail: {res.detail}")
elif args.command == "list":
fleet = scan_fleet_invites()
if args.json:
print(json.dumps(fleet, indent=2))
else:
print("\n=== NETVM FLEET INVITE CODES ===")
print(f" {'NODE':<8} {'INVITE CODE':<14} {'USES LEFT':<12} {'REDEEMED?':<12} {'REWARD / NOTE':<25}")
print(f" {'────':<8} {'───────────':<14} {'─────────':<12} {'─────────':<12} {'─────────────':<25}")
for row in fleet:
node = row.get("node", "")
code = row.get("code") or "N/A"
uses = f"{row.get('uses_remaining', 0)}/{row.get('max_uses', 30)}"
redeemed = "Yes" if row.get("has_redeemed") else "No"
reward = row.get("reward", {})
reward_str = reward.get("title", "-") if reward else "-"
print(f" {node:<8} {code:<14} {uses:<12} {redeemed:<12} {reward_str:<25}")
print()
elif args.command == "salvage":
salvage_res = salvage_blocked_node(args.node, helper_node=args.helper)
if args.json:
print(json.dumps(salvage_res, indent=2))
else:
print(f"\n=== SALVAGE REPORT FOR {args.node.upper()} ===")
print(f" Invite Code to Credit: {salvage_res.get('invite_code') or salvage_res.get('code_redeemed')}")
if salvage_res.get("success"):
print(f" Status: SUCCESS! Redeemed on {salvage_res.get('helper_node')}")
else:
print(f" Status: {salvage_res.get('error')}")
if salvage_res.get("share_instruction"):
print(f" Next Step: {salvage_res.get('share_instruction')}")
print()
else:
parser.print_help()
if __name__ == "__main__":
main()
+6
View File
@@ -52,6 +52,7 @@ class InputType(str, Enum):
MANUAL = "manual" # human/operator-authored DM MANUAL = "manual" # human/operator-authored DM
HEALTH = "health" # system health check results HEALTH = "health" # system health check results
HEARTBEAT = "heartbeat" # loopback liveness probes HEARTBEAT = "heartbeat" # loopback liveness probes
SALVAGE = "salvage" # token exhaustion & onboarding work orders
class Priority(str, Enum): class Priority(str, Enum):
@@ -139,6 +140,11 @@ _MODULATION: Dict[Tuple[InputType, Optional[str]], Tuple] = {
(InputType.HEALTH, "OK"): (False, Priority.ROUTINE, 0, 0, None), (InputType.HEALTH, "OK"): (False, Priority.ROUTINE, 0, 0, None),
(InputType.HEALTH, None): (True, Priority.IMPORTANT, 1800, 2, "opm"), (InputType.HEALTH, None): (True, Priority.IMPORTANT, 1800, 2, "opm"),
# Salvage: token depletion & onboarding rescue.
(InputType.SALVAGE, "BLOCKED"): (True, Priority.CRITICAL, 600, 3, "opm"),
(InputType.SALVAGE, "LOW"): (True, Priority.IMPORTANT, 900, 2, "opm"),
(InputType.SALVAGE, None): (True, Priority.CRITICAL, 900, 2, "opm"),
# Heartbeat loopback: NEVER tracked. Hard exclusion. # Heartbeat loopback: NEVER tracked. Hard exclusion.
(InputType.HEARTBEAT, None): (False, Priority.ROUTINE, 0, 0, None), (InputType.HEARTBEAT, None): (False, Priority.ROUTINE, 0, 0, None),
} }
+496
View File
@@ -0,0 +1,496 @@
#!/usr/bin/env python3
"""onboard_pipeline.py — End-to-end agent-driven onboarding & invite salvage pipeline.
Orchestrates the complete lifecycle:
1. Identifies the most urgent beneficiary agent in need of tokens (blocked first, then lowest balance).
2. Provisions infrastructure (WireGuard, dedicated netns, CDP port, chrome-box profile).
3. Launches authentication via cred_client / onboard-driver.
4. Waits for / accepts OTP code.
5. Checks age verification gate (Tailscale portal / Instagram link).
6. Automatically redeems the urgent agent's invite code on the newly onboarded node.
7. Injects operational DRIVE and marks the node active.
8. Can dispatch cryptographically signed work orders ([WO]) to prompting sidechats.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
REPO_ROOT = Path(__file__).resolve().parent.parent
BIN_DIR = REPO_ROOT / "bin"
sys.path.insert(0, str(BIN_DIR))
try:
import invite
from cred_client import CredClient
from settings_rpa import SettingsRPA
except ImportError:
pass
STAGE_INFRA = "infra_provisioned"
STAGE_INITIATE = "auth_initiated"
STAGE_AWAIT_OTP = "awaiting_otp"
STAGE_AUTH_ACTIVE = "auth_active"
STAGE_REDEEMED = "invite_redeemed"
STAGE_DRIVE_INJECTED = "drive_injected"
STAGE_COMPLETED = "completed"
STATE_DIR = Path(os.environ.get("NETVM_ONBOARD_STATE", "/tmp/netvm-onboard"))
@dataclass
class OnboardState:
node: str
email: str
beneficiary_node: Optional[str]
invite_code: Optional[str]
stage: str
cdp_port: Optional[int] = None
created_at: float = 0.0
updated_at: float = 0.0
detail: Optional[str] = None
redemption_result: Optional[Dict[str, Any]] = None
def save(self) -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
path = STATE_DIR / f"{self.node}.json"
self.updated_at = time.time()
with open(path, "w") as f:
json.dump(asdict(self), f, indent=2)
@classmethod
def load(cls, node: str) -> Optional[OnboardState]:
path = STATE_DIR / f"{node}.json"
if not path.exists():
return None
try:
with open(path) as f:
data = json.load(f)
return cls(**data)
except Exception:
return None
# Canonical agent operational roles in NetVM
AGENT_ROLES: Dict[str, str] = {
"646": "Production Lead / Swarm Execution",
"opm": "Fleet Coordinator / Loop Orchestrator",
"pip": "Production Agent / Pipeline Worker",
"muse": "Core Engine / Auditor & Dev",
"dev": "Development / Test & Staging",
"def": "Defense / Security & Standby",
}
# Base role priority multiplier
ROLE_WEIGHT_MULTIPLIER: Dict[str, float] = {
"646": 1.5, # Critical production executor
"opm": 1.4, # Central coordinator & orchestrator
"pip": 1.2, # High volume production worker
"muse": 1.1, # Auditor & core engine
"dev": 0.8, # Test & staging
"def": 0.7, # Defense & standby
}
def get_agent_metrics() -> Tuple[Dict[str, int], Dict[str, int]]:
"""Count job assignments and cumulative chat activity (work done over time) per agent."""
from collections import Counter
job_counts = Counter()
jobs_dir = REPO_ROOT / "jobs"
if jobs_dir.exists():
for p in jobs_dir.glob("*.json"):
try:
d = json.loads(p.read_text(encoding="utf-8"))
a = d.get("target_agent") or d.get("agent")
if a:
job_counts[a] += 1
except Exception:
pass
msg_counts = Counter()
chat_log = REPO_ROOT / "logs" / "chat-history.jsonl"
if chat_log.exists():
try:
with open(chat_log, encoding="utf-8") as f:
for line in f:
try:
row = json.loads(line)
s = row.get("sender") or row.get("from") or row.get("agent")
if s:
msg_counts[s] += 1
except Exception:
pass
except Exception:
pass
return dict(job_counts), dict(msg_counts)
def calculate_feeding_weights(nodes: Optional[List[str]] = None) -> List[Dict[str, Any]]:
"""Compute feeding weights for all agents considering:
- Urgency: Blocked (highest), weekly used %, token depletion
- Work done over time: Messages processed / chat history volume (strongest operational weight)
- Job amount: Active and assigned jobs in registry
- Role importance: Multiplier based on operational criticality
"""
target_nodes = nodes or ["646", "opm", "pip", "muse", "dev", "def"]
job_counts, msg_counts = get_agent_metrics()
usage_map = invite.fleet_usage(target_nodes)
invites_map = invite.fleet_invite_status(target_nodes)
rows = []
for n in target_nodes:
u = usage_map.get(n, {})
inv = invites_map.get(n, {})
code = inv.get("code") or "-"
wu = u.get("weekly_used_pct") or 0
extra_left = u.get("additional_left") or "-"
extra_used = u.get("additional_used_pct") or 0
is_blocked = (wu >= 100 and "0 tokens left" in str(extra_left)) or (wu >= 100 and extra_used >= 100)
jobs = job_counts.get(n, 0)
work_done = msg_counts.get(n, 0)
role_desc = AGENT_ROLES.get(n, "Agent Worker")
role_mult = ROLE_WEIGHT_MULTIPLIER.get(n, 1.0)
# Feeding score calculation:
# Base: Blocked = 1000 pts; Weekly limit 100% = 300 pts; proportional to weekly used %
urgency_score = 1000.0 if is_blocked else (300.0 if wu >= 100 else float(wu))
# Work done weight: 1 point per 10 messages (strongest historical work indicator)
work_score = float(work_done) * 0.1
# Job weight: 2 points per assigned job
job_score = float(jobs) * 2.0
# Composite feeding weight
feeding_weight = round((urgency_score + work_score + job_score) * role_mult, 1)
status_label = "BLOCKED" if is_blocked else ("LIMIT_REACHED" if wu >= 100 else "ACTIVE")
rows.append({
"node": n,
"role": role_desc,
"code": code,
"status": status_label,
"weekly_used_pct": wu,
"additional_left": extra_left,
"jobs_count": jobs,
"work_done_msgs": work_done,
"feeding_weight": feeding_weight,
"is_blocked": is_blocked,
"invites_left": inv.get("uses_remaining", 30),
})
# Sort descending by feeding weight
rows.sort(key=lambda r: r["feeding_weight"], reverse=True)
return rows
def select_urgent_beneficiary(explicit_node: Optional[str] = None, explicit_code: Optional[str] = None) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""Determine the optimal beneficiary agent to feed with Muse tokens and return (beneficiary_node, invite_code, reason).
Priority:
1. Explicit code / node requested by caller.
2. Highest feeding weight (combining urgency, work done over time, job volume, and role criticality).
"""
if explicit_code:
return explicit_node, explicit_code.strip().upper(), "Explicit invite code provided"
if explicit_node:
try:
inv = invite.get_invite(explicit_node)
if inv.get("ok") and inv.get("code"):
return explicit_node, inv["code"], f"Explicit beneficiary agent @{explicit_node}"
except Exception as e:
return explicit_node, None, f"Failed to fetch invite code for @{explicit_node}: {e}"
# Calculate weighted rankings across fleet
rankings = calculate_feeding_weights()
for row in rankings:
if row["code"] and row["code"] != "-":
n = row["node"]
code = row["code"]
weight = row["feeding_weight"]
st = row["status"]
role = row["role"]
work = row["work_done_msgs"]
reason = f"Top feeding weight: {weight} pts (@{n} [{role}] - status:{st}, work_done:{work} msgs, jobs:{row['jobs_count']})"
return n, code, reason
# Fallback to 646 or muse if available
for fallback in ["646", "pip", "muse", "opm"]:
try:
inv = invite.get_invite(fallback)
if inv.get("ok") and inv.get("code"):
return fallback, inv["code"], f"Default fleet agent @{fallback}"
except Exception:
pass
return None, None, "No active agent invite code found"
def provision_node_infra(node: str) -> Dict[str, Any]:
"""Execute ./bin/netvm-provision-node.sh <node> to setup netns, wireguard, chrome-box."""
script = BIN_DIR / "netvm-provision-node.sh"
cmd = [str(script), node]
res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode != 0:
return {
"ok": False,
"node": node,
"error": res.stderr.strip() or res.stdout.strip() or f"Exit {res.returncode}",
}
return {"ok": True, "node": node, "output": res.stdout.strip()}
def start_onboarding(node: str, email: str, beneficiary_node: Optional[str] = None, invite_code: Optional[str] = None, account_name: Optional[str] = None) -> Dict[str, Any]:
"""Phase 1 & 2: Provision infra, choose beneficiary invite code, and initiate authentication."""
b_node, code, reason = select_urgent_beneficiary(beneficiary_node, invite_code)
state = OnboardState(
node=node,
email=email,
beneficiary_node=b_node,
invite_code=code,
stage="starting",
created_at=time.time(),
updated_at=time.time(),
detail=reason,
)
state.save()
# 1. Provision infra
infra_res = provision_node_infra(node)
if not infra_res.get("ok"):
state.stage = "infra_failed"
state.detail = infra_res.get("error")
state.save()
return {"ok": False, "state": asdict(state), "error": f"Infra provisioning failed: {state.detail}"}
state.stage = STAGE_INFRA
state.save()
# 2. Initiate authentication
client = CredClient()
cred_res = client.initiate(node, email, service="muse", account_name=account_name)
st = cred_res.get("status")
if st == "awaiting_otp":
state.stage = STAGE_AWAIT_OTP
state.detail = f"OTP verification code sent to {email}"
state.save()
return {
"ok": True,
"status": "awaiting_otp",
"state": asdict(state),
"beneficiary_node": b_node,
"invite_code_queued": code,
"message": f"Verification code sent to {email}. Submit with: box onboard submit-otp --node {node} --otp <code>",
}
elif st == "active":
state.stage = STAGE_AUTH_ACTIVE
state.save()
# Immediately redeem queued code
return finish_onboarding_redemption(state)
else:
state.stage = "auth_error"
state.detail = cred_res.get("detail") or cred_res.get("message")
state.save()
return {"ok": False, "status": st, "state": asdict(state), "error": state.detail}
def submit_onboarding_otp(node: str, otp: str, email: Optional[str] = None) -> Dict[str, Any]:
"""Phase 3: Submit transient OTP and proceed to redemption upon success."""
state = OnboardState.load(node)
if not state:
state = OnboardState(
node=node,
email=email or "unknown",
beneficiary_node=None,
invite_code=None,
stage=STAGE_AWAIT_OTP,
created_at=time.time(),
)
client = CredClient()
res = client.submit_otp(node, otp, email=state.email or email)
st = res.get("status")
if st == "active":
state.stage = STAGE_AUTH_ACTIVE
state.detail = "Session authenticated successfully"
state.save()
return finish_onboarding_redemption(state)
else:
state.detail = res.get("detail") or res.get("message")
state.save()
return {"ok": False, "status": st, "state": asdict(state), "error": state.detail}
def finish_onboarding_redemption(state: OnboardState) -> Dict[str, Any]:
"""Phase 4 & 5: Redeem queued invite code and inject DRIVE."""
# Ensure beneficiary code is present
if not state.invite_code:
b_node, code, _ = select_urgent_beneficiary(state.beneficiary_node, None)
state.beneficiary_node = b_node
state.invite_code = code
redemption_info = None
if state.invite_code:
try:
# Redeem queued code on the fresh node
redemption = invite.redeem_invite(state.node, state.invite_code, timeout=20.0)
redemption_info = redemption
state.redemption_result = redemption
if redemption.get("ok"):
state.stage = STAGE_REDEEMED
state.detail = f"Successfully redeemed code {state.invite_code}! 1B tokens credited to @{state.beneficiary_node} and @{state.node}."
else:
state.detail = f"Redemption failed: {redemption.get('reason')} - {redemption.get('detail')}"
except Exception as e:
redemption_info = {"ok": False, "error": str(e)}
state.detail = f"Redemption exception: {e}"
# Phase 5: Inject DRIVE
try:
drive_script = BIN_DIR / "agent_md.py"
if drive_script.exists():
subprocess.run([sys.executable, str(drive_script), "inject-drive", state.node], capture_output=True, text=True, timeout=15)
state.stage = STAGE_COMPLETED
except Exception:
pass
state.save()
return {
"ok": True,
"status": "completed" if (redemption_info and redemption_info.get("ok")) else "auth_active_redemption_warning",
"state": asdict(state),
"beneficiary_node": state.beneficiary_node,
"invite_code": state.invite_code,
"redemption": redemption_info,
"message": f"Node @{state.node} is fully active! 1B tokens granted.",
}
def issue_salvage_work_order(blocked_node: str = "646", to_sidechat: str = "646 tasks") -> Dict[str, Any]:
"""Issue a cryptographically signed Work Order prompting fleet operators or agents to initiate onboarding."""
inv = invite.get_invite(blocked_node)
code = inv.get("code") or "UNKNOWN"
title = f"Salvage Blocked Agent @{blocked_node}"
body = (
f"Agent @{blocked_node} is out of tokens (code: {code}). "
f"Initiate client onboarding to grant 1 Billion tokens: box onboard <new_node> --email <client_email> --for {blocked_node}"
)
cmd = [
"python3", str(BIN_DIR / "super-cli.py"), "dm", "wo",
"--to", "opm",
"--target", to_sidechat,
"--title", title,
"--body", body,
"--priority", "urgent",
"--allow-main-chat"
]
res = subprocess.run(cmd, capture_output=True, text=True)
return {"ok": res.returncode == 0, "output": res.stdout.strip()}
def main() -> None:
parser = argparse.ArgumentParser(description="End-to-end agent-driven onboarding & invite salvage pipeline")
sub = parser.add_subparsers(dest="action")
p_start = sub.add_parser("start", help="Start full onboarding pipeline for a client node")
p_start.add_argument("node", help="New node label (e.g. dev2, client1)")
p_start.add_argument("--email", required=True, help="Client login email")
p_start.add_argument("--for", dest="for_agent", help="Beneficiary agent to unblock (defaults to most urgent)")
p_start.add_argument("--code", help="Explicit 6-character invite code to redeem")
p_start.add_argument("--account-name", help="Display name hint")
p_start.add_argument("--json", action="store_true", help="Emit JSON output")
p_otp = sub.add_parser("submit-otp", help="Submit transient OTP verification code")
p_otp.add_argument("node", help="Node label")
p_otp.add_argument("otp", help="6-digit verification code")
p_otp.add_argument("--email", help="Client email (optional)")
p_otp.add_argument("--json", action="store_true", help="Emit JSON output")
p_status = sub.add_parser("status", help="Check onboarding pipeline state for a node")
p_status.add_argument("node", help="Node label")
p_status.add_argument("--json", action="store_true", help="Emit JSON output")
p_wo = sub.add_parser("salvage-wo", help="Dispatch salvage work order to sidechat")
p_wo.add_argument("node", nargs="?", default="646", help="Blocked agent node (default: 646)")
p_wo.add_argument("--target", default="646 tasks", help="Target sidechat")
p_feed = sub.add_parser("feed-matrix", help="Display all agents ranked by feeding weight, job volume, role, and work done over time")
p_feed.add_argument("--json", action="store_true", help="Emit JSON output")
args = parser.parse_args()
if args.action == "start":
res = start_onboarding(args.node, args.email, beneficiary_node=getattr(args, "for_agent", None), invite_code=args.code, account_name=args.account_name)
if args.json:
print(json.dumps(res, indent=2))
elif res.get("ok"):
print(f"\n[ONBOARDING STARTED] Node: {args.node}")
print(f" Beneficiary: @{res.get('beneficiary_node')} (Invite Code: {res.get('invite_code_queued')})")
print(f" Message: {res.get('message')}\n")
else:
print(f"\n[ERROR] {res.get('error')}\n", file=sys.stderr)
sys.exit(1)
elif args.action == "submit-otp":
res = submit_onboarding_otp(args.node, args.otp, email=args.email)
if args.json:
print(json.dumps(res, indent=2))
elif res.get("ok"):
print(f"\n[ONBOARDING COMPLETE] {res.get('message')}")
print(f" Beneficiary Credited: @{res.get('beneficiary_node')} (+1,000,000,000 tokens)\n")
else:
print(f"\n[ERROR] {res.get('error')}\n", file=sys.stderr)
sys.exit(1)
elif args.action == "status":
state = OnboardState.load(args.node)
if not state:
print(f"No onboarding pipeline record found for {args.node}", file=sys.stderr)
sys.exit(1)
if args.json:
print(json.dumps(asdict(state), indent=2))
else:
print(f"\n=== ONBOARDING STATUS: @{state.node} ===")
print(f" Email: {state.email}")
print(f" Stage: {state.stage}")
print(f" Beneficiary: @{state.beneficiary_node} (Code: {state.invite_code})")
if state.detail:
print(f" Detail: {state.detail}")
print()
elif args.action == "salvage-wo":
res = issue_salvage_work_order(args.node, to_sidechat=args.target)
print(f"Salvage work order dispatched for @{args.node}: ok={res.get('ok')}")
elif args.action == "feed-matrix":
matrix = calculate_feeding_weights()
if args.json:
print(json.dumps(matrix, indent=2))
else:
print("\n=== FLEET FEEDING & TOKEN ALLOCATION MATRIX ===")
print(" (Weighted by: Urgency + Work Done Over Time + Job Amount + Role Criticality)\n")
print(f" {'RANK':4} {'AGENT':6} {'ROLE':38} {'FEED SCORE':11} {'STATUS':10} {'JOBS':6} {'WORK DONE':10} {'CODE':8}")
print(f" {'─'*4} {'─'*6} {'─'*38} {'─'*11} {'─'*10} {'─'*6} {'─'*10} {'─'*8}")
for idx, r in enumerate(matrix, 1):
print(f" #{idx:<3} {r['node']:<6} {r['role']:<38} {r['feeding_weight']:<11} {r['status']:<10} {r['jobs_count']:<6} {str(r['work_done_msgs']) + ' msgs':<10} {r['code']:<8}")
print()
if __name__ == "__main__":
main()
+443
View File
@@ -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()
+94
View File
@@ -0,0 +1,94 @@
# hatch_menu — Modular Settings-Menu Navigation + Toggles
`bin/hatch_menu/` drives the muse.ai Settings menu in the agent
browsers. One module per part: a site change means patching one file.
Exposed as `box chromebox permissions ...`; `bin/invite.py` usage
reads ride the same tree.
## Layout
```text
bin/hatch_menu/
__init__.py package surface (dialog + mouse + toggles fns)
mouse.py trusted input: real_click, escape, close, MouseError
dialog.py open_settings (retried), goto_tab, click_row,
describe_rows, go_back, dialog_text, TAB_NAMES
controls.py radio/switch list + set (verify, else trusted
click, verify again)
toggles.py toggle registry + sessions: resolve/get/set/list,
describe_tab, MenuError
tabs/
__init__.py TAB_MODULES (10 tabs, uniform describe(ws))
permissions.py defaults radios, website modes, protocols,
advanced switches, counts, tasks
general.py usage parse, theme, redeem entrypoint
data_controls.py model-improvement switch (rest read-only)
connectors|wallet|secure_store|messaging|devices|
help_support|legal.py read-only inventories
```
Dependency order (no cycles): `mouse` <- `dialog` <- `controls`
<- `tabs/*` <- `toggles`. Tab modules own their contracts
(headings, labels, slugs, modes); `toggles.py` only addresses them.
## Toggle addresses
Static: `permissions.connector_defaults`,
`permissions.web_access` (`auto_allow`/`always_ask`);
`permissions.advanced.transparent_proxy|tls_interception|
sni_mismatch_rejection`, `data_controls.ai_improvement` (`on`/`off`);
`general.theme` (match/default/blue/purple/pink/orange/green/
beige/monochrome).
Families: `permissions.websites:<host>` (`Allow`/`Ask`/`Deny`),
`permissions.protocols:<slug>` (`on`/`off`; slugs discovered live,
e.g. `mcp-sse`, `mcp-streamable`, `agent-skills`, `mcp-apps`,
`mcp-oauth`).
Every `set` verifies in place and reads back through a fresh
session; readback mismatch reports failure, never partial success.
Caller errors (unknown node/toggle/tab/value) raise `MenuError`
before any CDP traffic. Transport failures return `{"ok": False}`.
## CLI
```bash
box chromebox permissions <node> list [--tab TAB] [--json]
box chromebox permissions <node> get <toggle> [--json]
box chromebox permissions <node> set <toggle> <value> [--json]
box chromebox permissions <node> describe <tab> # JSON inventory
```
Exit 2 on caller errors, 1 on transport/unreadable toggles.
## Patch guide
| Site change | Patch |
|---|---|
| Dock button / dialog open flow | `dialog.py` (`open_settings`) |
| Radio/switch mechanics | `controls.py` |
| Permissions headings, modes, slugs, adv labels | `tabs/permissions.py` |
| Usage text, theme values | `tabs/general.py` |
| Data-controls switch label | `tabs/data_controls.py` |
| New settable toggle | tab module contract + `toggles.py` registry row |
| Trusted-click transport | `mouse.py` |
`describe <tab>` dumps a tab's live inventory for debugging.
## Provenance
Contracts come from live read-only DOM recon (2026-10-06):
Permissions radios `auto_allow`/`always_ask`, Websites Allow/Ask/
Deny chooser (real click to open), 8 protocol switches, Advanced
section (Transparent proxy off, TLS interception off, SNI mismatch
rejection on), Data-controls single switch, General theme radios.
Radix menus need real `Input.dispatchMouseEvent` press+release with
monotonic CDP ids; synthetic clicks fail. Recon never touches Reset.
## Tests
`tests/test_hatch_menu.py` (mocked CDP, per-module):
mouse/trusted-clicks, dialog open-retry/tab/row flows, control
verify-then-fallback, registry resolution + readback mismatch,
tab contracts on live-captured fixtures. `tests/test_invite.py`
covers the invite shims + flat usage parse.
+70
View File
@@ -0,0 +1,70 @@
# Invite Code + Usage Handler
`bin/invite.py` drives Muse.ai invite codes and usage limits through
the agent browsers, exposed as `box invite` / `box usage`.
## Flows
- **Find** (`get_invite`): `GET /api/hatch/invite` via in-page fetch
(primary), falling back to the main-chat Invite popover parse
(`hatch-invite-friends-button` -> `[data-slot="popover-content"]`
-> `Invite code revealed: XXXXXX`) when the API fails.
- **Redeem** (`redeem_invite`): `POST /api/hatch/invite-code/redeem`
`{code, supportsRedemptionStatus: true}` via in-page fetch.
Server reasons pass through verbatim: `already_redeemed`,
`invalid`, `used_up`, `revoked`, `window_expired`,
`rate_limited`, `unavailable`, `unknown`.
- **Usage** (`get_usage`): Settings > General DOM read through the
`hatch_menu` tree (`dialog` open/tab/text + `tabs/general.py`
parse, adapted to the flat CLI keys). Real mouse events open the
radix dock menu (`hatch-dock-more`; synthetic clicks do not work),
then the `settings_nav_click` item, then the General tab.
`open_settings()` / `click_settings_tab()` are public shims over
`hatch_menu.dialog` (single copy; see `docs/HATCH-MENU.md`).
## Eligibility & Lifecycle (observed live, 2026-10-06)
- One redemption per account (`already_redeemed`).
- 48h window from joining (`window_expired`).
- Codes carry limited uses (`used_up`, e.g. 30) and can be revoked.
- Inviter rewards accrue regardless of the inviter's own redemption
state (opm earned 4B from 4 redemptions while already redeemed).
- When account B redeems account A's code: BOTH accounts immediately
receive 1 billion Muse tokens.
## UI Quirks & Binary Attribution
- **Redeem field disappearance**: In Settings > General, the `Redeem invite code`
item is only present for fresh accounts (< 48 hours, has not redeemed). Once
redeemed, the field disappears completely and is replaced by the `Additional tokens`
ticker bar (`Never expires / X% used`).
- **Asynchronous DOM Loading**: In the React/Radix UI tree, usage statistics and the
redeem button render asynchronously after the dialog opens. RPA drivers must poll
briefly for usage text and progress bars rather than evaluating on the first tick.
## Onboarding & Feeding Pipeline (`box onboard`)
To feed token-deprived or blocked agents, the fleet automates onboarding of fresh client profiles:
- **`box onboard feed-matrix`**: Lists all agents ranked by composite feeding weight:
`Urgency (Blocked/Limit) + Work Done Over Time (cumulative messages processed) + Active Job Volume + Role Criticality`.
- **`box onboard start <new_node> --email <client_email> [--for <agent>]`**:
Provisions WireGuard tunnel, dedicated netns (`warp-<node>`), chrome-box profile,
initiates headless login, and queues the top-ranked beneficiary's invite code.
- **`box onboard submit-otp <node> <otp>`**:
Submits transient verification code; once the session is active, automatically
redeems the queued code in Settings, granting 1B tokens to both agents, and injects operational DRIVE.
- **`box onboard salvage-wo <node>`**:
Dispatches a cryptographically signed Work Order (`[WO]`) to the prompting sidechat.
- **Loop Modulation**: `InputType.SALVAGE` is registered in `box loop strat` with `CRITICAL` priority,
600s timeout, and 3 auto-nudges escalating to `opm`.
## Fleet State (2026-10-06)
- `646`: Unblocked! Restored via `dev`'s redemption of `REDCJ7`. Currently ~1B tokens banked.
- `dev`: Redeemed `REDCJ7`, currently ~1B tokens banked.
- `def`: Active, 0 redemptions (`A4OS1F`, window expired).
- `muse`: ~925M tokens banked (`81MDIR`).
- `pip`: ~897M tokens banked (`F4BGHN`).
- `opm`: 3.5B tokens banked (`14OGF2`, 4 friends redeemed).
+1
View File
@@ -7,5 +7,6 @@ _Recurring checks for your heartbeat worker. Add one per line, and the next tick
- Check local reverse SSH tunnel and connectivity through the jump host; ensure sshd and gcp-tunnel-up are alive. - Check local reverse SSH tunnel and connectivity through the jump host; ensure sshd and gcp-tunnel-up are alive.
- Check subagent-sessions.json for newly completed child sessions and harvest deliverables. - Check subagent-sessions.json for newly completed child sessions and harvest deliverables.
- Check active sidechats and #jobs for pending work orders, nudges, and unresolved follow-ups. - Check active sidechats and #jobs for pending work orders, nudges, and unresolved follow-ups.
- Audit local & fleet token balances (`box usage`); if any agent is BLOCKED or near limits, issue an urgent salvage work order (`box onboard salvage-wo <agent>`) to initiate onboarding.
- Verify CDP relay connectivity and local chromium browser health. - Verify CDP relay connectivity and local chromium browser health.
- Audit memory and state files for consistency; post compact heartbeat digest to designated sidechat before idling. - Audit memory and state files for consistency; post compact heartbeat digest to designated sidechat before idling.
+1
View File
@@ -53,6 +53,7 @@ file beats padding. All entries verified 2026-10-03/04.
- `~/bin/box` is box-relay.sh from bl (signature auth as operator-646, `-n exec-constrained`). - `~/bin/box` is box-relay.sh from bl (signature auth as operator-646, `-n exec-constrained`).
- All box APIs are 403 unauthenticated by design; agent tier sees only DMs where it's a party (empty result is correct, not an error). - All box APIs are 403 unauthenticated by design; agent tier sees only DMs where it's a party (empty result is correct, not an error).
- exec-constrained ops (verified live 2026-10-04): subagent.spawn {agent,title,prompt,wait}; thread.list {agent}; thread.view {agent,thread,limit}; dm.send {agent,to,target,message}; dm.read {agent,target,limit}; pipeline.run {name}; health.check {}. Don't guess arg schemas — probing burns rate-limit budget. - exec-constrained ops (verified live 2026-10-04): subagent.spawn {agent,title,prompt,wait}; thread.list {agent}; thread.view {agent,thread,limit}; dm.send {agent,to,target,message}; dm.read {agent,target,limit}; pipeline.run {name}; health.check {}. Don't guess arg schemas — probing burns rate-limit budget.
- Token Limits & Onboarding: `box usage` monitors weekly and additional tokens. If you or a peer are BLOCKED or exhausted, prompt for onboarding via `box onboard salvage-wo <agent>`. When onboarding a client, `box onboard start <node> --email <email> --for <agent>` auto-provisions and redeems 1B tokens upon sign-in.
## Tunnel / container ## Tunnel / container
- Reverse tunnel: VM 2226→container:22, 7683→container:7683. Watchdog `tunnel-watchdog-646` runs `recover-after-rebuild.sh` every 120s. - Reverse tunnel: VM 2226→container:22, 7683→container:7683. Watchdog `tunnel-watchdog-646` runs `recover-after-rebuild.sh` every 120s.
+437
View File
@@ -0,0 +1,437 @@
"""Tests for bin/hatch_menu/* (mocked CDP, no live browser traffic).
One class per module: mouse, dialog, controls, toggles, and the tab
contracts with live-captured Settings fixtures.
"""
import json
import sys
import unittest
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "bin"))
from hatch_menu import controls, dialog, mouse, toggles # noqa: E402
from hatch_menu.dialog import TAB_NAMES # noqa: E402
from hatch_menu.tabs import ( # noqa: E402
TAB_MODULES,
data_controls,
general,
permissions,
)
PIP_GENERAL = (
"General\nWallet\nWeekly limit resets on Oct 10\n100% used\n"
"Additional tokens\nNever expires\n10% used (901M tokens left)\n"
"Upgrade\nLanguage\nAppearance\nMode\nTheme color")
SIX_GENERAL = (
"100% used\nAdditional tokens\nNever expires\n"
"100% used (0 tokens left)\nUpgrade\nLanguage\nAppearance\n"
"Mode\nTheme color")
FRESH_GENERAL = (
"Usage\nFree plan\nWeekly limit resets on Oct 12\n15% used\n"
"Redeem invite code\n")
class FakeWS:
"""Websocket double; recv echoes the last sent id."""
def __init__(self):
self.sent = []
self.closed = False
def send(self, raw):
self.sent.append(json.loads(raw))
def recv(self):
return json.dumps({"id": self.sent[-1]["id"], "result": {}})
def close(self):
self.closed = True
class MouseTests(unittest.TestCase):
def test_real_click_sends_trusted_press_release(self):
ws = FakeWS()
mouse.real_click(ws, 36.0, 468.0)
self.assertEqual(len(ws.sent), 2)
press, release = ws.sent
self.assertEqual(press["method"], "Input.dispatchMouseEvent")
self.assertEqual(press["params"]["type"], "mousePressed")
self.assertEqual(release["params"]["type"], "mouseReleased")
self.assertEqual((press["params"]["x"], press["params"]["y"]),
(36.0, 468.0))
self.assertNotEqual(press["id"], release["id"])
def test_real_click_timeout_raises(self):
ws = mock.Mock()
ws.recv.side_effect = RuntimeError("boom")
with self.assertRaises(mouse.MouseError):
mouse.real_click(ws, 1, 2, timeout=0.01)
def test_escape_and_close_swallow_errors(self):
ws = mock.Mock()
ws.close.side_effect = RuntimeError("boom")
mouse.close(ws) # must not raise
with mock.patch.object(mouse, "cdp_evaluate",
side_effect=RuntimeError("boom")):
self.assertIsNone(mouse.escape(ws))
class DialogTests(unittest.TestCase):
def setUp(self):
p = mock.patch("time.sleep")
p.start()
self.addCleanup(p.stop)
def test_tab_names_complete(self):
self.assertEqual(len(TAB_NAMES), 10)
self.assertEqual(sorted(TAB_MODULES), sorted(TAB_NAMES))
def test_open_settings_success(self):
with mock.patch.object(dialog, "cdp_evaluate",
side_effect=[{"x": 1, "y": 2},
"CLICKED", True]), \
mock.patch.object(dialog, "real_click") as click:
self.assertTrue(dialog.open_settings(mock.Mock()))
click.assert_called_once_with(mock.ANY, 1, 2)
def test_open_settings_dock_missing(self):
with mock.patch.object(dialog, "cdp_evaluate",
side_effect=[None]), \
mock.patch.object(dialog, "real_click") as click:
self.assertFalse(dialog.open_settings(mock.Mock(), tries=1))
click.assert_not_called()
def test_open_settings_retries_then_succeeds(self):
seq = [{"x": 1, "y": 2}, "CLICKED", False,
{"x": 1, "y": 2}, "CLICKED", True]
with mock.patch.object(dialog, "cdp_evaluate",
side_effect=seq), \
mock.patch.object(dialog, "real_click"):
self.assertTrue(dialog.open_settings(mock.Mock(), tries=2))
def test_goto_tab_clicks(self):
with mock.patch.object(dialog, "cdp_evaluate",
return_value="CLICKED"):
self.assertTrue(dialog.goto_tab(mock.Mock(), "General"))
def test_goto_tab_no_match(self):
with mock.patch.object(dialog, "cdp_evaluate",
return_value="NO_TAB"):
self.assertFalse(dialog.goto_tab(mock.Mock(), "General"))
def test_dialog_text_none_on_error(self):
with mock.patch.object(dialog, "cdp_evaluate",
side_effect=RuntimeError("boom")):
self.assertIsNone(dialog.dialog_text(mock.Mock()))
def test_click_row_success_and_no_match(self):
with mock.patch.object(dialog, "cdp_evaluate",
side_effect=["CLICKED", "CLICKED",
"Websites\nrows"]):
self.assertTrue(dialog.click_row(mock.Mock(), "Websites",
"Permissions"))
with mock.patch.object(dialog, "cdp_evaluate",
side_effect=["CLICKED", "NO_MATCH"]):
self.assertFalse(dialog.click_row(mock.Mock(), "Websites",
"Permissions"))
def test_click_row_tab_failure(self):
with mock.patch.object(dialog, "cdp_evaluate",
return_value="NO_TAB") as cdp:
self.assertFalse(dialog.click_row(mock.Mock(), "Websites",
"Permissions"))
self.assertEqual(cdp.call_count, 1)
def test_describe_rows_maps(self):
rows = [{"name": "Websites", "help": "1 site", "extra": ""}]
with mock.patch.object(dialog, "goto_tab", return_value=True), \
mock.patch.object(dialog, "cdp_evaluate", return_value=rows):
self.assertEqual(dialog.describe_rows(mock.Mock(),
"Permissions"), rows)
class ControlsTests(unittest.TestCase):
def setUp(self):
p = mock.patch("time.sleep")
p.start()
self.addCleanup(p.stop)
def test_listers_empty_on_failure(self):
with mock.patch.object(controls, "cdp_evaluate",
side_effect=RuntimeError("boom")):
self.assertEqual(controls.list_radios(mock.Mock()), [])
self.assertEqual(controls.list_switches(mock.Mock()), [])
def test_set_radio_synthetic_success_skips_real_click(self):
rows = [{"heading": "Connector defaults", "value": "auto_allow",
"checked": True}]
with mock.patch.object(controls, "cdp_evaluate",
side_effect=["CLICKED", rows]), \
mock.patch.object(controls, "real_click") as click:
self.assertTrue(controls.set_radio_by_heading(
mock.Mock(), "Connector defaults", "auto_allow"))
click.assert_not_called()
def test_set_radio_falls_back_to_trusted_click(self):
off = [{"heading": "Connector defaults", "value": "auto_allow",
"checked": False}]
on = [{"heading": "Connector defaults", "value": "auto_allow",
"checked": True}]
with mock.patch.object(controls, "cdp_evaluate",
side_effect=["CLICKED", off,
{"x": 5, "y": 6}, on]), \
mock.patch.object(controls, "real_click") as click:
self.assertTrue(controls.set_radio_by_heading(
mock.Mock(), "Connector defaults", "auto_allow"))
click.assert_called_once()
def test_set_radio_no_rect_fails(self):
off = [{"heading": "Connector defaults", "value": "auto_allow",
"checked": False}]
with mock.patch.object(controls, "cdp_evaluate",
side_effect=["CLICKED", off, None]), \
mock.patch.object(controls, "real_click"):
self.assertFalse(controls.set_radio_by_heading(
mock.Mock(), "Connector defaults", "auto_allow"))
def test_switch_state_unique_and_ambiguous(self):
one = [{"label": "Transparent proxy row", "aria": "",
"checked": False}]
two = one + [{"label": "Transparent proxy clone", "aria": "",
"checked": True}]
with mock.patch.object(controls, "cdp_evaluate",
return_value=one):
self.assertIs(controls.switch_state(mock.Mock(),
"Transparent proxy"), False)
with mock.patch.object(controls, "cdp_evaluate",
return_value=two):
self.assertIsNone(controls.switch_state(mock.Mock(),
"Transparent proxy"))
with mock.patch.object(controls, "cdp_evaluate", return_value=[]):
self.assertIsNone(controls.switch_state(mock.Mock(), "nope"))
def test_set_switch_noop_when_already_there(self):
one = [{"label": "Transparent proxy row", "aria": "",
"checked": True}]
with mock.patch.object(controls, "cdp_evaluate",
return_value=one) as cdp:
self.assertTrue(controls.set_switch(mock.Mock(),
"Transparent proxy", True))
self.assertEqual(cdp.call_count, 1) # list only, no click
def test_set_switch_flips(self):
off = [{"label": "Transparent proxy row", "aria": "",
"checked": False}]
on = [{"label": "Transparent proxy row", "aria": "",
"checked": True}]
with mock.patch.object(controls, "cdp_evaluate",
side_effect=[off, "CLICKED", on]):
self.assertTrue(controls.set_switch(mock.Mock(),
"Transparent proxy", True))
class TogglesTests(unittest.TestCase):
def test_resolve_static_website_protocol(self):
spec = toggles.resolve_toggle("permissions.web_access")
self.assertEqual(spec["kind"], "radio-heading")
spec = toggles.resolve_toggle("permissions.websites:example.com")
self.assertEqual((spec["kind"], spec["host"]),
("website", "example.com"))
spec = toggles.resolve_toggle("permissions.protocols:mcp-sse")
self.assertEqual(spec["kind"], "protocol")
spec = toggles.resolve_toggle(
"permissions.protocols:Agent Skills endpoints")
self.assertEqual(spec["label"], "Agent Skills endpoints")
def test_resolve_unknowns_raise_before_cdp(self):
for bad in ("nope", "", "permissions.websites:",
"permissions.protocols:nope"):
with self.assertRaises(toggles.MenuError, msg=bad):
toggles.resolve_toggle(bad)
def test_normalize_onoff_table(self):
for yes in ("on", "ON", "true", "1", "yes", True):
self.assertTrue(toggles.normalize_onoff(yes), yes)
for no in ("off", "OFF", "false", "0", "no", False):
self.assertFalse(toggles.normalize_onoff(no), no)
for bad in ("maybe", "", None, 42):
self.assertIsNone(toggles.normalize_onoff(bad), bad)
def test_get_toggle_bad_node_and_toggle_raise(self):
with mock.patch.object(toggles, "get_cdp_ws") as g:
with self.assertRaises(toggles.MenuError):
toggles.get_toggle("nope", "permissions.web_access")
with self.assertRaises(toggles.MenuError):
toggles.get_toggle("pip", "nope")
g.assert_not_called()
def test_get_toggle_cdp_down_returns_error(self):
with mock.patch.object(toggles, "get_cdp_ws",
side_effect=ConnectionError("refused")):
res = toggles.get_toggle("pip", "permissions.web_access")
self.assertFalse(res["ok"])
self.assertIn("refused", res["error"])
def test_get_toggle_radio_heading(self):
rows = [{"heading": "Web access defaults", "value": "auto_allow",
"checked": False},
{"heading": "Web access defaults", "value": "always_ask",
"checked": True}]
with mock.patch.object(toggles, "get_cdp_ws",
return_value=(mock.Mock(), {})), \
mock.patch.object(dialog, "open_settings",
return_value=True), \
mock.patch.object(dialog, "goto_tab", return_value=True), \
mock.patch.object(controls, "list_radios", return_value=rows):
res = toggles.get_toggle("pip", "permissions.web_access")
self.assertEqual((res["ok"], res["value"]), (True, "always_ask"))
def test_set_toggle_bad_value_raises_before_cdp(self):
with mock.patch.object(toggles, "get_cdp_ws") as g:
with self.assertRaises(toggles.MenuError):
toggles.set_toggle("pip", "permissions.web_access",
"sometimes")
with self.assertRaises(toggles.MenuError):
toggles.set_toggle("pip", "permissions.websites:x.com",
"sometimes")
g.assert_not_called()
def test_set_toggle_success_with_readback(self):
with mock.patch.object(toggles, "get_cdp_ws",
return_value=(mock.Mock(), {})), \
mock.patch.object(dialog, "open_settings",
return_value=True), \
mock.patch.object(dialog, "goto_tab", return_value=True), \
mock.patch.object(controls, "set_radio_by_heading",
return_value=True), \
mock.patch.object(toggles, "get_toggle",
return_value={"ok": True,
"value": "always_ask"}):
res = toggles.set_toggle("pip", "permissions.web_access",
"always_ask")
self.assertEqual((res["ok"], res["value"]), (True, "always_ask"))
def test_set_toggle_readback_mismatch_fails(self):
with mock.patch.object(toggles, "get_cdp_ws",
return_value=(mock.Mock(), {})), \
mock.patch.object(dialog, "open_settings",
return_value=True), \
mock.patch.object(dialog, "goto_tab", return_value=True), \
mock.patch.object(controls, "set_radio_by_heading",
return_value=True), \
mock.patch.object(toggles, "get_toggle",
return_value={"ok": True,
"value": "auto_allow"}):
res = toggles.set_toggle("pip", "permissions.web_access",
"always_ask")
self.assertFalse(res["ok"])
self.assertIn("readback", res["error"])
def test_list_toggles_bad_tab_raises(self):
with self.assertRaises(toggles.MenuError):
toggles.list_toggles("pip", tab="Nope")
def test_list_toggles_aggregates(self):
with mock.patch.object(toggles, "get_cdp_ws",
return_value=(mock.Mock(), {})), \
mock.patch.object(dialog, "open_settings",
return_value=True), \
mock.patch("hatch_menu.tabs.permissions.all_toggles",
return_value={"permissions.web_access":
"always_ask"}), \
mock.patch("hatch_menu.tabs.data_controls.ai_improvement",
return_value=True), \
mock.patch("hatch_menu.tabs.general.theme",
return_value="blue"):
res = toggles.list_toggles("pip")
self.assertTrue(res["ok"])
self.assertEqual(res["toggles"]["permissions.web_access"],
"always_ask")
self.assertEqual(res["toggles"]["data_controls.ai_improvement"],
"on")
self.assertEqual(res["toggles"]["general.theme"], "blue")
def test_describe_tab(self):
with mock.patch.object(toggles, "get_cdp_ws",
return_value=(mock.Mock(), {})), \
mock.patch.object(dialog, "open_settings",
return_value=True), \
mock.patch.object(dialog, "goto_tab", return_value=True), \
mock.patch("hatch_menu.tabs.general.describe",
return_value={"theme": "blue"}):
res = toggles.describe_tab("pip", "General")
self.assertEqual(res["inventory"], {"theme": "blue"})
with self.assertRaises(toggles.MenuError):
toggles.describe_tab("pip", "Nope")
class TabContractTests(unittest.TestCase):
def test_general_parse_pip(self):
u = general.parse_usage_text(PIP_GENERAL)
self.assertEqual(u["weekly"], {"pct_used": 100,
"resets_on": "Oct 10"})
self.assertEqual(u["additional"]["tokens_left"], "901M")
self.assertEqual(u["additional"]["pct_used"], 10)
self.assertTrue(u["additional"]["never_expires"])
def test_general_parse_646_partial(self):
u = general.parse_usage_text(SIX_GENERAL)
self.assertEqual(u["weekly"]["pct_used"], 100)
self.assertIsNone(u["weekly"]["resets_on"])
self.assertEqual(u["additional"]["tokens_left"], "0")
def test_general_parse_empty_and_full_month(self):
self.assertIsNone(general.parse_usage_text(""))
self.assertIsNone(general.parse_usage_text(None))
u = general.parse_usage_text("Weekly limit resets on October 9")
self.assertEqual(u["weekly"]["resets_on"], "October 9")
def test_general_theme(self):
rows = [{"value": "default", "checked": False},
{"value": "blue", "checked": True}]
with mock.patch.object(dialog, "goto_tab", return_value=True), \
mock.patch.object(controls, "list_radios", return_value=rows):
self.assertEqual(general.theme(mock.Mock()), "blue")
with mock.patch.object(dialog, "goto_tab", return_value=False):
self.assertIsNone(general.theme(mock.Mock()))
def test_protocol_resolution(self):
self.assertEqual(permissions.resolve_protocol("mcp-sse"),
"Model Context Protocol servers (SSE)")
self.assertEqual(permissions.resolve_protocol(
"Agent Skills endpoints"), "Agent Skills endpoints")
self.assertIsNone(permissions.resolve_protocol("nope"))
self.assertIsNone(permissions.resolve_protocol(""))
self.assertIsNone(permissions.resolve_protocol(None))
def test_website_mode_rejects_bad_mode_without_cdp(self):
with mock.patch.object(permissions, "_eval") as ev:
self.assertFalse(permissions.set_website_mode(
mock.Mock(), "x.com", "Sometimes"))
ev.assert_not_called()
def test_back_to_root_prefers_back_affordance(self):
with mock.patch.object(dialog, "go_back", return_value=True), \
mock.patch.object(dialog, "dialog_text",
return_value="Manage permissions\nrows"):
self.assertTrue(permissions._back_to_root(mock.Mock()))
def test_data_controls_single_switch(self):
one = [{"label": "Help improve the model", "aria": "",
"checked": True}]
with mock.patch.object(dialog, "goto_tab", return_value=True), \
mock.patch.object(controls, "list_switches",
return_value=one):
self.assertTrue(data_controls.ai_improvement(mock.Mock()))
with mock.patch.object(dialog, "goto_tab", return_value=True), \
mock.patch.object(controls, "list_switches", return_value=[]):
self.assertIsNone(data_controls.ai_improvement(mock.Mock()))
if __name__ == "__main__":
unittest.main()
+261
View File
@@ -0,0 +1,261 @@
"""Tests for the invite code + usage handler (mocked CDP).
No live browser traffic: get_cdp_ws / cdp_evaluate are patched and the
DOM/API contracts asserted from fixtures captured live from muse.ai.
"""
import sys
import unittest
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "bin"))
import invite
API_OK = {"status": 200, "data": {
"code": "F4BGHN", "has_redeemed_invite_code": True,
"uses_remaining": 30, "use_count": 0, "reward": None}}
POPOVER_TEXT = (
"Invite friends\n\nInvite a friend and you'll both get 1 billion Muse "
"tokens when they redeem your code in Settings within 48 hours of "
"joining. 30 uses left.\n\nF4BGHN\n"
"Invite code revealed: F4BGHN\nCopy referral link")
PIP_GENERAL = (
"General\nWallet\nWeekly limit resets on Oct 10\n100% used\n"
"Additional tokens\nNever expires\n10% used (901M tokens left)\n"
"Upgrade\nLanguage\nAppearance\nMode\nTheme color")
SIX_GENERAL = (
"100% used\nAdditional tokens\nNever expires\n"
"100% used (0 tokens left)\nUpgrade\nLanguage\nAppearance\n"
"Mode\nTheme color")
class NormalizeTests(unittest.TestCase):
def test_accepts_and_normalizes(self):
self.assertEqual(invite.normalize_code("F4BGHN"), "F4BGHN")
self.assertEqual(invite.normalize_code("f4bghn"), "F4BGHN")
self.assertEqual(invite.normalize_code(" redcj7\n"), "REDCJ7")
def test_rejects_malformed(self):
for bad in ("", "ABC12", "ABCDEFG", "ABC-12", "ABC 12",
"abcde!", None, 123, "F4BGH\nN"):
self.assertIsNone(invite.normalize_code(bad), bad)
class GetInviteTests(unittest.TestCase):
def setUp(self):
p = mock.patch("time.sleep")
p.start()
self.addCleanup(p.stop)
g = mock.patch.object(invite, "get_cdp_ws",
return_value=(mock.Mock(), {}))
self.get_ws = g.start()
self.addCleanup(g.stop)
c = mock.patch.object(invite, "cdp_evaluate")
self.cdp = c.start()
self.addCleanup(c.stop)
def test_bad_node_before_cdp(self):
with self.assertRaises(invite.InviteError):
invite.get_invite("nope")
self.get_ws.assert_not_called()
def test_api_success(self):
self.cdp.return_value = dict(API_OK)
res = invite.get_invite("pip")
self.assertEqual(res, {"ok": True, "node": "pip", "source": "api",
"code": "F4BGHN", "has_redeemed": True,
"uses_remaining": 30, "use_count": 0,
"reward": None})
def test_api_failure_falls_back_to_popover(self):
self.cdp.side_effect = [{"error": "boom"}, "CLICKED",
POPOVER_TEXT, "ESC"]
res = invite.get_invite("pip")
self.assertTrue(res["ok"])
self.assertEqual(res["source"], "dom")
self.assertEqual(res["code"], "F4BGHN")
self.assertIsNone(res["has_redeemed"])
def test_no_api_no_popover(self):
self.cdp.side_effect = [None, "NO_BUTTON", "ESC"]
res = invite.get_invite("pip")
self.assertFalse(res["ok"])
self.assertIn("popover", res["error"])
def test_connection_error(self):
self.get_ws.side_effect = ConnectionError("refused")
res = invite.get_invite("def")
self.assertFalse(res["ok"])
self.assertIn("CDP", res["error"])
class RedeemTests(unittest.TestCase):
def setUp(self):
g = mock.patch.object(invite, "get_cdp_ws",
return_value=(mock.Mock(), {}))
self.get_ws = g.start()
self.addCleanup(g.stop)
c = mock.patch.object(invite, "cdp_evaluate")
self.cdp = c.start()
self.addCleanup(c.stop)
def test_bad_code_before_cdp(self):
for bad in ("xyz", "", None, "ABC-12"):
with self.assertRaises(invite.InviteError, msg=bad):
invite.redeem_invite("646", bad)
self.get_ws.assert_not_called()
def test_bad_node_before_cdp(self):
with self.assertRaises(invite.InviteError):
invite.redeem_invite("nope", "F4BGHN")
self.get_ws.assert_not_called()
def test_success(self):
self.cdp.return_value = {"status": 200, "ok": True, "data": {
"success": True, "redemptionStatus": "redeemed",
"detail": "You both got 1 billion Muse tokens."}}
res = invite.redeem_invite("646", "f4bghn")
self.assertTrue(res["ok"])
self.assertEqual(res["code"], "F4BGHN")
self.assertEqual(res["redemption_status"], "redeemed")
sent_js = self.cdp.call_args[0][1]
self.assertIn("F4BGHN", sent_js)
def test_reason_passthrough(self):
self.cdp.return_value = {"status": 400, "ok": False, "data": {
"success": False, "reason": "already_redeemed",
"detail": "You’ve already redeemed an invite code."}}
res = invite.redeem_invite("muse", "REDCJ7")
self.assertFalse(res["ok"])
self.assertEqual(res["reason"], "already_redeemed")
self.assertIn("already redeemed", res["detail"])
def test_transport_error(self):
self.cdp.side_effect = RuntimeError("ws blew up")
res = invite.redeem_invite("646", "F4BGHN")
self.assertFalse(res["ok"])
self.assertIn("ws blew up", res["error"])
def test_connection_error(self):
self.get_ws.side_effect = ConnectionError("refused")
res = invite.redeem_invite("646", "F4BGHN")
self.assertFalse(res["ok"])
self.assertEqual(res["code"], "F4BGHN")
class UsageTests(unittest.TestCase):
def test_parse_pip_general(self):
self.assertEqual(invite.parse_usage_text(PIP_GENERAL), {
"weekly_reset": "Oct 10", "weekly_used_pct": 100,
"additional_expires": "Never expires",
"additional_used_pct": 10,
"additional_left": "901M tokens left",
"has_redeemed": True})
def test_parse_646_general_partial(self):
self.assertEqual(invite.parse_usage_text(SIX_GENERAL), {
"weekly_reset": None, "weekly_used_pct": 100,
"additional_expires": "Never expires",
"additional_used_pct": 100,
"additional_left": "0 tokens left",
"has_redeemed": True})
def test_parse_empty(self):
self.assertEqual(invite.parse_usage_text(""), {
"weekly_reset": None, "weekly_used_pct": None,
"additional_expires": None, "additional_used_pct": None,
"additional_left": None, "has_redeemed": False})
def test_bad_node_before_cdp(self):
with mock.patch.object(invite, "get_cdp_ws") as g:
with self.assertRaises(invite.InviteError):
invite.get_usage("nope")
g.assert_not_called()
def test_settings_open_failure(self):
with mock.patch.object(invite, "get_cdp_ws",
return_value=(mock.Mock(), {})), \
mock.patch.object(invite, "open_settings",
return_value=False):
res = invite.get_usage("pip")
self.assertFalse(res["ok"])
self.assertIn("did not open", res["error"])
def test_success(self):
with mock.patch.object(invite, "get_cdp_ws",
return_value=(mock.Mock(), {})), \
mock.patch.object(invite, "open_settings",
return_value=True), \
mock.patch("hatch_menu.dialog.goto_tab",
return_value=True), \
mock.patch("hatch_menu.dialog.dialog_text",
return_value=PIP_GENERAL):
res = invite.get_usage("pip")
self.assertTrue(res["ok"])
self.assertEqual(res["weekly_reset"], "Oct 10")
self.assertEqual(res["additional_left"], "901M tokens left")
self.assertTrue(res["has_redeemed"])
def test_general_tab_failure(self):
with mock.patch.object(invite, "get_cdp_ws",
return_value=(mock.Mock(), {})), \
mock.patch.object(invite, "open_settings",
return_value=True), \
mock.patch("hatch_menu.dialog.goto_tab",
return_value=False):
res = invite.get_usage("pip")
self.assertFalse(res["ok"])
self.assertIn("General tab", res["error"])
class FleetTests(unittest.TestCase):
def test_invite_status_aggregates_and_never_raises(self):
with mock.patch.object(
invite, "get_invite",
side_effect=[{"ok": True, "node": "646"},
invite.InviteError("bad")]):
res = invite.fleet_invite_status(["646", "bogus"])
self.assertTrue(res["646"]["ok"])
self.assertFalse(res["bogus"]["ok"])
def test_usage_aggregates(self):
with mock.patch.object(
invite, "get_usage",
return_value={"ok": True, "node": "pip"}):
res = invite.fleet_usage(["pip"])
self.assertTrue(res["pip"]["ok"])
class ShimTests(unittest.TestCase):
"""open_settings/click_settings_tab delegate to the tree (single copy).
Trusted-click behavior itself is covered by tests/test_hatch_menu.py.
"""
def test_open_settings_delegates(self):
ws = mock.Mock()
with mock.patch("hatch_menu.dialog.open_settings",
return_value=True) as m:
self.assertTrue(invite.open_settings(ws))
m.assert_called_once_with(ws)
def test_open_settings_failure_passthrough(self):
with mock.patch("hatch_menu.dialog.open_settings",
return_value=False):
self.assertFalse(invite.open_settings(mock.Mock()))
def test_click_settings_tab_delegates(self):
ws = mock.Mock()
with mock.patch("hatch_menu.dialog.goto_tab",
return_value=True) as m:
self.assertTrue(invite.click_settings_tab(ws, "General"))
m.assert_called_once_with(ws, "General")
if __name__ == "__main__":
unittest.main()
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""test_invite_handler.py: Unit tests for invite code handler, usage parser, and settings RPA."""
import importlib.util
import json
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "bin"))
import invite
from invite_handler import InviteCodeInfo, InviteHandler, RedemptionResult, salvage_blocked_node
from settings_rpa import NodeUsage, SettingsRPA
class TestInviteCodeValidation(unittest.TestCase):
def test_normalize_valid_codes(self):
self.assertEqual(invite.normalize_code("REDCJ7"), "REDCJ7")
self.assertEqual(invite.normalize_code(" redcj7 "), "REDCJ7")
self.assertEqual(invite.normalize_code("81mdir"), "81MDIR")
self.assertEqual(invite.normalize_code("F4BGHN"), "F4BGHN")
self.assertEqual(invite.normalize_code("14ogf2"), "14OGF2")
def test_normalize_invalid_codes(self):
self.assertIsNone(invite.normalize_code("SHORT"))
self.assertIsNone(invite.normalize_code("TOOLONG123"))
self.assertIsNone(invite.normalize_code("INV@L!"))
self.assertIsNone(invite.normalize_code(""))
self.assertIsNone(invite.normalize_code(None))
self.assertIsNone(invite.normalize_code(123456))
class TestUsageParser(unittest.TestCase):
def test_parse_usage_blocked_node(self):
sample_646 = """
Settings
General
Meta Account
Password, security, personal details
Usage
Free plan
Weekly limit resets on Oct 9
100% used
Additional tokens
Never expires
100% used (0 tokens left)
Upgrade
Language
Appearance
"""
parsed = invite.parse_usage_text(sample_646)
self.assertEqual(parsed["weekly_reset"], "Oct 9")
self.assertEqual(parsed["weekly_used_pct"], 100)
self.assertEqual(parsed["additional_expires"], "Never expires")
self.assertEqual(parsed["additional_used_pct"], 100)
self.assertEqual(parsed["additional_left"], "0 tokens left")
def test_parse_usage_active_pip(self):
sample_pip = """
Usage
Free plan
Weekly limit resets on Oct 10
100% used
Additional tokens
Never expires
10% used (901M tokens left)
Upgrade
"""
parsed = invite.parse_usage_text(sample_pip)
self.assertEqual(parsed["weekly_reset"], "Oct 10")
self.assertEqual(parsed["weekly_used_pct"], 100)
self.assertEqual(parsed["additional_expires"], "Never expires")
self.assertEqual(parsed["additional_used_pct"], 10)
self.assertEqual(parsed["additional_left"], "901M tokens left")
self.assertTrue(parsed["has_redeemed"])
def test_parse_usage_fresh_account_no_extra_tokens(self):
sample_fresh = """
Usage
Free plan
Weekly limit resets on Oct 12
15% used
Redeem invite code
"""
parsed = invite.parse_usage_text(sample_fresh)
self.assertEqual(parsed["weekly_reset"], "Oct 12")
self.assertEqual(parsed["weekly_used_pct"], 15)
self.assertIsNone(parsed["additional_left"])
self.assertFalse(parsed["has_redeemed"])
def test_parse_usage_empty(self):
parsed = invite.parse_usage_text("")
self.assertIsNone(parsed["weekly_reset"])
self.assertIsNone(parsed["weekly_used_pct"])
self.assertIsNone(parsed["additional_left"])
self.assertFalse(parsed["has_redeemed"])
class TestInviteHandlerMocked(unittest.TestCase):
@patch("invite.get_cdp_ws")
@patch("invite.cdp_evaluate")
def test_get_invite_api_success(self, mock_eval, mock_ws):
mock_ws.return_value = (MagicMock(), MagicMock())
mock_eval.return_value = {
"status": 200,
"data": {
"code": "REDCJ7",
"uses_remaining": 30,
"use_count": 0,
"has_redeemed_invite_code": True,
"reward": None,
},
}
res = invite.get_invite("646")
self.assertTrue(res["ok"])
self.assertEqual(res["code"], "REDCJ7")
self.assertTrue(res["has_redeemed"])
self.assertEqual(res["uses_remaining"], 30)
@patch("invite.get_cdp_ws")
@patch("invite.cdp_evaluate")
def test_redeem_invite_already_redeemed(self, mock_eval, mock_ws):
mock_ws.return_value = (MagicMock(), MagicMock())
mock_eval.return_value = {
"status": 400,
"ok": False,
"data": {
"success": False,
"reason": "already_redeemed",
"detail": "You’ve already redeemed an invite code.",
},
}
res = invite.redeem_invite("muse", "REDCJ7")
self.assertFalse(res["ok"])
self.assertEqual(res["reason"], "already_redeemed")
self.assertIn("already redeemed", res["detail"])
@patch("invite.get_cdp_ws")
@patch("invite.cdp_evaluate")
def test_redeem_invite_success(self, mock_eval, mock_ws):
mock_ws.return_value = (MagicMock(), MagicMock())
mock_eval.return_value = {
"status": 200,
"ok": True,
"data": {
"success": True,
"redemptionStatus": "redeemed",
"detail": "Congratulations!",
},
}
res = invite.redeem_invite("def", "REDCJ7")
self.assertTrue(res["ok"])
self.assertEqual(res["redemption_status"], "redeemed")
def test_salvage_blocked_node_diagnosis(self):
with patch.object(InviteHandler, "find_code") as mock_find:
mock_find.return_value = InviteCodeInfo(
node="646",
code="REDCJ7",
uses_remaining=30,
use_count=0,
max_uses=30,
has_redeemed=True,
invite_state="ELIGIBLE",
reward=None,
method_used="api",
)
# All peers already redeemed
res = salvage_blocked_node("646")
self.assertFalse(res["success"])
self.assertEqual(res["invite_code"], "REDCJ7")
self.assertIn("An onboarding agent or fresh client profile must redeem this code", res["error"])
if __name__ == "__main__":
unittest.main()
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""test_onboard_pipeline.py — Unit tests for the onboarding and invite salvage pipeline."""
import json
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "bin"))
from onboard_pipeline import (
OnboardState,
finish_onboarding_redemption,
select_urgent_beneficiary,
start_onboarding,
submit_onboarding_otp,
)
class TestOnboardPipeline(unittest.TestCase):
def test_select_urgent_beneficiary_explicit(self):
b_node, code, reason = select_urgent_beneficiary("dev", "TEST01")
self.assertEqual(b_node, "dev")
self.assertEqual(code, "TEST01")
self.assertIn("Explicit", reason)
@patch("invite.fleet_invite_status")
@patch("invite.fleet_usage")
def test_select_urgent_beneficiary_blocked_first(self, mock_usage, mock_invite):
mock_usage.return_value = {
"646": {"weekly_used_pct": 100, "additional_used_pct": 100, "additional_left": "0 tokens left"},
"pip": {"weekly_used_pct": 100, "additional_used_pct": 10, "additional_left": "890M tokens left"},
"muse": {"weekly_used_pct": 50, "additional_used_pct": 0, "additional_left": "950M tokens left"},
}
mock_invite.return_value = {
"646": {"code": "REDCJ7"},
"pip": {"code": "F4BGHN"},
"muse": {"code": "81MDIR"},
}
b_node, code, reason = select_urgent_beneficiary()
self.assertEqual(b_node, "646")
self.assertEqual(code, "REDCJ7")
self.assertIn("BLOCKED", reason)
@patch("onboard_pipeline.provision_node_infra")
@patch("onboard_pipeline.CredClient")
@patch("onboard_pipeline.select_urgent_beneficiary")
def test_start_onboarding_awaiting_otp(self, mock_select, mock_cred_cls, mock_infra):
mock_select.return_value = ("646", "REDCJ7", "Blocked test")
mock_infra.return_value = {"ok": True}
mock_client = MagicMock()
mock_client.initiate.return_value = {
"status": "awaiting_otp",
"message": "OTP sent",
}
mock_cred_cls.return_value = mock_client
res = start_onboarding("testnode", "client@test.com")
self.assertTrue(res["ok"])
self.assertEqual(res["status"], "awaiting_otp")
self.assertEqual(res["beneficiary_node"], "646")
self.assertEqual(res["invite_code_queued"], "REDCJ7")
@patch("invite.redeem_invite")
@patch("subprocess.run")
def test_finish_onboarding_redemption_success(self, mock_subproc, mock_redeem):
mock_redeem.return_value = {"ok": True, "redemption_status": "redeemed"}
state = OnboardState(
node="testnode",
email="client@test.com",
beneficiary_node="646",
invite_code="REDCJ7",
stage="auth_active",
)
res = finish_onboarding_redemption(state)
self.assertTrue(res["ok"])
self.assertEqual(res["status"], "completed")
self.assertEqual(res["beneficiary_node"], "646")
mock_redeem.assert_called_once_with("testnode", "REDCJ7", timeout=20.0)
if __name__ == "__main__":
unittest.main()
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""test_settings_rpa.py: Unit tests for Settings RPA substrate."""
import json
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "bin"))
from settings_rpa import NodeUsage, SettingsRPA, cdp_dispatch_mouse_click, cdp_send_command
class TestSettingsRPAPrimitives(unittest.TestCase):
def test_cdp_send_command_matches_id(self):
mock_ws = MagicMock()
mock_ws.recv.return_value = json.dumps({"id": 10000, "result": {"success": True}})
# Mock _REQ_COUNTER next value
with patch("settings_rpa._REQ_COUNTER", iter([10000])):
res = cdp_send_command(mock_ws, "Test.method", {"param": 1})
self.assertEqual(res, {"success": True})
mock_ws.send.assert_called_once()
sent_msg = json.loads(mock_ws.send.call_args[0][0])
self.assertEqual(sent_msg["method"], "Test.method")
def test_cdp_dispatch_mouse_click(self):
mock_ws = MagicMock()
with patch("settings_rpa.cdp_send_command") as mock_send:
mock_send.return_value = {}
res = cdp_dispatch_mouse_click(mock_ws, 100.0, 200.0)
self.assertTrue(res)
self.assertEqual(mock_send.call_count, 2)
# Check mousePressed and mouseReleased
call_methods = [c[0][1] for c in mock_send.call_args_list]
self.assertEqual(call_methods, ["Input.dispatchMouseEvent", "Input.dispatchMouseEvent"])
@patch("settings_rpa.get_cdp_ws")
@patch("settings_rpa.cdp_evaluate")
def test_settings_dialog_check(self, mock_eval, mock_ws):
mock_ws.return_value = (MagicMock(), MagicMock())
mock_eval.return_value = True
rpa = SettingsRPA("646")
rpa.connect()
self.assertTrue(rpa.is_settings_dialog_open())
@patch("settings_rpa.get_cdp_ws")
@patch("settings_rpa.cdp_evaluate")
def test_read_usage_blocked(self, mock_eval, mock_ws):
mock_ws.return_value = (MagicMock(), MagicMock())
# Provide responses: open check (True), tab open check (True), click tab (True), usage dict
usage_payload = {
"dialogText": "Free plan\nWeekly limit resets on Oct 9\n100% used\nAdditional tokens\n100% used (0 tokens left)",
"bars": [
{"label": "Free plan", "percent": 100, "raw_text": "Free plan\nWeekly limit resets on Oct 9\n100% used"},
{"label": "Additional tokens", "percent": 100, "raw_text": "Additional tokens\n100% used (0 tokens left)"},
],
"plan": "Free plan",
"resetText": "Weekly limit resets on Oct 9",
"tokensLeft": "0 tokens left",
}
mock_eval.side_effect = [
True, # is_settings_dialog_open
True, # is_settings_dialog_open in select_tab
True, # click General tab
usage_payload, # read_usage evaluation
]
with SettingsRPA("646") as rpa:
usage = rpa.read_usage(keep_dialog_open=True)
self.assertEqual(usage.node, "646")
self.assertEqual(usage.weekly_percent_used, 100)
self.assertEqual(usage.extra_percent_used, 100)
self.assertTrue(usage.is_blocked)
self.assertEqual(usage.extra_tokens_remaining, "0 tokens left")
self.assertTrue(usage.has_redeemed)
@patch("settings_rpa.get_cdp_ws")
@patch("settings_rpa.cdp_evaluate")
def test_read_usage_not_redeemed(self, mock_eval, mock_ws):
mock_ws.return_value = (MagicMock(), MagicMock())
usage_payload = {
"dialogText": "Free plan\nWeekly limit resets on Oct 9\n20% used\nRedeem invite code",
"bars": [
{"label": "Free plan", "percent": 20, "raw_text": "Free plan\nWeekly limit resets on Oct 9\n20% used"},
],
"plan": "Free plan",
"resetText": "Weekly limit resets on Oct 9",
"tokensLeft": "",
}
mock_eval.side_effect = [
True, # is_settings_dialog_open
True, # is_settings_dialog_open in select_tab
True, # click General tab
usage_payload, # read_usage evaluation
]
with SettingsRPA("646") as rpa:
usage = rpa.read_usage(keep_dialog_open=True)
self.assertEqual(usage.node, "646")
self.assertEqual(usage.weekly_percent_used, 20)
self.assertEqual(usage.extra_percent_used, 0)
self.assertFalse(usage.is_blocked)
self.assertFalse(usage.has_redeemed)
if __name__ == "__main__":
unittest.main()