chore(fleet): sync operator memory, hatch menu dialogs, and watchdog alerts
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user