chore(fleet): sync operator memory, hatch menu dialogs, and watchdog alerts

This commit is contained in:
operator
2026-10-07 00:25:51 +00:00
parent 0065d11e97
commit 34b0ef9fe2
38 changed files with 2205 additions and 444 deletions
+28 -13
View File
@@ -168,14 +168,32 @@ def _eval(ws, js, timeout=8.0):
return None
VERIFY_TRIES = 10
VERIFY_PAUSE = 1.5
def _poll(check, tries=VERIFY_TRIES, pause=VERIFY_PAUSE):
"""Poll a state check until true. Fast exit; tolerates slow commits."""
for _ in range(tries):
try:
if check():
return True
except Exception:
pass
time.sleep(pause)
return False
def list_radios(ws):
"""All dialog radios with heading/label/value/checked (or [])."""
return _eval(ws, JS_LIST_RADIOS) or []
rows = _eval(ws, JS_LIST_RADIOS)
return rows if isinstance(rows, list) else []
def list_switches(ws):
"""All dialog switches with row label + checked (or [])."""
return _eval(ws, JS_LIST_SWITCHES) or []
rows = _eval(ws, JS_LIST_SWITCHES)
return rows if isinstance(rows, list) else []
def radio_state(ws, heading, value):
@@ -188,9 +206,9 @@ def radio_state(ws, heading, value):
def set_radio_by_heading(ws, heading, value):
"""Set a heading-grouped radio; verify, else trusted click, verify."""
"""Set a heading-grouped radio; poll, else trusted click, poll."""
if _eval(ws, JS_CLICK_RADIO % (heading, value)) == "CLICKED" \
and radio_state(ws, heading, value) is True:
and _poll(lambda: 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:
@@ -199,8 +217,7 @@ def set_radio_by_heading(ws, heading, value):
real_click(ws, rect["x"], rect["y"])
except Exception:
return False
time.sleep(0.6)
return radio_state(ws, heading, value) is True
return _poll(lambda: radio_state(ws, heading, value) is True)
def radio_aria_state(ws, name):
@@ -214,9 +231,9 @@ def radio_aria_state(ws, name):
def set_radio_by_aria(ws, name):
"""Set an aria-labeled radio; verify, else trusted click, verify."""
"""Set an aria-labeled radio; poll, else trusted click, poll."""
if _eval(ws, JS_CLICK_RADIO_ARIA % name) == "CLICKED" \
and radio_aria_state(ws, name) is True:
and _poll(lambda: 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:
@@ -225,8 +242,7 @@ def set_radio_by_aria(ws, name):
real_click(ws, rect["x"], rect["y"])
except Exception:
return False
time.sleep(0.6)
return radio_aria_state(ws, name) is True
return _poll(lambda: radio_aria_state(ws, name) is True)
def switch_state(ws, label):
@@ -247,7 +263,7 @@ def set_switch(ws, label, on):
if state == bool(on):
return True
if _eval(ws, JS_CLICK_SWITCH % label) == "CLICKED" \
and switch_state(ws, label) is bool(on):
and _poll(lambda: switch_state(ws, label) is bool(on)):
return True
rect = _eval(ws, JS_SWITCH_RECT % label)
if not rect or "x" not in rect:
@@ -256,5 +272,4 @@ def set_switch(ws, label, on):
real_click(ws, rect["x"], rect["y"])
except Exception:
return False
time.sleep(0.6)
return switch_state(ws, label) is bool(on)
return _poll(lambda: switch_state(ws, label) is bool(on))
+15 -4
View File
@@ -6,6 +6,7 @@ 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 json
import time
from approvals import cdp_evaluate
@@ -16,6 +17,10 @@ TAB_NAMES = ["General", "Connectors", "Wallet", "Secure store",
"Data controls", "Help & support", "Legal info"]
TABS = TAB_NAMES # legacy alias
# Tab rail buttons carry bare tab names and live outside any nav
# landmark, so row matchers exclude them by exact text (live 2026-10-06).
_JS_TABS = json.dumps(TAB_NAMES)
DOCK_MORE_TESTID = "hatch-dock-more"
JS_DOCK_RECT = ("(() => { const b = document.querySelector("
@@ -41,21 +46,26 @@ JS_GOTO_TAB_TMPL = ("(() => { const b = Array.from(document."
JS_CLICK_ROW_TMPL = ("((name) => {"
" const d = document.querySelector('[role=\"dialog\"]');"
" if (!d) return 'NO_DIALOG';"
" const TABS = " + _JS_TABS + ";"
" 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()));"
" const t = els.find(e => {"
" const txt = (e.innerText || '').trim();"
" return !TABS.includes(txt) && txt.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 TABS = " + _JS_TABS + ";"
" const inNav = (el) => !!el.closest("
"'nav, [role=\"tablist\"], [role=\"navigation\"]');"
" return Array.from(d.querySelectorAll("
"'button, [role=\"button\"], a')).filter(e => !inNav(e))"
".filter(e => !TABS.includes((e.innerText || '').trim()))"
".map(e => { const lines = (e.innerText || '').trim().split('\\n');"
" return {name: (lines[0] || '').slice(0, 80),"
" detail: lines.slice(1).join(' / ').slice(0, 120)}; }); })()")
@@ -85,7 +95,7 @@ def dialog_text(ws, timeout=8.0, limit=4000):
text = cdp_evaluate(ws, JS_DIALOG_TEXT, timeout=timeout)
except Exception:
return None
if not text:
if not isinstance(text, str) or not text:
return None
return text[:limit]
@@ -135,7 +145,8 @@ 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 []
rows = _eval(ws, JS_DESCRIBE_ROWS, timeout=timeout)
return rows if isinstance(rows, list) else []
def go_back(ws):
+9 -3
View File
@@ -1,12 +1,13 @@
"""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.
The switch label is pinned from live recon; resolution prefers it
and falls back to single-switch, then keyword match. Import/Delete
rows are inventoried, never touched.
"""
from hatch_menu import controls, dialog
TAB = "Data controls"
SWITCH_LABEL = "Help improve our AI models"
_KEYWORDS = ("improv", "train", "model", "data", "usage")
@@ -15,6 +16,11 @@ def _resolve(ws):
if not dialog.goto_tab(ws, TAB):
return None
switches = controls.list_switches(ws)
for s in switches:
blob = ((s.get("label") or "") + " "
+ (s.get("aria") or "")).lower()
if SWITCH_LABEL.lower() in blob:
return s
if len(switches) == 1:
return switches[0]
for kw in _KEYWORDS:
+1 -1
View File
@@ -10,7 +10,7 @@ from hatch_menu import controls, dialog
TAB = "General"
THEME_VALUES = ("match", "default", "blue", "purple", "pink",
THEME_VALUES = ("avatar", "default", "blue", "purple", "pink",
"orange", "green", "beige", "monochrome")
_FREE_RE = re.compile(r"\bfree plan\b", re.IGNORECASE)
+113 -74
View File
@@ -25,7 +25,18 @@ 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",
# Row titles (first line of each protocol row) pinned live 2026-10-06:
# network primitives on every node checked; MCP titles kept
# defensively in case they appear on other plans/accounts.
PROTOCOL_SLUGS = {"Outbound SSH": "outbound-ssh",
"Outgoing email (SMTP)": "smtp",
"Email mailbox access (IMAP, POP3)": "imap-pop3",
"Database connections": "database",
"File transfer (FTP)": "ftp",
"External DNS lookups": "dns",
"Other TCP connections": "other-tcp",
"Other UDP traffic": "other-udp",
"Model Context Protocol servers (SSE)": "mcp-sse",
"Model Context Protocol servers (Streamable HTTP)":
"mcp-streamable",
"Agent Skills endpoints": "agent-skills",
@@ -35,20 +46,16 @@ PROTOCOL_SLUGS = {"Model Context Protocol servers (SSE)": "mcp-sse",
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};
});
const out = [];
for (const b of d.querySelectorAll('button')) {
const m = (b.getAttribute('aria-label') || '').match(
/^Change permission mode for (.+),\\s*(Allow|Ask|Deny)$/i);
if (!m) continue;
const r = b.getBoundingClientRect();
out.push({host: m[1].trim(), mode: m[2],
x: r.x + r.width / 2, y: r.y + r.height / 2});
}
return out;
})()"""
JS_MODE_MENU = """(() => {
@@ -64,27 +71,19 @@ JS_CLICK_MODE = """((mode) => {
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;
let el = s.parentElement, title = '', depth = 0;
while (el && el !== d && depth < 6) {
const t = (el.innerText || '').trim().replace(/\\s+/g, ' ');
if (t && t.length < 250) { label = t; break; }
const t = (el.innerText || '').trim().split('\\n')[0] || '';
if (t) { title = t.slice(0, 80); break; }
el = el.parentElement;
depth += 1;
}
const r = s.getBoundingClientRect();
return {label: label.slice(0, 120),
return {title: title,
checked: s.getAttribute('aria-checked') === 'true',
x: r.x + r.width / 2, y: r.y + r.height / 2};
});
@@ -98,23 +97,55 @@ def _eval(ws, js, timeout=8.0):
return None
def _slug(label):
def _stable_rows(ws, js, retries=4, pause=1.5):
"""Repeat a row read until two consecutive reads agree.
Guards mid-animation partial DOM (innerText shifts while the
sub-page slides in). Returns the agreed list, or None.
"""
last = "sentinel"
for _ in range(retries):
rows = _eval(ws, js)
if isinstance(rows, list) and rows == last:
return rows
last = rows if isinstance(rows, list) else "sentinel"
time.sleep(pause)
return last if isinstance(last, list) else None
def _slug(title):
"""Protocol slug: registry hit, else slugified, else None."""
if label in PROTOCOL_SLUGS:
return PROTOCOL_SLUGS[label]
if title in PROTOCOL_SLUGS:
return PROTOCOL_SLUGS[title]
clean = re.sub(r"[^a-z0-9]+", "-",
label.strip().lower()).strip("-")
title.strip().lower()).strip("-")
return clean or None
def _canon_mode(mode):
"""Canonical Allow/Ask/Deny (case-insensitive); passthrough else."""
for m in WEBSITE_MODES:
if (mode or "").lower() == m.lower():
return m
return mode
def resolve_protocol(name):
"""Slug or label fragment -> row label, None when unresolvable."""
"""Slug/title -> row title, None when unresolvable.
Exact slug or title first; then a unique case-insensitive
substring over titles+slugs (so 'ssh' finds Outbound SSH).
"""
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
for title, slug in PROTOCOL_SLUGS.items():
if want == slug or want == title.lower():
return title
hits = [t for t, s in PROTOCOL_SLUGS.items()
if want in t.lower() or want in s]
if len(hits) == 1:
return hits[0]
return None
@@ -195,8 +226,7 @@ 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)
return _stable_rows(ws, JS_WEBSITES)
def websites(ws):
@@ -204,7 +234,8 @@ def websites(ws):
rows = _websites_raw(ws)
if rows is None:
return []
out = [{"host": r.get("host"), "mode": r.get("mode")} for r in rows]
out = [{"host": r.get("host"), "mode": _canon_mode(r.get("mode"))}
for r in rows]
_back_to_root(ws)
return out
@@ -218,7 +249,12 @@ def website_mode(ws, host):
def set_website_mode(ws, host, mode):
"""Set one host mode via the mode chooser. Verify + readback. Bool."""
"""Set one host mode via the mode chooser. Bool.
One-way for Ask/Deny: the override row leaves the allowed list
(no add UI), so removal verifies by absence. No-op when already
there; absent hosts fail (nothing to click).
"""
if mode not in WEBSITE_MODES:
return False
rows = _websites_raw(ws)
@@ -230,14 +266,18 @@ def set_website_mode(ws, host, mode):
if target is None:
_back_to_root(ws)
return False
if _canon_mode(target.get("mode")) == mode:
_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.8)
items = _eval(ws, JS_MODE_MENU) or []
texts = [(i.get("text") or "") for i in items]
items = _eval(ws, JS_MODE_MENU)
texts = [(i.get("text") or "") for i in items] \
if isinstance(items, list) else []
if mode not in texts:
escape(ws)
_back_to_root(ws)
@@ -246,26 +286,19 @@ def set_website_mode(ws, host, mode):
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
for _ in range(5):
time.sleep(2.0)
rows = _eval(ws, JS_WEBSITES)
if not isinstance(rows, list):
continue
cur = next((_canon_mode(r.get("mode")) for r in rows
if (r.get("host") or "").lower() == host.lower()),
None)
if cur == mode:
if mode in ("Ask", "Deny"):
if cur is None:
_back_to_root(ws)
return True
elif cur == mode:
_back_to_root(ws)
return True
escape(ws)
@@ -277,36 +310,35 @@ 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)
return _stable_rows(ws, JS_PROTO_ROWS)
def protocols(ws):
"""[{slug, label, on}] (back at root afterwards)."""
"""[{slug, title, on}] (back at root afterwards)."""
rows = _protocols_raw(ws)
if rows is None:
return []
out = [{"slug": _slug(r.get("label", "")),
"label": r.get("label", ""),
out = [{"slug": _slug(r.get("title", "")),
"title": r.get("title", ""),
"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."""
def protocol_state(ws, title):
"""on/off for one protocol row title, None when absent."""
for row in protocols(ws):
if row.get("label") == label:
if row.get("title") == title:
return row.get("on")
return None
def set_protocol(ws, label, on):
def set_protocol(ws, title, 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)
target = next((r for r in rows if r.get("title") == title), None)
if target is None:
_back_to_root(ws)
return False
@@ -319,12 +351,19 @@ def set_protocol(ws, label, on):
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
for _ in range(8):
time.sleep(2.0)
rows = _eval(ws, JS_PROTO_ROWS)
if not isinstance(rows, list):
continue
cur = next((r for r in rows if r.get("title") == title), None)
if cur is not None and bool(cur.get("checked")) == want:
_back_to_root(ws)
return True
_back_to_root(ws)
return ok
# In-dialog verify missed (slow commit or commit-on-close); the
# toggles-level fresh readback is the source of truth.
return False
def manage_counts(ws):
+16 -8
View File
@@ -182,6 +182,10 @@ def get_toggle(node, name):
else:
value = None
if value is None:
if kind == "website":
return {"ok": False, "node": node, "toggle": name,
"error": "host not in Websites list (effective: "
"permissions.web_access default)"}
return {"ok": False, "node": node, "toggle": name,
"error": "toggle not readable (site changed?)"}
return {"ok": True, "node": node, "toggle": name, "value": value}
@@ -226,15 +230,19 @@ def set_toggle(node, name, value):
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?)"}
# The fresh-session readback is the source of truth: switch
# commits can land slowly or on dialog close, after the
# in-flow verify had its chance.
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}
if readback.get("ok") and readback.get("value") == want:
out = {"ok": True, "node": node, "toggle": name,
"value": want}
if not ok:
out["readback_only"] = True
return out
return {"ok": False, "node": node, "toggle": name,
"error": "readback mismatch (want %r, got %r)"
% (want, readback.get("value"))}
except Exception as e:
return {"ok": False, "node": node, "toggle": name,
"error": "%s: %s" % (type(e).__name__, e)}