diff --git a/bin/hatch_menu/__init__.py b/bin/hatch_menu/__init__.py new file mode 100644 index 0000000..4add944 --- /dev/null +++ b/bin/hatch_menu/__init__.py @@ -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", +] diff --git a/bin/hatch_menu/controls.py b/bin/hatch_menu/controls.py new file mode 100644 index 0000000..64a72bc --- /dev/null +++ b/bin/hatch_menu/controls.py @@ -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) diff --git a/bin/hatch_menu/dialog.py b/bin/hatch_menu/dialog.py new file mode 100644 index 0000000..5598f90 --- /dev/null +++ b/bin/hatch_menu/dialog.py @@ -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) diff --git a/bin/hatch_menu/mouse.py b/bin/hatch_menu/mouse.py new file mode 100644 index 0000000..493ebe9 --- /dev/null +++ b/bin/hatch_menu/mouse.py @@ -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)) diff --git a/bin/hatch_menu/tabs/__init__.py b/bin/hatch_menu/tabs/__init__.py new file mode 100644 index 0000000..831562a --- /dev/null +++ b/bin/hatch_menu/tabs/__init__.py @@ -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"] diff --git a/bin/hatch_menu/tabs/connectors.py b/bin/hatch_menu/tabs/connectors.py new file mode 100644 index 0000000..cfe8b79 --- /dev/null +++ b/bin/hatch_menu/tabs/connectors.py @@ -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]} diff --git a/bin/hatch_menu/tabs/data_controls.py b/bin/hatch_menu/tabs/data_controls.py new file mode 100644 index 0000000..074df90 --- /dev/null +++ b/bin/hatch_menu/tabs/data_controls.py @@ -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)} diff --git a/bin/hatch_menu/tabs/devices.py b/bin/hatch_menu/tabs/devices.py new file mode 100644 index 0000000..4f06a23 --- /dev/null +++ b/bin/hatch_menu/tabs/devices.py @@ -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]} diff --git a/bin/hatch_menu/tabs/general.py b/bin/hatch_menu/tabs/general.py new file mode 100644 index 0000000..9c644ba --- /dev/null +++ b/bin/hatch_menu/tabs/general.py @@ -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)} diff --git a/bin/hatch_menu/tabs/help_support.py b/bin/hatch_menu/tabs/help_support.py new file mode 100644 index 0000000..938dc8d --- /dev/null +++ b/bin/hatch_menu/tabs/help_support.py @@ -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]} diff --git a/bin/hatch_menu/tabs/legal.py b/bin/hatch_menu/tabs/legal.py new file mode 100644 index 0000000..d954073 --- /dev/null +++ b/bin/hatch_menu/tabs/legal.py @@ -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]} diff --git a/bin/hatch_menu/tabs/messaging.py b/bin/hatch_menu/tabs/messaging.py new file mode 100644 index 0000000..806ba7e --- /dev/null +++ b/bin/hatch_menu/tabs/messaging.py @@ -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]} diff --git a/bin/hatch_menu/tabs/permissions.py b/bin/hatch_menu/tabs/permissions.py new file mode 100644 index 0000000..9c3c932 --- /dev/null +++ b/bin/hatch_menu/tabs/permissions.py @@ -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)} diff --git a/bin/hatch_menu/tabs/secure_store.py b/bin/hatch_menu/tabs/secure_store.py new file mode 100644 index 0000000..0872076 --- /dev/null +++ b/bin/hatch_menu/tabs/secure_store.py @@ -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]} diff --git a/bin/hatch_menu/tabs/wallet.py b/bin/hatch_menu/tabs/wallet.py new file mode 100644 index 0000000..2bbb891 --- /dev/null +++ b/bin/hatch_menu/tabs/wallet.py @@ -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]} diff --git a/bin/hatch_menu/toggles.py b/bin/hatch_menu/toggles.py new file mode 100644 index 0000000..86bc2fd --- /dev/null +++ b/bin/hatch_menu/toggles.py @@ -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) diff --git a/bin/invite.py b/bin/invite.py new file mode 100644 index 0000000..3fe47ed --- /dev/null +++ b/bin/invite.py @@ -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 diff --git a/bin/invite_handler.py b/bin/invite_handler.py new file mode 100755 index 0000000..c6d6207 --- /dev/null +++ b/bin/invite_handler.py @@ -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() diff --git a/bin/modulate.py b/bin/modulate.py index b5af7e2..7f88b65 100644 --- a/bin/modulate.py +++ b/bin/modulate.py @@ -52,6 +52,7 @@ class InputType(str, Enum): MANUAL = "manual" # human/operator-authored DM HEALTH = "health" # system health check results HEARTBEAT = "heartbeat" # loopback liveness probes + SALVAGE = "salvage" # token exhaustion & onboarding work orders 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, 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. (InputType.HEARTBEAT, None): (False, Priority.ROUTINE, 0, 0, None), } diff --git a/bin/onboard_pipeline.py b/bin/onboard_pipeline.py new file mode 100755 index 0000000..e62bfd5 --- /dev/null +++ b/bin/onboard_pipeline.py @@ -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 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 ", + } + 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 --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() diff --git a/bin/settings_rpa.py b/bin/settings_rpa.py new file mode 100755 index 0000000..84b590f --- /dev/null +++ b/bin/settings_rpa.py @@ -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() diff --git a/docs/HATCH-MENU.md b/docs/HATCH-MENU.md new file mode 100644 index 0000000..9be4914 --- /dev/null +++ b/docs/HATCH-MENU.md @@ -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:` (`Allow`/`Ask`/`Deny`), +`permissions.protocols:` (`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 list [--tab TAB] [--json] +box chromebox permissions get [--json] +box chromebox permissions set [--json] +box chromebox permissions describe # 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 ` 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. diff --git a/docs/INVITE-HANDLER.md b/docs/INVITE-HANDLER.md new file mode 100644 index 0000000..c69c3eb --- /dev/null +++ b/docs/INVITE-HANDLER.md @@ -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 --email [--for ]`**: + Provisions WireGuard tunnel, dedicated netns (`warp-`), chrome-box profile, + initiates headless login, and queues the top-ranked beneficiary's invite code. +- **`box onboard submit-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 `**: + 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). + diff --git a/shared/operators/HEARTBEAT.md b/shared/operators/HEARTBEAT.md index 7811c3c..ea79a56 100644 --- a/shared/operators/HEARTBEAT.md +++ b/shared/operators/HEARTBEAT.md @@ -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 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. +- 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 `) to initiate onboarding. - 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. diff --git a/shared/operators/TOOLS.md b/shared/operators/TOOLS.md index e5d9c76..d00c909 100644 --- a/shared/operators/TOOLS.md +++ b/shared/operators/TOOLS.md @@ -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`). - 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. +- 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 `. When onboarding a client, `box onboard start --email --for ` auto-provisions and redeems 1B tokens upon sign-in. ## Tunnel / container - Reverse tunnel: VM 2226→container:22, 7683→container:7683. Watchdog `tunnel-watchdog-646` runs `recover-after-rebuild.sh` every 120s. diff --git a/tests/test_hatch_menu.py b/tests/test_hatch_menu.py new file mode 100644 index 0000000..799cd93 --- /dev/null +++ b/tests/test_hatch_menu.py @@ -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() diff --git a/tests/test_invite.py b/tests/test_invite.py new file mode 100644 index 0000000..eaf13b6 --- /dev/null +++ b/tests/test_invite.py @@ -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() diff --git a/tests/test_invite_handler.py b/tests/test_invite_handler.py new file mode 100644 index 0000000..209a2d3 --- /dev/null +++ b/tests/test_invite_handler.py @@ -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() diff --git a/tests/test_onboard_pipeline.py b/tests/test_onboard_pipeline.py new file mode 100644 index 0000000..fb89a47 --- /dev/null +++ b/tests/test_onboard_pipeline.py @@ -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() diff --git a/tests/test_settings_rpa.py b/tests/test_settings_rpa.py new file mode 100644 index 0000000..e16c798 --- /dev/null +++ b/tests/test_settings_rpa.py @@ -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()