feat(muse): harden choice watcher concurrency, add rules dictionary, resume pool, and session bind
This commit is contained in:
+353
-38
@@ -28,6 +28,7 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import fcntl
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -53,6 +54,7 @@ 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
|
||||||
|
CLAIM_TTL_SECONDS = 30 # concurrent-claim window: bounds wedge if winner dies pre-send
|
||||||
RULES_FILE = os.path.join(REPO_ROOT, "muse-choices-rules.json")
|
RULES_FILE = os.path.join(REPO_ROOT, "muse-choices-rules.json")
|
||||||
HOLD_WINDOW_SECONDS = 120 # D3: short hold window, then expire to approve
|
HOLD_WINDOW_SECONDS = 120 # D3: short hold window, then expire to approve
|
||||||
NEGATIVE_KEYS = {"muse-approval": "2", "yn": "n"} # D2 deny keys
|
NEGATIVE_KEYS = {"muse-approval": "2", "yn": "n"} # D2 deny keys
|
||||||
@@ -163,6 +165,13 @@ def logfile_for(socket_path, pane_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def answered_file_for(socket_path, pane_id):
|
||||||
|
"""On-disk answered-sig store: restarts must not re-answer prompts."""
|
||||||
|
return os.path.join(
|
||||||
|
STATE_DIR, "%s-%s-%s.answered.json" % (FILE_PREFIX, slug_socket(socket_path), clean_pane(pane_id))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WatcherLog:
|
class WatcherLog:
|
||||||
"""Never-raising JSON-lines logger with best-effort rotation."""
|
"""Never-raising JSON-lines logger with best-effort rotation."""
|
||||||
|
|
||||||
@@ -624,21 +633,49 @@ def _child_pids(pid):
|
|||||||
|
|
||||||
|
|
||||||
def muse_approval_flags(cmd_argv):
|
def muse_approval_flags(cmd_argv):
|
||||||
"""Approval posture of a muse argv: {"auto_approve", "flags"}."""
|
"""Approval posture of a muse argv.
|
||||||
|
|
||||||
|
Returns {"auto_approve", "flags", "profile", "mode", "bypass"}.
|
||||||
|
profile is the --permission-profile id (built-ins :read-only,
|
||||||
|
:standard, :unrestricted) or None; mode is "yolo" for --yolo, the
|
||||||
|
profile id when one was passed, else "default"; bypass is True
|
||||||
|
when tool-approval prompts are disabled at launch (yolo,
|
||||||
|
--disable-approval, or --approval-mode=never), in which case the
|
||||||
|
pane shows no approval dialogs (questions may still render, so the
|
||||||
|
watcher stays active).
|
||||||
|
"""
|
||||||
flags = []
|
flags = []
|
||||||
argv = cmd_argv or []
|
argv = cmd_argv or []
|
||||||
if "--yolo" in argv:
|
if "--yolo" in argv:
|
||||||
flags.append("yolo")
|
flags.append("yolo")
|
||||||
if "--disable-approval" in argv:
|
if "--disable-approval" in argv:
|
||||||
flags.append("disable-approval")
|
flags.append("disable-approval")
|
||||||
|
if "--disable-sandbox" in argv:
|
||||||
|
flags.append("disable-sandbox")
|
||||||
|
if "--trust-workspace" in argv:
|
||||||
|
flags.append("trust-workspace")
|
||||||
|
profile = None
|
||||||
for i, arg in enumerate(argv):
|
for i, arg in enumerate(argv):
|
||||||
if arg == "--approval-mode" and i + 1 < len(argv):
|
if arg == "--approval-mode" and i + 1 < len(argv):
|
||||||
flags.append("approval-mode=%s" % argv[i + 1])
|
flags.append("approval-mode=%s" % argv[i + 1])
|
||||||
elif arg.startswith("--approval-mode="):
|
elif arg.startswith("--approval-mode="):
|
||||||
flags.append("approval-mode=%s" % arg.split("=", 1)[1])
|
flags.append("approval-mode=%s" % arg.split("=", 1)[1])
|
||||||
|
elif arg == "--permission-profile" and i + 1 < len(argv):
|
||||||
|
profile = argv[i + 1]
|
||||||
|
flags.append("permission-profile=%s" % profile)
|
||||||
|
elif arg.startswith("--permission-profile="):
|
||||||
|
profile = arg.split("=", 1)[1]
|
||||||
|
flags.append("permission-profile=%s" % profile)
|
||||||
auto = ("yolo" in flags or "disable-approval" in flags
|
auto = ("yolo" in flags or "disable-approval" in flags
|
||||||
or "approval-mode=never" in flags)
|
or "approval-mode=never" in flags)
|
||||||
return {"auto_approve": auto, "flags": flags}
|
if "yolo" in flags:
|
||||||
|
mode = "yolo"
|
||||||
|
elif profile is not None:
|
||||||
|
mode = profile
|
||||||
|
else:
|
||||||
|
mode = "default"
|
||||||
|
return {"auto_approve": auto, "flags": flags, "profile": profile,
|
||||||
|
"mode": mode, "bypass": auto}
|
||||||
|
|
||||||
|
|
||||||
def launch_opt_out(cmd_argv):
|
def launch_opt_out(cmd_argv):
|
||||||
@@ -688,9 +725,11 @@ def pane_muse_argv(socket_path, pane_id):
|
|||||||
|
|
||||||
# Minimum pane geometry for reliable approval rendering. Empirically
|
# Minimum pane geometry for reliable approval rendering. Empirically
|
||||||
# derived: a 35x7 tile drops approval text the matcher needs, while
|
# derived: a 35x7 tile drops approval text the matcher needs, while
|
||||||
# 35x35/36x35/71x27 panes answer cleanly. Below either bound the pane
|
# 35x35/36x35/71x27 panes answer cleanly (width 35 works when tall
|
||||||
# is flagged squeezed (see `box runtime layout` / `spread`).
|
# enough; capture uses -J so wrapping is width-independent). Below
|
||||||
MIN_APPROVAL_WIDTH = 40
|
# either bound the pane is flagged squeezed (see `box runtime layout` /
|
||||||
|
# `spread`).
|
||||||
|
MIN_APPROVAL_WIDTH = 35
|
||||||
MIN_APPROVAL_HEIGHT = 12
|
MIN_APPROVAL_HEIGHT = 12
|
||||||
|
|
||||||
# Session naming convention (see NODES.md): <node>--<role>--<id>
|
# Session naming convention (see NODES.md): <node>--<role>--<id>
|
||||||
@@ -749,7 +788,8 @@ def runtime_rows(socket_path):
|
|||||||
or pane_height < MIN_APPROVAL_HEIGHT))
|
or pane_height < MIN_APPROVAL_HEIGHT))
|
||||||
node = node_from_session(session)
|
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": [], "profile": None,
|
||||||
|
"mode": None, "bypass": None}
|
||||||
if is_muse and pane_pid:
|
if is_muse and pane_pid:
|
||||||
candidates = [pane_pid] + _child_pids(pane_pid)
|
candidates = [pane_pid] + _child_pids(pane_pid)
|
||||||
for cand in candidates:
|
for cand in candidates:
|
||||||
@@ -763,7 +803,7 @@ def runtime_rows(socket_path):
|
|||||||
continue
|
continue
|
||||||
st = runtime_state(text)
|
st = runtime_state(text)
|
||||||
match = st["match"] or {}
|
match = st["match"] or {}
|
||||||
watcher_pid = is_running(socket_path, pane_id)
|
watcher_pid = watcher_alive(socket_path, pane_id)
|
||||||
rows.append({
|
rows.append({
|
||||||
"socket": socket_path, "session": session,
|
"socket": socket_path, "session": session,
|
||||||
"window": window, "pane": pane_id, "cmd": cmd,
|
"window": window, "pane": pane_id, "cmd": cmd,
|
||||||
@@ -773,6 +813,9 @@ def runtime_rows(socket_path):
|
|||||||
"squeezed": squeezed,
|
"squeezed": squeezed,
|
||||||
"auto_approve": posture["auto_approve"],
|
"auto_approve": posture["auto_approve"],
|
||||||
"approval_flags": posture["flags"],
|
"approval_flags": posture["flags"],
|
||||||
|
"permission_mode": posture["mode"],
|
||||||
|
"permission_profile": posture["profile"],
|
||||||
|
"permission_bypass": posture["bypass"],
|
||||||
"state": st["state"],
|
"state": st["state"],
|
||||||
"prompt_kind": match.get("kind"),
|
"prompt_kind": match.get("kind"),
|
||||||
"prompt_key": match.get("key"),
|
"prompt_key": match.get("key"),
|
||||||
@@ -813,14 +856,124 @@ def pane_state(socket_path, pane_id):
|
|||||||
|
|
||||||
|
|
||||||
class WatcherState:
|
class WatcherState:
|
||||||
"""Tracks prompt stability and once-per-prompt answering."""
|
"""Tracks prompt stability and once-per-prompt answering.
|
||||||
|
|
||||||
def __init__(self):
|
When persist_path is set, answered sigs survive restarts (atomic
|
||||||
|
JSON store): a daemon that restarts while an answered prompt is
|
||||||
|
still visible must not answer it again (observed live: same sig
|
||||||
|
re-answered minutes later, stray "1" landing in the input box).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, persist_path=None):
|
||||||
self.pending_sig = None
|
self.pending_sig = None
|
||||||
self.stable_count = 0
|
self.stable_count = 0
|
||||||
self.answered_sigs = {}
|
self.answered_sigs = {}
|
||||||
self.answer_times = []
|
self.answer_times = []
|
||||||
self.last_capped_sig = None
|
self.last_capped_sig = None
|
||||||
|
self._persist_path = persist_path
|
||||||
|
if persist_path:
|
||||||
|
self._load_answered()
|
||||||
|
|
||||||
|
def _load_answered(self):
|
||||||
|
try:
|
||||||
|
with open(self._persist_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
now = time.time()
|
||||||
|
for sig, ts in data.items():
|
||||||
|
if (isinstance(sig, str) and isinstance(ts, (int, float))
|
||||||
|
and now - ts < ANSWERED_TTL_SECONDS):
|
||||||
|
self.answered_sigs[sig] = ts
|
||||||
|
|
||||||
|
def _save_answered(self):
|
||||||
|
if not self._persist_path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tmp = "%s.tmp.%d" % (self._persist_path, os.getpid())
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(self.answered_sigs, f)
|
||||||
|
os.replace(tmp, self._persist_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def refresh_answered(self):
|
||||||
|
"""Merge on-disk answered sigs into memory. Never raises.
|
||||||
|
|
||||||
|
Cross-process once-per-prompt: a peer watcher that answered
|
||||||
|
after our startup persisted its sig; re-reading before we type
|
||||||
|
suppresses the duplicate ('11' in the input box).
|
||||||
|
"""
|
||||||
|
if not self._persist_path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(self._persist_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
now = time.time()
|
||||||
|
for sig, ts in data.items():
|
||||||
|
if (isinstance(sig, str) and isinstance(ts, (int, float))
|
||||||
|
and now - ts < ANSWERED_TTL_SECONDS):
|
||||||
|
if sig not in self.answered_sigs:
|
||||||
|
self.answered_sigs[sig] = ts
|
||||||
|
|
||||||
|
def try_claim(self, sig, now=None):
|
||||||
|
"""Atomically claim a sig for this process. True iff we won.
|
||||||
|
|
||||||
|
O_CREAT|O_EXCL makes the first claimer win even when two
|
||||||
|
watchers reach the same stable prompt in the same poll window;
|
||||||
|
the loser suppresses instead of double-typing. Claims expire
|
||||||
|
after CLAIM_TTL_SECONDS (concurrent window only; stuck-dialog
|
||||||
|
recovery is governed by the answered store's longer TTL), so a
|
||||||
|
winner that dies between claim and send wedges at most briefly.
|
||||||
|
Memory-only states (no persist path) always win. Never raises.
|
||||||
|
"""
|
||||||
|
if not self._persist_path:
|
||||||
|
return True
|
||||||
|
now = time.time() if now is None else now
|
||||||
|
base = os.path.basename(self._persist_path)
|
||||||
|
directory = os.path.dirname(self._persist_path) or STATE_DIR
|
||||||
|
# Prune expired claims for this pane (best-effort).
|
||||||
|
try:
|
||||||
|
for name in os.listdir(directory):
|
||||||
|
if not name.startswith(base + ".") or not name.endswith(".claim"):
|
||||||
|
continue
|
||||||
|
p = os.path.join(directory, name)
|
||||||
|
try:
|
||||||
|
with open(p) as f:
|
||||||
|
rec = json.load(f)
|
||||||
|
ts = float(rec.get("ts", 0))
|
||||||
|
except Exception:
|
||||||
|
ts = 0
|
||||||
|
try:
|
||||||
|
if now - ts >= CLAIM_TTL_SECONDS:
|
||||||
|
os.remove(p)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
path = "%s.%s.claim" % (self._persist_path, sig)
|
||||||
|
try:
|
||||||
|
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
|
except FileExistsError:
|
||||||
|
return False
|
||||||
|
except OSError:
|
||||||
|
return True # claim store unavailable: fail open, send once
|
||||||
|
try:
|
||||||
|
os.write(fd, json.dumps({"pid": os.getpid(),
|
||||||
|
"ts": now}).encode())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
os.close(fd)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
def _prune_times(self, now):
|
def _prune_times(self, now):
|
||||||
cutoff = now - 3600
|
cutoff = now - 3600
|
||||||
@@ -869,6 +1022,7 @@ class WatcherState:
|
|||||||
self.answer_times.append(now)
|
self.answer_times.append(now)
|
||||||
self.pending_sig = None
|
self.pending_sig = None
|
||||||
self.stable_count = 0
|
self.stable_count = 0
|
||||||
|
self._save_answered()
|
||||||
|
|
||||||
|
|
||||||
def _tmux(socket_path, *args, timeout=5):
|
def _tmux(socket_path, *args, timeout=5):
|
||||||
@@ -887,9 +1041,15 @@ def pane_exists(socket_path, pane_id):
|
|||||||
|
|
||||||
|
|
||||||
def capture_pane(socket_path, pane_id, history=80):
|
def capture_pane(socket_path, pane_id, history=80):
|
||||||
|
"""Capture pane text with wrapped rows joined (-J).
|
||||||
|
|
||||||
|
-J makes matching width-independent: narrow panes wrap the same
|
||||||
|
dialog onto more physical rows, which otherwise pushes cue/option
|
||||||
|
spans apart and breaks the matcher.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
r = _tmux(socket_path, "capture-pane", "-p", "-t", pane_id, "-S", "-%d" % history,
|
r = _tmux(socket_path, "capture-pane", "-p", "-J", "-t", pane_id,
|
||||||
timeout=5)
|
"-S", "-%d" % history, timeout=5)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
return None
|
return None
|
||||||
return r.stdout
|
return r.stdout
|
||||||
@@ -1065,10 +1225,53 @@ def list_holds():
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _peer_suppressed(state, sig, now, log):
|
||||||
|
"""True when a peer watcher already owns sig; suppress our send.
|
||||||
|
|
||||||
|
Checks the shared on-disk answered store first (peer answered
|
||||||
|
earlier and persisted), then attempts an atomic claim (peer racing
|
||||||
|
us in the same window). On suppression the pending prompt is
|
||||||
|
reset so later polls re-observe cleanly; answered-store hits are
|
||||||
|
already merged into memory, claim-loss is not recorded (peer's
|
||||||
|
send owns it, and its claim expires quickly if it dies). Never
|
||||||
|
raises; memory-only states never suppress.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
state.refresh_answered()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ts = state.answered_sigs.get(sig)
|
||||||
|
if ts is not None and now - ts < ANSWERED_TTL_SECONDS:
|
||||||
|
try:
|
||||||
|
log.log("info", "duplicate suppressed (peer answered)",
|
||||||
|
sig=sig)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
state.pending_sig = None
|
||||||
|
state.stable_count = 0
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if not state.try_claim(sig, now):
|
||||||
|
try:
|
||||||
|
log.log("info", "duplicate suppressed (peer claimed)",
|
||||||
|
sig=sig)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
state.pending_sig = None
|
||||||
|
state.stable_count = 0
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _check_hold(socket_path, pane_id, state, match, now, log):
|
def _check_hold(socket_path, pane_id, state, match, now, log):
|
||||||
"""Rule-hold gate. Returns None (fresh: evaluate rules), "released"
|
"""Rule-hold gate. Returns None (fresh: evaluate rules), "released"
|
||||||
(hold over: approve WITHOUT re-evaluating, else expiry would
|
(hold over: approve WITHOUT re-evaluating, else expiry would
|
||||||
re-hold forever), "held", or "denied".
|
re-hold forever), "held", "denied", or "duplicate-suppressed".
|
||||||
|
|
||||||
The holdfile is the single source of truth (crash-safe,
|
The holdfile is the single source of truth (crash-safe,
|
||||||
box-visible): present + fresh => suppress; directive deny => deny
|
box-visible): present + fresh => suppress; directive deny => deny
|
||||||
@@ -1093,6 +1296,8 @@ def _check_hold(socket_path, pane_id, state, match, now, log):
|
|||||||
log.log("warn", "resolve-deny refused: D2 never denies questions",
|
log.log("warn", "resolve-deny refused: D2 never denies questions",
|
||||||
sig=sig, kind=match["kind"])
|
sig=sig, kind=match["kind"])
|
||||||
return "held"
|
return "held"
|
||||||
|
if _peer_suppressed(state, sig, now, log):
|
||||||
|
return "duplicate-suppressed"
|
||||||
ok = send_answer(socket_path, pane_id, neg, enter=True)
|
ok = send_answer(socket_path, pane_id, neg, enter=True)
|
||||||
clear_hold(socket_path, pane_id)
|
clear_hold(socket_path, pane_id)
|
||||||
state.record_answer(sig, now)
|
state.record_answer(sig, now)
|
||||||
@@ -1132,8 +1337,8 @@ def _poll_once(socket_path, pane_id, state, log, dry_run=False):
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
gate = _check_hold(socket_path, pane_id, state, match, now, log) \
|
gate = _check_hold(socket_path, pane_id, state, match, now, log) \
|
||||||
if match is not None else None
|
if match is not None else None
|
||||||
if gate in ("held", "denied"):
|
if gate in ("held", "denied", "duplicate-suppressed"):
|
||||||
if gate == "denied":
|
if gate in ("denied", "duplicate-suppressed"):
|
||||||
state.pending_sig = None
|
state.pending_sig = None
|
||||||
state.stable_count = 0
|
state.stable_count = 0
|
||||||
return gate
|
return gate
|
||||||
@@ -1175,6 +1380,8 @@ def _poll_once(socket_path, pane_id, state, log, dry_run=False):
|
|||||||
rule=rule["id"] if rule else None)
|
rule=rule["id"] if rule else None)
|
||||||
state.record_answer(match["sig"], now)
|
state.record_answer(match["sig"], now)
|
||||||
return "dry-denied"
|
return "dry-denied"
|
||||||
|
if _peer_suppressed(state, match["sig"], now, log):
|
||||||
|
return "duplicate-suppressed"
|
||||||
ok = send_answer(socket_path, pane_id, neg, enter=True)
|
ok = send_answer(socket_path, pane_id, neg, enter=True)
|
||||||
state.record_answer(match["sig"], now)
|
state.record_answer(match["sig"], now)
|
||||||
log.log("info" if ok else "error", "denied %s" % neg,
|
log.log("info" if ok else "error", "denied %s" % neg,
|
||||||
@@ -1216,6 +1423,8 @@ def _poll_once(socket_path, pane_id, state, log, dry_run=False):
|
|||||||
options=match["options"], cue=match["cue"])
|
options=match["options"], cue=match["cue"])
|
||||||
state.record_answer(match["sig"], now)
|
state.record_answer(match["sig"], now)
|
||||||
return "dry-answered"
|
return "dry-answered"
|
||||||
|
if _peer_suppressed(state, match["sig"], now, log):
|
||||||
|
return "duplicate-suppressed"
|
||||||
ok = send_answer(socket_path, pane_id, key,
|
ok = send_answer(socket_path, pane_id, key,
|
||||||
enter=match.get("enter", True))
|
enter=match.get("enter", True))
|
||||||
state.record_answer(match["sig"], now)
|
state.record_answer(match["sig"], now)
|
||||||
@@ -1242,12 +1451,45 @@ def _poll_once(socket_path, pane_id, state, log, dry_run=False):
|
|||||||
return "waiting"
|
return "waiting"
|
||||||
|
|
||||||
|
|
||||||
|
def _log_posture(socket_path, pane_id, log):
|
||||||
|
"""Log the pane's permission posture once at watcher start.
|
||||||
|
|
||||||
|
The watcher answers with per-choice logging in every mode (that
|
||||||
|
trail is the default path and informs policy); a bypass posture
|
||||||
|
(yolo / approval disabled) additionally gets a box audit record,
|
||||||
|
since the session then makes choices outside the trail. Never
|
||||||
|
raises.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
posture = muse_approval_flags(pane_muse_argv(socket_path, pane_id))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
log.log("info", "pane posture", mode=posture["mode"],
|
||||||
|
bypass=posture["bypass"], flags=posture["flags"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if posture["bypass"] or posture["mode"] not in ("default", None):
|
||||||
|
audit("muse-choice-posture",
|
||||||
|
name="%s:%s" % (os.path.basename(socket_path), pane_id),
|
||||||
|
extra={"socket": socket_path, "pane": pane_id,
|
||||||
|
"mode": posture["mode"],
|
||||||
|
"profile": posture["profile"],
|
||||||
|
"bypass": posture["bypass"],
|
||||||
|
"flags": posture["flags"]})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def watch_loop(socket_path, pane_id, dry_run=False):
|
def watch_loop(socket_path, pane_id, dry_run=False):
|
||||||
"""Main daemon loop. Returns only when the pane is gone or signalled."""
|
"""Main daemon loop. Returns only when the pane is gone or signalled."""
|
||||||
log = WatcherLog(logfile_for(socket_path, pane_id))
|
log = WatcherLog(logfile_for(socket_path, pane_id))
|
||||||
state = WatcherState()
|
state = WatcherState(
|
||||||
|
persist_path=answered_file_for(socket_path, pane_id))
|
||||||
log.log("info", "watcher started", socket=socket_path, pane=pane_id,
|
log.log("info", "watcher started", socket=socket_path, pane=pane_id,
|
||||||
dry_run=dry_run, pid=os.getpid())
|
dry_run=dry_run, pid=os.getpid())
|
||||||
|
_log_posture(socket_path, pane_id, log)
|
||||||
polls = 0
|
polls = 0
|
||||||
answers = 0
|
answers = 0
|
||||||
last_heartbeat = time.time()
|
last_heartbeat = time.time()
|
||||||
@@ -1346,6 +1588,30 @@ def is_running(socket_path, pane_id):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def watcher_alive(socket_path, pane_id):
|
||||||
|
"""Pid of the live watcher for a pane, pidfile or orphan.
|
||||||
|
|
||||||
|
is_running covers the normal pidfile case; _watch_procs catches an
|
||||||
|
identical watcher alive with a lost pidfile (/tmp cleaned under
|
||||||
|
it). Starters must consult this (not is_running alone) or they
|
||||||
|
spawn a second daemon onto the same pane (double answers). Never
|
||||||
|
raises; None means no live watcher.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pid = is_running(socket_path, pane_id)
|
||||||
|
if pid:
|
||||||
|
return pid
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
for proc in _watch_procs():
|
||||||
|
if proc["socket"] == socket_path and proc["pane"] == pane_id:
|
||||||
|
return proc["pid"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _daemonize():
|
def _daemonize():
|
||||||
"""Double-fork away from the controlling terminal (survives shell exit)."""
|
"""Double-fork away from the controlling terminal (survives shell exit)."""
|
||||||
if os.fork() != 0:
|
if os.fork() != 0:
|
||||||
@@ -1361,27 +1627,85 @@ def _daemonize():
|
|||||||
os.close(devnull)
|
os.close(devnull)
|
||||||
|
|
||||||
|
|
||||||
|
_PIDFILE_LOCK_FH = None
|
||||||
|
|
||||||
|
|
||||||
def _claim_pidfile(pidfile):
|
def _claim_pidfile(pidfile):
|
||||||
"""Claim a pidfile for this process. Returns False if another live
|
"""Claim a pidfile for this process. Returns False if another
|
||||||
watcher owns it (concurrent box + timer starts must not pile up)."""
|
starter holds it. Kernel-enforced: the winner holds an exclusive
|
||||||
|
flock for its whole lifetime, so concurrent box + timer starts can
|
||||||
|
never pile two daemons onto one pane (double answers, '11' in the
|
||||||
|
input box). Call _release_pidfile() on exit."""
|
||||||
|
global _PIDFILE_LOCK_FH
|
||||||
me = os.getpid()
|
me = os.getpid()
|
||||||
try:
|
try:
|
||||||
with open(pidfile) as f:
|
fh = open(pidfile, "a+")
|
||||||
other = int(f.read().strip())
|
except OSError:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except (OSError, IOError):
|
||||||
|
fh.close()
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
fh.seek(0)
|
||||||
|
content = fh.read().strip()
|
||||||
|
other = int(content) if content else None
|
||||||
except Exception:
|
except Exception:
|
||||||
other = None
|
other = None
|
||||||
if other and other != me and _pid_alive(other) and _pid_is_watcher(other):
|
if other and other != me and _pid_alive(other) and _pid_is_watcher(other):
|
||||||
|
# Live foreign owner (e.g. a daemon from before locking existed,
|
||||||
|
# or a pid recycled into a watcher): yield to it.
|
||||||
|
try:
|
||||||
|
fcntl.flock(fh, fcntl.LOCK_UN)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
fh.close()
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
with open(pidfile, "w") as f:
|
fh.seek(0)
|
||||||
f.write(str(me))
|
fh.truncate()
|
||||||
|
fh.write(str(me))
|
||||||
|
fh.flush()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if _PIDFILE_LOCK_FH is not None:
|
||||||
|
try:
|
||||||
|
_PIDFILE_LOCK_FH.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_PIDFILE_LOCK_FH = fh
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _release_pidfile(pidfile):
|
||||||
|
"""Drop the claim taken by _claim_pidfile: remove the pidfile only
|
||||||
|
if we still own it, then release the lock. Never raises."""
|
||||||
|
global _PIDFILE_LOCK_FH
|
||||||
|
try:
|
||||||
|
with open(pidfile) as f:
|
||||||
|
owner = f.read().strip()
|
||||||
|
except Exception:
|
||||||
|
owner = ""
|
||||||
|
if owner == str(os.getpid()):
|
||||||
|
try:
|
||||||
|
os.remove(pidfile)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if _PIDFILE_LOCK_FH is not None:
|
||||||
|
try:
|
||||||
|
fcntl.flock(_PIDFILE_LOCK_FH, fcntl.LOCK_UN)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
_PIDFILE_LOCK_FH.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_PIDFILE_LOCK_FH = None
|
||||||
|
|
||||||
|
|
||||||
def start_watcher(socket_path, pane_id, dry_run=False):
|
def start_watcher(socket_path, pane_id, dry_run=False):
|
||||||
pid = is_running(socket_path, pane_id)
|
pid = watcher_alive(socket_path, pane_id)
|
||||||
if pid:
|
if pid:
|
||||||
return {"ok": False, "status": "already_running", "pid": pid}
|
return {"ok": False, "status": "already_running", "pid": pid}
|
||||||
if not pane_exists(socket_path, pane_id):
|
if not pane_exists(socket_path, pane_id):
|
||||||
@@ -1400,7 +1724,7 @@ def start_watcher(socket_path, pane_id, dry_run=False):
|
|||||||
|
|
||||||
|
|
||||||
def stop_watcher(socket_path, pane_id, timeout=5):
|
def stop_watcher(socket_path, pane_id, timeout=5):
|
||||||
pid = is_running(socket_path, pane_id)
|
pid = watcher_alive(socket_path, pane_id)
|
||||||
if not pid:
|
if not pid:
|
||||||
return {"ok": True, "status": "not_running"}
|
return {"ok": True, "status": "not_running"}
|
||||||
try:
|
try:
|
||||||
@@ -1448,7 +1772,7 @@ def _start_detached(socket_path, pane_id, dry_run=False):
|
|||||||
os._exit(0)
|
os._exit(0)
|
||||||
os.waitpid(pid, 0)
|
os.waitpid(pid, 0)
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
return is_running(socket_path, pane_id) is not None
|
return watcher_alive(socket_path, pane_id) is not None
|
||||||
|
|
||||||
|
|
||||||
def start_all(dry_run=False, sockets=None):
|
def start_all(dry_run=False, sockets=None):
|
||||||
@@ -1461,7 +1785,7 @@ def start_all(dry_run=False, sockets=None):
|
|||||||
if not panes:
|
if not panes:
|
||||||
results.append({"socket": sock, "status": "no_muse_panes"})
|
results.append({"socket": sock, "status": "no_muse_panes"})
|
||||||
for pane in panes:
|
for pane in panes:
|
||||||
if is_running(sock, pane):
|
if watcher_alive(sock, pane):
|
||||||
results.append({"socket": sock, "pane": pane,
|
results.append({"socket": sock, "pane": pane,
|
||||||
"status": "already_running"})
|
"status": "already_running"})
|
||||||
continue
|
continue
|
||||||
@@ -1524,7 +1848,7 @@ def reconcile(sockets=None):
|
|||||||
if not os.path.exists(sock):
|
if not os.path.exists(sock):
|
||||||
continue
|
continue
|
||||||
for pane in muse_panes(sock):
|
for pane in muse_panes(sock):
|
||||||
if is_running(sock, pane):
|
if watcher_alive(sock, pane):
|
||||||
already.append("%s:%s" % (sock, pane))
|
already.append("%s:%s" % (sock, pane))
|
||||||
continue
|
continue
|
||||||
if _start_detached(sock, pane, dry_run=desired["dry_run"]):
|
if _start_detached(sock, pane, dry_run=desired["dry_run"]):
|
||||||
@@ -1660,7 +1984,7 @@ def main(argv=None):
|
|||||||
|
|
||||||
args = ap.parse_args(argv)
|
args = ap.parse_args(argv)
|
||||||
if args.cmd == "start":
|
if args.cmd == "start":
|
||||||
existing = is_running(args.socket, args.pane)
|
existing = watcher_alive(args.socket, args.pane)
|
||||||
if existing:
|
if existing:
|
||||||
print(json.dumps({"ok": True, "status": "already_running",
|
print(json.dumps({"ok": True, "status": "already_running",
|
||||||
"pid": existing,
|
"pid": existing,
|
||||||
@@ -1674,7 +1998,7 @@ def main(argv=None):
|
|||||||
os._exit(0)
|
os._exit(0)
|
||||||
_, status = os.waitpid(pid, 0)
|
_, status = os.waitpid(pid, 0)
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
running = is_running(args.socket, args.pane)
|
running = watcher_alive(args.socket, args.pane)
|
||||||
print(json.dumps({"ok": running is not None, "pid": running,
|
print(json.dumps({"ok": running is not None, "pid": running,
|
||||||
"log": logfile_for(args.socket, args.pane)}))
|
"log": logfile_for(args.socket, args.pane)}))
|
||||||
return 0 if running else 1
|
return 0 if running else 1
|
||||||
@@ -1716,16 +2040,7 @@ def main(argv=None):
|
|||||||
try:
|
try:
|
||||||
return watch_loop(args.socket, args.pane, dry_run=args.dry_run)
|
return watch_loop(args.socket, args.pane, dry_run=args.dry_run)
|
||||||
finally:
|
finally:
|
||||||
try:
|
_release_pidfile(pidfile)
|
||||||
with open(pidfile) as f:
|
|
||||||
owner = f.read().strip()
|
|
||||||
except Exception:
|
|
||||||
owner = ""
|
|
||||||
if owner == str(os.getpid()):
|
|
||||||
try:
|
|
||||||
os.remove(pidfile)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
if args.cmd == "match":
|
if args.cmd == "match":
|
||||||
text = sys.stdin.read()
|
text = sys.stdin.read()
|
||||||
print(json.dumps(find_choice_prompt(text, tail_window=args.tail_window), indent=1))
|
print(json.dumps(find_choice_prompt(text, tail_window=args.tail_window), indent=1))
|
||||||
|
|||||||
Executable
+545
@@ -0,0 +1,545 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""muse_resume_pool: per-repo, profile-aware Muse Code resume pool.
|
||||||
|
|
||||||
|
Why this exists
|
||||||
|
---------------
|
||||||
|
``muse resume`` scopes its picker by workspace but is blind to muse-auth
|
||||||
|
profiles, and the TUI ``/resume`` reads session logs itself and lumps every
|
||||||
|
workspace into one heap. A session resumed under a different credential
|
||||||
|
than the one that created it fails server-side: the continuation is
|
||||||
|
cryptographically bound to the creating account, so the server rejects the
|
||||||
|
resume. This tool lists only the sessions that can actually resume here
|
||||||
|
and now, and guards ``resume`` calls before they fail.
|
||||||
|
|
||||||
|
Data sources (read-only, no secrets)
|
||||||
|
------------------------------------
|
||||||
|
- ``~/.local/share/muse/session-index.db`` ``sessions`` table (``mode=ro``).
|
||||||
|
Fallback when the index is missing: scan ``sessions/*/*/*/*/session.jsonl``
|
||||||
|
for ``runtime.session.metadata`` (workspace_root) and
|
||||||
|
``session.name.changed`` (session_name) records, mirroring ``/resume``.
|
||||||
|
- ``~/.config/muse/active_profile``, ``session_profiles.json``,
|
||||||
|
``switch_history.jsonl``: profile *names* only. ``auth.json`` token bytes
|
||||||
|
are never read, logged, or compared.
|
||||||
|
|
||||||
|
Profile resolution mirrors ``muse-auth``: cached ``session_profiles.json``
|
||||||
|
mapping wins; otherwise the latest switch at or before session start; else
|
||||||
|
the earliest switch; else the active profile as fallback. When the auth
|
||||||
|
dir is unreadable (e.g. inside the Muse sandbox, which masks it), the
|
||||||
|
profile is ``unknown`` and only proven mismatches are hidden/blocked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
INDEX_COLUMNS = (
|
||||||
|
"session_id",
|
||||||
|
"session_name",
|
||||||
|
"workspace_root",
|
||||||
|
"workspace_key",
|
||||||
|
"provider_id",
|
||||||
|
"model_id",
|
||||||
|
"git_branch",
|
||||||
|
"title",
|
||||||
|
"first_user_prompt",
|
||||||
|
"created_at_us",
|
||||||
|
"updated_at_us",
|
||||||
|
"prompt_count",
|
||||||
|
"status",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_paths():
|
||||||
|
home = os.path.expanduser("~")
|
||||||
|
data_home = os.environ.get("XDG_DATA_HOME", os.path.join(home, ".local", "share"))
|
||||||
|
return {
|
||||||
|
"index_db": os.path.join(data_home, "muse", "session-index.db"),
|
||||||
|
"sessions_dir": os.path.join(data_home, "muse", "sessions"),
|
||||||
|
"config_dir": os.path.expanduser("~/.config/muse"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_workspace(cwd=None):
|
||||||
|
"""Repo root for the pool: git top-level, else real cwd."""
|
||||||
|
cwd = cwd or os.getcwd()
|
||||||
|
try:
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "-C", cwd, "rev-parse", "--show-toplevel"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if out.returncode == 0 and out.stdout.strip():
|
||||||
|
return os.path.realpath(out.stdout.strip())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return os.path.realpath(cwd)
|
||||||
|
|
||||||
|
|
||||||
|
def load_index_rows(index_db):
|
||||||
|
"""Read session rows from the index (read-only). None if unavailable."""
|
||||||
|
if not os.path.exists(index_db):
|
||||||
|
return None
|
||||||
|
cols = ", ".join(INDEX_COLUMNS)
|
||||||
|
try:
|
||||||
|
uri = "file:{}?mode=ro".format(index_db.replace("?", "%3F"))
|
||||||
|
conn = sqlite3.connect(uri, uri=True, timeout=5)
|
||||||
|
try:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.execute(
|
||||||
|
"SELECT {} FROM sessions ORDER BY "
|
||||||
|
"updated_at_us DESC, created_at_us DESC, session_id ASC".format(cols)
|
||||||
|
)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except sqlite3.Error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_log_for_session(session_log):
|
||||||
|
"""Extract workspace/name/title from one session.jsonl (bounded read)."""
|
||||||
|
workspace = None
|
||||||
|
name = None
|
||||||
|
title = None
|
||||||
|
first_prompt = None
|
||||||
|
created_at_us = None
|
||||||
|
updated_at_us = None
|
||||||
|
prompt_count = 0
|
||||||
|
try:
|
||||||
|
with open(session_log, "r", errors="replace") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rec = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if "children" in rec: # retained permission frame wrapper
|
||||||
|
continue
|
||||||
|
rec_at = rec.get("recorded_at")
|
||||||
|
if isinstance(rec_at, int):
|
||||||
|
if created_at_us is None:
|
||||||
|
created_at_us = rec_at
|
||||||
|
updated_at_us = rec_at
|
||||||
|
ptype = rec.get("payload_type", "")
|
||||||
|
payload = rec.get("payload", {}) if isinstance(rec.get("payload"), dict) else {}
|
||||||
|
if ptype == "runtime.session.metadata":
|
||||||
|
record = payload.get("record", {})
|
||||||
|
workspace = workspace or record.get("workspace_root")
|
||||||
|
elif ptype == "session.name.changed":
|
||||||
|
if payload.get("new_name"):
|
||||||
|
name = payload["new_name"]
|
||||||
|
elif ptype == "runtime.session":
|
||||||
|
event = payload.get("event", {})
|
||||||
|
if event.get("kind") == "started" and not first_prompt:
|
||||||
|
prompt = event.get("prompt") or ""
|
||||||
|
first_prompt = prompt[:200]
|
||||||
|
title = title or prompt[:80]
|
||||||
|
prompt_count += 1
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if workspace is None and name is None and created_at_us is None:
|
||||||
|
return None
|
||||||
|
session_id = os.path.basename(os.path.dirname(session_log))
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"session_name": name,
|
||||||
|
"workspace_root": workspace,
|
||||||
|
"workspace_key": workspace,
|
||||||
|
"provider_id": None,
|
||||||
|
"model_id": None,
|
||||||
|
"git_branch": None,
|
||||||
|
"title": title or "New session",
|
||||||
|
"first_user_prompt": first_prompt,
|
||||||
|
"created_at_us": created_at_us,
|
||||||
|
"updated_at_us": updated_at_us or created_at_us,
|
||||||
|
"prompt_count": prompt_count,
|
||||||
|
"status": "valid",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_session_logs(sessions_dir):
|
||||||
|
"""Fallback pool source: scan top-level session.jsonl files directly."""
|
||||||
|
pattern = os.path.join(sessions_dir, "*", "*", "*", "*", "session.jsonl")
|
||||||
|
rows = []
|
||||||
|
for path in glob.glob(pattern):
|
||||||
|
row = _scan_log_for_session(path)
|
||||||
|
if row:
|
||||||
|
rows.append(row)
|
||||||
|
rows.sort(
|
||||||
|
key=lambda r: (
|
||||||
|
r.get("updated_at_us") or 0,
|
||||||
|
r.get("created_at_us") or 0,
|
||||||
|
r.get("session_id") or "",
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def load_auth_state(config_dir):
|
||||||
|
"""Load profile names only. Never touches auth.json token bytes."""
|
||||||
|
state = {
|
||||||
|
"readable": False,
|
||||||
|
"active": None,
|
||||||
|
"session_profiles": {},
|
||||||
|
"switch_history": [],
|
||||||
|
}
|
||||||
|
if not os.path.isdir(config_dir):
|
||||||
|
return state
|
||||||
|
if not (os.access(config_dir, os.R_OK) and os.access(config_dir, os.X_OK)):
|
||||||
|
return state
|
||||||
|
state["readable"] = True
|
||||||
|
try:
|
||||||
|
with open(os.path.join(config_dir, "active_profile"), "r") as fh:
|
||||||
|
state["active"] = fh.read().strip() or None
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
with open(os.path.join(config_dir, "session_profiles.json"), "r") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
state["session_profiles"] = {
|
||||||
|
str(k): str(v) for k, v in data.items()
|
||||||
|
}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
history = []
|
||||||
|
with open(os.path.join(config_dir, "switch_history.jsonl"), "r") as fh:
|
||||||
|
for line in fh:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entry = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if entry.get("profile"):
|
||||||
|
history.append(entry)
|
||||||
|
history.sort(key=lambda e: e.get("epoch", 0))
|
||||||
|
state["switch_history"] = history
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_profile(session_id, created_at_us, auth_state):
|
||||||
|
"""Return (profile_or_None, source). Mirrors muse-auth resolution order."""
|
||||||
|
if not auth_state.get("readable"):
|
||||||
|
return None, "unknown"
|
||||||
|
cached = auth_state.get("session_profiles", {})
|
||||||
|
if session_id in cached:
|
||||||
|
return cached[session_id], "cached"
|
||||||
|
history = auth_state.get("switch_history", [])
|
||||||
|
start_epoch = (created_at_us / 1e6) if created_at_us else None
|
||||||
|
if history and start_epoch:
|
||||||
|
for switch in reversed(history):
|
||||||
|
if switch.get("epoch", 0) <= start_epoch:
|
||||||
|
return switch.get("profile"), "history"
|
||||||
|
return history[0].get("profile"), "history"
|
||||||
|
if auth_state.get("active"):
|
||||||
|
return auth_state["active"], "fallback"
|
||||||
|
return None, "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_rows(rows, auth_state):
|
||||||
|
"""Attach profile + source to each row (mutates and returns rows)."""
|
||||||
|
for row in rows:
|
||||||
|
profile, source = resolve_profile(
|
||||||
|
row.get("session_id"), row.get("created_at_us"), auth_state
|
||||||
|
)
|
||||||
|
row["auth_profile"] = profile
|
||||||
|
row["auth_source"] = source
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def pool_for_workspace(rows, workspace):
|
||||||
|
"""Exact workspace_key match (workspace_root fallback), index order kept."""
|
||||||
|
pool = []
|
||||||
|
for row in rows:
|
||||||
|
key = row.get("workspace_key") or row.get("workspace_root")
|
||||||
|
if key and os.path.realpath(key) == workspace:
|
||||||
|
pool.append(row)
|
||||||
|
return pool
|
||||||
|
|
||||||
|
|
||||||
|
def split_resumable(pool, active_profile):
|
||||||
|
"""(resumable, blocked): only proven profile mismatches are blocked.
|
||||||
|
|
||||||
|
Unknown profiles (sandboxed auth dir, no mapping/history) stay resumable
|
||||||
|
but flagged, since blocking them would hide possibly valid sessions.
|
||||||
|
"""
|
||||||
|
resumable, blocked = [], []
|
||||||
|
for row in pool:
|
||||||
|
profile = row.get("auth_profile")
|
||||||
|
if active_profile and profile and profile != active_profile:
|
||||||
|
blocked.append(row)
|
||||||
|
else:
|
||||||
|
resumable.append(row)
|
||||||
|
return resumable, blocked
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_ref(rows, ref):
|
||||||
|
"""Resolve UUID / UUID prefix / session name. Returns (matches, kind)."""
|
||||||
|
ref = (ref or "").strip()
|
||||||
|
if not ref:
|
||||||
|
return [], "empty"
|
||||||
|
exact = [r for r in rows if r.get("session_id") == ref]
|
||||||
|
if exact:
|
||||||
|
return exact, "uuid"
|
||||||
|
named = [r for r in rows if (r.get("session_name") or "") == ref]
|
||||||
|
if named:
|
||||||
|
return named, "name"
|
||||||
|
if len(ref) >= 8:
|
||||||
|
prefixed = [
|
||||||
|
r for r in rows if (r.get("session_id") or "").startswith(ref)
|
||||||
|
]
|
||||||
|
if prefixed:
|
||||||
|
return prefixed, "prefix"
|
||||||
|
return [], "none"
|
||||||
|
|
||||||
|
|
||||||
|
def check_resume(rows, ref, workspace, auth_state):
|
||||||
|
"""Guard decision for resuming ``ref`` from ``workspace``.
|
||||||
|
|
||||||
|
Returns dict(ok=bool, reason=str, detail=str, fix=str, row=row|None).
|
||||||
|
"""
|
||||||
|
matches, kind = resolve_ref(rows, ref)
|
||||||
|
if kind == "empty" or not matches:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reason": "unknown-session",
|
||||||
|
"detail": "No session matches '{}'.".format(ref),
|
||||||
|
"fix": "List this repo's pool: muse_resume_pool.py pool",
|
||||||
|
"row": None,
|
||||||
|
}
|
||||||
|
if len(matches) > 1:
|
||||||
|
ids = ", ".join(m["session_id"][:12] for m in matches[:5])
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reason": "ambiguous",
|
||||||
|
"detail": "'{}' matches {} sessions: {}".format(ref, len(matches), ids),
|
||||||
|
"fix": "Use a longer UUID prefix or the full session id.",
|
||||||
|
"row": None,
|
||||||
|
}
|
||||||
|
row = matches[0]
|
||||||
|
key = row.get("workspace_key") or row.get("workspace_root")
|
||||||
|
if not key or os.path.realpath(key) != workspace:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reason": "wrong-workspace",
|
||||||
|
"detail": "Session '{}' belongs to workspace '{}', not '{}'.".format(
|
||||||
|
row.get("session_name") or row["session_id"][:12], key, workspace
|
||||||
|
),
|
||||||
|
"fix": "cd '{}' first, or pick a session from this repo's pool.".format(key or "?"),
|
||||||
|
"row": row,
|
||||||
|
}
|
||||||
|
if row.get("status") and row["status"] != "valid":
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reason": "bad-status",
|
||||||
|
"detail": "Session '{}' has status '{}'.".format(
|
||||||
|
row.get("session_name") or row["session_id"][:12], row["status"]
|
||||||
|
),
|
||||||
|
"fix": "Pick a session with status 'valid' from this repo's pool.",
|
||||||
|
"row": row,
|
||||||
|
}
|
||||||
|
active = auth_state.get("active")
|
||||||
|
profile = row.get("auth_profile")
|
||||||
|
if active and profile and profile != active:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reason": "wrong-profile",
|
||||||
|
"detail": "Session '{}' was created under muse-auth profile '{}' "
|
||||||
|
"but the active profile is '{}'; the server would reject the "
|
||||||
|
"resume (continuation is bound to the creating credential).".format(
|
||||||
|
row.get("session_name") or row["session_id"][:12], profile, active
|
||||||
|
),
|
||||||
|
"fix": "Run `muse-auth use {}` (outside the sandbox), then resume.".format(profile),
|
||||||
|
"row": row,
|
||||||
|
}
|
||||||
|
detail = "Session '{}' is resumable here.".format(
|
||||||
|
row.get("session_name") or row["session_id"][:12]
|
||||||
|
)
|
||||||
|
if not auth_state.get("readable"):
|
||||||
|
detail += " (Profile unverified: auth dir unreadable from this shell.)"
|
||||||
|
elif not profile:
|
||||||
|
detail += " (Profile unknown: no mapping or switch history.)"
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"reason": "ok",
|
||||||
|
"detail": detail,
|
||||||
|
"fix": "",
|
||||||
|
"row": row,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_rows(paths):
|
||||||
|
"""Index first, log-scan fallback. Returns (rows, source)."""
|
||||||
|
rows = load_index_rows(paths["index_db"])
|
||||||
|
if rows is not None:
|
||||||
|
return rows, "index"
|
||||||
|
return scan_session_logs(paths["sessions_dir"]), "log-scan"
|
||||||
|
|
||||||
|
|
||||||
|
def format_pool_table(resumable, blocked, show_all):
|
||||||
|
lines = []
|
||||||
|
header = "{:<18} {:<12} {:<10} {:<5} {}".format(
|
||||||
|
"NAME", "SESSION", "PROFILE", "MSGS", "TITLE"
|
||||||
|
)
|
||||||
|
lines.append(header)
|
||||||
|
for row in resumable:
|
||||||
|
flag = "?" if not row.get("auth_profile") else " "
|
||||||
|
lines.append(
|
||||||
|
"{:<18} {:<12} {:<10} {:<5} {}{}".format(
|
||||||
|
(row.get("session_name") or "-")[:18],
|
||||||
|
(row.get("session_id") or "")[:12],
|
||||||
|
(row.get("auth_profile") or "?")[:10],
|
||||||
|
row.get("prompt_count", 0),
|
||||||
|
flag,
|
||||||
|
(row.get("title") or "")[:60],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if show_all:
|
||||||
|
for row in blocked:
|
||||||
|
lines.append(
|
||||||
|
"{:<18} {:<12} {:<10} {:<5} {} [BLOCKED: profile mismatch]".format(
|
||||||
|
(row.get("session_name") or "-")[:18],
|
||||||
|
(row.get("session_id") or "")[:12],
|
||||||
|
(row.get("auth_profile") or "?")[:10],
|
||||||
|
row.get("prompt_count", 0),
|
||||||
|
(row.get("title") or "")[:60],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif blocked:
|
||||||
|
lines.append(
|
||||||
|
"({} session(s) hidden: wrong muse-auth profile; use --all to show)".format(
|
||||||
|
len(blocked)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_pool(args, paths):
|
||||||
|
workspace = os.path.realpath(args.workspace or canonical_workspace())
|
||||||
|
rows, source = load_rows(paths)
|
||||||
|
auth_state = load_auth_state(paths["config_dir"])
|
||||||
|
annotate_rows(rows, auth_state)
|
||||||
|
pool = pool_for_workspace(rows, workspace)
|
||||||
|
resumable, blocked = split_resumable(pool, auth_state.get("active"))
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({
|
||||||
|
"workspace": workspace,
|
||||||
|
"source": source,
|
||||||
|
"active_profile": auth_state.get("active"),
|
||||||
|
"auth_readable": auth_state.get("readable"),
|
||||||
|
"resumable": resumable,
|
||||||
|
"blocked": blocked if args.all else [],
|
||||||
|
"blocked_count": len(blocked),
|
||||||
|
}, indent=2, default=str))
|
||||||
|
return 0
|
||||||
|
print("workspace: {} (source: {})".format(workspace, source))
|
||||||
|
if auth_state.get("readable"):
|
||||||
|
print("active profile: {}".format(auth_state.get("active") or "(none)"))
|
||||||
|
else:
|
||||||
|
print("active profile: ? (auth dir unreadable from this shell)")
|
||||||
|
if not pool:
|
||||||
|
print("No sessions for this workspace.")
|
||||||
|
return 0
|
||||||
|
print(format_pool_table(resumable, blocked, args.all))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_check(args, paths):
|
||||||
|
workspace = os.path.realpath(args.workspace or canonical_workspace())
|
||||||
|
rows, _ = load_rows(paths)
|
||||||
|
auth_state = load_auth_state(paths["config_dir"])
|
||||||
|
annotate_rows(rows, auth_state)
|
||||||
|
decision = check_resume(rows, args.ref, workspace, auth_state)
|
||||||
|
if args.json:
|
||||||
|
row = dict(decision["row"]) if decision["row"] else None
|
||||||
|
print(json.dumps({
|
||||||
|
"ok": decision["ok"],
|
||||||
|
"reason": decision["reason"],
|
||||||
|
"detail": decision["detail"],
|
||||||
|
"fix": decision["fix"],
|
||||||
|
"row": row,
|
||||||
|
}, indent=2, default=str))
|
||||||
|
else:
|
||||||
|
status = "OK" if decision["ok"] else "BLOCKED ({})".format(decision["reason"])
|
||||||
|
print("{}: {}".format(status, decision["detail"]))
|
||||||
|
if decision["fix"]:
|
||||||
|
print("fix: {}".format(decision["fix"]))
|
||||||
|
return 0 if decision["ok"] else 1
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_resume(args, paths):
|
||||||
|
workspace = os.path.realpath(args.workspace or canonical_workspace())
|
||||||
|
rows, _ = load_rows(paths)
|
||||||
|
auth_state = load_auth_state(paths["config_dir"])
|
||||||
|
annotate_rows(rows, auth_state)
|
||||||
|
if args.ref == "--last":
|
||||||
|
pool = pool_for_workspace(rows, workspace)
|
||||||
|
resumable, _ = split_resumable(pool, auth_state.get("active"))
|
||||||
|
if not resumable:
|
||||||
|
print("No resumable sessions for this workspace.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
session_id = resumable[0]["session_id"]
|
||||||
|
else:
|
||||||
|
decision = check_resume(rows, args.ref, workspace, auth_state)
|
||||||
|
if not decision["ok"]:
|
||||||
|
print("refusing to resume: {}".format(decision["detail"]), file=sys.stderr)
|
||||||
|
if decision["fix"]:
|
||||||
|
print("fix: {}".format(decision["fix"]), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
session_id = decision["row"]["session_id"]
|
||||||
|
cmd = ["muse-code", "resume", session_id]
|
||||||
|
if args.dry_run:
|
||||||
|
print("would exec: {}".format(" ".join(cmd)))
|
||||||
|
return 0
|
||||||
|
os.execvp(cmd[0], cmd)
|
||||||
|
return 0 # unreachable
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="muse_resume_pool",
|
||||||
|
description="Per-repo, profile-aware Muse Code resume pool and guard.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--workspace",
|
||||||
|
help="Workspace root to scope to (default: git top-level or cwd).",
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
p_pool = sub.add_parser("pool", help="List sessions resumable here and now.")
|
||||||
|
p_pool.add_argument("--all", action="store_true",
|
||||||
|
help="Also show profile-blocked sessions.")
|
||||||
|
p_pool.add_argument("--json", action="store_true", help="Machine-readable output.")
|
||||||
|
p_pool.set_defaults(func=cmd_pool)
|
||||||
|
|
||||||
|
p_check = sub.add_parser("check", help="Explain whether a resume would succeed.")
|
||||||
|
p_check.add_argument("ref", help="Session UUID, UUID prefix, or session name.")
|
||||||
|
p_check.add_argument("--json", action="store_true", help="Machine-readable output.")
|
||||||
|
p_check.set_defaults(func=cmd_check)
|
||||||
|
|
||||||
|
p_resume = sub.add_parser("resume", help="Guard then exec muse-code resume.")
|
||||||
|
p_resume.add_argument("ref", help="Session UUID, prefix, name, or --last.")
|
||||||
|
p_resume.add_argument("--dry-run", action="store_true",
|
||||||
|
help="Print the resume command instead of exec'ing.")
|
||||||
|
p_resume.set_defaults(func=cmd_resume)
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
return args.func(args, default_paths())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+401
@@ -0,0 +1,401 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""muse_session_bind.py — Per-session credential isolation (P3).
|
||||||
|
|
||||||
|
Each muse session gets its own config root::
|
||||||
|
|
||||||
|
/tmp/muse-session-<pid>/muse/
|
||||||
|
<everything symlinked from the global config EXCEPT auth.json>
|
||||||
|
auth.json <- COPY of the bound profile's credentials (0600)
|
||||||
|
/tmp/muse-session-<pid>/bind.json <- {profile, pid, created, auth_src}
|
||||||
|
|
||||||
|
``launch`` execs muse with XDG_CONFIG_HOME pointed at the session dir
|
||||||
|
(the binary resolves its config root as $XDG_CONFIG_HOME/muse, else
|
||||||
|
$HOME/.config/muse), so switching profiles never disturbs live
|
||||||
|
sessions: the fleet-wide 400 outage class disappears by construction.
|
||||||
|
Exec (not supervise) preserves the pane's ``muse-bin`` identity, so
|
||||||
|
watcher coverage and ``box runtime`` keep working unchanged.
|
||||||
|
|
||||||
|
Token refreshes land in the session copy. ``save``/``reap`` copy newer
|
||||||
|
bytes back to the profile store (newest-wins across concurrent
|
||||||
|
sessions sharing a profile; nothing is ever written to the legacy
|
||||||
|
global auth.json). Dead sessions are reaped by scan, so kill -9 loses
|
||||||
|
nothing but promptness.
|
||||||
|
|
||||||
|
Companion to the peer's muse_resume_pool (which reads
|
||||||
|
session_profiles.json): ``launch --session-id`` records the binding
|
||||||
|
there for future resume guards.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
SESSION_PREFIX = "muse-session-"
|
||||||
|
BIND_FILENAME = "bind.json"
|
||||||
|
AUTH_FILENAME = "auth.json"
|
||||||
|
|
||||||
|
|
||||||
|
def default_config_src():
|
||||||
|
"""Global config source (explicit env wins, else the real home)."""
|
||||||
|
return (os.environ.get("MUSE_CONFIG_SRC")
|
||||||
|
or os.path.join(os.path.expanduser("~"), ".config", "muse"))
|
||||||
|
|
||||||
|
|
||||||
|
def session_dir_for(parent, pid):
|
||||||
|
return os.path.join(parent, "%s%d" % (SESSION_PREFIX, pid))
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def _pid_alive(pid):
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _pid_is_muse(pid):
|
||||||
|
"""True if pid's cmdline looks like a muse session (pid-reuse guard)."""
|
||||||
|
try:
|
||||||
|
with open("/proc/%d/cmdline" % pid, "rb") as f:
|
||||||
|
cmd = f.read().decode(errors="replace").lower()
|
||||||
|
return "muse-bin" in cmd or "muse-code" in cmd
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprint(path):
|
||||||
|
"""Short sha256 of a credential file for logs (never the bytes)."""
|
||||||
|
try:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
h.update(f.read())
|
||||||
|
return h.hexdigest()[:12]
|
||||||
|
except OSError:
|
||||||
|
return "missing"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_private_bytes(path, data):
|
||||||
|
"""Write bytes with 0600 perms, atomically. Returns True on success."""
|
||||||
|
try:
|
||||||
|
tmp = "%s.tmp.%d" % (path, os.getpid())
|
||||||
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
try:
|
||||||
|
os.write(fd, data)
|
||||||
|
os.fsync(fd)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def profile_auth_path(config_src, profile):
|
||||||
|
return os.path.join(config_src, "accounts", profile, AUTH_FILENAME)
|
||||||
|
|
||||||
|
|
||||||
|
def read_bind(sessdir):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(sessdir, BIND_FILENAME)) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def session_liveness(sessdir):
|
||||||
|
"""live | dead | unknown (no/invalid bind record: never reap)."""
|
||||||
|
bind = read_bind(sessdir)
|
||||||
|
if not bind or not isinstance(bind.get("pid"), int):
|
||||||
|
return "unknown"
|
||||||
|
pid = bind["pid"]
|
||||||
|
if _pid_alive(pid) and _pid_is_muse(pid):
|
||||||
|
return "live"
|
||||||
|
return "dead"
|
||||||
|
|
||||||
|
|
||||||
|
def list_bound(parent="/tmp"):
|
||||||
|
"""Session dirs carrying our bind record (foreign dirs ignored)."""
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
names = sorted(os.listdir(parent))
|
||||||
|
except OSError:
|
||||||
|
return out
|
||||||
|
for name in names:
|
||||||
|
if not name.startswith(SESSION_PREFIX):
|
||||||
|
continue
|
||||||
|
sessdir = os.path.join(parent, name)
|
||||||
|
if not os.path.isdir(sessdir):
|
||||||
|
continue
|
||||||
|
if read_bind(sessdir) is None:
|
||||||
|
continue
|
||||||
|
out.append(sessdir)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_session_dir(parent, pid, config_src, auth_src, profile):
|
||||||
|
"""Create the isolated config root. Returns sessdir.
|
||||||
|
|
||||||
|
Raises RuntimeError when the slot is held by a live session, or
|
||||||
|
OSError/ValueError for missing sources.
|
||||||
|
"""
|
||||||
|
auth_src = os.path.realpath(auth_src)
|
||||||
|
if not os.path.isfile(auth_src):
|
||||||
|
raise ValueError("no credentials at %s" % auth_src)
|
||||||
|
if not os.path.isdir(config_src):
|
||||||
|
raise ValueError("no config source at %s" % config_src)
|
||||||
|
sessdir = session_dir_for(parent, pid)
|
||||||
|
cfgdir = os.path.join(sessdir, "muse")
|
||||||
|
if os.path.exists(sessdir):
|
||||||
|
if session_liveness(sessdir) == "live":
|
||||||
|
raise RuntimeError("session slot %s is live" % sessdir)
|
||||||
|
shutil.rmtree(sessdir, ignore_errors=True)
|
||||||
|
os.makedirs(cfgdir)
|
||||||
|
for entry in sorted(os.listdir(config_src)):
|
||||||
|
if entry == AUTH_FILENAME:
|
||||||
|
continue
|
||||||
|
target = os.path.join(config_src, entry)
|
||||||
|
try:
|
||||||
|
os.symlink(target, os.path.join(cfgdir, entry))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
with open(auth_src, "rb") as f:
|
||||||
|
creds = f.read()
|
||||||
|
if not _write_private_bytes(os.path.join(cfgdir, AUTH_FILENAME), creds):
|
||||||
|
raise OSError("cannot plant auth.json in %s" % cfgdir)
|
||||||
|
with open(os.path.join(sessdir, BIND_FILENAME), "w") as f:
|
||||||
|
json.dump({"profile": profile, "pid": pid,
|
||||||
|
"created": _now(), "auth_src": auth_src}, f, indent=1)
|
||||||
|
return sessdir
|
||||||
|
|
||||||
|
|
||||||
|
def record_session_profile(config_src, session_id, profile):
|
||||||
|
"""Note session->profile for resume guards. Returns True on success."""
|
||||||
|
path = os.path.join(config_src, "session_profiles.json")
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
data = {}
|
||||||
|
data[str(session_id)] = str(profile)
|
||||||
|
try:
|
||||||
|
tmp = "%s.tmp.%d" % (path, os.getpid())
|
||||||
|
with open(tmp, "w") as f:
|
||||||
|
json.dump(data, f, indent=1)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def save_session(sessdir, config_src=None):
|
||||||
|
"""Sync a session copy back to its profile when newer.
|
||||||
|
|
||||||
|
Returns {"status", ...}; statuses: synced | skipped-stale |
|
||||||
|
skipped-missing | no-bind. Never raises, never logs token bytes.
|
||||||
|
"""
|
||||||
|
config_src = config_src or default_config_src()
|
||||||
|
bind = read_bind(sessdir)
|
||||||
|
if not bind or not bind.get("profile"):
|
||||||
|
return {"status": "no-bind", "sessdir": sessdir}
|
||||||
|
profile = bind["profile"]
|
||||||
|
sess_auth = os.path.join(sessdir, "muse", AUTH_FILENAME)
|
||||||
|
dest = os.path.realpath(profile_auth_path(config_src, profile))
|
||||||
|
if not os.path.isfile(sess_auth):
|
||||||
|
return {"status": "skipped-missing", "sessdir": sessdir,
|
||||||
|
"profile": profile}
|
||||||
|
try:
|
||||||
|
sess_mtime = os.path.getmtime(sess_auth)
|
||||||
|
except OSError:
|
||||||
|
return {"status": "skipped-missing", "sessdir": sessdir,
|
||||||
|
"profile": profile}
|
||||||
|
try:
|
||||||
|
dest_mtime = os.path.getmtime(dest)
|
||||||
|
except OSError:
|
||||||
|
dest_mtime = -1
|
||||||
|
if dest_mtime >= sess_mtime:
|
||||||
|
return {"status": "skipped-stale", "sessdir": sessdir,
|
||||||
|
"profile": profile, "session_fp": _fingerprint(sess_auth),
|
||||||
|
"profile_fp": _fingerprint(dest)}
|
||||||
|
try:
|
||||||
|
with open(sess_auth, "rb") as f:
|
||||||
|
creds = f.read()
|
||||||
|
except OSError:
|
||||||
|
return {"status": "skipped-missing", "sessdir": sessdir,
|
||||||
|
"profile": profile}
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if not _write_private_bytes(dest, creds):
|
||||||
|
return {"status": "error", "sessdir": sessdir, "profile": profile}
|
||||||
|
return {"status": "synced", "sessdir": sessdir, "profile": profile,
|
||||||
|
"session_fp": _fingerprint(sess_auth),
|
||||||
|
"profile_fp": _fingerprint(dest)}
|
||||||
|
|
||||||
|
|
||||||
|
def reap(parent="/tmp", config_src=None):
|
||||||
|
"""Sync + remove dead bound sessions. Returns {"reaped", "live"}."""
|
||||||
|
config_src = config_src or default_config_src()
|
||||||
|
reaped, live = [], []
|
||||||
|
for sessdir in list_bound(parent):
|
||||||
|
if session_liveness(sessdir) == "live":
|
||||||
|
live.append(sessdir)
|
||||||
|
continue
|
||||||
|
res = save_session(sessdir, config_src)
|
||||||
|
shutil.rmtree(sessdir, ignore_errors=True)
|
||||||
|
reaped.append({"sessdir": sessdir, "save": res["status"],
|
||||||
|
"profile": res.get("profile")})
|
||||||
|
return {"reaped": reaped, "live": live}
|
||||||
|
|
||||||
|
|
||||||
|
def launch(profile=None, auth_file=None, session_id=None, config_src=None,
|
||||||
|
cmd=None, parent="/tmp", dry_run=False, _exec=os.execvpe):
|
||||||
|
"""Bind then exec. With dry_run, return the plan without exec'ing."""
|
||||||
|
config_src = config_src or default_config_src()
|
||||||
|
if auth_file:
|
||||||
|
auth_src = os.path.realpath(auth_file)
|
||||||
|
elif profile:
|
||||||
|
auth_src = profile_auth_path(config_src, profile)
|
||||||
|
else:
|
||||||
|
raise ValueError("need --profile or --auth-file")
|
||||||
|
if not os.path.isfile(auth_src):
|
||||||
|
raise ValueError("no credentials at %s" % auth_src)
|
||||||
|
pid = os.getpid()
|
||||||
|
if dry_run:
|
||||||
|
return {"sessdir": session_dir_for(parent, pid),
|
||||||
|
"xdg_config_home": session_dir_for(parent, pid),
|
||||||
|
"profile": profile, "auth_src": auth_src,
|
||||||
|
"cmd": cmd or []}
|
||||||
|
# Reap BEFORE building: our own fresh dir would read as dead (the
|
||||||
|
# launcher is python, not muse, until it execs) and eat itself.
|
||||||
|
reap(parent=parent, config_src=config_src)
|
||||||
|
sessdir = build_session_dir(parent, pid, config_src, auth_src,
|
||||||
|
profile or "explicit")
|
||||||
|
if session_id:
|
||||||
|
record_session_profile(config_src, session_id,
|
||||||
|
profile or "explicit")
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["XDG_CONFIG_HOME"] = sessdir
|
||||||
|
env["MUSE_SESSION_BIND_DIR"] = sessdir
|
||||||
|
_exec(cmd[0], cmd, env)
|
||||||
|
return None # unreachable; exec replaces the image
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status(args):
|
||||||
|
config_src = args.config_src or default_config_src()
|
||||||
|
rows = []
|
||||||
|
for sessdir in list_bound(args.parent):
|
||||||
|
bind = read_bind(sessdir) or {}
|
||||||
|
sess_auth = os.path.join(sessdir, "muse", AUTH_FILENAME)
|
||||||
|
prof_auth = profile_auth_path(config_src, bind.get("profile", ""))
|
||||||
|
rows.append({"sessdir": sessdir, "profile": bind.get("profile"),
|
||||||
|
"pid": bind.get("pid"),
|
||||||
|
"liveness": session_liveness(sessdir),
|
||||||
|
"session_fp": _fingerprint(sess_auth),
|
||||||
|
"profile_fp": _fingerprint(prof_auth)})
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({"sessions": rows}, indent=1))
|
||||||
|
else:
|
||||||
|
if not rows:
|
||||||
|
print("No bound sessions under %s." % args.parent)
|
||||||
|
return 0
|
||||||
|
for r in rows:
|
||||||
|
print("%s profile=%s pid=%s %s session=%s profile=%s" % (
|
||||||
|
r["sessdir"], r["profile"], r["pid"], r["liveness"],
|
||||||
|
r["session_fp"], r["profile_fp"]))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
prog="muse_session_bind",
|
||||||
|
description="Per-session credential isolation for muse.")
|
||||||
|
ap.add_argument("--parent", default="/tmp",
|
||||||
|
help="Session dir parent (default /tmp).")
|
||||||
|
ap.add_argument("--config-src", default=None,
|
||||||
|
help="Global config source (default ~/.config/muse).")
|
||||||
|
sub = ap.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
p_l = sub.add_parser("launch", help="Bind a profile, then exec muse.")
|
||||||
|
p_l.add_argument("--profile", default=None)
|
||||||
|
p_l.add_argument("--auth-file", default=None)
|
||||||
|
p_l.add_argument("--session-id", default=None)
|
||||||
|
p_l.add_argument("--dry-run", action="store_true")
|
||||||
|
p_l.add_argument("cmd", nargs=argparse.REMAINDER,
|
||||||
|
help="Command after --, e.g. -- muse-code")
|
||||||
|
|
||||||
|
p_s = sub.add_parser("save", help="Sync session tokens back to profile.")
|
||||||
|
g = p_s.add_mutually_exclusive_group(required=True)
|
||||||
|
g.add_argument("--pid", type=int)
|
||||||
|
g.add_argument("--dir")
|
||||||
|
g.add_argument("--all", action="store_true")
|
||||||
|
p_s.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
p_r = sub.add_parser("reap", help="Sync + remove dead sessions.")
|
||||||
|
p_r.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
p_st = sub.add_parser("status", help="List bound sessions.")
|
||||||
|
p_st.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
config_src = args.config_src or default_config_src()
|
||||||
|
if args.command == "launch":
|
||||||
|
cmd = [c for c in args.cmd if c != "--"]
|
||||||
|
if not cmd and not args.dry_run:
|
||||||
|
print("launch needs a command: launch ... -- muse-code [...]",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
plan = launch(profile=args.profile, auth_file=args.auth_file,
|
||||||
|
session_id=args.session_id, config_src=config_src,
|
||||||
|
cmd=cmd, parent=args.parent,
|
||||||
|
dry_run=args.dry_run)
|
||||||
|
except (ValueError, RuntimeError, OSError) as e:
|
||||||
|
print("launch refused: %s" % (e,), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if args.dry_run:
|
||||||
|
print(json.dumps(plan, indent=1))
|
||||||
|
return 0
|
||||||
|
if args.command == "save":
|
||||||
|
if args.pid is not None:
|
||||||
|
targets = [session_dir_for(args.parent, args.pid)]
|
||||||
|
elif args.dir:
|
||||||
|
targets = [args.dir]
|
||||||
|
else:
|
||||||
|
targets = list_bound(args.parent)
|
||||||
|
results = [save_session(t, config_src) for t in targets]
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({"saved": results}, indent=1))
|
||||||
|
else:
|
||||||
|
for r in results:
|
||||||
|
print("%s: %s" % (r["sessdir"], r["status"]))
|
||||||
|
return 0
|
||||||
|
if args.command == "reap":
|
||||||
|
res = reap(parent=args.parent, config_src=config_src)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(res, indent=1))
|
||||||
|
else:
|
||||||
|
for r in res["reaped"]:
|
||||||
|
print("reaped %s (%s)" % (r["sessdir"], r["save"]))
|
||||||
|
if not res["reaped"]:
|
||||||
|
print("Nothing to reap.")
|
||||||
|
return 0
|
||||||
|
if args.command == "status":
|
||||||
|
return cmd_status(args)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+7
-2
@@ -954,7 +954,7 @@ def cmd_runtime(args):
|
|||||||
print()
|
print()
|
||||||
return
|
return
|
||||||
headers = ["SOCKET", "SESSION", "NODE", "PANE", "CMD",
|
headers = ["SOCKET", "SESSION", "NODE", "PANE", "CMD",
|
||||||
"STATE", "APPROVE", "WATCHER"]
|
"STATE", "APPROVE", "MODE", "WATCHER"]
|
||||||
table = []
|
table = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
state = r["state"]
|
state = r["state"]
|
||||||
@@ -964,15 +964,20 @@ def cmd_runtime(args):
|
|||||||
if r["is_muse"]:
|
if r["is_muse"]:
|
||||||
approve = (badge_ok("YES") if r["auto_approve"]
|
approve = (badge_ok("YES") if r["auto_approve"]
|
||||||
else badge_err("NO"))
|
else badge_err("NO"))
|
||||||
|
mode = r.get("permission_mode") or "default"
|
||||||
|
if r.get("permission_bypass") and mode != "yolo":
|
||||||
|
mode += "!"
|
||||||
|
mode = mode[:14]
|
||||||
else:
|
else:
|
||||||
approve = badge_dim("-")
|
approve = badge_dim("-")
|
||||||
|
mode = badge_dim("-")
|
||||||
watcher = (badge_ok("ALIVE %s" % r["watcher_pid"])
|
watcher = (badge_ok("ALIVE %s" % r["watcher_pid"])
|
||||||
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["node"] or badge_dim("-"),
|
||||||
r["pane"], (r["cmd"] or "")[:26], state,
|
r["pane"], (r["cmd"] or "")[:26], state,
|
||||||
approve, watcher])
|
approve, mode, watcher])
|
||||||
print_table(headers, table)
|
print_table(headers, table)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# MUSE-AUTH-CLI Decision Record
|
||||||
|
|
||||||
|
Status: **Draft** — taken over in this checkout 2026-10-07 per user choice.
|
||||||
|
Only explicit user acceptance moves this document (or any decision) to Final.
|
||||||
|
|
||||||
|
Handoff note: a prior grill session settled D1–D11 and U1 and reportedly
|
||||||
|
marked its own record Final, but that file lives in another checkout (absent
|
||||||
|
here; this repo has no MUSE-AUTH-CLI.md, PI-AGENT-AUTH.md, OPERATORS.md, or
|
||||||
|
agy-auth-switch). D1–D11 details below are CARRIED, not verified — their full
|
||||||
|
text needs a paste or peer handoff before this record can go Final.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Define the `muse-auth` CLI: per-profile credential switcher
|
||||||
|
(`~/.config/muse/accounts/<name>/auth.json`), tailnet push/pull of profiles
|
||||||
|
between nodes, and a session spend logger — without stranding live sessions
|
||||||
|
(the 2026-10-07 fleet-wide 400 outage was a mid-stream credential swap).
|
||||||
|
|
||||||
|
## Non-goals (proposed)
|
||||||
|
|
||||||
|
- Implementation of `muse-auth` (needs a separate explicit request).
|
||||||
|
- `agy-auth-switch` validation (Track B, separate lane; PI-AGENT-AUTH.md is Final).
|
||||||
|
- OPERATORS.md amendment for agent-invokable keys (U2 follow-on, own delta).
|
||||||
|
|
||||||
|
## Settled (from prior-session transcript, unverified here)
|
||||||
|
|
||||||
|
### U1. Allowance reset period — SETTLED (calendar month)
|
||||||
|
|
||||||
|
Profile token allowances reset on the 1st of each month UTC, matching
|
||||||
|
standard billing cycles (not a rolling 30-day window).
|
||||||
|
|
||||||
|
### D10/D11. Agent-invokable key handling — SETTLED in principle, amendment pending
|
||||||
|
|
||||||
|
Decisions exist; codification as an OPERATORS.md amendment delta is the U2
|
||||||
|
follow-on and is UNRESOLVED.
|
||||||
|
|
||||||
|
### D1–D11 (remaining detail) — CARRIED, text unavailable
|
||||||
|
|
||||||
|
Full decision text was settled in the prior session but is not present in
|
||||||
|
this checkout. CARRIED as-is; paste or peer handoff required to verify.
|
||||||
|
This record cannot go Final until they are quoted or re-settled here.
|
||||||
|
|
||||||
|
## Scope contract (ACCEPTED 2026-10-07; user chose "accept the scope as written")
|
||||||
|
|
||||||
|
- Artifact boundary: IN — this decision record only. OUT — runtime code,
|
||||||
|
tests, OPERATORS.md amendment, Track B validation.
|
||||||
|
- Done means: (1) push/pull file-set decision settled; (2) live-session
|
||||||
|
guard decision settled; (3) D1–D11 text verified or re-settled;
|
||||||
|
(4) user explicitly accepts this record as Final.
|
||||||
|
- Later stages (implementation, U2 amendment) each return for their own
|
||||||
|
interview; accepting this record never approves them.
|
||||||
|
- "Go"/"do it all" authorize only the boundary above.
|
||||||
|
|
||||||
|
## Settled Decisions (New)
|
||||||
|
|
||||||
|
### P1. Push/pull transfer file set — SETTLED (Credentials + Metadata)
|
||||||
|
|
||||||
|
Transfer `auth.json` (cookies, tokens, session identity) and `metadata.json` (plan tier, spend watermarks, profile label). Ephemeral caches, runtime logs, and local locks are omitted from transfer. (`profile.json` in the earlier grill options was shorthand for this file and is superseded; confirmed 2026-10-07.)
|
||||||
|
|
||||||
|
### P2. Live-session switch guard — SETTLED (Block with Force Override)
|
||||||
|
|
||||||
|
Refuse to switch credentials if active `muse-bin` or worker processes are detected holding the old profile identity. Operators must either terminate active processes first or explicitly pass `--force` to override, preventing mid-stream 400 outages caused by stale in-memory tokens. (Confirmed in this interview 2026-10-07.)
|
||||||
|
|
||||||
|
## Pending
|
||||||
|
|
||||||
|
None. (All pending architectural decisions P1 and P2 are settled).
|
||||||
|
|
||||||
|
## Session credential isolation (P3 — BUILT 2026-10-07, user-ordered)
|
||||||
|
|
||||||
|
Each muse session runs with an isolated config dir
|
||||||
|
`/tmp/muse-session-<pid>/muse`: symlinks to `~/.config/muse/*` except
|
||||||
|
`auth.json`, which is replaced with the bound profile's credentials;
|
||||||
|
refreshed tokens sync back to the profile on session exit/save.
|
||||||
|
|
||||||
|
Implications (unresolved): this largely obsoletes P2's block (switching
|
||||||
|
stops disturbing live sessions; the guard becomes a backstop for
|
||||||
|
legacy non-isolated sessions). Open risks: token sync-back races when
|
||||||
|
two sessions share a profile (solved: newest-wins by mtime),
|
||||||
|
sessions killed -9 never syncing (solved: reap-by-scan, no exit hook),
|
||||||
|
symlink fragility (accepted: rebuilt per launch).
|
||||||
|
|
||||||
|
Implementation: bin/muse_session_bind.py (`launch` builds the dir and
|
||||||
|
execs with XDG_CONFIG_HOME; `save`/`reap` sync back; `status` lists).
|
||||||
|
Key integration choice: exec, not supervise, so panes keep their
|
||||||
|
muse-bin identity and watcher coverage is untouched. Tests:
|
||||||
|
tests/test_muse_session_bind.py (13). Follow-ups for the owning lanes:
|
||||||
|
wire `box runtime launch` / resume-pool `resume` through the binder,
|
||||||
|
and arm a reap timer once the profile store (P1) exists.
|
||||||
@@ -2473,5 +2473,16 @@
|
|||||||
"agent": "opm",
|
"agent": "opm",
|
||||||
"title": "nonexistent-test",
|
"title": "nonexistent-test",
|
||||||
"created_at": "2026-10-06T23:15:46.206197+00:00"
|
"created_at": "2026-10-06T23:15:46.206197+00:00"
|
||||||
|
},
|
||||||
|
"61b59274": {
|
||||||
|
"thread_uuid": "ee8a7190-b137-43f0-840d-a632e48a1555",
|
||||||
|
"agent": "pip",
|
||||||
|
"title": "61b59274",
|
||||||
|
"created_at": "2026-10-07T01:11:47.250101+00:00"
|
||||||
|
},
|
||||||
|
"pipe-234295": {
|
||||||
|
"thread_uuid": "dc9e6bad-94bd-4893-948c-1b35ec0523e0",
|
||||||
|
"agent": "646",
|
||||||
|
"created_at": "2026-10-07T01:14:57.101511+00:00"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"_schema": "muse-choices-rules/v1",
|
||||||
|
"_notes": [
|
||||||
|
"Deny/hold dictionary for the muse-choices daemon (decisions D0-D4).",
|
||||||
|
"First matching rule wins. Fields: id (required), kind (str|list, optional),",
|
||||||
|
"token (str|list, exact match on explicit-phrase key, optional), command",
|
||||||
|
"(regex on the approval $ block, approval kinds only, optional), text",
|
||||||
|
"(regex on cue+options, optional), decision (approve|deny|hold), reason.",
|
||||||
|
"Deny rules apply to permission kinds only (muse-approval, yn) and are",
|
||||||
|
"skipped for question kinds (D2). No match => approve top-choice (D4).",
|
||||||
|
"Seed policy: risky => hold (operator window, then approve). No active",
|
||||||
|
"deny rules yet -- add them from experience, one at a time, with tests."
|
||||||
|
],
|
||||||
|
"version": 1,
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": "explicit-destructive-token",
|
||||||
|
"kind": "explicit-phrase",
|
||||||
|
"token": ["ABORT", "DELETE", "DESTROY", "WIPE", "KILL"],
|
||||||
|
"decision": "hold",
|
||||||
|
"reason": "destructive magic word; hold for operator eyes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cmd-destructive-shell",
|
||||||
|
"kind": ["muse-approval", "muse-approval-collapsed"],
|
||||||
|
"command": "\\brm\\s+-rf?\\b|\\bmkfs\\b|\\bdd\\s+[^\\n]*\\bof=|\\b(shutdown|reboot|halt|poweroff)\\b|git\\s+push\\S*\\s+--force\\b|git\\s+reset\\s+--hard\\b|\\bterraform\\s+(destroy|apply)\\b",
|
||||||
|
"decision": "hold",
|
||||||
|
"reason": "destructive shell command; hold for operator eyes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cmd-database-drop",
|
||||||
|
"kind": ["muse-approval", "muse-approval-collapsed"],
|
||||||
|
"command": "(?i)\\bdrop\\s+(table|database|schema)\\b|\\btruncate\\s+table\\b",
|
||||||
|
"decision": "hold",
|
||||||
|
"reason": "destructive database statement; hold for operator eyes"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -505,6 +505,83 @@ class TestPidfileClaim(unittest.TestCase):
|
|||||||
self.assertTrue(w._claim_pidfile(path))
|
self.assertTrue(w._claim_pidfile(path))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnsweredPersistence(unittest.TestCase):
|
||||||
|
"""Answered sigs survive restarts: a daemon that restarts while an
|
||||||
|
answered prompt is still visible must not answer it again (live
|
||||||
|
double-answer -> stray "1" in the input box)."""
|
||||||
|
|
||||||
|
def test_restart_suppresses_answered_sig(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
path = td + "/x.answered.json"
|
||||||
|
s1 = w.WatcherState(persist_path=path)
|
||||||
|
now = time.time()
|
||||||
|
s1.record_answer("sig-abc", now)
|
||||||
|
s2 = w.WatcherState(persist_path=path)
|
||||||
|
self.assertEqual(s2.observe({"sig": "sig-abc"}, now + 5),
|
||||||
|
"none")
|
||||||
|
|
||||||
|
def test_expired_sig_not_loaded(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
path = td + "/x.answered.json"
|
||||||
|
s1 = w.WatcherState(persist_path=path)
|
||||||
|
s1.record_answer("sig-old",
|
||||||
|
time.time() - w.ANSWERED_TTL_SECONDS - 10)
|
||||||
|
s2 = w.WatcherState(persist_path=path)
|
||||||
|
self.assertNotIn("sig-old", s2.answered_sigs)
|
||||||
|
|
||||||
|
def test_corrupt_store_loads_empty(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
path = td + "/x.answered.json"
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write("not json{{{")
|
||||||
|
s = w.WatcherState(persist_path=path)
|
||||||
|
self.assertEqual(s.answered_sigs, {})
|
||||||
|
|
||||||
|
def test_memory_only_without_path(self):
|
||||||
|
s = w.WatcherState()
|
||||||
|
s.record_answer("sig-x", time.time())
|
||||||
|
self.assertIn("sig-x", s.answered_sigs)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPidfileFlock(unittest.TestCase):
|
||||||
|
"""Single daemon per pane, kernel-enforced: concurrent starters
|
||||||
|
must not pile up (double answers, '11' in the input box)."""
|
||||||
|
|
||||||
|
def _child_claim(self, path):
|
||||||
|
import subprocess
|
||||||
|
code = ("import sys; sys.path.insert(0, %r); "
|
||||||
|
"import muse_choice_watcher as w; "
|
||||||
|
"sys.exit(0 if w._claim_pidfile(%r) else 3)") % (
|
||||||
|
str(BIN_DIR), path)
|
||||||
|
return subprocess.run([sys.executable, "-c", code],
|
||||||
|
capture_output=True, timeout=30)
|
||||||
|
|
||||||
|
def test_second_process_refused_while_held(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
path = td + "/x.pid"
|
||||||
|
self.assertTrue(w._claim_pidfile(path))
|
||||||
|
try:
|
||||||
|
r = self._child_claim(path)
|
||||||
|
self.assertEqual(r.returncode, 3)
|
||||||
|
finally:
|
||||||
|
w._release_pidfile(path)
|
||||||
|
|
||||||
|
def test_claim_succeeds_after_release(self):
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
path = td + "/x.pid"
|
||||||
|
self.assertTrue(w._claim_pidfile(path))
|
||||||
|
w._release_pidfile(path)
|
||||||
|
self.assertFalse(os.path.exists(path))
|
||||||
|
r = self._child_claim(path)
|
||||||
|
self.assertEqual(r.returncode, 0)
|
||||||
|
|
||||||
|
|
||||||
class TestReconcileFailed(unittest.TestCase):
|
class TestReconcileFailed(unittest.TestCase):
|
||||||
def test_failed_starts_recorded(self):
|
def test_failed_starts_recorded(self):
|
||||||
with mock.patch.object(w, "get_desired",
|
with mock.patch.object(w, "get_desired",
|
||||||
@@ -1451,5 +1528,124 @@ class TestBoxResolve(unittest.TestCase):
|
|||||||
self.assertIsNone(data["held"])
|
self.assertIsNone(data["held"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestPeerAnsweredReload(unittest.TestCase):
|
||||||
|
"""A peer watcher answering first must suppress this watcher.
|
||||||
|
|
||||||
|
Concurrent duplicates share only the on-disk answered store: a
|
||||||
|
second watcher that loaded before the peer's answer must re-check
|
||||||
|
the store before typing, or both type '1' ('11' in the input box).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_disk_answer_suppresses_send(self):
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
path = td + "/x.answered.json"
|
||||||
|
state = w.WatcherState(persist_path=path)
|
||||||
|
log = mock.Mock()
|
||||||
|
sig = w.find_choice_prompt(PROMPT_YN)["sig"]
|
||||||
|
with mock.patch.object(w, "pane_exists", return_value=True), \
|
||||||
|
mock.patch.object(w, "capture_pane",
|
||||||
|
side_effect=[PROMPT_YN] * 4), \
|
||||||
|
mock.patch.object(w, "send_answer",
|
||||||
|
return_value=True) as send, \
|
||||||
|
mock.patch.object(w, "audit"):
|
||||||
|
out1 = w._poll_once("/tmp/s", "%1", state, log,
|
||||||
|
dry_run=False)
|
||||||
|
self.assertEqual(out1, "seen")
|
||||||
|
# Peer answers the same prompt and persists first.
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump({sig: time.time()}, f)
|
||||||
|
out2 = w._poll_once("/tmp/s", "%1", state, log,
|
||||||
|
dry_run=False)
|
||||||
|
send.assert_not_called()
|
||||||
|
self.assertIn(out2, ("duplicate-suppressed", "none"))
|
||||||
|
self.assertIn(sig, state.answered_sigs)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureJoinWrapped(unittest.TestCase):
|
||||||
|
def test_capture_joins_wrapped_lines(self):
|
||||||
|
with mock.patch.object(w, "_tmux") as t:
|
||||||
|
t.return_value = mock.Mock(returncode=0, stdout="ok")
|
||||||
|
w.capture_pane("/tmp/s", "%1")
|
||||||
|
args = t.call_args[0]
|
||||||
|
self.assertIn("-J", args)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogPosture(unittest.TestCase):
|
||||||
|
"""Watcher start records the pane's permission posture.
|
||||||
|
|
||||||
|
The watcher answers with per-choice logging in every mode; bypass
|
||||||
|
postures (yolo / approval disabled) additionally audit, since the
|
||||||
|
session then acts outside the choice trail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_bypass_audits(self):
|
||||||
|
log = mock.Mock()
|
||||||
|
with mock.patch.object(w, "pane_muse_argv",
|
||||||
|
return_value=["muse", "--yolo"]), \
|
||||||
|
mock.patch.object(w, "audit") as audit:
|
||||||
|
w._log_posture("/tmp/s", "%1", log)
|
||||||
|
log.log.assert_called_once()
|
||||||
|
self.assertEqual(log.log.call_args[0][1], "pane posture")
|
||||||
|
self.assertEqual(log.log.call_args[1]["mode"], "yolo")
|
||||||
|
audit.assert_called_once()
|
||||||
|
self.assertEqual(audit.call_args[0][0], "muse-choice-posture")
|
||||||
|
self.assertTrue(audit.call_args[1]["extra"]["bypass"])
|
||||||
|
|
||||||
|
def test_default_logs_without_audit(self):
|
||||||
|
log = mock.Mock()
|
||||||
|
with mock.patch.object(w, "pane_muse_argv",
|
||||||
|
return_value=["muse"]), \
|
||||||
|
mock.patch.object(w, "audit") as audit:
|
||||||
|
w._log_posture("/tmp/s", "%1", log)
|
||||||
|
log.log.assert_called_once()
|
||||||
|
audit.assert_not_called()
|
||||||
|
|
||||||
|
def test_nondefault_profile_audits(self):
|
||||||
|
log = mock.Mock()
|
||||||
|
with mock.patch.object(w, "pane_muse_argv",
|
||||||
|
return_value=["muse", "--permission-profile",
|
||||||
|
":unrestricted"]), \
|
||||||
|
mock.patch.object(w, "audit") as audit:
|
||||||
|
w._log_posture("/tmp/s", "%1", log)
|
||||||
|
audit.assert_called_once()
|
||||||
|
extra = audit.call_args[1]["extra"]
|
||||||
|
self.assertEqual(extra["mode"], ":unrestricted")
|
||||||
|
self.assertFalse(extra["bypass"])
|
||||||
|
|
||||||
|
def test_argv_failure_never_raises(self):
|
||||||
|
log = mock.Mock()
|
||||||
|
with mock.patch.object(w, "pane_muse_argv",
|
||||||
|
side_effect=RuntimeError("tmux")), \
|
||||||
|
mock.patch.object(w, "audit") as audit:
|
||||||
|
w._log_posture("/tmp/s", "%1", log) # must not raise
|
||||||
|
log.log.assert_not_called()
|
||||||
|
audit.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestReconcileOrphan(unittest.TestCase):
|
||||||
|
"""Reconcile must not start a second watcher when an orphan with a
|
||||||
|
lost pidfile is already alive for the pane."""
|
||||||
|
|
||||||
|
def test_orphan_counts_as_already(self):
|
||||||
|
orphan = [{"pid": 7777, "socket": "/tmp/sock", "pane": "%37"}]
|
||||||
|
with mock.patch.object(w, "get_desired",
|
||||||
|
return_value={"enabled": True,
|
||||||
|
"dry_run": False}), \
|
||||||
|
mock.patch.object(w, "muse_panes", return_value=["%37"]), \
|
||||||
|
mock.patch.object(w, "is_running", return_value=None), \
|
||||||
|
mock.patch.object(w, "_watch_procs", return_value=orphan), \
|
||||||
|
mock.patch.object(w, "_start_detached") as start, \
|
||||||
|
mock.patch.object(w, "status_all", return_value=[]), \
|
||||||
|
mock.patch.object(w, "audit"), \
|
||||||
|
mock.patch.object(w.os.path, "exists", return_value=True):
|
||||||
|
res = w.reconcile(sockets=["/tmp/sock"])
|
||||||
|
start.assert_not_called()
|
||||||
|
self.assertEqual(res["already"], ["/tmp/sock:%37"])
|
||||||
|
self.assertEqual(res["failed"], [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""test_muse_resume_pool.py — focused tests for the per-repo, profile-aware
|
||||||
|
Muse Code resume pool and guard. Fixture-only: never touches the real
|
||||||
|
~/.config/muse or ~/.local/share/muse."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, os.path.join(REPO_ROOT, "bin"))
|
||||||
|
|
||||||
|
import muse_resume_pool as mrp
|
||||||
|
|
||||||
|
|
||||||
|
WS_NETVM = "/home/super/Projects/NetVM"
|
||||||
|
WS_HOME = "/home/super"
|
||||||
|
|
||||||
|
|
||||||
|
def make_row(session_id, workspace, name=None, created_us=1791000000000000,
|
||||||
|
updated_us=1791300000000000, status="valid", title="t"):
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"session_name": name,
|
||||||
|
"workspace_root": workspace,
|
||||||
|
"workspace_key": workspace,
|
||||||
|
"provider_id": "meta",
|
||||||
|
"model_id": "m",
|
||||||
|
"git_branch": None,
|
||||||
|
"title": title,
|
||||||
|
"first_user_prompt": title,
|
||||||
|
"created_at_us": created_us,
|
||||||
|
"updated_at_us": updated_us,
|
||||||
|
"prompt_count": 3,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_auth(active="muse", mapping=None, history=None, readable=True):
|
||||||
|
return {
|
||||||
|
"readable": readable,
|
||||||
|
"active": active,
|
||||||
|
"session_profiles": dict(mapping or {}),
|
||||||
|
"switch_history": list(history or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfileResolution(unittest.TestCase):
|
||||||
|
def test_cached_mapping_wins_over_history(self):
|
||||||
|
auth = make_auth(
|
||||||
|
mapping={"s1": "def"},
|
||||||
|
history=[{"profile": "muse", "epoch": 1790000000.0}],
|
||||||
|
)
|
||||||
|
profile, source = mrp.resolve_profile("s1", 1791000000000000, auth)
|
||||||
|
self.assertEqual((profile, source), ("def", "cached"))
|
||||||
|
|
||||||
|
def test_history_picks_latest_switch_before_start(self):
|
||||||
|
auth = make_auth(history=[
|
||||||
|
{"profile": "muse", "epoch": 1790000000.0},
|
||||||
|
{"profile": "def", "epoch": 1791100000.0},
|
||||||
|
{"profile": "opm", "epoch": 1791400000.0},
|
||||||
|
])
|
||||||
|
profile, source = mrp.resolve_profile("sx", 1791200000000000, auth)
|
||||||
|
self.assertEqual((profile, source), ("def", "history"))
|
||||||
|
|
||||||
|
def test_history_predating_all_switches_uses_earliest(self):
|
||||||
|
auth = make_auth(history=[
|
||||||
|
{"profile": "def", "epoch": 1791100000.0},
|
||||||
|
{"profile": "opm", "epoch": 1791400000.0},
|
||||||
|
])
|
||||||
|
profile, source = mrp.resolve_profile("sx", 1790000000000000, auth)
|
||||||
|
self.assertEqual((profile, source), ("def", "history"))
|
||||||
|
|
||||||
|
def test_no_history_falls_back_to_active(self):
|
||||||
|
auth = make_auth(active="pip", history=[])
|
||||||
|
profile, source = mrp.resolve_profile("sx", 1791200000000000, auth)
|
||||||
|
self.assertEqual((profile, source), ("pip", "fallback"))
|
||||||
|
|
||||||
|
def test_unreadable_auth_dir_is_unknown(self):
|
||||||
|
auth = make_auth(readable=False)
|
||||||
|
profile, source = mrp.resolve_profile("s1", 1791200000000000, auth)
|
||||||
|
self.assertEqual((profile, source), (None, "unknown"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkspacePool(unittest.TestCase):
|
||||||
|
def test_exact_workspace_match_only(self):
|
||||||
|
rows = [
|
||||||
|
make_row("a" * 32, WS_NETVM, name="n1"),
|
||||||
|
make_row("b" * 32, WS_HOME, name="n2"),
|
||||||
|
make_row("c" * 32, WS_NETVM, name="n3"),
|
||||||
|
]
|
||||||
|
pool = mrp.pool_for_workspace(rows, WS_NETVM)
|
||||||
|
self.assertEqual([r["session_id"] for r in pool], ["a" * 32, "c" * 32])
|
||||||
|
|
||||||
|
def test_subdirectory_does_not_match(self):
|
||||||
|
rows = [make_row("a" * 32, WS_NETVM + "/subdir")]
|
||||||
|
self.assertEqual(mrp.pool_for_workspace(rows, WS_NETVM), [])
|
||||||
|
|
||||||
|
def test_split_hides_only_proven_mismatches(self):
|
||||||
|
rows = [
|
||||||
|
make_row("a" * 32, WS_NETVM),
|
||||||
|
make_row("b" * 32, WS_NETVM),
|
||||||
|
make_row("c" * 32, WS_NETVM),
|
||||||
|
]
|
||||||
|
rows[0]["auth_profile"] = "muse"
|
||||||
|
rows[1]["auth_profile"] = "def"
|
||||||
|
rows[2]["auth_profile"] = None # unknown stays visible, flagged
|
||||||
|
resumable, blocked = mrp.split_resumable(rows, "muse")
|
||||||
|
self.assertEqual([r["session_id"] for r in resumable], ["a" * 32, "c" * 32])
|
||||||
|
self.assertEqual([r["session_id"] for r in blocked], ["b" * 32])
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefResolution(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.rows = [
|
||||||
|
make_row("01a113f1" + "0" * 24, WS_NETVM, name="alder-halo"),
|
||||||
|
make_row("01a113d8" + "1" * 24, WS_NETVM, name="blue-merak"),
|
||||||
|
make_row("01a113f1" + "2" * 24, WS_NETVM, name="third-wheel"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_full_uuid(self):
|
||||||
|
matches, kind = mrp.resolve_ref(self.rows, "01a113f1" + "0" * 24)
|
||||||
|
self.assertEqual((len(matches), kind), (1, "uuid"))
|
||||||
|
|
||||||
|
def test_name(self):
|
||||||
|
matches, kind = mrp.resolve_ref(self.rows, "blue-merak")
|
||||||
|
self.assertEqual((len(matches), kind), (1, "name"))
|
||||||
|
|
||||||
|
def test_prefix(self):
|
||||||
|
matches, kind = mrp.resolve_ref(self.rows, "01a113f100")
|
||||||
|
self.assertEqual((len(matches), kind), (1, "prefix"))
|
||||||
|
|
||||||
|
def test_short_prefix_rejected(self):
|
||||||
|
matches, kind = mrp.resolve_ref(self.rows, "01a1")
|
||||||
|
self.assertEqual((matches, kind), ([], "none"))
|
||||||
|
|
||||||
|
def test_unknown(self):
|
||||||
|
matches, kind = mrp.resolve_ref(self.rows, "nope-missing")
|
||||||
|
self.assertEqual((matches, kind), ([], "none"))
|
||||||
|
|
||||||
|
def test_ambiguous_prefix(self):
|
||||||
|
matches, kind = mrp.resolve_ref(self.rows, "01a113f1")
|
||||||
|
self.assertEqual(len(matches), 2)
|
||||||
|
self.assertEqual(kind, "prefix")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGuard(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.rows = mrp.annotate_rows([
|
||||||
|
make_row("01a113f1" + "0" * 24, WS_NETVM, name="alder-halo"),
|
||||||
|
make_row("f" * 32, WS_HOME, name="far-away"),
|
||||||
|
], make_auth(active="muse", mapping={
|
||||||
|
"01a113f1" + "0" * 24: "def",
|
||||||
|
"f" * 32: "muse",
|
||||||
|
}))
|
||||||
|
|
||||||
|
def test_wrong_profile_blocked_with_fix(self):
|
||||||
|
d = mrp.check_resume(self.rows, "alder-halo", WS_NETVM,
|
||||||
|
make_auth(active="muse", mapping={
|
||||||
|
"01a113f1" + "0" * 24: "def"}))
|
||||||
|
self.assertFalse(d["ok"])
|
||||||
|
self.assertEqual(d["reason"], "wrong-profile")
|
||||||
|
self.assertIn("muse-auth use def", d["fix"])
|
||||||
|
|
||||||
|
def test_matching_profile_ok(self):
|
||||||
|
auth = make_auth(active="muse", mapping={"01a113f1" + "0" * 24: "muse"})
|
||||||
|
rows = mrp.annotate_rows(
|
||||||
|
[make_row("01a113f1" + "0" * 24, WS_NETVM, name="alder-halo")], auth)
|
||||||
|
d = mrp.check_resume(rows, "alder-halo", WS_NETVM, auth)
|
||||||
|
self.assertTrue(d["ok"])
|
||||||
|
self.assertEqual(d["reason"], "ok")
|
||||||
|
|
||||||
|
def test_wrong_workspace_blocked(self):
|
||||||
|
auth = make_auth(active="muse", mapping={"f" * 32: "muse"})
|
||||||
|
rows = mrp.annotate_rows([make_row("f" * 32, WS_HOME, name="far-away")], auth)
|
||||||
|
d = mrp.check_resume(rows, "far-away", WS_NETVM, auth)
|
||||||
|
self.assertFalse(d["ok"])
|
||||||
|
self.assertEqual(d["reason"], "wrong-workspace")
|
||||||
|
self.assertIn("cd", d["fix"])
|
||||||
|
|
||||||
|
def test_unknown_session(self):
|
||||||
|
d = mrp.check_resume(self.rows, "ghost-nope", WS_NETVM, make_auth())
|
||||||
|
self.assertFalse(d["ok"])
|
||||||
|
self.assertEqual(d["reason"], "unknown-session")
|
||||||
|
|
||||||
|
def test_bad_status_blocked(self):
|
||||||
|
auth = make_auth(active="muse")
|
||||||
|
rows = mrp.annotate_rows(
|
||||||
|
[make_row("d" * 32, WS_NETVM, name="stale-one", status="deleted")], auth)
|
||||||
|
d = mrp.check_resume(rows, "stale-one", WS_NETVM, auth)
|
||||||
|
self.assertFalse(d["ok"])
|
||||||
|
self.assertEqual(d["reason"], "bad-status")
|
||||||
|
|
||||||
|
def test_unknown_profile_warns_not_blocks(self):
|
||||||
|
auth = make_auth(readable=False)
|
||||||
|
rows = mrp.annotate_rows(
|
||||||
|
[make_row("e" * 32, WS_NETVM, name="sandboxed-one")], auth)
|
||||||
|
d = mrp.check_resume(rows, "sandboxed-one", WS_NETVM, auth)
|
||||||
|
self.assertTrue(d["ok"])
|
||||||
|
self.assertIn("unverified", d["detail"].lower())
|
||||||
|
|
||||||
|
|
||||||
|
class TestIndexAndLogSources(unittest.TestCase):
|
||||||
|
def test_load_index_rows_read_only(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
db_path = os.path.join(tmp, "session-index.db")
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE sessions (session_id TEXT PRIMARY KEY,"
|
||||||
|
" session_name TEXT, workspace_root TEXT, workspace_key TEXT,"
|
||||||
|
" provider_id TEXT, model_id TEXT, git_branch TEXT, title TEXT,"
|
||||||
|
" first_user_prompt TEXT, created_at_us INTEGER,"
|
||||||
|
" updated_at_us INTEGER, prompt_count INTEGER, status TEXT)"
|
||||||
|
)
|
||||||
|
older, newer = make_row("a" * 32, WS_NETVM, name="old",
|
||||||
|
updated_us=1791000000000000), \
|
||||||
|
make_row("b" * 32, WS_NETVM, name="new",
|
||||||
|
updated_us=1791300000000000)
|
||||||
|
for r in (older, newer):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
tuple(r[c] for c in mrp.INDEX_COLUMNS),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
rows = mrp.load_index_rows(db_path)
|
||||||
|
self.assertEqual([r["session_name"] for r in rows], ["new", "old"])
|
||||||
|
|
||||||
|
def test_missing_index_returns_none(self):
|
||||||
|
self.assertIsNone(mrp.load_index_rows("/nonexistent/x.db"))
|
||||||
|
|
||||||
|
def test_log_scan_fallback_extracts_workspace_and_name(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
session_id = "9" * 32
|
||||||
|
sdir = os.path.join(tmp, "2026", "10", "07", session_id)
|
||||||
|
os.makedirs(sdir)
|
||||||
|
records = [
|
||||||
|
{"payload_type": "runtime.session.metadata",
|
||||||
|
"recorded_at": 1791000000000000,
|
||||||
|
"payload": {"record": {"workspace_root": WS_NETVM}}},
|
||||||
|
{"payload_type": "session.name.changed",
|
||||||
|
"recorded_at": 1791000001000000,
|
||||||
|
"payload": {"new_name": "scanned-one"}},
|
||||||
|
{"payload_type": "runtime.session",
|
||||||
|
"recorded_at": 1791000002000000,
|
||||||
|
"payload": {"event": {"kind": "started", "prompt": "hello world"}}},
|
||||||
|
]
|
||||||
|
with open(os.path.join(sdir, "session.jsonl"), "w") as fh:
|
||||||
|
for rec in records:
|
||||||
|
fh.write(json.dumps(rec) + "\n")
|
||||||
|
rows = mrp.scan_session_logs(tmp)
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
row = rows[0]
|
||||||
|
self.assertEqual(row["session_id"], session_id)
|
||||||
|
self.assertEqual(row["workspace_key"], WS_NETVM)
|
||||||
|
self.assertEqual(row["session_name"], "scanned-one")
|
||||||
|
self.assertEqual(row["prompt_count"], 1)
|
||||||
|
|
||||||
|
def test_auth_state_never_reads_auth_json(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
with open(os.path.join(tmp, "active_profile"), "w") as fh:
|
||||||
|
fh.write("muse\n")
|
||||||
|
with open(os.path.join(tmp, "session_profiles.json"), "w") as fh:
|
||||||
|
json.dump({"s1": "muse"}, fh)
|
||||||
|
with open(os.path.join(tmp, "switch_history.jsonl"), "w") as fh:
|
||||||
|
fh.write(json.dumps({"profile": "muse", "epoch": 1.0}) + "\n")
|
||||||
|
# A decoy auth.json with secret bytes must not be opened: make it
|
||||||
|
# unreadable and assert load still succeeds without error.
|
||||||
|
decoy = os.path.join(tmp, "auth.json")
|
||||||
|
with open(decoy, "w") as fh:
|
||||||
|
fh.write('{"secret": true}')
|
||||||
|
os.chmod(decoy, 0)
|
||||||
|
try:
|
||||||
|
state = mrp.load_auth_state(tmp)
|
||||||
|
finally:
|
||||||
|
os.chmod(decoy, 0o600)
|
||||||
|
self.assertTrue(state["readable"])
|
||||||
|
self.assertEqual(state["active"], "muse")
|
||||||
|
self.assertEqual(state["session_profiles"], {"s1": "muse"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""test_muse_session_bind.py — Per-session credential isolation (P3).
|
||||||
|
|
||||||
|
Covers: session dir layout (symlinks + private auth copy), newest-wins
|
||||||
|
save-back that never touches the global auth.json, dead-only reap,
|
||||||
|
exec env, and the resume-pool session binding record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
REPO_ROOT = Path("/home/super/Projects/NetVM")
|
||||||
|
BIN_DIR = REPO_ROOT / "bin"
|
||||||
|
sys.path.insert(0, str(BIN_DIR))
|
||||||
|
|
||||||
|
import muse_session_bind as b
|
||||||
|
|
||||||
|
|
||||||
|
def _mkconfig(root):
|
||||||
|
"""Fake global config: profile creds + global files."""
|
||||||
|
cfg = os.path.join(root, "config")
|
||||||
|
os.makedirs(os.path.join(cfg, "accounts", "alice"))
|
||||||
|
with open(os.path.join(cfg, "accounts", "alice", "auth.json"), "wb") as f:
|
||||||
|
f.write(b"TOKEN-ALICE")
|
||||||
|
with open(os.path.join(cfg, "auth.json"), "wb") as f:
|
||||||
|
f.write(b"GLOBAL-TOKEN")
|
||||||
|
with open(os.path.join(cfg, "settings.json"), "w") as f:
|
||||||
|
f.write("{}")
|
||||||
|
with open(os.path.join(cfg, "notes.txt"), "w") as f:
|
||||||
|
f.write("hi")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuild(unittest.TestCase):
|
||||||
|
def test_layout_links_and_private_copy(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
cfg = _mkconfig(td)
|
||||||
|
parent = os.path.join(td, "run")
|
||||||
|
os.makedirs(parent)
|
||||||
|
sess = b.build_session_dir(
|
||||||
|
parent, 4242, cfg,
|
||||||
|
os.path.join(cfg, "accounts", "alice", "auth.json"),
|
||||||
|
"alice")
|
||||||
|
muse = os.path.join(sess, "muse")
|
||||||
|
# Everything but auth.json is a symlink to global.
|
||||||
|
self.assertTrue(os.path.islink(os.path.join(muse, "settings.json")))
|
||||||
|
self.assertTrue(os.path.islink(os.path.join(muse, "notes.txt")))
|
||||||
|
self.assertTrue(os.path.islink(os.path.join(muse, "accounts")))
|
||||||
|
# auth.json is a real file with the profile bytes, 0600.
|
||||||
|
auth = os.path.join(muse, "auth.json")
|
||||||
|
self.assertFalse(os.path.islink(auth))
|
||||||
|
with open(auth, "rb") as f:
|
||||||
|
self.assertEqual(f.read(), b"TOKEN-ALICE")
|
||||||
|
self.assertEqual(stat.S_IMODE(os.stat(auth).st_mode), 0o600)
|
||||||
|
bind = json.load(open(os.path.join(sess, "bind.json")))
|
||||||
|
self.assertEqual((bind["profile"], bind["pid"]),
|
||||||
|
("alice", 4242))
|
||||||
|
|
||||||
|
def test_missing_creds_refused(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
cfg = _mkconfig(td)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
b.build_session_dir(td, 1, cfg,
|
||||||
|
os.path.join(td, "nope.json"), "alice")
|
||||||
|
|
||||||
|
def test_live_slot_refused_stale_slot_wiped(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
cfg = _mkconfig(td)
|
||||||
|
auth = os.path.join(cfg, "accounts", "alice", "auth.json")
|
||||||
|
sess = b.build_session_dir(td, 99, cfg, auth, "alice")
|
||||||
|
with mock.patch.object(b, "session_liveness",
|
||||||
|
return_value="live"):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
b.build_session_dir(td, 99, cfg, auth, "alice")
|
||||||
|
with mock.patch.object(b, "session_liveness",
|
||||||
|
return_value="dead"):
|
||||||
|
sess2 = b.build_session_dir(td, 99, cfg, auth, "alice")
|
||||||
|
self.assertEqual(sess2, sess)
|
||||||
|
self.assertTrue(os.path.isfile(
|
||||||
|
os.path.join(sess2, "muse", "auth.json")))
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveBack(unittest.TestCase):
|
||||||
|
def _bound(self, td, pid=777):
|
||||||
|
cfg = _mkconfig(td)
|
||||||
|
parent = os.path.join(td, "run")
|
||||||
|
os.makedirs(parent)
|
||||||
|
sess = b.build_session_dir(
|
||||||
|
parent, pid, cfg,
|
||||||
|
os.path.join(cfg, "accounts", "alice", "auth.json"), "alice")
|
||||||
|
return cfg, sess
|
||||||
|
|
||||||
|
def test_newer_session_syncs_to_profile(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
cfg, sess = self._bound(td)
|
||||||
|
prof = os.path.join(cfg, "accounts", "alice", "auth.json")
|
||||||
|
sauth = os.path.join(sess, "muse", "auth.json")
|
||||||
|
with open(sauth, "wb") as f:
|
||||||
|
f.write(b"TOKEN-REFRESHED")
|
||||||
|
now = time.time()
|
||||||
|
os.utime(prof, (now - 100, now - 100))
|
||||||
|
os.utime(sauth, (now, now))
|
||||||
|
res = b.save_session(sess, cfg)
|
||||||
|
self.assertEqual(res["status"], "synced")
|
||||||
|
with open(prof, "rb") as f:
|
||||||
|
self.assertEqual(f.read(), b"TOKEN-REFRESHED")
|
||||||
|
self.assertEqual(stat.S_IMODE(os.stat(prof).st_mode), 0o600)
|
||||||
|
# Global auth.json untouched.
|
||||||
|
with open(os.path.join(cfg, "auth.json"), "rb") as f:
|
||||||
|
self.assertEqual(f.read(), b"GLOBAL-TOKEN")
|
||||||
|
|
||||||
|
def test_stale_session_skipped(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
cfg, sess = self._bound(td)
|
||||||
|
prof = os.path.join(cfg, "accounts", "alice", "auth.json")
|
||||||
|
sauth = os.path.join(sess, "muse", "auth.json")
|
||||||
|
now = time.time()
|
||||||
|
os.utime(prof, (now, now))
|
||||||
|
os.utime(sauth, (now - 100, now - 100))
|
||||||
|
res = b.save_session(sess, cfg)
|
||||||
|
self.assertEqual(res["status"], "skipped-stale")
|
||||||
|
with open(prof, "rb") as f:
|
||||||
|
self.assertEqual(f.read(), b"TOKEN-ALICE")
|
||||||
|
|
||||||
|
def test_unbound_dir_never_reaped(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
foreign = os.path.join(td, "muse-session-1")
|
||||||
|
os.makedirs(foreign)
|
||||||
|
with open(os.path.join(foreign, "keep.txt"), "w") as f:
|
||||||
|
f.write("x")
|
||||||
|
self.assertEqual(b.list_bound(td), [])
|
||||||
|
res = b.reap(parent=td, config_src=os.path.join(td, "cfg"))
|
||||||
|
self.assertEqual(res["reaped"], [])
|
||||||
|
self.assertTrue(os.path.isfile(
|
||||||
|
os.path.join(foreign, "keep.txt")))
|
||||||
|
|
||||||
|
def test_reap_dead_keeps_live(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
cfg = _mkconfig(td)
|
||||||
|
parent = os.path.join(td, "run")
|
||||||
|
os.makedirs(parent)
|
||||||
|
auth = os.path.join(cfg, "accounts", "alice", "auth.json")
|
||||||
|
dead = b.build_session_dir(parent, 11, cfg, auth, "alice")
|
||||||
|
live = b.build_session_dir(parent, 22, cfg, auth, "alice")
|
||||||
|
with mock.patch.object(
|
||||||
|
b, "session_liveness",
|
||||||
|
side_effect=lambda s: "live" if s == live else "dead"):
|
||||||
|
res = b.reap(parent=parent, config_src=cfg)
|
||||||
|
self.assertEqual([r["sessdir"] for r in res["reaped"]], [dead])
|
||||||
|
self.assertEqual(res["live"], [live])
|
||||||
|
self.assertFalse(os.path.exists(dead))
|
||||||
|
self.assertTrue(os.path.isdir(live))
|
||||||
|
|
||||||
|
|
||||||
|
class TestLaunch(unittest.TestCase):
|
||||||
|
def test_dry_run_plans_without_touching_disk(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
cfg = _mkconfig(td)
|
||||||
|
plan = b.launch(profile="alice", config_src=cfg,
|
||||||
|
cmd=["muse-code", "--foo"], parent=td,
|
||||||
|
dry_run=True)
|
||||||
|
self.assertIn("muse-session-", plan["sessdir"])
|
||||||
|
self.assertEqual(plan["xdg_config_home"], plan["sessdir"])
|
||||||
|
self.assertTrue(plan["auth_src"].endswith(
|
||||||
|
"accounts/alice/auth.json"))
|
||||||
|
self.assertFalse(os.path.exists(plan["sessdir"]))
|
||||||
|
|
||||||
|
def test_launch_execs_with_isolated_env(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
cfg = _mkconfig(td)
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake_exec(path, argv, env):
|
||||||
|
seen.update(path=path, argv=argv, env=env)
|
||||||
|
|
||||||
|
with mock.patch.object(b.os, "getpid", return_value=555):
|
||||||
|
b.launch(profile="alice", config_src=cfg,
|
||||||
|
cmd=["muse-code", "chat"], parent=td,
|
||||||
|
_exec=fake_exec)
|
||||||
|
sess = os.path.join(td, "muse-session-555")
|
||||||
|
self.assertEqual(seen["path"], "muse-code")
|
||||||
|
self.assertEqual(seen["env"]["XDG_CONFIG_HOME"], sess)
|
||||||
|
self.assertEqual(seen["env"]["MUSE_SESSION_BIND_DIR"], sess)
|
||||||
|
self.assertTrue(os.path.isfile(
|
||||||
|
os.path.join(sess, "muse", "auth.json")))
|
||||||
|
|
||||||
|
def test_launch_records_session_profile(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
cfg = _mkconfig(td)
|
||||||
|
with mock.patch.object(b.os, "getpid", return_value=556):
|
||||||
|
b.launch(profile="alice", config_src=cfg,
|
||||||
|
session_id="sess-1", cmd=["muse-code"],
|
||||||
|
parent=td, _exec=lambda *a: None)
|
||||||
|
data = json.load(open(os.path.join(cfg, "session_profiles.json")))
|
||||||
|
self.assertEqual(data, {"sess-1": "alice"})
|
||||||
|
|
||||||
|
def test_launch_without_profile_or_auth_refused(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
b.launch(cmd=["muse-code"], dry_run=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLiveness(unittest.TestCase):
|
||||||
|
def test_recycled_pid_is_dead(self):
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
sess = os.path.join(td, "muse-session-9")
|
||||||
|
os.makedirs(sess)
|
||||||
|
with open(os.path.join(sess, "bind.json"), "w") as f:
|
||||||
|
json.dump({"profile": "alice", "pid": 9}, f)
|
||||||
|
with mock.patch.object(b, "_pid_alive", return_value=True), \
|
||||||
|
mock.patch.object(b, "_pid_is_muse", return_value=False):
|
||||||
|
self.assertEqual(b.session_liveness(sess), "dead")
|
||||||
|
|
||||||
|
def test_pid_is_muse_matches_binary_names(self):
|
||||||
|
import io
|
||||||
|
with mock.patch("builtins.open",
|
||||||
|
mock.mock_open(read_data=b"muse-bin-1.4\x00--x\x00")):
|
||||||
|
self.assertTrue(b._pid_is_muse(123))
|
||||||
|
with mock.patch("builtins.open",
|
||||||
|
mock.mock_open(read_data=b"python3\x00foo\x00")):
|
||||||
|
self.assertFalse(b._pid_is_muse(123))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user