feat(systemd): add and enable continuous tmux-auto-approver user daemon

This commit is contained in:
operator
2026-10-07 00:49:23 +00:00
parent 34b0ef9fe2
commit e25d2cf4cc
12 changed files with 915 additions and 62 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ Add `--json` to any command for machine-readable output when parsing results in
- `box lookup summary|fleet|threads|unread|approvals` — seamless one-shot lookups. - `box lookup summary|fleet|threads|unread|approvals` — seamless one-shot lookups.
- `box job list` / `box job log` — scheduled jobs and execution events. - `box job list` / `box job log` — scheduled jobs and execution events.
- `box harvest status` / `box followup list` — harvest watermarks / pending nudges. - `box harvest status` / `box followup list` — harvest watermarks / pending nudges.
- `box muse-choices on|off|status|logs|reconcile` — Muse TUI auto-answer daemon switch, state, and per-pane logs (default on; `off` is the box-command opt-out). - `box muse-choices on|off|status|logs|reconcile|resolve` — Muse TUI auto-answer daemon switch, state, per-pane logs, held-prompt resolve (default on; `off` is the box-command opt-out).
- `box runtime list|send|launch|layout|spread` — Muse CLI tmux runtimes: live state + approval posture, send-keys input, auto-approved launches, pane-geometry layout + spread for squeezed panes. - `box runtime list|send|launch|layout|spread` — Muse CLI tmux runtimes: live state + approval posture, send-keys input, auto-approved launches, pane-geometry layout + spread for squeezed panes.
- `box tmux tally` / `box tmux auto [status|on|off|watch|once|logs|match]` — multi-socket Tmux worker tally, regex auto-approver daemon & guardrails. - `box tmux tally` / `box tmux auto [status|on|off|watch|once|logs|match]` — multi-socket Tmux worker tally, regex auto-approver daemon & guardrails.
- `box onboard connects` / `box onboard-tui` — fleet & client onboarding inventory, CDP ports, OTP salvage & 4-surface TUI. - `box onboard connects` / `box onboard-tui` — fleet & client onboarding inventory, CDP ports, OTP salvage & 4-surface TUI.
+13
View File
@@ -14,3 +14,16 @@ Unified naming: node == agent == profile == API account.
| opm | warp-opm | 104.28.195.181 | 9440 | active | opm (artglobal.cc@gmail.com, email_otp, Nico Parada) | | opm | warp-opm | 104.28.195.181 | 9440 | active | opm (artglobal.cc@gmail.com, email_otp, Nico Parada) |
| def | warp-def | 104.28.195.181 | 9450 | active | def (defnotabotnet@gmail.com, email_otp, IG paradahub) | | def | warp-def | 104.28.195.181 | 9450 | active | def (defnotabotnet@gmail.com, email_otp, IG paradahub) |
| dev | warp-dev | 104.28.195.181 | 9455 | active | dev (paradaproduced@gmail.com, email_otp, IG veryraremeta) | | dev | warp-dev | 104.28.195.181 | 9455 | active | dev (paradaproduced@gmail.com, email_otp, IG veryraremeta) |
## Session naming (supervision)
Remote nodes issue jobs to this box; execution lives on the shared
stable tmux server, separated by session name (not by socket):
`<node>--<role>--<id>` — e.g. `pip--worker--01`, `muse--repair--09`.
Roles: `worker` (persistent swarm/daemon), `repair` (fix sessions),
`watch` (auto-approver tails), `runtime` (interactive CLI). Ad-hoc
sessions carry no node and show `-` in `box runtime list`. Session
creators owned by existing flows keep their names until owners rename;
new sessions should follow the convention from birth.
+6 -33
View File
@@ -138,29 +138,6 @@ JS_CLICK_SWITCH = """((label) => {
return 'CLICKED'; return 'CLICKED';
})('%s')""" })('%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): def _eval(ws, js, timeout=8.0):
try: try:
return cdp_evaluate(ws, js, timeout=timeout) return cdp_evaluate(ws, js, timeout=timeout)
@@ -256,20 +233,16 @@ def switch_state(ws, label):
def set_switch(ws, label, on): def set_switch(ws, label, on):
"""Set a switch by row-label/aria match; no-op when already there.""" """Set a switch by row-label/aria match; no-op when already there.
Single click only: a fallback re-click would UNDO a slow commit
(switches toggle). The fresh-session readback decides.
"""
state = switch_state(ws, label) state = switch_state(ws, label)
if state is None: if state is None:
return False return False
if state == bool(on): if state == bool(on):
return True return True
if _eval(ws, JS_CLICK_SWITCH % label) == "CLICKED" \ if _eval(ws, JS_CLICK_SWITCH % label) != "CLICKED":
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:
return False
try:
real_click(ws, rect["x"], rect["y"])
except Exception:
return False return False
return _poll(lambda: switch_state(ws, label) is bool(on)) return _poll(lambda: switch_state(ws, label) is bool(on))
+50 -7
View File
@@ -1,15 +1,49 @@
"""Data controls tab: model-improvement switch (read-only otherwise). """Data controls tab: model-improvement switch (read-only otherwise).
The switch label is pinned from live recon; resolution prefers it The switch label is pinned from live recon; resolution prefers it
and falls back to single-switch, then keyword match. Import/Delete and falls back to single-switch, then keyword match. Sets use a
rows are inventoried, never touched. trusted click: synthetic clicks are proven no-ops here (2026-10-06).
Import/Delete rows are inventoried, never touched.
""" """
import time
from approvals import cdp_evaluate
from hatch_menu import controls, dialog from hatch_menu import controls, dialog
from hatch_menu.mouse import real_click
TAB = "Data controls" TAB = "Data controls"
SWITCH_LABEL = "Help improve our AI models" SWITCH_LABEL = "Help improve our AI models"
_KEYWORDS = ("improv", "train", "model", "data", "usage") _KEYWORDS = ("improv", "train", "model", "data", "usage")
JS_AI_RECT_TMPL = """((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 _resolve(ws): def _resolve(ws):
"""The improvement switch dict, or None when not resolvable.""" """The improvement switch dict, or None when not resolvable."""
@@ -39,15 +73,24 @@ def ai_improvement(ws):
def set_ai_improvement(ws, on): def set_ai_improvement(ws, on):
"""Set the improvement switch; verify-then-fallback. Bool.""" """Set via one trusted click; synthetic clicks are no-ops. Bool."""
sw = _resolve(ws) sw = _resolve(ws)
if sw is None: if sw is None:
return False return False
label = (sw.get("aria") or "").strip() \ if bool(sw.get("checked")) == bool(on):
or (sw.get("label") or "")[:40].strip() return True
if not label: rect = _eval(ws, JS_AI_RECT_TMPL % SWITCH_LABEL)
if not rect or "x" not in rect:
return False
try:
real_click(ws, rect["x"], rect["y"])
except Exception:
return False
for _ in range(8):
time.sleep(2.0)
if ai_improvement(ws) is bool(on):
return True
return False return False
return controls.set_switch(ws, label, on)
def describe(ws): def describe(ws):
+7 -1
View File
@@ -44,7 +44,10 @@ TOGGLES = {
"values": ("on", "off")}, "values": ("on", "off")},
"data_controls.ai_improvement": { "data_controls.ai_improvement": {
"tab": "Data controls", "kind": "switch", "tab": "Data controls", "kind": "switch",
"values": ("on", "off")}, "values": ("on", "off"),
# Live 2026-10-06: the site ignores every input gesture here
# (synthetic/real/double/hover/keyboard/drag) — reads fine.
"readonly": True},
"general.theme": { "general.theme": {
"tab": "General", "kind": "radio-aria", "tab": "General", "kind": "radio-aria",
"values": _GEN.THEME_VALUES}, "values": _GEN.THEME_VALUES},
@@ -200,6 +203,9 @@ def get_toggle(node, name):
def set_toggle(node, name, value): def set_toggle(node, name, value):
"""Set one toggle with readback. Never partially reports success.""" """Set one toggle with readback. Never partially reports success."""
spec = resolve_toggle(name) spec = resolve_toggle(name)
if spec.get("readonly"):
raise MenuError("%s is read-only: the site ignores all input "
"gestures there (locked?)" % name)
want = _normalize_value(spec, value) want = _normalize_value(spec, value)
_check_node(node) _check_node(node)
try: try:
+298 -3
View File
@@ -53,6 +53,11 @@ POLL_INTERVAL = 0.5
STABILITY_POLLS = 2 STABILITY_POLLS = 2
MAX_ANSWERS_PER_HOUR = 20 MAX_ANSWERS_PER_HOUR = 20
ANSWERED_TTL_SECONDS = 600 # identical prompt back after 10m => stuck, allow one recovery answer ANSWERED_TTL_SECONDS = 600 # identical prompt back after 10m => stuck, allow one recovery answer
RULES_FILE = os.path.join(REPO_ROOT, "muse-choices-rules.json")
HOLD_WINDOW_SECONDS = 120 # D3: short hold window, then expire to approve
NEGATIVE_KEYS = {"muse-approval": "2", "yn": "n"} # D2 deny keys
QUESTION_KINDS = frozenset({"interview", "letter", "numbered", "explicit-phrase"})
_RULES_CACHE = {"key": None, "rules": []}
LOG_MAX_BYTES = 1_000_000 LOG_MAX_BYTES = 1_000_000
HEARTBEAT_SECONDS = 60 HEARTBEAT_SECONDS = 60
TAIL_WINDOW = 25 # only prompts in the last N lines count (no stale scrollback) TAIL_WINDOW = 25 # only prompts in the last N lines count (no stale scrollback)
@@ -420,7 +425,8 @@ def _find_muse_approval(window):
context = [ln.strip()[:120] for ln in window[cue_idx:no_idx + 1]] context = [ln.strip()[:120] for ln in window[cue_idx:no_idx + 1]]
return {"sig": _sig_for("muse-approval", context), return {"sig": _sig_for("muse-approval", context),
"kind": "muse-approval", "key": "1", "options": options, "kind": "muse-approval", "key": "1", "options": options,
"cue": cue, "start": cue_idx, "end": no_idx} "cue": cue, "context": context,
"start": cue_idx, "end": no_idx}
def _find_collapsed_approval(window): def _find_collapsed_approval(window):
@@ -464,7 +470,7 @@ def _find_collapsed_approval(window):
return {"sig": _sig_for("muse-approval-collapsed", context), return {"sig": _sig_for("muse-approval-collapsed", context),
"kind": "muse-approval-collapsed", "key": "Enter", "kind": "muse-approval-collapsed", "key": "Enter",
"enter": False, "options": options, "cue": cue, "enter": False, "options": options, "cue": cue,
"start": cue_idx, "end": end} "context": context, "start": cue_idx, "end": end}
def _find_interview(window): def _find_interview(window):
@@ -687,6 +693,27 @@ def pane_muse_argv(socket_path, pane_id):
MIN_APPROVAL_WIDTH = 40 MIN_APPROVAL_WIDTH = 40
MIN_APPROVAL_HEIGHT = 12 MIN_APPROVAL_HEIGHT = 12
# Session naming convention (see NODES.md): <node>--<role>--<id>
# separates node runtimes on the shared stable server, e.g.
# pip--worker--01. Ad-hoc sessions carry no node and show "-".
NODE_NAMES = ("muse", "pip", "646", "opm", "def", "dev")
NODE_SESSION_RE = re.compile(r"^([A-Za-z0-9]+)--([A-Za-z0-9-]+)--([A-Za-z0-9]+)$")
def node_from_session(session_name):
"""Node owning a tmux session per the naming convention.
Returns the node name, or None for ad-hoc sessions and unknown
node prefixes. Pure function for supervision display.
"""
if not session_name:
return None
m = NODE_SESSION_RE.match(session_name)
if not m:
return None
node = m.group(1).lower()
return node if node in NODE_NAMES else None
def runtime_rows(socket_path): def runtime_rows(socket_path):
"""One row per pane: identity, approval posture, live state, watcher. """One row per pane: identity, approval posture, live state, watcher.
@@ -720,6 +747,7 @@ def runtime_rows(socket_path):
squeezed = (pane_width is not None and pane_height is not None squeezed = (pane_width is not None and pane_height is not None
and (pane_width < MIN_APPROVAL_WIDTH and (pane_width < MIN_APPROVAL_WIDTH
or pane_height < MIN_APPROVAL_HEIGHT)) or pane_height < MIN_APPROVAL_HEIGHT))
node = node_from_session(session)
is_muse = "muse-bin" in cmd or "muse-code" in cmd is_muse = "muse-bin" in cmd or "muse-code" in cmd
posture = {"auto_approve": None, "flags": []} posture = {"auto_approve": None, "flags": []}
if is_muse and pane_pid: if is_muse and pane_pid:
@@ -740,6 +768,7 @@ def runtime_rows(socket_path):
"socket": socket_path, "session": session, "socket": socket_path, "session": session,
"window": window, "pane": pane_id, "cmd": cmd, "window": window, "pane": pane_id, "cmd": cmd,
"pid": pane_pid, "is_muse": is_muse, "pid": pane_pid, "is_muse": is_muse,
"node": node,
"width": pane_width, "height": pane_height, "width": pane_width, "height": pane_height,
"squeezed": squeezed, "squeezed": squeezed,
"auto_approve": posture["auto_approve"], "auto_approve": posture["auto_approve"],
@@ -882,6 +911,214 @@ def send_answer(socket_path, pane_id, letter="A", enter=True):
return False return False
def _load_rules(log=None):
"""Load the D0 rules dictionary, cached by (mtime, size). Never raises:
a missing or broken file means no rules (fail open per D4)."""
global _RULES_CACHE
try:
st = os.stat(RULES_FILE)
key = (st.st_mtime, st.st_size)
except OSError:
key = "missing"
if _RULES_CACHE["key"] == key:
return _RULES_CACHE["rules"]
rules = []
if key != "missing":
try:
with open(RULES_FILE) as f:
data = json.load(f)
rules = [r for r in data.get("rules", [])
if isinstance(r, dict) and r.get("id")]
except Exception as e:
if log is not None:
try:
log.log("warn", "rules file unreadable, failing open",
error="%r" % (e,))
except Exception:
pass
rules = []
_RULES_CACHE = {"key": key, "rules": rules}
return rules
def _as_list(v):
return v if isinstance(v, list) else [v]
def _rule_matches(rule, match):
kind = rule.get("kind")
if kind is not None and match["kind"] not in _as_list(kind):
return False
token = rule.get("token")
if token is not None:
if match["kind"] != "explicit-phrase":
return False
if match["key"] not in _as_list(token):
return False
cmd = rule.get("command")
if cmd is not None:
ctx = match.get("context") or match.get("options", [])
try:
if not re.search(cmd, "\n".join(ctx)):
return False
except re.error:
return False
text = rule.get("text")
if text is not None:
hay = "\n".join(match.get("options", []) + [match.get("cue") or ""])
try:
if not re.search(text, hay):
return False
except re.error:
return False
return True
def evaluate_rules(match, log=None):
"""First matching rule wins -> (decision, rule). No match -> approve.
D2: deny rules are skipped for question kinds (questions always
resolve top-choice). Collapsed approvals have no visible No option,
so deny downgrades to hold there rather than auto-approving
something a rule flagged risky.
"""
for rule in _load_rules(log=log):
if not _rule_matches(rule, match):
continue
d = rule.get("decision", "approve")
if d not in ("approve", "deny", "hold"):
d = "approve"
if d == "deny":
if match["kind"] in NEGATIVE_KEYS:
return "deny", rule
if match["kind"] in QUESTION_KINDS:
continue
downgraded = dict(rule)
downgraded["downgraded_from"] = "deny"
return "hold", downgraded
return d, rule
return "approve", None
def holdfile_for(socket_path, pane_id):
return os.path.join(
STATE_DIR, "%s-%s-%s.held.json" % (FILE_PREFIX, slug_socket(socket_path),
clean_pane(pane_id)))
def write_hold(socket_path, pane_id, rec):
try:
with open(holdfile_for(socket_path, pane_id), "w") as f:
json.dump(rec, f)
return True
except Exception:
return False
def read_hold(socket_path, pane_id):
try:
with open(holdfile_for(socket_path, pane_id)) as f:
rec = json.load(f)
return rec if isinstance(rec, dict) and rec.get("sig") else None
except Exception:
return None
def clear_hold(socket_path, pane_id):
try:
os.remove(holdfile_for(socket_path, pane_id))
except OSError:
pass
def list_holds():
"""All holdfiles, pruning ones long expired with no watcher to own them."""
out = []
try:
names = os.listdir(STATE_DIR)
except OSError:
return out
now = time.time()
for name in names:
if not name.startswith(FILE_PREFIX + "-") or not name.endswith(".held.json"):
continue
path = os.path.join(STATE_DIR, name)
try:
with open(path) as f:
rec = json.load(f)
except Exception:
continue
if not isinstance(rec, dict) or not rec.get("sig"):
continue
try:
expired_ago = now - float(rec.get("held_until", 0))
except (TypeError, ValueError):
expired_ago = 0
if expired_ago > 300:
try:
os.remove(path)
except OSError:
pass
continue
rec["_file"] = name
out.append(rec)
return out
def _check_hold(socket_path, pane_id, state, match, now, log):
"""Rule-hold gate. Returns None (fresh: evaluate rules), "released"
(hold over: approve WITHOUT re-evaluating, else expiry would
re-hold forever), "held", or "denied".
The holdfile is the single source of truth (crash-safe,
box-visible): present + fresh => suppress; directive deny => deny
now; expired or operator-cleared => release back to approve.
"""
sig = match["sig"]
hf = read_hold(socket_path, pane_id)
if hf is None:
return None
if hf.get("sig") != sig:
clear_hold(socket_path, pane_id) # stale hold for a gone prompt
return None
if hf.get("directive") == "approve":
clear_hold(socket_path, pane_id)
log.log("info", "hold released by operator, answering", sig=sig)
return "released"
if hf.get("directive") == "deny":
neg = NEGATIVE_KEYS.get(match["kind"])
if neg is None:
hf["directive"] = None
write_hold(socket_path, pane_id, hf)
log.log("warn", "resolve-deny refused: D2 never denies questions",
sig=sig, kind=match["kind"])
return "held"
ok = send_answer(socket_path, pane_id, neg, enter=True)
clear_hold(socket_path, pane_id)
state.record_answer(sig, now)
log.log("info" if ok else "error", "denied %s (operator resolve)" % neg,
sig=sig, ok=ok, kind=match["kind"], key=neg)
audit("muse-choice-denied",
name="%s:%s" % (os.path.basename(socket_path), pane_id),
extra={"socket": socket_path, "pane": pane_id, "sig": sig,
"ok": ok, "kind": match["kind"], "key": neg,
"via": "resolve"})
return "denied"
try:
expired = now >= float(hf.get("held_until", 0))
except (TypeError, ValueError):
expired = True
if expired:
clear_hold(socket_path, pane_id)
log.log("info", "hold expired, releasing to approve", sig=sig)
audit("muse-choice-hold-expired",
name="%s:%s" % (os.path.basename(socket_path), pane_id),
extra={"socket": socket_path, "pane": pane_id, "sig": sig,
"kind": match["kind"], "rule": hf.get("rule")})
return "released"
return "held"
def _poll_once(socket_path, pane_id, state, log, dry_run=False): def _poll_once(socket_path, pane_id, state, log, dry_run=False):
"""Run one poll iteration. Returns an outcome string ("gone" tells the """Run one poll iteration. Returns an outcome string ("gone" tells the
caller to exit). Logs only state transitions, never per-poll spam.""" caller to exit). Logs only state transitions, never per-poll spam."""
@@ -893,6 +1130,14 @@ def _poll_once(socket_path, pane_id, state, log, dry_run=False):
return "capture-failed" return "capture-failed"
match = find_choice_prompt(text) match = find_choice_prompt(text)
now = time.time() now = time.time()
gate = _check_hold(socket_path, pane_id, state, match, now, log) \
if match is not None else None
if gate in ("held", "denied"):
if gate == "denied":
state.pending_sig = None
state.stable_count = 0
return gate
skip_eval = (gate == "released")
verdict = state.observe(match, now) verdict = state.observe(match, now)
if verdict == "wait" and state.stable_count == 1: if verdict == "wait" and state.stable_count == 1:
log.log("info", "prompt seen", kind=match["kind"], key=match["key"], log.log("info", "prompt seen", kind=match["kind"], key=match["key"],
@@ -915,7 +1160,56 @@ def _poll_once(socket_path, pane_id, state, log, dry_run=False):
sig=match["sig"], kind=match["kind"]) sig=match["sig"], kind=match["kind"])
state.record_answer(match["sig"], now) state.record_answer(match["sig"], now)
return "held" return "held"
if skip_eval:
decision, rule = "approve", None
via = "hold-released"
else:
decision, rule = evaluate_rules(match, log)
via = "direct"
key = match["key"] key = match["key"]
if decision == "deny":
neg = NEGATIVE_KEYS[match["kind"]]
if dry_run:
log.log("info", "dry-run would deny %s" % neg,
sig=match["sig"], kind=match["kind"], key=neg,
rule=rule["id"] if rule else None)
state.record_answer(match["sig"], now)
return "dry-denied"
ok = send_answer(socket_path, pane_id, neg, enter=True)
state.record_answer(match["sig"], now)
log.log("info" if ok else "error", "denied %s" % neg,
sig=match["sig"], ok=ok, kind=match["kind"], key=neg,
rule=rule["id"] if rule else None)
audit("muse-choice-denied",
name="%s:%s" % (os.path.basename(socket_path), pane_id),
extra={"socket": socket_path, "pane": pane_id,
"sig": match["sig"], "ok": ok, "kind": match["kind"],
"key": neg, "via": "rule",
"rule": rule["id"] if rule else None})
return "denied"
if decision == "hold":
until = now + HOLD_WINDOW_SECONDS
write_hold(socket_path, pane_id, {
"sig": match["sig"], "kind": match["kind"], "key": key,
"text": (match["cue"] or "")[:200],
"rule": rule["id"] if rule else None,
"reason": (rule.get("reason") if rule else None) or "",
"downgraded_from": (rule.get("downgraded_from")
if rule else None),
"held_until": until, "directive": None,
"socket": socket_path, "pane": pane_id})
log.log("info", "held by rule %s" % (rule["id"] if rule else "?"),
sig=match["sig"], kind=match["kind"],
rule=rule["id"] if rule else None,
reason=(rule.get("reason") if rule else None) or "")
if not dry_run:
audit("muse-choice-held",
name="%s:%s" % (os.path.basename(socket_path), pane_id),
extra={"socket": socket_path, "pane": pane_id,
"sig": match["sig"], "kind": match["kind"],
"rule": rule["id"] if rule else None,
"held_until": until})
return "held"
if dry_run: if dry_run:
log.log("info", "dry-run would answer %s" % key, log.log("info", "dry-run would answer %s" % key,
sig=match["sig"], kind=match["kind"], key=key, sig=match["sig"], kind=match["kind"], key=key,
@@ -933,7 +1227,8 @@ def _poll_once(socket_path, pane_id, state, log, dry_run=False):
extra={"socket": socket_path, "pane": pane_id, extra={"socket": socket_path, "pane": pane_id,
"sig": match["sig"], "ok": ok, "kind": match["kind"], "sig": match["sig"], "ok": ok, "kind": match["kind"],
"key": key, "options": match["options"], "key": key, "options": match["options"],
"cue": match["cue"]}) "cue": match["cue"], "via": via,
"rule": rule["id"] if rule else None})
return "answered" return "answered"
if verdict == "capped": if verdict == "capped":
if state.last_capped_sig != match["sig"]: if state.last_capped_sig != match["sig"]:
+64 -4
View File
@@ -953,8 +953,8 @@ def cmd_runtime(args):
print(c_dim(" No panes found.")) print(c_dim(" No panes found."))
print() print()
return return
headers = ["SOCKET", "SESSION", "PANE", "CMD", "STATE", headers = ["SOCKET", "SESSION", "NODE", "PANE", "CMD",
"APPROVE", "WATCHER"] "STATE", "APPROVE", "WATCHER"]
table = [] table = []
for r in rows: for r in rows:
state = r["state"] state = r["state"]
@@ -970,6 +970,7 @@ def cmd_runtime(args):
if r["watcher_alive"] else badge_dim("-")) if r["watcher_alive"] else badge_dim("-"))
table.append([os.path.basename(r["socket"]), table.append([os.path.basename(r["socket"]),
"%s:%s" % (r["session"], r["window"]), "%s:%s" % (r["session"], r["window"]),
r["node"] or badge_dim("-"),
r["pane"], (r["cmd"] or "")[:26], state, r["pane"], (r["cmd"] or "")[:26], state,
approve, watcher]) approve, watcher])
print_table(headers, table) print_table(headers, table)
@@ -1149,8 +1150,12 @@ def cmd_runtime(args):
spread, failed = [], [] spread, failed = [], []
for t in targets: for t in targets:
name = "spread-%s" % t["pane"] name = "spread-%s" % t["pane"]
# -t session: pins the new window to the SOURCE session.
# Without it break-pane follows the ATTACHED session and
# flings panes across sessions (observed live on scratch).
r = mcw._tmux(t["socket"], "break-pane", "-s", t["pane"], r = mcw._tmux(t["socket"], "break-pane", "-s", t["pane"],
"-n", name, timeout=15) "-t", "%s:" % t["session"], "-n", name,
timeout=15)
if r.returncode == 0: if r.returncode == 0:
spread.append("%s:%s" % (t["session"], t["pane"])) spread.append("%s:%s" % (t["session"], t["pane"]))
else: else:
@@ -1272,13 +1277,51 @@ def cmd_muse_choices(args):
print(line.rstrip("\n")) print(line.rstrip("\n"))
print() print()
elif action == "resolve":
sock = getattr(args, "socket", None)
pane = getattr(args, "pane", None)
decision = getattr(args, "decision", None)
hf = mcw.read_hold(sock, pane)
if not hf:
if as_json:
print(json.dumps({"ok": True, "held": None,
"note": "nothing held"}, indent=2))
return
print(c_dim("\n Nothing held on %s:%s.\n" % (sock, pane)))
return
if decision == "deny" and hf.get("kind") not in mcw.NEGATIVE_KEYS:
msg = ("refusing deny on %s prompt (D2: questions always "
"resolve top-choice); use approve or wait for expiry"
% (hf.get("kind") or "?"))
if as_json:
print(json.dumps({"ok": False, "reason": msg}, indent=2))
return
print(c_red("\n Error: " + msg + "\n"), file=sys.stderr)
sys.exit(1)
hf["directive"] = decision
mcw.write_hold(sock, pane, hf)
mcw.audit("muse-choice-resolved",
name="%s:%s" % (os.path.basename(sock), pane),
caller=caller,
extra={"socket": sock, "pane": pane,
"decision": decision, "sig": hf.get("sig")})
if as_json:
print(json.dumps({"ok": True, "resolved": decision,
"pane": pane}, indent=2))
return
print(c_green("\n✔ Hold on %s:%s will %s within a poll."
% (sock, pane,
"approve" if decision == "approve" else "deny")))
print()
else: # status else: # status
desired = mcw.get_desired() desired = mcw.get_desired()
watchers = mcw.status_all() watchers = mcw.status_all()
answers = mcw.recent_answers(5) answers = mcw.recent_answers(5)
held = mcw.list_holds()
if as_json: if as_json:
print(json.dumps({"ok": True, "desired": desired, print(json.dumps({"ok": True, "desired": desired,
"watchers": watchers, "watchers": watchers, "held": held,
"recent_answers": answers}, indent=2)) "recent_answers": answers}, indent=2))
return return
state_badge = badge_ok("ON") if desired["enabled"] else badge_dim("OFF") state_badge = badge_ok("ON") if desired["enabled"] else badge_dim("OFF")
@@ -1302,6 +1345,18 @@ def cmd_muse_choices(args):
print_table(headers, rows) print_table(headers, rows)
else: else:
print(c_dim(" No watcher state files.")) print(c_dim(" No watcher state files."))
if held:
print(c_bold("\n Held prompts (resolve via: box muse-choices resolve --socket S --pane P approve|deny):"))
for h in held:
try:
left = max(0, int(float(h.get("held_until", 0)) - time.time()))
except (TypeError, ValueError):
left = -1
print(f" • {badge_warn('HELD')} {c_bold(h.get('pane', '?'))} "
f"{h.get('kind')}/{h.get('key')} rule={h.get('rule')} "
f"expires in {left}s")
print(f" {c_dim((h.get('reason') or '')[:100])}")
print(f" {c_dim((h.get('text') or '')[:100])}")
if answers: if answers:
print(c_bold("\n Recent answers (box audit feed):")) print(c_bold("\n Recent answers (box audit feed):"))
for a in answers: for a in answers:
@@ -5634,6 +5689,11 @@ def build_parser():
p_mc_rec = mc_sub.add_parser("reconcile", parents=[common], help="Enforce desired state now (start missing / stop excess)") p_mc_rec = mc_sub.add_parser("reconcile", parents=[common], help="Enforce desired state now (start missing / stop excess)")
p_mc_res = mc_sub.add_parser("resolve", parents=[common], help="Resolve a held prompt (approve now or deny it)")
p_mc_res.add_argument("--socket", required=True, help="Tmux socket path (e.g. /tmp/tmux-1000/default)")
p_mc_res.add_argument("--pane", required=True, help="Pane id (e.g. %%37)")
p_mc_res.add_argument("decision", choices=["approve", "deny"], help="Release the hold to approve, or deny it (permission kinds only)")
# Domain: RUNTIME # Domain: RUNTIME
p_rt = subparsers.add_parser("runtime", parents=[common], help="Muse CLI tmux runtimes: list states, send input, launch auto-approved") p_rt = subparsers.add_parser("runtime", parents=[common], help="Muse CLI tmux runtimes: list states, send input, launch auto-approved")
rt_sub = p_rt.add_subparsers(dest="rt_action") rt_sub = p_rt.add_subparsers(dest="rt_action")
+7
View File
@@ -54,6 +54,13 @@ that only the readback confirms carry `"readback_only": true`.
Website Ask/Deny is one-way: the override row leaves the allowed Website Ask/Deny is one-way: the override row leaves the allowed
list (no add UI), verified by absence; absent hosts read as list (no add UI), verified by absence; absent hosts read as
"not in Websites list" (effective: web-access default). "not in Websites list" (effective: web-access default).
Switch sets click once and never fall back to a second click: a
re-click would undo a slow commit (switches toggle). Advanced
switches take one synthetic click; protocol and Data-controls
switches need trusted clicks (synthetic is a proven no-op there).
Radios keep their trusted-click fallback (idempotent).
`data_controls.ai_improvement` is read-only: the site ignores every
input gesture on that switch (proven live; reads fine, sets raise).
Caller errors (unknown node/toggle/tab/value) raise `MenuError` Caller errors (unknown node/toggle/tab/value) raise `MenuError`
before any CDP traffic. Transport failures return `{"ok": False}`. before any CDP traffic. Transport failures return `{"ok": False}`.
+28 -13
View File
@@ -36,27 +36,42 @@ breaker, ensure-node-supervision feed, host_evidence fallback.
OUT: onboarding pipeline lifecycle, completion auditor, loop-health OUT: onboarding pipeline lifecycle, completion auditor, loop-health
(each keeps its own owner and interviews separately). (each keeps its own owner and interviews separately).
### D2. Kill-path precedence (chromebox-watchdog vs agent-health) — UNRESOLVED ### D2. Kill-path precedence (chromebox-watchdog vs agent-health) — SETTLED (recommended accepted)
Both can kill a browser today; only time guards (<2 min) de-conflict them. Policy: each supervisor owns its signal — chromebox-watchdog owns
CDP/egress/process failure, agent-health owns API-layer failure.
Browser restart stays the shared actuator, and both keep their <2-min
fresh-browser guards. No code change; this paragraph is the guard
against future edits dropping either side.
### D3. Egress-down fall-through (relaunch chrome after failed tunnel restart?) — UNRESOLVED ### D3. Egress-down fall-through (relaunch chrome after failed tunnel restart?) — SETTLED (recommended accepted)
Observed 19:12Z: tunnel restart failed, watchdog relaunched chrome 3× Policy: stop after a failed tunnel restart. Log the egress failure
anyway (one FAILED page). Browser was never the problem. loudly and page it as a tunnel fault, never as a browser fault; skip
the chrome relaunch. The next 2-min run retries the tunnel. (Requires
a chromebox-watchdog.sh change — implementation needs a separate
explicit request.)
### D4. Circuit-breaker thresholds (3 futile / 30 min cooldown) — UNRESOLVED ### D4. Circuit-breaker thresholds (3 futile / 30 min cooldown) — SETTLED (recommended accepted)
Current values unvalidated against real recurrence intervals. Keep 3 strikes / 1800s cooldown as initial values, with a review
trigger: revisit if one node trips the circuit more than twice in a
week (threshold too touchy) or a stuck node sits out a full cooldown
under operator eyes (cooldown too long).
### D5. Coverage source of truth — UNRESOLVED ### D5. Coverage source of truth — SETTLED (recommended accepted)
Registry-only vs registry+installed-timers for browser verdicts. Registry-only: every active NODES.md row is supervised, period. The
ensure script guarantees the timer follows the row, and a missing
timer degrades to an honest `unknown` (no journal runs) rather than a
wrong verdict. No second inventory.
### D6. Concurrent-edit protocol for shared supervision files — UNRESOLVED ### D6. Concurrent-edit protocol for shared supervision files — SETTLED (recommended accepted)
Two agents editing super-cli.py / watchdogs / runbook; one clobber Protocol: claim-before-edit (announce shared-file claims in chat),
(18:03Z) and one unattributed commit (c9143a5) already occurred. anchored-edits-only on shared files (never full-file rewrites, so
concurrent work interleaves), re-read before staging, and prompt
mine-only commits so landed work survives the next clobber.
### D7. Done means — UNRESOLVED ### D7. Done means — UNRESOLVED
@@ -78,4 +93,4 @@ loops registry-driven with tests; ensure hook live; this doc Final.
## Unresolved items ## Unresolved items
D2–D7 unresolved. D1 settled. D7 unresolved. D1–D6 settled.
+29
View File
@@ -239,6 +239,35 @@ class TestRuntimeRows(unittest.TestCase):
self.assertTrue(rows[1]["squeezed"]) self.assertTrue(rows[1]["squeezed"])
class TestNodeFromSession(unittest.TestCase):
def test_conforming_sessions(self):
self.assertEqual(w.node_from_session("pip--worker--01"), "pip")
self.assertEqual(w.node_from_session("muse--repair--09"), "muse")
self.assertEqual(w.node_from_session("646--watch--a1"), "646")
self.assertEqual(w.node_from_session("PIP--X--1"), "pip")
def test_adhoc_sessions_have_no_node(self):
for name in ("muse", "repair-09", "swarm-worker", "main",
"", None):
self.assertIsNone(w.node_from_session(name), name)
def test_rejects_unknown_node_and_single_dash(self):
self.assertIsNone(w.node_from_session("foo--worker--01"))
self.assertIsNone(w.node_from_session("pip-worker-01"))
self.assertIsNone(w.node_from_session("pip--worker"))
def test_runtime_rows_carry_node(self):
listing = "pip--audit--a1\t1\t%37\tbash\t2880158\t80\t24\n"
r = mock.Mock(returncode=0, stdout=listing, stderr="")
with mock.patch.object(w, "_tmux", return_value=r), \
mock.patch.object(w, "capture_pane",
return_value=STATE_SHELL), \
mock.patch.object(w, "is_running", return_value=None):
rows = w.runtime_rows("/tmp/sock")
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["node"], "pip")
class TestSpreadTargets(unittest.TestCase): class TestSpreadTargets(unittest.TestCase):
def _row(self, pane, is_muse, squeezed): def _row(self, pane, is_muse, squeezed):
return {"socket": "/tmp/s", "session": "muse", "window": "1", return {"socket": "/tmp/s", "session": "muse", "window": "1",
+47
View File
@@ -261,6 +261,18 @@ class ControlsTests(unittest.TestCase):
self.assertTrue(controls.set_switch(mock.Mock(), self.assertTrue(controls.set_switch(mock.Mock(),
"Transparent proxy", True)) "Transparent proxy", True))
def test_set_switch_never_reclicks(self):
# A fallback re-click would undo a slow commit (switches
# toggle), so a poll miss returns False without real Click.
off = [{"label": "Transparent proxy row", "aria": "",
"checked": False}]
with mock.patch.object(controls, "cdp_evaluate",
side_effect=[off, "CLICKED"] + [off] * 10), \
mock.patch.object(controls, "real_click") as click:
self.assertFalse(controls.set_switch(mock.Mock(),
"Transparent proxy", True))
click.assert_not_called()
def test_listers_reject_wrong_types(self): def test_listers_reject_wrong_types(self):
with mock.patch.object(controls, "cdp_evaluate", with mock.patch.object(controls, "cdp_evaluate",
return_value="error"): return_value="error"):
@@ -332,6 +344,13 @@ class TogglesTests(unittest.TestCase):
res = toggles.get_toggle("pip", "permissions.web_access") res = toggles.get_toggle("pip", "permissions.web_access")
self.assertEqual((res["ok"], res["value"]), (True, "always_ask")) self.assertEqual((res["ok"], res["value"]), (True, "always_ask"))
def test_set_toggle_readonly_raises_before_cdp(self):
with mock.patch.object(toggles, "get_cdp_ws") as g:
with self.assertRaises(toggles.MenuError):
toggles.set_toggle("pip", "data_controls.ai_improvement",
"off")
g.assert_not_called()
def test_set_toggle_bad_value_raises_before_cdp(self): def test_set_toggle_bad_value_raises_before_cdp(self):
with mock.patch.object(toggles, "get_cdp_ws") as g: with mock.patch.object(toggles, "get_cdp_ws") as g:
with self.assertRaises(toggles.MenuError): with self.assertRaises(toggles.MenuError):
@@ -552,6 +571,34 @@ class TabContractTests(unittest.TestCase):
return_value=sws): return_value=sws):
self.assertTrue(data_controls.ai_improvement(mock.Mock())) self.assertTrue(data_controls.ai_improvement(mock.Mock()))
def test_set_ai_improvement_noop(self):
row = {"label": "Help improve our AI models", "aria": "",
"checked": True}
with mock.patch.object(dialog, "goto_tab", return_value=True), \
mock.patch.object(controls, "list_switches",
return_value=[row]), \
mock.patch.object(data_controls, "real_click") as click, \
mock.patch("time.sleep"):
self.assertTrue(data_controls.set_ai_improvement(
mock.Mock(), True))
click.assert_not_called()
def test_set_ai_improvement_real_click(self):
off = {"label": "Help improve our AI models", "aria": "",
"checked": False}
on = {"label": "Help improve our AI models", "aria": "",
"checked": True}
with mock.patch.object(dialog, "goto_tab", return_value=True), \
mock.patch.object(controls, "list_switches",
side_effect=[[off], [on]]), \
mock.patch.object(data_controls, "cdp_evaluate",
return_value={"x": 1, "y": 2}), \
mock.patch.object(data_controls, "real_click") as click, \
mock.patch("time.sleep"):
self.assertTrue(data_controls.set_ai_improvement(
mock.Mock(), True))
click.assert_called_once()
def test_data_controls_single_switch(self): def test_data_controls_single_switch(self):
one = [{"label": "Help improve the model", "aria": "", one = [{"label": "Help improve the model", "aria": "",
"checked": True}] "checked": True}]
+365
View File
@@ -1086,5 +1086,370 @@ class TestExplicitPhrase(unittest.TestCase):
"explicit-phrase") "explicit-phrase")
class TestRulesEval(unittest.TestCase):
def _use_rules(self, tmpdir, data):
import json
path = tmpdir + "/rules.json"
if isinstance(data, str):
with open(path, "w") as f:
f.write(data)
else:
with open(path, "w") as f:
json.dump(data, f)
w._RULES_CACHE = {"key": None, "rules": []}
return mock.patch.object(w, "RULES_FILE", path)
def _rm_approval(self):
return PROMPT_MUSE_APPROVAL.replace(
"tmux -S /tmp/tmux-1000/default capture-pane -p -t %39",
"rm -rf /tmp/scratch")
def test_no_file_approves(self):
import tempfile
with tempfile.TemporaryDirectory() as td, \
mock.patch.object(w, "RULES_FILE",
td + "/missing.json"):
w._RULES_CACHE = {"key": None, "rules": []}
d, r = w.evaluate_rules(
w.find_choice_prompt(PROMPT_ABC))
self.assertEqual((d, r), ("approve", None))
def test_bad_json_fails_open(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
with self._use_rules(td, "{not json"):
d, r = w.evaluate_rules(
w.find_choice_prompt(PROMPT_ABC), mock.Mock())
self.assertEqual((d, r), ("approve", None))
def test_token_match_holds(self):
import tempfile
rules = {"rules": [{"id": "t", "kind": "explicit-phrase",
"token": ["ABORT", "DELETE"],
"decision": "hold"}]}
with tempfile.TemporaryDirectory() as td:
with self._use_rules(td, rules):
m = w.find_choice_prompt("Reply ABORT to cancel.\n")
d, r = w.evaluate_rules(m)
self.assertEqual(d, "hold")
self.assertEqual(r["id"], "t")
def test_token_nonmatch_approves(self):
import tempfile
rules = {"rules": [{"id": "t", "kind": "explicit-phrase",
"token": ["ABORT"], "decision": "hold"}]}
with tempfile.TemporaryDirectory() as td:
with self._use_rules(td, rules):
m = w.find_choice_prompt("Reply ACCEPT to approve.\n")
d, r = w.evaluate_rules(m)
self.assertEqual((d, r), ("approve", None))
def test_command_match_holds(self):
import tempfile
rules = {"rules": [{"id": "c", "kind": "muse-approval",
"command": r"\brm\s+-rf?\b",
"decision": "hold"}]}
with tempfile.TemporaryDirectory() as td:
with self._use_rules(td, rules):
m = w.find_choice_prompt(self._rm_approval())
self.assertIsNotNone(m)
d, r = w.evaluate_rules(m)
self.assertEqual(d, "hold")
def test_kind_mismatch_skips(self):
import tempfile
rules = {"rules": [{"id": "c", "kind": "yn",
"command": r"\brm\s+-rf?\b",
"decision": "hold"}]}
with tempfile.TemporaryDirectory() as td:
with self._use_rules(td, rules):
m = w.find_choice_prompt(self._rm_approval())
d, r = w.evaluate_rules(m)
self.assertEqual((d, r), ("approve", None))
def test_deny_question_skipped(self):
import tempfile
rules = {"rules": [{"id": "d", "kind": "explicit-phrase",
"token": ["ABORT"], "decision": "deny"}]}
with tempfile.TemporaryDirectory() as td:
with self._use_rules(td, rules):
m = w.find_choice_prompt("Reply ABORT to cancel.\n")
d, r = w.evaluate_rules(m)
self.assertEqual((d, r), ("approve", None))
def test_deny_approval(self):
import tempfile
rules = {"rules": [{"id": "d", "kind": "muse-approval",
"decision": "deny"}]}
with tempfile.TemporaryDirectory() as td:
with self._use_rules(td, rules):
m = w.find_choice_prompt(PROMPT_MUSE_APPROVAL)
d, r = w.evaluate_rules(m)
self.assertEqual(d, "deny")
def test_deny_collapsed_downgrades_to_hold(self):
import tempfile
rules = {"rules": [{"id": "d",
"kind": "muse-approval-collapsed",
"decision": "deny"}]}
with tempfile.TemporaryDirectory() as td:
with self._use_rules(td, rules):
m = w.find_choice_prompt(PROMPT_COLLAPSED_APPROVAL)
d, r = w.evaluate_rules(m)
self.assertEqual(d, "hold")
self.assertEqual(r["downgraded_from"], "deny")
def test_unknown_decision_approves(self):
import tempfile
rules = {"rules": [{"id": "x", "decision": "explode"}]}
with tempfile.TemporaryDirectory() as td:
with self._use_rules(td, rules):
d, r = w.evaluate_rules(
w.find_choice_prompt(PROMPT_ABC))
self.assertEqual(d, "approve")
def test_bad_regex_never_matches(self):
import tempfile
rules = {"rules": [{"id": "x", "command": "[invalid",
"decision": "hold"}]}
with tempfile.TemporaryDirectory() as td:
with self._use_rules(td, rules):
d, r = w.evaluate_rules(
w.find_choice_prompt(PROMPT_MUSE_APPROVAL))
self.assertEqual((d, r), ("approve", None))
class TestHoldFlow(unittest.TestCase):
def _polls(self, tmpdir, text, n, hold_rule=True):
import time
state = w.WatcherState()
log = mock.Mock()
rule = {"id": "t", "reason": "test hold"}
eff = ("hold", rule) if hold_rule else ("approve", None)
with mock.patch.object(w, "STATE_DIR", tmpdir), \
mock.patch.object(w, "pane_exists", return_value=True), \
mock.patch.object(w, "capture_pane",
side_effect=[text] * (2 * n)), \
mock.patch.object(w, "send_answer",
return_value=True) as send, \
mock.patch.object(w, "audit") as audit, \
mock.patch.object(w, "evaluate_rules",
return_value=eff):
outcomes = [w._poll_once("/tmp/s", "%1", state, log,
dry_run=False)
for _ in range(n)]
return state, log, send, audit, outcomes
def test_hold_suppresses_and_writes_file(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
state, log, send, audit, outcomes = self._polls(
td, PROMPT_YN, 2)
self.assertEqual(outcomes, ["seen", "held"])
send.assert_not_called()
held_calls = [c for c in audit.call_args_list
if c.args[0] == "muse-choice-held"]
self.assertEqual(len(held_calls), 1)
def test_held_persists_without_reaudit(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
state, log, send, audit, outcomes = self._polls(
td, PROMPT_YN, 4)
self.assertEqual(outcomes,
["seen", "held", "held", "held"])
send.assert_not_called()
held_calls = [c for c in audit.call_args_list
if c.args[0] == "muse-choice-held"]
self.assertEqual(len(held_calls), 1)
def test_resolve_approve_releases(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
with mock.patch.object(w, "STATE_DIR", td):
state, log, send, audit, outcomes = self._polls(
td, PROMPT_YN, 2)
hf = w.read_hold("/tmp/s", "%1")
self.assertIsNotNone(hf)
hf["directive"] = "approve" # what box resolve does
w.write_hold("/tmp/s", "%1", hf)
with mock.patch.object(w, "pane_exists",
return_value=True), \
mock.patch.object(w, "capture_pane",
return_value=PROMPT_YN), \
mock.patch.object(w, "send_answer",
return_value=True) as send2, \
mock.patch.object(w, "audit") as audit2, \
mock.patch.object(w, "evaluate_rules") as ev:
out = w._poll_once("/tmp/s", "%1", state, log,
dry_run=False)
self.assertEqual(out, "answered")
send2.assert_called_once_with("/tmp/s", "%1", "y", enter=True)
ev.assert_not_called() # release bypasses re-evaluation
def test_resolve_deny_sends_negative(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
with mock.patch.object(w, "STATE_DIR", td):
state, log, send, audit, outcomes = self._polls(
td, PROMPT_YN, 2)
hf = w.read_hold("/tmp/s", "%1")
hf["directive"] = "deny"
w.write_hold("/tmp/s", "%1", hf)
with mock.patch.object(w, "pane_exists",
return_value=True), \
mock.patch.object(w, "capture_pane",
return_value=PROMPT_YN), \
mock.patch.object(w, "send_answer",
return_value=True) as send2, \
mock.patch.object(w, "audit"):
out = w._poll_once("/tmp/s", "%1", state, log,
dry_run=False)
self.assertEqual(out, "denied")
send2.assert_called_once_with("/tmp/s", "%1", "n", enter=True)
def test_resolve_deny_refused_on_questions(self):
import tempfile
text = "Reply ABORT to cancel.\n"
with tempfile.TemporaryDirectory() as td:
with mock.patch.object(w, "STATE_DIR", td):
state, log, send, audit, outcomes = self._polls(
td, text, 2)
hf = w.read_hold("/tmp/s", "%1")
hf["directive"] = "deny"
w.write_hold("/tmp/s", "%1", hf)
with mock.patch.object(w, "pane_exists",
return_value=True), \
mock.patch.object(w, "capture_pane",
return_value=text), \
mock.patch.object(w, "send_answer",
return_value=True) as send2, \
mock.patch.object(w, "audit"):
out = w._poll_once("/tmp/s", "%1", state, log,
dry_run=False)
self.assertIsNone(w.read_hold("/tmp/s", "%1")
["directive"])
self.assertEqual(out, "held")
send2.assert_not_called()
def test_expiry_releases_to_approve(self):
import tempfile
import time
with tempfile.TemporaryDirectory() as td:
with mock.patch.object(w, "STATE_DIR", td):
state, log, send, audit, outcomes = self._polls(
td, PROMPT_YN, 2)
hf = w.read_hold("/tmp/s", "%1")
hf["held_until"] = time.time() - 1
w.write_hold("/tmp/s", "%1", hf)
with mock.patch.object(w, "pane_exists",
return_value=True), \
mock.patch.object(w, "capture_pane",
return_value=PROMPT_YN), \
mock.patch.object(w, "send_answer",
return_value=True) as send2, \
mock.patch.object(w, "audit") as audit2, \
mock.patch.object(w, "evaluate_rules") as ev:
out = w._poll_once("/tmp/s", "%1", state, log,
dry_run=False)
self.assertEqual(out, "answered")
send2.assert_called_once()
ev.assert_not_called()
expired = [c for c in audit2.call_args_list
if c.args[0] == "muse-choice-hold-expired"]
self.assertEqual(len(expired), 1)
def test_stale_hold_cleared(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
with mock.patch.object(w, "STATE_DIR", td):
state, log, send, audit, outcomes = self._polls(
td, PROMPT_YN, 2)
self.assertIsNotNone(w.read_hold("/tmp/s", "%1"))
with mock.patch.object(w, "pane_exists",
return_value=True), \
mock.patch.object(w, "capture_pane",
return_value=PROMPT_ABC), \
mock.patch.object(w, "send_answer",
return_value=True), \
mock.patch.object(w, "audit"), \
mock.patch.object(w, "evaluate_rules",
return_value=("approve", None)):
out = w._poll_once("/tmp/s", "%1", state, log,
dry_run=False)
self.assertIsNone(w.read_hold("/tmp/s", "%1"))
self.assertEqual(out, "seen")
class TestBoxResolve(unittest.TestCase):
@classmethod
def setUpClass(cls):
import importlib.util
spec = importlib.util.spec_from_file_location(
"supercli_test", str(BIN_DIR / "super-cli.py"))
cls.cli = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cls.cli)
def _ns(self, decision):
import argparse
return argparse.Namespace(mc_action="resolve", socket="/tmp/s",
pane="%1", decision=decision, json=True)
def _hold(self, tmpdir, kind="yn"):
import time
with mock.patch.object(w, "STATE_DIR", tmpdir):
w.write_hold("/tmp/s", "%1",
{"sig": "s1", "kind": kind, "key": "y",
"text": "t", "rule": "r",
"held_until": time.time() + 60,
"directive": None})
def _run(self, tmpdir, decision):
import io
import json
from contextlib import redirect_stdout
buf = io.StringIO()
with mock.patch.object(w, "STATE_DIR", tmpdir), \
mock.patch.object(w, "audit"), \
redirect_stdout(buf):
self.cli.cmd_muse_choices(self._ns(decision))
return json.loads(buf.getvalue())
def test_resolve_approve_sets_directive(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
self._hold(td)
data = self._run(td, "approve")
self.assertTrue(data["ok"])
with mock.patch.object(w, "STATE_DIR", td):
hf = w.read_hold("/tmp/s", "%1")
self.assertEqual(hf["directive"], "approve")
def test_resolve_deny_sets_directive(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
self._hold(td, kind="yn")
data = self._run(td, "deny")
self.assertTrue(data["ok"])
with mock.patch.object(w, "STATE_DIR", td):
hf = w.read_hold("/tmp/s", "%1")
self.assertEqual(hf["directive"], "deny")
def test_resolve_deny_refused_on_questions(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
self._hold(td, kind="interview")
data = self._run(td, "deny")
self.assertFalse(data["ok"])
self.assertIn("D2", data["reason"])
def test_resolve_nothing_held(self):
import tempfile
with tempfile.TemporaryDirectory() as td:
data = self._run(td, "approve")
self.assertTrue(data["ok"])
self.assertIsNone(data["held"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()