"""Portable access to the captured real FrameBridge tmux CUA-TTY engine. The captured Manager/LocalServer are loaded from original/gerameg, never from an installed ALPHA. _tty_backend.py preserves RealTerminalMixin with a recorded portability diff. The public RPC uses same-UID sockets plus authorized=True. """ from __future__ import annotations import argparse import base64 from dataclasses import asdict, dataclass, fields import fcntl import hashlib import importlib import importlib.util import json import math import os from pathlib import Path import re import shutil import signal import socket import socketserver import stat import struct import subprocess import sys import threading import time import uuid import zlib _SESSION = re.compile(r"[a-f0-9]{32}\Z") _ERROR = re.compile(r"[A-Z][A-Z0-9_]{0,79}\Z") _WIRE_CHARS = 1800 _STARTED_CHILDREN = {} def _source_root(): here = Path(__file__).resolve().parents[2] prefix = Path(os.environ.get("FRAMEBRIDGE_EXPANDED_PREFIX", str(here))) for p in (prefix / "original/gerameg", prefix / "sources/original-gerameg", here / "original/gerameg", here / "sources/original-gerameg"): if (p / "framebridge_cua_tty/core.py").is_file(): return p raise RuntimeError("TTY_ORIGINAL_SOURCE_NOT_FOUND") def _load_captured(): name = "framebridge_expanded._captured_cua_tty" if name not in sys.modules: path = _source_root() / "framebridge_cua_tty" spec = importlib.util.spec_from_file_location( name, path / "__init__.py", submodule_search_locations=[str(path)]) module = importlib.util.module_from_spec(spec) sys.modules[name] = module spec.loader.exec_module(module) return (importlib.import_module(name + ".core"), importlib.import_module(name + ".server")) _core, _server = _load_captured() TerminalDenied = _core.TerminalDenied from ._tty_backend import RealTerminalMixin from .tty_sudo_control import SudoControlError @dataclass(frozen=True) class Limits: max_sessions: int = 16 min_rows: int = 2 max_rows: int = 500 min_cols: int = 10 max_cols: int = 1000 max_text_bytes: int = 65536 max_screen_bytes: int = 262144 history_lines: int = 10000 idle_timeout_s: int = 0 command_timeout_s: int = 5 rpc_max_bytes: int = 1048576 def _private(path): path = Path(path) if path.is_symlink(): raise TerminalDenied("TTY_PRIVATE_PATH_IS_SYMLINK") path.mkdir(mode=0o700, parents=True, exist_ok=True) s = path.stat() if s.st_uid != os.geteuid() or stat.S_IMODE(s.st_mode) != 0o700: raise TerminalDenied("TTY_PRIVATE_DIRECTORY_REQUIRED") return path def state_path(value=None): return Path(value or os.environ.get("FRAMEBRIDGE_EXPANDED_STATE", str(Path.home() / ".local/state/framebridge-expanded"))).expanduser().absolute() def runtime_path(state): state = state_path(state) tag = hashlib.sha256(str(state).encode()).hexdigest()[:16] base = Path("/run/user") / str(os.geteuid()) try: safe = base.is_dir() and base.stat().st_uid == os.geteuid() and not base.is_symlink() except OSError: safe = False if not safe: base = Path("/tmp") return base / ("fbe-tty-" + str(os.geteuid()) + "-" + tag) def _config(state): path = state / "tty.json" data = {} if path.exists(): if path.is_symlink() or path.stat().st_uid != os.geteuid(): raise TerminalDenied("TTY_CONFIG_OWNER_INVALID") try: data = json.loads(path.read_text()) except (ValueError, OSError): raise TerminalDenied("TTY_CONFIG_INVALID") from None if type(data) is not dict or set(data) - {"limits", "cwd"}: raise TerminalDenied("TTY_CONFIG_INVALID") values = data.get("limits", {}) allowed = {f.name for f in fields(Limits)} if type(values) is not dict or set(values) - allowed: raise TerminalDenied("TTY_LIMITS_INVALID") limits = Limits(**values) if any(type(getattr(limits, f.name)) is not int for f in fields(Limits)): raise TerminalDenied("TTY_LIMITS_INVALID") if not (1 <= limits.max_sessions <= 4096 and 1 <= limits.min_rows <= limits.max_rows <= 65535 and 1 <= limits.min_cols <= limits.max_cols <= 65535 and 1 <= limits.max_text_bytes <= limits.rpc_max_bytes // 2 and 1024 <= limits.max_screen_bytes <= limits.rpc_max_bytes - 4096 and 0 <= limits.history_lines <= 1000000 and 0 <= limits.idle_timeout_s and 1 <= limits.command_timeout_s <= 300 and 65536 <= limits.rpc_max_bytes <= 16777216): raise TerminalDenied("TTY_LIMITS_INVALID") cwd = Path(data.get("cwd", str(state / "home"))).expanduser().absolute() if not cwd.exists(): cwd.mkdir(mode=0o700, parents=True) if not cwd.is_dir(): raise TerminalDenied("TTY_CWD_INVALID") return limits, cwd def _identity(pid): try: p = Path("/proc") / str(pid) parts = (p / "stat").read_text().rsplit(")", 1)[1].split() return {"pid": int(pid), "birth": parts[19], "uid": p.stat().st_uid, "state": parts[0]} except (OSError, ValueError, IndexError): return None def _alive(record): now = _identity(record.get("pid", 0)) return bool(now and now["birth"] == record.get("birth") and now["uid"] == os.geteuid() and now["state"] != "Z") def _write_json(path, value): tmp = path.with_name(path.name + ".new") tmp.write_text(json.dumps(value, ensure_ascii=True, indent=2) + "\n") tmp.chmod(0o600) tmp.replace(path) def _safe_error(exc): name = str(exc.args[0]) if getattr(exc, "args", ()) else "" if name.startswith("OWNER_"): return "TTY_INPUT_CONTEXT_INVALID" return name if _ERROR.fullmatch(name) else "TTY_INTERNAL_ERROR" class PortableManager(RealTerminalMixin, _core.Manager): """The operative real-terminal mixin with portable runtime configuration.""" def __init__(self, state, limits, home): self.profile = state self.limits = limits self.home = home self.tmux_socket = runtime_path(state) / "tmux.sock" self.tmux_bin = shutil.which("tmux") if not self.tmux_bin: raise TerminalDenied("TMUX_NOT_AVAILABLE") # The original constructor initializes its lock and bookkeeping. # Session policy is supplied by this profile, not its old staging cap. super().__init__(owner_verifier=self._local_context, max_sessions=2, idle_timeout_s=600) self.max_sessions = limits.max_sessions self._last_used = {} @staticmethod def _local_context(context): return (type(context) is dict and context.get("program") == "bash" and context.get("operation") in {"text.type", "key.send"} and type(context.get("session_id")) is str and _SESSION.fullmatch(context["session_id"]) is not None and type(context.get("payload_sha256")) is str and re.fullmatch(r"[a-f0-9]{64}", context["payload_sha256"]) is not None and context.get("owner_assertion") == {}) def _size(self, rows, cols): if (type(rows) is not int or type(cols) is not int or not self.limits.min_rows <= rows <= self.limits.max_rows or not self.limits.min_cols <= cols <= self.limits.max_cols): raise TerminalDenied("SIZE_INVALID") def open(self, program="bash", rows=24, cols=80): if program != "bash": raise TerminalDenied("PROGRAM_NOT_SUPPORTED") self._size(rows, cols) result = super().open("bash", rows, cols) self._last_used[result["session_id"]] = time.monotonic() return result def reap(self): super().reap() if self.limits.idle_timeout_s: now = time.monotonic() for sid, last in list(self._last_used.items()): if now - last > self.limits.idle_timeout_s and sid in self._real: try: super().abort(sid) finally: self._last_used.pop(sid, None) def touch(self, sid): if sid not in self._real: raise TerminalDenied("SESSION_NOT_FOUND") self._last_used[sid] = time.monotonic() def abort(self, sid): if sid not in self._real: raise TerminalDenied("SESSION_NOT_FOUND") result = super().abort(sid) self._last_used.pop(sid, None) return result def status(self): result = super().status() for key in ("owner_device_connected", "owner_signature_required"): result.pop(key, None) result.update({"input_policy": "same-uid-authorized", "input_enabled": True, "limits": asdict(self.limits), "real_idle_timeout_s": self.limits.idle_timeout_s or None}) return result class PortableHandler(socketserver.StreamRequestHandler): def send(self, value): raw = (json.dumps(value, ensure_ascii=True, separators=(",", ":")) + "\n").encode() if len(raw) > self.server.limits.rpc_max_bytes: raw = b'{"ok":false,"error":"TTY_RESPONSE_TOO_LARGE"}\n' self.wfile.write(raw) def handle(self): try: self.request.settimeout(self.server.limits.command_timeout_s + 5) _, uid, _ = struct.unpack("3i", self.request.getsockopt( socket.SOL_SOCKET, socket.SO_PEERCRED, 12)) if uid != self.server.owner_uid: self.send({"ok": False, "error": "SAME_UID_REQUIRED"}) return raw = self.rfile.readline(self.server.limits.rpc_max_bytes + 1) if len(raw) > self.server.limits.rpc_max_bytes or not raw.endswith(b"\n"): raise TerminalDenied("TTY_REQUEST_TOO_LARGE_OR_TRUNCATED") payload = json.loads(raw) result = dispatch(self.server.manager, payload) self.send(result) except (TerminalDenied, SudoControlError, KeyError) as exc: self.send({"ok": False, "error": _safe_error(exc)}) except (ValueError, TypeError, UnicodeError): self.send({"ok": False, "error": "TTY_REQUEST_INVALID"}) except Exception: self.send({"ok": False, "error": "TTY_INTERNAL_ERROR"}) class PortableServer(_server.LocalServer): def __init__(self, path, manager): p = Path(path).absolute() _private(p.parent) if p.exists() or p.is_symlink(): raise TerminalDenied("TTY_SOCKET_ALREADY_EXISTS") self.owner_uid = os.geteuid() self.manager = manager self.limits = manager.limits self._socket_path = p old = os.umask(0o077) try: socketserver.UnixStreamServer.__init__(self, str(p), PortableHandler) finally: os.umask(old) p.chmod(0o600) _SHAPES = { "status": (set(), set()), "open": (set(), {"program", "rows", "cols"}), "observe": ({"session_id"}, set()), "key.send": ({"session_id", "key"}, set()), "text.type": ({"session_id", "text"}, set()), "resize": ({"session_id", "rows", "cols"}, set()), "close": ({"session_id"}, set()), "sudo": ({"session_id", "mode"}, set()), } def dispatch(manager, payload): if type(payload) is not dict: raise TerminalDenied("TTY_REQUEST_INVALID") if payload.get("authorized") is not True: raise TerminalDenied("AUTHORIZED_TRUE_REQUIRED") op = payload.get("op") if op == "abort": op = "close" if type(op) is not str or op not in _SHAPES: raise TerminalDenied("TTY_OPERATION_INVALID") required, optional = _SHAPES[op] keys = set(payload) - {"op", "authorized"} if not required <= keys or keys - required - optional: raise TerminalDenied("TTY_REQUEST_INVALID") sid = payload.get("session_id") if sid is not None and (type(sid) is not str or not _SESSION.fullmatch(sid)): raise TerminalDenied("SESSION_ID_INVALID") with manager._lock: manager.reap() if op not in {"status", "open"}: manager.touch(sid) if op == "status": data = manager.status() elif op == "open": data = manager.open(payload.get("program", "bash"), payload.get("rows", 24), payload.get("cols", 80)) elif op == "sudo": data = manager.sudo(sid, payload["mode"]) if payload["mode"] != "status" and data.get("cleanup_pending"): return {"ok": False, "error": "TTY_SUDO_TRANSITION_CLEANUP_PENDING", "result": data, "transport": "same-uid-unix"} elif op == "observe": data = manager.observe(sid) data = dict(data, observation_kind="terminal_transcript", authentic_pixels=False, full_screen=False) elif op == "key.send": data = manager.key(sid, payload["key"], approved_context={}) elif op == "text.type": data = manager.text(sid, payload["text"], approved_context={}) elif op == "resize": manager._size(payload["rows"], payload["cols"]) data = manager.resize(sid, payload["rows"], payload["cols"]) else: data = manager.abort(sid) return {"ok": True, "result": data, "transport": "same-uid-unix"} def request(state, payload): """Send one authorized operation to this profile's real TTY daemon.""" try: if type(payload) is not dict or payload.get("authorized") is not True: return {"ok": False, "error": "AUTHORIZED_TRUE_REQUIRED"} state = state_path(state) path = runtime_path(state) / "agent.sock" if not path.exists(): return {"ok": False, "error": "TTY_NOT_RUNNING"} s = path.lstat() if (not stat.S_ISSOCK(s.st_mode) or s.st_uid != os.geteuid() or stat.S_IMODE(s.st_mode) != 0o600): return {"ok": False, "error": "TTY_SOCKET_UNTRUSTED"} limits, _ = _config(state) raw = (json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + "\n").encode() if len(raw) > limits.rpc_max_bytes: return {"ok": False, "error": "TTY_REQUEST_TOO_LARGE_OR_TRUNCATED"} with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: client.settimeout(limits.command_timeout_s + 10) client.connect(str(path)) client.sendall(raw) client.shutdown(socket.SHUT_WR) received = bytearray() while True: part = client.recv(65536) if not part: break received.extend(part) if len(received) > limits.rpc_max_bytes: return {"ok": False, "error": "TTY_RESPONSE_TOO_LARGE"} value = json.loads(received) return value if type(value) is dict else {"ok": False, "error": "TTY_RESPONSE_INVALID"} except TerminalDenied as exc: return {"ok": False, "error": _safe_error(exc)} except (OSError, TimeoutError): return {"ok": False, "error": "TTY_UNAVAILABLE"} except (ValueError, TypeError, UnicodeError): return {"ok": False, "error": "TTY_RESPONSE_INVALID"} def status(state=None): return request(state, {"op": "status", "authorized": True}) def serve(state=None): state = _private(state_path(state)) limits, home = _config(state) rt = _private(runtime_path(state)) lock = (state / "tty.lock").open("a") fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) path = rt / "agent.sock" if path.exists() or path.is_symlink(): old = status(state) if old.get("ok"): raise TerminalDenied("TTY_ALREADY_RUNNING") st = path.lstat() if not stat.S_ISSOCK(st.st_mode) or st.st_uid != os.geteuid(): raise TerminalDenied("TTY_SOCKET_UNTRUSTED") path.unlink() manager = PortableManager(state, limits, home) def stopped(_signum, _frame): raise SystemExit(0) signal.signal(signal.SIGTERM, stopped) signal.signal(signal.SIGINT, stopped) pidfile = state / "tty-process.json" _write_json(pidfile, {**_identity(os.getpid()), "socket": str(path), "state_dir": str(state)}) try: with PortableServer(path, manager) as server: print(json.dumps({"ok": True, "ready": True, "socket": str(path), "policy": "same-uid-authorized", "x11_required": False}), flush=True) server.serve_forever(poll_interval=0.2) finally: pidfile.unlink(missing_ok=True) def start(state=None): state = _private(state_path(state)) current = status(state) if current.get("ok"): return current env = os.environ.copy() root = str(Path(__file__).resolve().parents[1]) env["PYTHONPATH"] = root + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "") log = (state / "tty.log").open("ab") proc = subprocess.Popen([sys.executable, "-m", "framebridge_expanded.tty", "serve", "--state", str(state)], env=env, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, umask=0o077) log.close() _STARTED_CHILDREN[str(state)] = proc deadline = time.monotonic() + 5 while proc.poll() is None and time.monotonic() < deadline: current = status(state) if current.get("ok"): return current time.sleep(0.1) return {"ok": False, "error": "TTY_START_FAILED"} def stop(state=None): state = state_path(state) p = state / "tty-process.json" if not p.is_file() or p.is_symlink(): return {"ok": True, "state": "STOPPED", "already_stopped": True} try: record = json.loads(p.read_text()) if not _alive(record): return {"ok": True, "state": "STOPPED", "already_stopped": True} os.kill(record["pid"], signal.SIGTERM) deadline = time.monotonic() + 10 while _alive(record) and time.monotonic() < deadline: time.sleep(0.1) return {"ok": not _alive(record), "state": "STOPPED" if not _alive(record) else "STOP_TIMEOUT"} except (ValueError, OSError, KeyError): return {"ok": False, "error": "TTY_STOP_FAILED"} finally: child = _STARTED_CHILDREN.get(str(state)) if child is not None: try: child.wait(timeout=2) _STARTED_CHILDREN.pop(str(state), None) except subprocess.TimeoutExpired: pass def _wire_reply(state, result): cache = _private(state_path(state) / "tty-replies") for old in cache.glob("*.json"): if time.time() - old.stat().st_mtime > 3600: old.unlink(missing_ok=True) raw = json.dumps(result, ensure_ascii=True, separators=(",", ":")).encode() encoded = base64.b64encode(zlib.compress(raw, 6)).decode("ascii") chunks = [encoded[i:i + _WIRE_CHARS] for i in range(0, len(encoded), _WIRE_CHARS)] ticket = uuid.uuid4().hex item = {"ticket": ticket, "sha256": hashlib.sha256(raw).hexdigest(), "bytes": len(raw), "chunks": chunks} _write_json(cache / (ticket + ".json"), item) return {"ok": True, "wire": "zlib-base64-v1", "ticket": ticket, "sha256": item["sha256"], "bytes": len(raw), "chunks": len(chunks), "index": 0, "data": chunks[0]} def read_reply(state, ticket, index): if type(ticket) is not str or not _SESSION.fullmatch(ticket) or type(index) is not int or index < 0: return {"ok": False, "error": "TTY_REPLY_INVALID"} p = state_path(state) / "tty-replies" / (ticket + ".json") try: st = p.lstat() if not stat.S_ISREG(st.st_mode) or st.st_uid != os.geteuid() or st.st_mode & 0o077: raise ValueError() item = json.loads(p.read_text()) return {"ok": True, "ticket": ticket, "index": index, "data": item["chunks"][index], "chunks": len(item["chunks"]), "sha256": item["sha256"]} except (OSError, ValueError, KeyError, IndexError): return {"ok": False, "error": "TTY_REPLY_NOT_FOUND"} def main(argv=None): parser = argparse.ArgumentParser(description="Real persistent Bash CUA-TTY over a same-UID Unix socket.") parser.add_argument("command", choices=("serve", "start", "stop", "status", "request", "read")) parser.add_argument("--state", default=None) parser.add_argument("--payload-b64", default=None) parser.add_argument("--wire", action="store_true", help="Return a compressed, chunked response for bounded transports.") parser.add_argument("--ticket") parser.add_argument("--index", type=int, default=0) args = parser.parse_args(argv) try: if args.command == "serve": serve(args.state) return 0 if args.command == "start": result = start(args.state) elif args.command == "stop": result = stop(args.state) elif args.command == "status": result = status(args.state) elif args.command == "read": result = read_reply(args.state, args.ticket, args.index) else: if args.payload_b64: raw = base64.b64decode(args.payload_b64, validate=True) else: raw = sys.stdin.buffer.read(16777217) if len(raw) > 16777216: raise TerminalDenied("TTY_REQUEST_TOO_LARGE_OR_TRUNCATED") result = request(args.state, json.loads(raw)) if args.wire: result = _wire_reply(args.state, result) print(json.dumps(result, ensure_ascii=True, separators=(",", ":"))) return 0 if result.get("ok") else 1 except TerminalDenied as exc: print(json.dumps({"ok": False, "error": _safe_error(exc)})) return 1 except (ValueError, TypeError, UnicodeError): print(json.dumps({"ok": False, "error": "TTY_REQUEST_INVALID"})) return 1 if __name__ == "__main__": raise SystemExit(main())