feat(box): passkey fetch, agent key-approval flow, unified lookups, tmux agent UX
- box passkey [show|fetch] (+ muse passkey): documents VM-only passkey (/srv/box/passkey.txt, fallback /etc/netvm/passkey.txt on 34.139.37.135), probes VM over SSH with graceful fallback; --json supported. No secrets on bl. - approvals: request_key_approval / check_node_key_request; KEY_APPROVAL status surfaced in `box approvals check`; allow/deny resolve + audit to box-ctl.jsonl; never auto-approved. New `box approvals request-key <node> --reason`. - box lookup (summary|fleet|threads|unread|approvals|key|docs) and docs-lookup engine with lookup_internal/ database (docs_internal symlink). - muse-tmux: non-TTY attach falls back to scrollback capture; prune NameError fix. - box/muse passthrough for tmux/muse/docs; thread list/view alias + prefix resolve. - Docs: AGENTS.md, AGENT-TOOLING.md, BOX-WEB-SURFACE-GUIDE.md, README. - Tests: key-approval + passkey tests; sync stale sidechat UUIDs and manifest name. - .gitignore runtime trackers (subagent-sessions, conversation-nudge-tracker).
This commit is contained in:
+461
-71
@@ -48,11 +48,55 @@ TRUSTED_IPS = {
|
||||
"34.139.37.135", # VM (gateway)
|
||||
"100.123.153.75", # bl (main compute)
|
||||
"100.81.31.9", # VM tailnet
|
||||
"1.1.1.1", # Cloudflare DNS
|
||||
"1.0.0.1", # Cloudflare DNS
|
||||
}
|
||||
|
||||
# Trusted infrastructure domains safe for automated approval
|
||||
TRUSTED_DOMAINS = {
|
||||
"muse-dev.online",
|
||||
}
|
||||
|
||||
|
||||
def is_trusted_target(target: str, card_text: str = "") -> bool:
|
||||
"""Check if the extracted approval target is trusted infrastructure.
|
||||
|
||||
Fail-closed: only the parsed target (IP or hostname) is evaluated. Free-form
|
||||
card text is deliberately NOT substring-matched, since an untrusted request
|
||||
could mention a trusted domain in its purpose string. `card_text` is kept
|
||||
for signature compatibility.
|
||||
"""
|
||||
if not target:
|
||||
return False
|
||||
target = target.strip().lower().rstrip(".")
|
||||
if target in TRUSTED_IPS:
|
||||
return True
|
||||
for dom in TRUSTED_DOMAINS:
|
||||
if target == dom or target.endswith("." + dom):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
REDACT_PATTERNS = [
|
||||
(re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]+=*", re.IGNORECASE), "Bearer [REDACTED]"),
|
||||
(re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9._-]{10,}", re.IGNORECASE), "[JWT-REDACTED]"),
|
||||
(re.compile(r"(?i)\b(api[_-]?key|token|secret|password|auth|passkey)\s*[:=]\s*(['\"]?)([A-Za-z0-9_\-\.]{4,})\2"), r"\1: \2[REDACTED]\2"),
|
||||
(re.compile(r"-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----"), "[PRIVATE-KEY-REDACTED]"),
|
||||
]
|
||||
|
||||
|
||||
def redact_sensitive(text: str) -> str:
|
||||
"""Mask credentials, tokens, and passkeys in text."""
|
||||
if not text or not isinstance(text, str):
|
||||
return text
|
||||
out = text
|
||||
for pattern, repl in REDACT_PATTERNS:
|
||||
out = pattern.sub(repl, out)
|
||||
return out
|
||||
|
||||
|
||||
def log_box_ctl(action: str, name: str = None, caller: str = "box-approvals", extra: dict = None):
|
||||
"""Log an audit event to box-ctl.jsonl."""
|
||||
"""Log an audit event to box-ctl.jsonl with secret redaction."""
|
||||
try:
|
||||
rec = {
|
||||
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
@@ -61,13 +105,76 @@ def log_box_ctl(action: str, name: str = None, caller: str = "box-approvals", ex
|
||||
"caller": caller,
|
||||
}
|
||||
if extra:
|
||||
rec.update(extra)
|
||||
clean_extra = {}
|
||||
for k, v in extra.items():
|
||||
if isinstance(v, str):
|
||||
clean_extra[k] = redact_sensitive(v)
|
||||
else:
|
||||
clean_extra[k] = v
|
||||
rec.update(clean_extra)
|
||||
with open(CTL_LOG, "a") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def request_key_approval(node: str, reason: str = "", caller: str = "agent") -> dict:
|
||||
"""Register a key/passkey approval request for a node in box-ctl.jsonl."""
|
||||
reason = reason or "Operator passkey access requested"
|
||||
log_box_ctl(
|
||||
"key-approval-request",
|
||||
name=node,
|
||||
caller=caller,
|
||||
extra={"reason": reason},
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"node": node,
|
||||
"status": "KEY_APPROVAL_REQUESTED",
|
||||
"reason": reason,
|
||||
"caller": caller,
|
||||
"note": "Request recorded in audit log. Operator can approve via 'box approvals allow <node>'."
|
||||
}
|
||||
|
||||
|
||||
def check_node_key_request(node: str) -> dict:
|
||||
"""Check if node has an active unfulfilled key approval request in box-ctl.jsonl."""
|
||||
if not CTL_LOG.exists():
|
||||
return None
|
||||
latest_req = None
|
||||
resolved = False
|
||||
try:
|
||||
with open(CTL_LOG, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rec = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if rec.get("name") != node:
|
||||
continue
|
||||
act = rec.get("action")
|
||||
if act == "key-approval-request":
|
||||
latest_req = rec
|
||||
resolved = False
|
||||
elif act in ("key-approval-allow", "key-approval-deny", "approval-allow", "approval-deny"):
|
||||
resolved = True
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if latest_req and not resolved:
|
||||
return {
|
||||
"node": node,
|
||||
"reason": latest_req.get("reason", "Operator passkey access requested"),
|
||||
"requested_at": latest_req.get("ts", ""),
|
||||
"caller": latest_req.get("caller", ""),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def get_node_connection_info(node: str) -> dict:
|
||||
"""Return peer_ip, cdp_port, and netns for a given node."""
|
||||
if netvm_registry:
|
||||
@@ -94,8 +201,8 @@ def get_node_connection_info(node: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def get_cdp_ws(node: str, timeout: float = 3.0):
|
||||
"""Connect to the node's active browser page over CDP WebSocket."""
|
||||
def get_node_pages(node: str, timeout: float = 3.0) -> list:
|
||||
"""Return all active page targets for a node."""
|
||||
if websocket is None:
|
||||
raise RuntimeError("websocket-client library is required")
|
||||
|
||||
@@ -125,13 +232,20 @@ def get_cdp_ws(node: str, timeout: float = 3.0):
|
||||
pages = [t for t in tabs if t.get("type") == "page"]
|
||||
if not pages:
|
||||
raise ConnectionError(f"No active page found for node {node}")
|
||||
return pages
|
||||
|
||||
ws_url = pages[0].get("webSocketDebuggerUrl")
|
||||
|
||||
def get_cdp_ws(node: str, page_idx: int = 0, timeout: float = 3.0):
|
||||
"""Connect to a node's browser page over CDP WebSocket."""
|
||||
pages = get_node_pages(node, timeout=timeout)
|
||||
if page_idx >= len(pages):
|
||||
page_idx = 0
|
||||
target_page = pages[page_idx]
|
||||
ws_url = target_page.get("webSocketDebuggerUrl")
|
||||
if not ws_url:
|
||||
raise ConnectionError(f"No webSocketDebuggerUrl for node {node}")
|
||||
|
||||
ws = websocket.create_connection(ws_url, timeout=timeout)
|
||||
return ws, pages[0]
|
||||
return ws, target_page
|
||||
|
||||
|
||||
def cdp_evaluate(ws, js_expr: str, await_promise: bool = False, timeout: float = 3.0):
|
||||
@@ -233,6 +347,16 @@ JS_INSPECT_APPROVALS = """(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Task rows in the Activity sidebar waiting on human input
|
||||
// (e.g. "Launch 5 OPM Sub-Agents\\nAsked for input to start the launch\\n5:53 pm").
|
||||
const inputWaits = [];
|
||||
for (const b of document.querySelectorAll('button.rounded-10, a.rounded-10')) {
|
||||
const lines = (b.innerText || '').split('\\n').map(s => s.trim()).filter(Boolean);
|
||||
if (lines.length >= 2 && /^(asked for (input|details)|waiting for (your )?(input|reply|approval)|needs your input)/i.test(lines[1])) {
|
||||
inputWaits.push({ task: lines[0], status: lines[1], when: lines[2] || '' });
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
has_pending: !!activeCard && (hasAllowOnce || hasDeny),
|
||||
card_text: cardText.slice(0, 1000),
|
||||
@@ -240,16 +364,40 @@ JS_INSPECT_APPROVALS = """(() => {
|
||||
has_allow_once: hasAllowOnce,
|
||||
has_always_allow: hasAlwaysAllow,
|
||||
has_deny: hasDeny,
|
||||
history: historyBadges.slice(0, 5)
|
||||
history: historyBadges.slice(0, 5),
|
||||
input_waits: inputWaits.slice(0, 10)
|
||||
});
|
||||
})()"""
|
||||
|
||||
|
||||
def inspect_node_approvals(node: str) -> dict:
|
||||
"""Inspect a node for active browser approval prompts."""
|
||||
"""Inspect a node across all open page targets for active approval prompts and input waits."""
|
||||
try:
|
||||
ws, page = get_cdp_ws(node, timeout=2.5)
|
||||
pages = get_node_pages(node, timeout=2.5)
|
||||
except Exception as e:
|
||||
key_req = check_node_key_request(node)
|
||||
if key_req:
|
||||
return {
|
||||
"node": node,
|
||||
"status": "KEY_APPROVAL",
|
||||
"has_pending": True,
|
||||
"title": f"Passkey requested: {key_req.get('reason')}",
|
||||
"purpose": key_req.get("reason"),
|
||||
"ip": "34.139.37.135",
|
||||
"target": "34.139.37.135 (VM passkey)",
|
||||
"is_trusted": True,
|
||||
"buttons": ["Allow", "Deny"],
|
||||
"has_allow_once": True,
|
||||
"has_always_allow": False,
|
||||
"has_deny": True,
|
||||
"raw_text": f"Agent on node {node} requested passkey: {key_req.get('reason')}",
|
||||
"history": [],
|
||||
"input_waits": [],
|
||||
"page_title": "",
|
||||
"page_url": "",
|
||||
"ws_url": "",
|
||||
"key_request": key_req,
|
||||
}
|
||||
return {
|
||||
"node": node,
|
||||
"status": "UNREACHABLE",
|
||||
@@ -257,66 +405,140 @@ def inspect_node_approvals(node: str) -> dict:
|
||||
"has_pending": False,
|
||||
}
|
||||
|
||||
try:
|
||||
val_str = cdp_evaluate(ws, JS_INSPECT_APPROVALS, timeout=3.0)
|
||||
ws.close()
|
||||
if not val_str or not isinstance(val_str, str):
|
||||
return {
|
||||
"node": node,
|
||||
"status": "CLEAR",
|
||||
"has_pending": False,
|
||||
"page_title": page.get("title", ""),
|
||||
"page_url": page.get("url", ""),
|
||||
}
|
||||
all_input_waits = []
|
||||
first_page = pages[0]
|
||||
last_err = None
|
||||
|
||||
data = json.loads(val_str)
|
||||
has_pending = data.get("has_pending", False)
|
||||
card_text = data.get("card_text", "")
|
||||
|
||||
# Parse details
|
||||
ip = None
|
||||
m_ip = re.search(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", card_text)
|
||||
if m_ip:
|
||||
ip = m_ip.group(0)
|
||||
|
||||
# Extract title and purpose summary
|
||||
lines = [line.strip() for line in card_text.split("\n") if line.strip()]
|
||||
title = lines[0] if lines else "Permission request"
|
||||
purpose = lines[1] if len(lines) > 1 else ""
|
||||
|
||||
is_trusted = False
|
||||
if ip:
|
||||
is_trusted = ip in TRUSTED_IPS
|
||||
|
||||
return {
|
||||
"node": node,
|
||||
"status": "PENDING" if has_pending else "CLEAR",
|
||||
"has_pending": has_pending,
|
||||
"title": title,
|
||||
"purpose": purpose,
|
||||
"ip": ip,
|
||||
"is_trusted": is_trusted,
|
||||
"buttons": data.get("buttons", []),
|
||||
"has_allow_once": data.get("has_allow_once", False),
|
||||
"has_always_allow": data.get("has_always_allow", False),
|
||||
"has_deny": data.get("has_deny", False),
|
||||
"raw_text": card_text,
|
||||
"history": data.get("history", []),
|
||||
"page_title": page.get("title", ""),
|
||||
"page_url": page.get("url", ""),
|
||||
}
|
||||
except Exception as e:
|
||||
for page in pages:
|
||||
ws_url = page.get("webSocketDebuggerUrl")
|
||||
if not ws_url:
|
||||
continue
|
||||
ws = None
|
||||
try:
|
||||
ws = websocket.create_connection(ws_url, timeout=2.0)
|
||||
val_str = cdp_evaluate(ws, JS_INSPECT_APPROVALS, timeout=2.5)
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
ws = None
|
||||
if not val_str or not isinstance(val_str, str):
|
||||
continue
|
||||
data = json.loads(val_str)
|
||||
if data.get("input_waits"):
|
||||
all_input_waits.extend(data["input_waits"])
|
||||
|
||||
if data.get("has_pending"):
|
||||
card_text = data.get("card_text", "")
|
||||
ip = None
|
||||
target = None
|
||||
m_t = re.search(
|
||||
r"(?:connection to|share information with|contact|connect to)\s+([A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z0-9])",
|
||||
card_text,
|
||||
)
|
||||
if m_t:
|
||||
target = m_t.group(1)
|
||||
m_ip = re.search(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", target or card_text)
|
||||
if m_ip:
|
||||
ip = m_ip.group(0)
|
||||
if not target:
|
||||
target = ip
|
||||
if not target:
|
||||
m_domain = re.search(r"\b([a-zA-Z0-9-]+\.)+(?:online|com|net|org|io|dev)\b", card_text)
|
||||
if m_domain:
|
||||
target = m_domain.group(0)
|
||||
|
||||
lines = [line.strip() for line in card_text.split("\n") if line.strip()]
|
||||
title = redact_sensitive(lines[0] if lines else "Permission request")
|
||||
purpose = redact_sensitive(lines[1] if len(lines) > 1 else "")
|
||||
|
||||
is_trusted = is_trusted_target(target or ip, card_text)
|
||||
|
||||
return {
|
||||
"node": node,
|
||||
"status": "PENDING",
|
||||
"has_pending": True,
|
||||
"title": title,
|
||||
"purpose": purpose,
|
||||
"ip": ip,
|
||||
"target": target or ip or "-",
|
||||
"is_trusted": is_trusted,
|
||||
"buttons": data.get("buttons", []),
|
||||
"has_allow_once": data.get("has_allow_once", False),
|
||||
"has_always_allow": data.get("has_always_allow", False),
|
||||
"has_deny": data.get("has_deny", False),
|
||||
"raw_text": redact_sensitive(card_text),
|
||||
"history": data.get("history", []),
|
||||
"input_waits": all_input_waits,
|
||||
"page_title": page.get("title", ""),
|
||||
"page_url": page.get("url", ""),
|
||||
"ws_url": ws_url,
|
||||
}
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if ws:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Deduplicate input waits by task name
|
||||
unique_waits = []
|
||||
seen_tasks = set()
|
||||
for w in all_input_waits:
|
||||
t_name = redact_sensitive(w.get("task", ""))
|
||||
status_text = redact_sensitive(w.get("status", ""))
|
||||
if t_name not in seen_tasks:
|
||||
seen_tasks.add(t_name)
|
||||
unique_waits.append({
|
||||
"task": t_name,
|
||||
"status": status_text,
|
||||
"when": w.get("when", ""),
|
||||
})
|
||||
|
||||
key_req = check_node_key_request(node)
|
||||
if key_req:
|
||||
return {
|
||||
"node": node,
|
||||
"status": "ERROR",
|
||||
"error": str(e),
|
||||
"has_pending": False,
|
||||
"status": "KEY_APPROVAL",
|
||||
"has_pending": True,
|
||||
"title": f"Passkey requested: {key_req.get('reason')}",
|
||||
"purpose": key_req.get("reason"),
|
||||
"ip": "34.139.37.135",
|
||||
"target": "34.139.37.135 (VM passkey)",
|
||||
"is_trusted": True,
|
||||
"buttons": ["Allow", "Deny"],
|
||||
"has_allow_once": True,
|
||||
"has_always_allow": False,
|
||||
"has_deny": True,
|
||||
"raw_text": f"Agent on node {node} requested passkey: {key_req.get('reason')}",
|
||||
"history": [],
|
||||
"input_waits": unique_waits,
|
||||
"page_title": first_page.get("title", ""),
|
||||
"page_url": first_page.get("url", ""),
|
||||
"ws_url": first_page.get("webSocketDebuggerUrl", ""),
|
||||
"key_request": key_req,
|
||||
}
|
||||
|
||||
status = "INPUT_WAIT" if unique_waits else ("ERROR" if last_err and not first_page else "CLEAR")
|
||||
return {
|
||||
"node": node,
|
||||
"status": status,
|
||||
"has_pending": False,
|
||||
"title": "No pending approvals",
|
||||
"purpose": "",
|
||||
"ip": None,
|
||||
"target": "-",
|
||||
"is_trusted": False,
|
||||
"buttons": [],
|
||||
"has_allow_once": False,
|
||||
"has_always_allow": False,
|
||||
"has_deny": False,
|
||||
"raw_text": "",
|
||||
"history": [],
|
||||
"input_waits": unique_waits,
|
||||
"page_title": first_page.get("title", ""),
|
||||
"page_url": first_page.get("url", ""),
|
||||
"ws_url": first_page.get("webSocketDebuggerUrl", ""),
|
||||
}
|
||||
|
||||
|
||||
def check_fleet_approvals(nodes: list = None) -> list:
|
||||
"""Check approval status across the fleet."""
|
||||
@@ -333,6 +555,28 @@ def allow_node_approval(node: str, always: bool = False, force: bool = False, ca
|
||||
if not info.get("has_pending"):
|
||||
return {"ok": False, "node": node, "error": "No pending approval dialog found on node"}
|
||||
|
||||
if info.get("status") == "KEY_APPROVAL":
|
||||
key_req = info.get("key_request") or check_node_key_request(node) or {}
|
||||
reason = key_req.get("reason", info.get("purpose", ""))
|
||||
log_box_ctl(
|
||||
"key-approval-allow",
|
||||
name=node,
|
||||
caller=caller,
|
||||
extra={
|
||||
"reason": reason,
|
||||
"forced": force,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"node": node,
|
||||
"type": "key_approval",
|
||||
"decision": "allow",
|
||||
"dismissed": True,
|
||||
"reason": reason,
|
||||
"title": info.get("title"),
|
||||
}
|
||||
|
||||
if not info.get("is_trusted") and not force:
|
||||
target = info.get("ip") or "unrecognized target"
|
||||
return {
|
||||
@@ -343,7 +587,11 @@ def allow_node_approval(node: str, always: bool = False, force: bool = False, ca
|
||||
}
|
||||
|
||||
try:
|
||||
ws, _ = get_cdp_ws(node, timeout=3.0)
|
||||
ws_url = info.get("ws_url")
|
||||
if ws_url:
|
||||
ws = websocket.create_connection(ws_url, timeout=3.0)
|
||||
else:
|
||||
ws, _ = get_cdp_ws(node, timeout=3.0)
|
||||
except Exception as e:
|
||||
return {"ok": False, "node": node, "error": f"Failed to connect to CDP: {e}"}
|
||||
|
||||
@@ -351,11 +599,19 @@ def allow_node_approval(node: str, always: bool = False, force: bool = False, ca
|
||||
if always:
|
||||
js_click = """(() => {
|
||||
const btns = Array.from(document.querySelectorAll('button'));
|
||||
const btn = btns.find(b => (b.innerText||'').toLowerCase().includes('always allow'));
|
||||
let btn = btns.find(b => (b.innerText||'').toLowerCase().includes('always allow'));
|
||||
if (btn) {
|
||||
btn.click();
|
||||
return 'CLICKED_ALWAYS';
|
||||
}
|
||||
btn = document.querySelector('button[data-hatch-approval-primary-action="true"]');
|
||||
if (!btn) {
|
||||
btn = btns.find(b => (b.innerText||'').toLowerCase().includes('allow once') || (b.innerText||'').trim().toLowerCase() === 'allow');
|
||||
}
|
||||
if (btn) {
|
||||
btn.click();
|
||||
return 'CLICKED_PRIMARY_FALLBACK';
|
||||
}
|
||||
return 'NOT_FOUND';
|
||||
})()"""
|
||||
else:
|
||||
@@ -422,13 +678,38 @@ def allow_node_approval(node: str, always: bool = False, force: bool = False, ca
|
||||
|
||||
|
||||
def deny_node_approval(node: str, caller: str = "box-approvals") -> dict:
|
||||
"""Deny a pending approval on a node (click 'Deny')."""
|
||||
"""Deny a pending approval on a node (click 'Deny' or deny key request)."""
|
||||
info = inspect_node_approvals(node)
|
||||
if not info.get("has_pending"):
|
||||
return {"ok": False, "node": node, "error": "No pending approval dialog found on node"}
|
||||
|
||||
if info.get("status") == "KEY_APPROVAL":
|
||||
key_req = info.get("key_request") or check_node_key_request(node) or {}
|
||||
reason = key_req.get("reason", info.get("purpose", ""))
|
||||
log_box_ctl(
|
||||
"key-approval-deny",
|
||||
name=node,
|
||||
caller=caller,
|
||||
extra={
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"node": node,
|
||||
"type": "key_approval",
|
||||
"decision": "deny",
|
||||
"dismissed": True,
|
||||
"reason": reason,
|
||||
"title": info.get("title"),
|
||||
}
|
||||
|
||||
try:
|
||||
ws, _ = get_cdp_ws(node, timeout=3.0)
|
||||
ws_url = info.get("ws_url")
|
||||
if ws_url:
|
||||
ws = websocket.create_connection(ws_url, timeout=3.0)
|
||||
else:
|
||||
ws, _ = get_cdp_ws(node, timeout=3.0)
|
||||
except Exception as e:
|
||||
return {"ok": False, "node": node, "error": f"Failed to connect to CDP: {e}"}
|
||||
|
||||
@@ -481,8 +762,8 @@ def deny_node_approval(node: str, caller: str = "box-approvals") -> dict:
|
||||
return {"ok": False, "node": node, "error": str(e)}
|
||||
|
||||
|
||||
def auto_approve_fleet(nodes: list = None, caller: str = "box-approvals") -> dict:
|
||||
"""Scan fleet nodes and automatically approve any requests to TRUSTED_IPS."""
|
||||
def auto_approve_fleet(nodes: list = None, always: bool = True, caller: str = "box-approvals") -> dict:
|
||||
"""Scan fleet nodes and automatically approve any requests to TRUSTED_IPS with Always Allow."""
|
||||
fleet = check_fleet_approvals(nodes)
|
||||
approved = []
|
||||
untrusted = []
|
||||
@@ -491,12 +772,16 @@ def auto_approve_fleet(nodes: list = None, caller: str = "box-approvals") -> dic
|
||||
for item in fleet:
|
||||
node = item["node"]
|
||||
if item.get("has_pending"):
|
||||
if item.get("is_trusted"):
|
||||
res = allow_node_approval(node, caller=caller)
|
||||
if item.get("status") == "KEY_APPROVAL":
|
||||
# Key approvals require explicit operator decision, never auto-approve
|
||||
untrusted.append(item)
|
||||
elif item.get("is_trusted"):
|
||||
res = allow_node_approval(node, always=always, caller=caller)
|
||||
approved.append({
|
||||
"node": node,
|
||||
"target_ip": item.get("ip"),
|
||||
"title": item.get("title"),
|
||||
"decision": "always" if always else "allow_once",
|
||||
"res": res,
|
||||
})
|
||||
else:
|
||||
@@ -510,3 +795,108 @@ def auto_approve_fleet(nodes: list = None, caller: str = "box-approvals") -> dic
|
||||
"untrusted_pending": untrusted,
|
||||
"clear_nodes": clear,
|
||||
}
|
||||
|
||||
|
||||
def reply_node_task(node: str, message: str, allow_main_chat: bool = False, caller: str = "box-approvals") -> dict:
|
||||
"""Send an operator reply into the waiting agent's thread to answer an input prompt."""
|
||||
info = inspect_node_approvals(node)
|
||||
page_url = info.get("page_url", "")
|
||||
page_title = info.get("page_title", "")
|
||||
ws_url = info.get("ws_url")
|
||||
|
||||
# Check if target is Main Chat
|
||||
# Main chat signatures: not sidechat and (no /thread/ or title contains 'Chat —')
|
||||
is_main = "sidechat" not in page_url.lower() and ("/thread/" not in page_url or "chat —" in page_title.lower())
|
||||
if is_main and not allow_main_chat:
|
||||
return {
|
||||
"ok": False,
|
||||
"node": node,
|
||||
"error": "Active thread appears to be Main Chat. Refusing reply by sidechat-first policy. Pass --allow-main-chat to confirm intentional operator override.",
|
||||
"page_title": page_title,
|
||||
"page_url": page_url,
|
||||
}
|
||||
|
||||
try:
|
||||
if ws_url:
|
||||
ws = websocket.create_connection(ws_url, timeout=3.0)
|
||||
else:
|
||||
ws, _ = get_cdp_ws(node, timeout=3.0)
|
||||
except Exception as e:
|
||||
return {"ok": False, "node": node, "error": f"Failed to connect to CDP: {e}"}
|
||||
|
||||
try:
|
||||
msg_esc = message.replace('\\', '\\\\').replace('`', '\\`').replace('$', '\\$').replace('"', '\\"')
|
||||
js_type_and_send = f"""(async() => {{
|
||||
const input = document.querySelector('[contenteditable="true"]') ||
|
||||
document.querySelector('textarea[placeholder*="Message"]') ||
|
||||
document.querySelector('textarea');
|
||||
if (!input) return 'NO_INPUT';
|
||||
input.focus();
|
||||
document.execCommand('insertText', false, "{msg_esc}");
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
const sendBtn = Array.from(document.querySelectorAll('button')).find(b => {{
|
||||
const a = (b.getAttribute('aria-label') || '').toLowerCase();
|
||||
const t = (b.innerText || '').toLowerCase();
|
||||
return a.includes('send') || t === 'send';
|
||||
}});
|
||||
if (sendBtn && !sendBtn.disabled) {{
|
||||
sendBtn.click();
|
||||
return 'CLICKED_SEND';
|
||||
}}
|
||||
const ke = new KeyboardEvent('keydown', {{key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true}});
|
||||
input.dispatchEvent(ke);
|
||||
return 'ENTER_SENT';
|
||||
}})()"""
|
||||
send_res = cdp_evaluate(ws, js_type_and_send, await_promise=True, timeout=5.0)
|
||||
ws.close()
|
||||
|
||||
log_box_ctl(
|
||||
"approval-reply",
|
||||
name=node,
|
||||
caller=caller,
|
||||
extra={
|
||||
"message_len": len(message),
|
||||
"page_url": page_url,
|
||||
"allow_main_chat": allow_main_chat,
|
||||
"send_res": send_res,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"node": node,
|
||||
"send_result": send_res,
|
||||
"page_title": page_title,
|
||||
"page_url": page_url,
|
||||
}
|
||||
except Exception as e:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": False, "node": node, "error": str(e)}
|
||||
|
||||
|
||||
def dismiss_node_task(node: str, caller: str = "box-approvals") -> dict:
|
||||
"""Close any open task modal dialog or popup on a node."""
|
||||
try:
|
||||
ws, _ = get_cdp_ws(node, timeout=3.0)
|
||||
except Exception as e:
|
||||
return {"ok": False, "node": node, "error": f"Failed to connect to CDP: {e}"}
|
||||
|
||||
try:
|
||||
js_dismiss = """(() => {
|
||||
const close = document.querySelector('[aria-label="Close"], button[data-slot="dialog-close"]');
|
||||
if (close) { close.click(); return 'CLICKED_CLOSE'; }
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true}));
|
||||
return 'ESCAPE_SENT';
|
||||
})()"""
|
||||
res = cdp_evaluate(ws, js_dismiss, timeout=2.0)
|
||||
ws.close()
|
||||
return {"ok": True, "node": node, "result": res}
|
||||
except Exception as e:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": False, "node": node, "error": str(e)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user