feat(muse): harden choice watcher concurrency, add rules dictionary, resume pool, and session bind
This commit is contained in:
@@ -505,6 +505,83 @@ class TestPidfileClaim(unittest.TestCase):
|
||||
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):
|
||||
def test_failed_starts_recorded(self):
|
||||
with mock.patch.object(w, "get_desired",
|
||||
@@ -1451,5 +1528,124 @@ class TestBoxResolve(unittest.TestCase):
|
||||
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__":
|
||||
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