Files
box/bin/muse_session_bind.py
T

402 lines
14 KiB
Python
Raw Normal View History

#!/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())