FrameBridge 0.3.1 community source release
This commit is contained in:
commit
b23819fff1
1354 files changed
+343716
No files matched your search
@@ -0,0 +1,301 @@
|
||||
"""Bind real framebuffer observations to the visible, owned tmux terminal.
|
||||
|
||||
This module never selects a window, pane or VT. Input is permitted only to the
|
||||
pane that was active in the client on the observed foreground VT. Capture files
|
||||
and manifests are stored by the caller in its private, immutable capture store.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
class ScreenControlError(RuntimeError):
|
||||
def __init__(self, code, **details):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.details = details
|
||||
|
||||
|
||||
ControlError = ScreenControlError
|
||||
|
||||
|
||||
KEYS = {
|
||||
"ENTER": "Enter", "RETURN": "Enter", "TAB": "Tab", "SHIFT_TAB": "BTab",
|
||||
"ESC": "Escape", "BACKSPACE": "BSpace", "DELETE": "DC", "INSERT": "IC",
|
||||
"UP": "Up", "DOWN": "Down", "LEFT": "Left", "RIGHT": "Right",
|
||||
"HOME": "Home", "END": "End", "PAGE_UP": "PPage", "PAGE_DOWN": "NPage",
|
||||
"SPACE": "Space", "CTRL_C": "C-c", "CTRL_D": "C-d", "CTRL_Z": "C-z",
|
||||
"CTRL_A": "C-a", "CTRL_E": "C-e", "CTRL_L": "C-l", "CTRL_U": "C-u",
|
||||
"CTRL_K": "C-k", "CTRL_R": "C-r", "CTRL_W": "C-w",
|
||||
**{"F" + str(n): "F" + str(n) for n in range(1, 13)},
|
||||
}
|
||||
|
||||
|
||||
def _active_vt():
|
||||
value = Path("/sys/class/tty/tty0/active").read_text().strip()
|
||||
if re.fullmatch(r"tty[1-9][0-9]*", value) is None:
|
||||
raise ScreenControlError("SCREEN_ACTIVE_VT_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _proc(pid, *, tmux=False):
|
||||
p = Path("/proc") / str(int(pid))
|
||||
if p.stat().st_uid != os.getuid():
|
||||
raise ScreenControlError("SCREEN_PROCESS_OWNER_MISMATCH")
|
||||
raw = (p / "stat").read_text()
|
||||
fields = raw[raw.rfind(")") + 2:].split()
|
||||
if tmux and os.path.realpath(p / "exe") != os.path.realpath("/usr/bin/tmux"):
|
||||
raise ScreenControlError("SCREEN_TMUX_PROCESS_MISMATCH")
|
||||
return {"pid": int(pid), "start_ticks": int(fields[19]),
|
||||
"uid": p.stat().st_uid}
|
||||
|
||||
|
||||
def _socket_identity(path):
|
||||
"""Reject symlinks and paths outside the owned runtime directories."""
|
||||
p = Path(path)
|
||||
if not p.is_absolute() or ".." in p.parts:
|
||||
raise ScreenControlError("SCREEN_SOCKET_PATH_UNTRUSTED")
|
||||
roots = [Path("/run/user") / str(os.getuid()),
|
||||
Path("/tmp") / ("tmux-" + str(os.getuid()))]
|
||||
root = next((r for r in roots if p.is_relative_to(r)), None)
|
||||
if root is None:
|
||||
raise ScreenControlError("SCREEN_SOCKET_PATH_UNTRUSTED")
|
||||
current = root
|
||||
for part in ("", *p.relative_to(root).parts):
|
||||
if part:
|
||||
current /= part
|
||||
st = current.lstat()
|
||||
if stat.S_ISLNK(st.st_mode) or st.st_uid != os.getuid():
|
||||
raise ScreenControlError("SCREEN_SOCKET_OWNER_MISMATCH")
|
||||
if current != p:
|
||||
if not stat.S_ISDIR(st.st_mode) or stat.S_IMODE(st.st_mode) & 0o022:
|
||||
raise ScreenControlError("SCREEN_SOCKET_PARENT_UNTRUSTED")
|
||||
elif not stat.S_ISSOCK(st.st_mode):
|
||||
raise ScreenControlError("SCREEN_SOCKET_NOT_SOCKET")
|
||||
return {"path": str(p), "device": st.st_dev, "inode": st.st_ino,
|
||||
"uid": st.st_uid}
|
||||
|
||||
|
||||
def _server_sockets():
|
||||
"""Only probe sockets actually held by a same-UID tmux server process."""
|
||||
unix = {}
|
||||
for line in Path("/proc/net/unix").read_text().splitlines()[1:]:
|
||||
fields = line.split(maxsplit=7)
|
||||
if len(fields) == 8 and fields[7].startswith("/"):
|
||||
unix[fields[6]] = fields[7]
|
||||
found = []
|
||||
for p in Path("/proc").iterdir():
|
||||
if not p.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
if p.stat().st_uid != os.getuid() or (p / "comm").read_text().strip() != "tmux: server":
|
||||
continue
|
||||
process = _proc(int(p.name), tmux=True)
|
||||
for fd in (p / "fd").iterdir():
|
||||
target = os.readlink(fd)
|
||||
match = re.fullmatch(r"socket:\[([0-9]+)\]", target)
|
||||
if match and match[1] in unix:
|
||||
try:
|
||||
identity = _socket_identity(unix[match[1]])
|
||||
except (OSError, ScreenControlError):
|
||||
continue
|
||||
found.append((identity, process))
|
||||
if _proc(int(p.name), tmux=True) != process:
|
||||
raise ScreenControlError("SCREEN_TMUX_SERVER_CHANGED")
|
||||
except (FileNotFoundError, PermissionError, ProcessLookupError):
|
||||
continue
|
||||
unique = {(s[0]["path"], s[1]["pid"]): s for s in found}
|
||||
if len(unique) > 32:
|
||||
raise ScreenControlError("SCREEN_TMUX_SERVER_LIMIT")
|
||||
return list(unique.values())
|
||||
|
||||
|
||||
def _tmux(socket_path, *args):
|
||||
env = {k: v for k, v in os.environ.items() if k not in ("TMUX", "TMUX_PANE")}
|
||||
env.update({"LC_ALL": "C.UTF-8", "TERM": "xterm-256color"})
|
||||
try:
|
||||
result = subprocess.run(["/usr/bin/tmux", "-S", socket_path, *args],
|
||||
capture_output=True, text=True, env=env, timeout=4)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise ScreenControlError("SCREEN_TMUX_COMMAND_FAILED") from exc
|
||||
if result.returncode or len(result.stdout.encode()) > 65536:
|
||||
raise ScreenControlError("SCREEN_TMUX_COMMAND_FAILED")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _rows(value, size):
|
||||
out = []
|
||||
for line in value.splitlines():
|
||||
fields = line.split("|")
|
||||
if len(fields) != size:
|
||||
raise ScreenControlError("SCREEN_TMUX_METADATA_INVALID")
|
||||
out.append(fields)
|
||||
return out
|
||||
|
||||
|
||||
def snapshot_binding():
|
||||
"""Read-only snapshot of the active visible terminal; never fall back hidden."""
|
||||
vt = _active_vt()
|
||||
tty = "/dev/" + vt
|
||||
matches = []
|
||||
client_format = "|".join(["#{client_pid}", "#{client_tty}", "#{session_id}",
|
||||
"#{client_width}", "#{client_height}", "#{client_control_mode}"])
|
||||
pane_format = "|".join(["#{session_id}", "#{window_id}", "#{window_active}",
|
||||
"#{pane_id}", "#{pane_pid}", "#{pane_tty}", "#{pane_active}",
|
||||
"#{pane_left}", "#{pane_top}", "#{pane_width}", "#{pane_height}",
|
||||
"#{window_width}", "#{window_height}", "#{pane_dead}",
|
||||
"#{window_zoomed_flag}", "#{pane_in_mode}"])
|
||||
for socket_identity, server in _server_sockets():
|
||||
path = socket_identity["path"]
|
||||
try:
|
||||
clients = [c for c in _rows(_tmux(path, "list-clients", "-F", client_format), 6)
|
||||
if c[1] == tty and c[5] == "0"]
|
||||
if not clients:
|
||||
continue
|
||||
panes = _rows(_tmux(path, "list-panes", "-a", "-F", pane_format), 16)
|
||||
for client in clients:
|
||||
visible = [p for p in panes if p[0] == client[2] and p[2] == "1"
|
||||
and p[6] == "1" and p[13] == "0"]
|
||||
if len(visible) != 1:
|
||||
raise ScreenControlError("SCREEN_VISIBLE_PANE_AMBIGUOUS")
|
||||
p = visible[0]
|
||||
binding = {"version": 1, "backend": "visible-tmux", "node": os.uname().nodename,
|
||||
"uid": os.getuid(), "active_vt": vt, "client_tty": tty,
|
||||
"socket": socket_identity, "server": server,
|
||||
"client": _proc(int(client[0]), tmux=True),
|
||||
"session_id": p[0], "window_id": p[1], "pane_id": p[3],
|
||||
"pane": _proc(int(p[4])), "pane_tty": p[5],
|
||||
"client_size": [int(client[3]), int(client[4])],
|
||||
"window_size": [int(p[11]), int(p[12])],
|
||||
"pane_geometry": [int(x) for x in p[7:11]],
|
||||
"window_zoomed": p[14] == "1", "pane_in_mode": p[15] == "1"}
|
||||
if _socket_identity(path) != socket_identity or _proc(server["pid"], tmux=True) != server:
|
||||
raise ScreenControlError("SCREEN_TMUX_SERVER_CHANGED")
|
||||
matches.append(binding)
|
||||
except ScreenControlError as exc:
|
||||
if exc.code == "SCREEN_TMUX_COMMAND_FAILED":
|
||||
continue
|
||||
raise
|
||||
if _active_vt() != vt:
|
||||
raise ScreenControlError("SCREEN_ACTIVE_VT_CHANGED")
|
||||
if not matches:
|
||||
raise ScreenControlError("SCREEN_VISIBLE_TMUX_NOT_FOUND")
|
||||
if len(matches) != 1:
|
||||
raise ScreenControlError("SCREEN_VISIBLE_CLIENT_AMBIGUOUS")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def validate_binding(expected):
|
||||
if not isinstance(expected, dict) or expected.get("backend") != "visible-tmux":
|
||||
raise ScreenControlError("SCREEN_BINDING_REQUIRED")
|
||||
current = snapshot_binding()
|
||||
if current != expected:
|
||||
raise ScreenControlError("SCREEN_BINDING_CHANGED")
|
||||
return current
|
||||
|
||||
|
||||
def bind_screen(manifest):
|
||||
if not isinstance(manifest, dict) or not isinstance(manifest.get("capture_id"), str) or not manifest["capture_id"]:
|
||||
raise ScreenControlError("SCREEN_CAPTURE_ID_REQUIRED")
|
||||
if manifest.get("source") != "linux-framebuffer" or manifest.get("screen_type") != "tty":
|
||||
raise ScreenControlError("SCREEN_CONTROL_MODE_UNSUPPORTED")
|
||||
binding = snapshot_binding()
|
||||
if manifest.get("active_vt") != binding["active_vt"]:
|
||||
raise ScreenControlError("SCREEN_CAPTURE_VT_MISMATCH")
|
||||
if manifest.get("node") != binding["node"] or manifest.get("uid") != binding["uid"]:
|
||||
raise ScreenControlError("SCREEN_CAPTURE_NODE_MISMATCH")
|
||||
return {**manifest, "binding": binding}
|
||||
|
||||
|
||||
def _verify_capture(manifest, max_age_s):
|
||||
if not isinstance(manifest, dict) or not manifest.get("capture_id"):
|
||||
raise ScreenControlError("SCREEN_CAPTURE_ID_REQUIRED")
|
||||
if manifest.get("source") != "linux-framebuffer" or manifest.get("screen_type") != "tty":
|
||||
raise ScreenControlError("SCREEN_CONTROL_MODE_UNSUPPORTED")
|
||||
if manifest.get("synthetic") is not False or manifest.get("pixel_source_verified") is not True:
|
||||
raise ScreenControlError("SCREEN_REAL_CAPTURE_REQUIRED")
|
||||
if max_age_s is not None and (not isinstance(max_age_s, (int, float)) or not 0 < max_age_s <= 300):
|
||||
raise ScreenControlError("SCREEN_CAPTURE_AGE_LIMIT_INVALID")
|
||||
try:
|
||||
when = datetime.fromisoformat(manifest["completed_at"])
|
||||
if when.tzinfo is None:
|
||||
raise ValueError()
|
||||
age = (datetime.now(timezone.utc) - when).total_seconds()
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise ScreenControlError("SCREEN_CAPTURE_TIME_INVALID") from None
|
||||
if age < -5 or (max_age_s is not None and age > max_age_s):
|
||||
raise ScreenControlError("SCREEN_CAPTURE_EXPIRED")
|
||||
path = Path(manifest.get("path", ""))
|
||||
expected = manifest.get("sha256", "")
|
||||
if not path.is_absolute() or re.fullmatch(r"[a-f0-9]{64}", expected) is None:
|
||||
raise ScreenControlError("SCREEN_CAPTURE_FILE_INVALID")
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or st.st_size > 64 * 1024 * 1024:
|
||||
raise ScreenControlError("SCREEN_CAPTURE_FILE_UNTRUSTED")
|
||||
with os.fdopen(fd, "rb", closefd=False) as f:
|
||||
digest = hashlib.file_digest(f, "sha256").hexdigest()
|
||||
if digest != expected:
|
||||
raise ScreenControlError("SCREEN_CAPTURE_SHA256_MISMATCH")
|
||||
finally:
|
||||
os.close(fd)
|
||||
except OSError as exc:
|
||||
raise ScreenControlError("SCREEN_CAPTURE_FILE_UNAVAILABLE") from exc
|
||||
|
||||
|
||||
def act(capture_manifest, kind, *, text=None, key=None, max_age_s=None):
|
||||
"""Send literal text (no controls/newline) or one explicit allowlisted key.
|
||||
|
||||
The RPC caller must load this manifest by capture_id from its private store;
|
||||
it must not accept a replacement manifest supplied by a remote caller.
|
||||
Foreground changes are checked before and after dispatch. An independent
|
||||
local user's VT switch concurrent with the actual write cannot be made
|
||||
atomic with tmux; postflight failure therefore reports performed=True.
|
||||
"""
|
||||
if kind in ("text", "text.type", "type"):
|
||||
if key is not None or not isinstance(text, str) or not text or len(text.encode()) > 16384:
|
||||
raise ScreenControlError("SCREEN_TEXT_INVALID")
|
||||
if any(ord(c) < 32 or ord(c) == 127 for c in text):
|
||||
raise ScreenControlError("SCREEN_TEXT_CONTROL_CHARACTER")
|
||||
command = ["send-keys", "-l", "--", text]
|
||||
action = "text.type"
|
||||
elif kind in ("key", "key.send"):
|
||||
if text is not None or not isinstance(key, str) or key not in KEYS:
|
||||
raise ScreenControlError("SCREEN_KEY_INVALID")
|
||||
command = ["send-keys", KEYS[key]]
|
||||
action = "key.send"
|
||||
else:
|
||||
raise ScreenControlError("SCREEN_ACTION_UNSUPPORTED")
|
||||
_verify_capture(capture_manifest, max_age_s)
|
||||
expected = capture_manifest.get("binding")
|
||||
binding = validate_binding(expected)
|
||||
if binding.get("pane_in_mode"):
|
||||
raise ScreenControlError("SCREEN_TMUX_MODE_INPUT_UNSUPPORTED")
|
||||
if capture_manifest.get("active_vt") != binding["active_vt"]:
|
||||
raise ScreenControlError("SCREEN_CAPTURE_VT_MISMATCH")
|
||||
if capture_manifest.get("node") != binding["node"] or capture_manifest.get("uid") != binding["uid"]:
|
||||
raise ScreenControlError("SCREEN_CAPTURE_NODE_MISMATCH")
|
||||
# A second check immediately before dispatch prevents using a stale probe.
|
||||
binding = validate_binding(expected)
|
||||
path, pane = binding["socket"]["path"], binding["pane_id"]
|
||||
_tmux(path, command[0], "-t", pane, *command[1:])
|
||||
try:
|
||||
validate_binding(expected)
|
||||
except (ScreenControlError, OSError) as exc:
|
||||
raise ScreenControlError("SCREEN_CHANGED_AFTER_INPUT", performed=True,
|
||||
action=action, capture_id=capture_manifest["capture_id"]) from exc
|
||||
return {"ok": True, "performed": True, "action": action,
|
||||
"capture_id": capture_manifest["capture_id"], "binding": binding,
|
||||
"input_bytes": len(text.encode()) if action == "text.type" else None,
|
||||
"key": key if action == "key.send" else None,
|
||||
"input_target": "observed-visible-pane"}
|
||||
Reference in new issue
Block a user