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
+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):