#!/usr/bin/env python3 """muse_session_bind.py — Per-session credential isolation (P3). Each muse session gets its own config root:: /tmp/muse-session-/muse/ auth.json <- COPY of the bound profile's credentials (0600) /tmp/muse-session-/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())