FrameBridge 0.3.1 community source release

This commit is contained in:
FrameBridge Development committed 2026-10-26 22:09:18 -04:00
commit b23819fff1
1354 files changed
+343716

No files matched your search

@@ -0,0 +1 @@
"""Actual active-screen capture and bound visible terminal input."""
@@ -0,0 +1,575 @@
#!/usr/bin/env python3
"""Capture actual Linux VT or X11 root pixels, never rendered terminal text.
Active X11 sessions use XGetImage, and Wayland fails explicitly. The framebuffer
backend is never used as a substitute for an active graphical session.
"""
from __future__ import annotations
import array
import base64
import ctypes
import ctypes.util
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
import fcntl
import hashlib
import json
import math
import os
from pathlib import Path
import pwd
import re
import socket
import stat
import struct
import subprocess
import sys
import threading
import uuid
import zlib
MAX_PIXELS = 4096 * 2160
MAX_FRAMEBUFFER_BYTES = 128 * 1024 * 1024
MAX_IMAGE_BYTES = 32 * 1024 * 1024
MAX_CHUNK_BYTES = 2400
CAPTURE_ID = re.compile(r"[0-9a-f]{32}\Z")
SESSION_ID = re.compile(r"[A-Za-z0-9_-]{1,64}\Z")
DISPLAY_ID = re.compile(r":[0-9]{1,5}(?:\.[0-9]{1,3})?\Z")
_X11_LOCK = threading.RLock()
FBIOGET_VSCREENINFO = 0x4600
FBIOGET_FSCREENINFO = 0x4602
class CaptureError(RuntimeError):
def __init__(self, code: str):
self.code = code
super().__init__(code)
def require(condition: bool, code: str) -> None:
if not condition:
raise CaptureError(code)
@dataclass(frozen=True)
class Foreground:
active_vt: str
session_id: str
uid: int
leader: int
session_type: str
tty: str
seat: str
vtnr: int
display: str = ""
def choose_foreground(active_vt: str, sessions: list[dict], seat: str) -> Foreground:
"""Require an identifiable active text session on the actual foreground VT."""
require(re.fullmatch(r"tty[1-9][0-9]*", active_vt) is not None, "ACTIVE_VT_UNVERIFIED")
active = [s for s in sessions if s.get("Seat") == seat and s.get("Active") == "yes"
and s.get("Remote") == "no"]
require(not any(s.get("Type") in {"x11", "wayland"} for s in active),
"GRAPHICAL_CAPTURE_REQUIRED")
matching = [s for s in active if s.get("Type") == "tty" and s.get("TTY") == active_vt
and s.get("VTNr") == active_vt[3:]]
require(len(matching) == 1, "FOREGROUND_SESSION_UNVERIFIED")
s = matching[0]
try:
result = Foreground(active_vt, s["Id"], int(s["User"]), int(s["Leader"]),
s["Type"], s["TTY"], s["Seat"], int(s["VTNr"]))
except (KeyError, TypeError, ValueError):
raise CaptureError("FOREGROUND_SESSION_INVALID") from None
require(SESSION_ID.fullmatch(result.session_id) is not None and result.leader > 0,
"FOREGROUND_SESSION_INVALID")
return result
def choose_display_foreground(active_vt: str, sessions: list[dict], seat: str) -> Foreground:
active = [s for s in sessions if s.get("Seat") == seat and s.get("Active") == "yes"
and s.get("Remote") == "no"]
require(len(active) == 1, "FOREGROUND_SESSION_UNVERIFIED")
s = active[0]
if s.get("Type") == "tty":
return choose_foreground(active_vt, sessions, seat)
require(s.get("Type") != "wayland", "WAYLAND_CAPTURE_UNSUPPORTED")
require(s.get("Type") == "x11", "SCREEN_TYPE_UNSUPPORTED")
# LightDM may leave TTY empty while supplying the actual numeric VT.
# Missing metadata or an explicit contradictory TTY is still rejected.
require(re.fullmatch(r"tty[1-9][0-9]*", active_vt) is not None
and s.get("VTNr") == active_vt[3:] and s.get("TTY") in ("", active_vt),
"GRAPHICAL_VT_UNVERIFIED")
require(DISPLAY_ID.fullmatch(s.get("Display", "")) is not None, "X11_DISPLAY_UNVERIFIED")
try:
f = Foreground(active_vt, s["Id"], int(s["User"]), int(s["Leader"]), "x11",
s["TTY"], s["Seat"], int(s["VTNr"]), s["Display"])
except (KeyError, ValueError, TypeError):
raise CaptureError("FOREGROUND_SESSION_INVALID") from None
require(f.uid == os.getuid() and f.leader > 0 and SESSION_ID.fullmatch(f.session_id) is not None,
"X11_SESSION_IDENTITY_MISMATCH")
return f
def foreground(seat: str = "seat0", *, allow_graphics: bool = False) -> Foreground:
def run(args: list[str]) -> str:
try:
p = subprocess.run(args, check=False, capture_output=True, timeout=4,
text=True, env={**os.environ, "LC_ALL": "C"})
except (OSError, subprocess.TimeoutExpired):
raise CaptureError("SESSION_QUERY_FAILED") from None
require(p.returncode == 0 and len(p.stdout) <= 65536, "SESSION_QUERY_FAILED")
return p.stdout
ids = []
for line in run(["loginctl", "list-sessions", "--no-legend", "--no-pager"]).splitlines():
if line.strip():
sid = line.split()[0]
require(SESSION_ID.fullmatch(sid) is not None, "SESSION_QUERY_INVALID")
ids.append(sid)
require(len(ids) <= 32, "SESSION_COUNT_EXCEEDED")
fields = ["Id", "Active", "Type", "Seat", "VTNr", "TTY", "User", "Leader", "Remote", "Display"]
sessions = []
for sid in ids:
args = ["loginctl", "show-session", sid, "--no-pager"]
for key in fields:
args += ["-p", key]
sessions.append(dict(line.split("=", 1) for line in run(args).splitlines() if "=" in line))
try:
active_vt = Path("/sys/class/tty/tty0/active").read_text().strip()
except OSError:
raise CaptureError("ACTIVE_VT_UNAVAILABLE") from None
return (choose_display_foreground(active_vt, sessions, seat) if allow_graphics
else choose_foreground(active_vt, sessions, seat))
class _Fix(ctypes.Structure):
_fields_ = [("id", ctypes.c_char * 16), ("smem_start", ctypes.c_ulong),
("smem_len", ctypes.c_uint32), ("type", ctypes.c_uint32),
("type_aux", ctypes.c_uint32), ("visual", ctypes.c_uint32),
("xpanstep", ctypes.c_uint16), ("ypanstep", ctypes.c_uint16),
("ywrapstep", ctypes.c_uint16), ("line_length", ctypes.c_uint32),
("mmio_start", ctypes.c_ulong), ("mmio_len", ctypes.c_uint32),
("accel", ctypes.c_uint32), ("capabilities", ctypes.c_uint16),
("reserved", ctypes.c_uint16 * 2)]
@dataclass(frozen=True)
class Geometry:
width: int
height: int
virtual_width: int
virtual_height: int
xoffset: int
yoffset: int
stride: int
bpp: int
red: tuple[int, int]
green: tuple[int, int]
blue: tuple[int, int]
memory_bytes: int
driver: str = ""
byteorder: str = sys.byteorder
def validate_geometry(g: Geometry) -> None:
require(0 < g.width <= 4096 and 0 < g.height <= 2160 and
g.width * g.height <= MAX_PIXELS, "SCREEN_SIZE_UNSUPPORTED")
require(g.bpp in {16, 24, 32} and g.byteorder in {"little", "big"}, "PIXEL_FORMAT_UNSUPPORTED")
pixel_bytes = g.bpp // 8
require(g.virtual_width >= g.width and g.virtual_height >= g.height and
0 <= g.xoffset <= g.virtual_width - g.width and
0 <= g.yoffset <= g.virtual_height - g.height, "VIEWPORT_INVALID")
require(g.stride >= g.virtual_width * pixel_bytes and
0 < g.memory_bytes <= MAX_FRAMEBUFFER_BYTES and
(g.yoffset + g.height) * g.stride <= g.memory_bytes, "FRAMEBUFFER_LAYOUT_INVALID")
masks = []
for offset, length in (g.red, g.green, g.blue):
require(1 <= length <= 8 and 0 <= offset and offset + length <= g.bpp,
"COLOR_LAYOUT_UNSUPPORTED")
masks.append(((1 << length) - 1) << offset)
require(not any(a & b for i, a in enumerate(masks) for b in masks[i + 1:]),
"COLOR_LAYOUT_OVERLAP")
def read_geometry(fd: int) -> Geometry:
var = array.array("I", [0] * 40)
require(var.itemsize == 4, "HOST_ABI_UNSUPPORTED")
fixed_buffer = bytearray(ctypes.sizeof(_Fix))
try:
fcntl.ioctl(fd, FBIOGET_VSCREENINFO, var, True)
fcntl.ioctl(fd, FBIOGET_FSCREENINFO, fixed_buffer, True)
except OSError:
raise CaptureError("FRAMEBUFFER_INFO_UNAVAILABLE") from None
fixed = _Fix.from_buffer_copy(fixed_buffer)
require(fixed.type == 0 and fixed.visual == 2 and var[7] == 0 and var[20] == 0
and var[34] == 0 and all(var[i] == 0 for i in (10, 13, 16)),
"FRAMEBUFFER_FORMAT_UNSUPPORTED")
g = Geometry(var[0], var[1], var[2], var[3], var[4], var[5], fixed.line_length,
var[6], (var[8], var[9]), (var[11], var[12]), (var[14], var[15]),
fixed.smem_len, bytes(fixed.id).decode("ascii", "replace"))
validate_geometry(g)
return g
def decode_framebuffer(raw: bytes, g: Geometry) -> bytes:
"""Losslessly select visible pixels and decode the kernel-reported RGB fields."""
validate_geometry(g)
end = (g.yoffset + g.height - 1) * g.stride + (g.xoffset + g.width) * (g.bpp // 8)
require(len(raw) >= end, "FRAMEBUFFER_READ_SHORT")
pixel_bytes = g.bpp // 8
output = bytearray(g.width * g.height * 3)
byte_fields = g.bpp in {24, 32} and all(n == 8 and off % 8 == 0
for off, n in (g.red, g.green, g.blue))
for y in range(g.height):
start = (y + g.yoffset) * g.stride + g.xoffset * pixel_bytes
row = raw[start:start + g.width * pixel_bytes]
dst = y * g.width * 3
if byte_fields:
for component, (off, _) in enumerate((g.red, g.green, g.blue)):
byte = off // 8 if g.byteorder == "little" else pixel_bytes - 1 - off // 8
output[dst + component:dst + g.width * 3:3] = row[byte::pixel_bytes]
else:
for x in range(g.width):
value = int.from_bytes(row[x * pixel_bytes:(x + 1) * pixel_bytes], g.byteorder)
for component, (off, n) in enumerate((g.red, g.green, g.blue)):
mask = (1 << n) - 1
output[dst + x * 3 + component] = (((value >> off) & mask) * 255 + mask // 2) // mask
return bytes(output)
def encode_png(rgb: bytes, width: int, height: int, *, prefer_indexed: bool = False) -> bytes:
require(len(rgb) == width * height * 3 and width > 0 and height > 0, "PIXEL_LENGTH_INVALID")
def chunk(kind: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xffffffff)
# A palette changes only PNG storage, never pixels. Stop the palette attempt
# as soon as the 257th exact RGB value appears, retaining true RGB for photos.
palette = {}
indexes = None
if prefer_indexed:
indexes = bytearray(width * height)
for pixel, start in enumerate(range(0, len(rgb), 3)):
color = rgb[start:start + 3]
index = palette.get(color)
if index is None:
if len(palette) == 256:
indexes = None
break
index = len(palette)
palette[color] = index
indexes[pixel] = index
if indexes is not None:
depth = 1 if len(palette) <= 2 else 2 if len(palette) <= 4 else 4 if len(palette) <= 16 else 8
per_byte = 8 // depth
compressor = zlib.compressobj(9)
rows = []
for y in range(height):
source = indexes[y * width:(y + 1) * width]
if depth == 8:
packed = source
else:
packed = bytearray((width * depth + 7) // 8)
for x, value in enumerate(source):
packed[x // per_byte] |= value << (8 - depth * ((x % per_byte) + 1))
rows.append(compressor.compress(b"\0" + packed))
rows.append(compressor.flush())
return (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, depth, 3, 0, 0, 0))
+ chunk(b"PLTE", b"".join(palette)) + chunk(b"IDAT", b"".join(rows))
+ chunk(b"IEND", b""))
# RGB pixels are stored exactly; no fonts, labels, scaling or other overlays.
compressor = zlib.compressobj(9)
rows = []
for y in range(height):
rows.append(compressor.compress(b"\0" + rgb[y * width * 3:(y + 1) * width * 3]))
rows.append(compressor.flush())
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", b"".join(rows)) + chunk(b"IEND", b""))
def _directory(output_dir: Path | str) -> Path:
path = Path(output_dir).absolute()
path.mkdir(parents=True, mode=0o700, exist_ok=True)
s = path.lstat()
require(stat.S_ISDIR(s.st_mode) and not path.is_symlink() and s.st_uid == os.getuid()
and s.st_mode & 0o077 == 0, "CAPTURE_DIRECTORY_UNTRUSTED")
return path
def _write_new(path: Path, data: bytes) -> None:
try:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
except OSError:
raise CaptureError("CAPTURE_ARTIFACT_CREATE_FAILED") from None
with os.fdopen(fd, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
def require_unchanged(before: Foreground, after: Foreground) -> None:
require(before == after, "FOREGROUND_CHANGED_DURING_CAPTURE")
class _XImage(ctypes.Structure):
_fields_ = [("width", ctypes.c_int), ("height", ctypes.c_int), ("xoffset", ctypes.c_int),
("format", ctypes.c_int), ("data", ctypes.c_void_p), ("byte_order", ctypes.c_int),
("bitmap_unit", ctypes.c_int), ("bitmap_bit_order", ctypes.c_int),
("bitmap_pad", ctypes.c_int), ("depth", ctypes.c_int),
("bytes_per_line", ctypes.c_int), ("bits_per_pixel", ctypes.c_int),
("red_mask", ctypes.c_ulong), ("green_mask", ctypes.c_ulong),
("blue_mask", ctypes.c_ulong), ("obdata", ctypes.c_void_p),
("functions", ctypes.c_void_p * 6)]
def _mask_field(mask: int) -> tuple[int, int]:
require(mask > 0, "X11_COLOR_MASK_INVALID")
offset = (mask & -mask).bit_length() - 1
shifted = mask >> offset
require(shifted & (shifted + 1) == 0, "X11_COLOR_MASK_INVALID")
return offset, shifted.bit_length()
def _x11_leader_environment(f: Foreground, display: str) -> dict[str, str]:
"""Read the user leader's two context fields, never a display manager's env."""
try:
leader = Path("/proc") / str(f.leader)
owner = leader.stat().st_uid
require(owner in {0, f.uid}, "X11_LEADER_IDENTITY_MISMATCH")
if owner != f.uid:
# LightDM's session leader belongs to root. It is not the desktop
# user and its authentication context must not be read or reused.
return {}
environment = (leader / "environ").read_bytes()
require(len(environment) <= 1024 * 1024, "X11_CONTEXT_TOO_LARGE")
values = {}
for item in environment.split(b"\0"):
if b"=" not in item:
continue
name, value = item.split(b"=", 1)
if name in {b"DISPLAY", b"XAUTHORITY"}:
values[name.decode()] = value.decode("utf-8", "strict")
require(values.get("DISPLAY", display) == display, "X11_DISPLAY_IDENTITY_MISMATCH")
return values
except (OSError, UnicodeDecodeError):
return {}
def _x11_context(f: Foreground, display: str | None, xauthority: str | None) -> tuple[str, str | None]:
require(f.session_type == "x11" and f.uid == os.getuid(), "X11_SESSION_IDENTITY_MISMATCH")
display = display or f.display
require(isinstance(display, str) and DISPLAY_ID.fullmatch(display) is not None
and display == f.display, "X11_DISPLAY_IDENTITY_MISMATCH")
if xauthority is None:
# Use the matching process context or the user leader's whitelist.
# Authentication bytes are never inspected, logged or persisted here.
if os.environ.get("DISPLAY") == display:
xauthority = os.environ.get("XAUTHORITY")
if xauthority is None:
xauthority = _x11_leader_environment(f, display).get("XAUTHORITY")
if xauthority is None:
# Resolve the verified user's canonical home, not a caller's HOME.
# Xlib's normal fallback is made explicit and validated below.
try:
account = pwd.getpwuid(f.uid)
except KeyError:
raise CaptureError("X11_AUTHORITY_UNAVAILABLE") from None
require(account.pw_uid == f.uid and Path(account.pw_dir).is_absolute(),
"X11_AUTHORITY_PATH_INVALID")
xauthority = str(Path(account.pw_dir) / ".Xauthority")
if xauthority is not None:
require(isinstance(xauthority, str) and len(xauthority) <= 4096 and "\0" not in xauthority,
"X11_AUTHORITY_PATH_INVALID")
path = Path(xauthority)
try:
s = path.lstat()
except OSError:
raise CaptureError("X11_AUTHORITY_UNAVAILABLE") from None
require(path.is_absolute() and stat.S_ISREG(s.st_mode) and s.st_uid == f.uid
and s.st_mode & 0o077 == 0, "X11_AUTHORITY_UNTRUSTED")
return display, xauthority
def capture_x11_pixels(f: Foreground, *, display: str | None = None,
xauthority: str | None = None) -> tuple[bytes, Geometry, dict]:
display, xauthority = _x11_context(f, display, xauthority)
library = ctypes.util.find_library("X11")
require(library is not None, "X11_LIBRARY_UNAVAILABLE")
with _X11_LOCK:
try:
x = ctypes.CDLL(library)
x.XOpenDisplay.argtypes = [ctypes.c_char_p]; x.XOpenDisplay.restype = ctypes.c_void_p
x.XCloseDisplay.argtypes = [ctypes.c_void_p]
x.XDefaultRootWindow.argtypes = [ctypes.c_void_p]; x.XDefaultRootWindow.restype = ctypes.c_ulong
x.XGetGeometry.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int),
*([ctypes.POINTER(ctypes.c_uint)] * 4)]
x.XGetGeometry.restype = ctypes.c_int
x.XGetImage.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_int, ctypes.c_int,
ctypes.c_uint, ctypes.c_uint, ctypes.c_ulong, ctypes.c_int]
x.XGetImage.restype = ctypes.POINTER(_XImage)
x.XDestroyImage.argtypes = [ctypes.POINTER(_XImage)]
x.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int]
except (OSError, AttributeError):
raise CaptureError("X11_LIBRARY_UNAVAILABLE") from None
old_authority = os.environ.get("XAUTHORITY")
try:
if xauthority is not None:
os.environ["XAUTHORITY"] = xauthority
else:
os.environ.pop("XAUTHORITY", None)
connection = x.XOpenDisplay(display.encode("ascii"))
finally:
if old_authority is None:
os.environ.pop("XAUTHORITY", None)
else:
os.environ["XAUTHORITY"] = old_authority
require(bool(connection), "X11_DISPLAY_UNREADABLE")
image = None
try:
root = x.XDefaultRootWindow(connection)
def dimensions() -> tuple[int, int, int]:
rr = ctypes.c_ulong(); px = ctypes.c_int(); py = ctypes.c_int()
width = ctypes.c_uint(); height = ctypes.c_uint(); border = ctypes.c_uint(); depth = ctypes.c_uint()
ok = x.XGetGeometry(connection, root, ctypes.byref(rr), ctypes.byref(px), ctypes.byref(py),
ctypes.byref(width), ctypes.byref(height), ctypes.byref(border), ctypes.byref(depth))
require(bool(ok), "X11_GEOMETRY_UNAVAILABLE")
require(0 < width.value <= 4096 and 0 < height.value <= 2160
and width.value * height.value <= MAX_PIXELS, "SCREEN_SIZE_UNSUPPORTED")
return width.value, height.value, depth.value
width, height, depth = dimensions()
x.XSync(connection, 0)
image = x.XGetImage(connection, root, 0, 0, width, height, ctypes.c_ulong(-1).value, 2)
require(bool(image), "X11_IMAGE_UNAVAILABLE")
info = image.contents
require(info.width == width and info.height == height and info.xoffset == 0
and info.format == 2 and info.byte_order in {0, 1} and bool(info.data),
"X11_IMAGE_FORMAT_UNSUPPORTED")
count = info.bytes_per_line * height
g = Geometry(width, height, width, height, 0, 0, info.bytes_per_line, info.bits_per_pixel,
_mask_field(info.red_mask), _mask_field(info.green_mask), _mask_field(info.blue_mask),
count, "X11-XGetImage", "little" if info.byte_order == 0 else "big")
validate_geometry(g)
raw = ctypes.string_at(info.data, count)
require(dimensions() == (width, height, depth), "GEOMETRY_CHANGED_DURING_CAPTURE")
return decode_framebuffer(raw, g), g, {"display": display, "root_window": root, "depth": depth}
finally:
if image:
x.XDestroyImage(image)
x.XCloseDisplay(connection)
def capture_screen(output_dir: Path | str, *, device: str = "/dev/fb0", seat: str = "seat0",
display: str | None = None, xauthority: str | None = None) -> dict:
"""Capture immutable real screen pixels or fail; never substitute a text render."""
output_dir = _directory(output_dir)
before = foreground(seat, allow_graphics=True)
started = datetime.now(timezone.utc).isoformat()
if before.session_type == "x11":
rgb, g, backend = capture_x11_pixels(before, display=display, xauthority=xauthority)
require_unchanged(before, foreground(seat, allow_graphics=True))
png = encode_png(rgb, g.width, g.height)
source = "x11-root-XGetImage"
else:
png, g = _capture_framebuffer(before, device, seat)
backend = {}
source = "linux-framebuffer"
require(len(png) <= MAX_IMAGE_BYTES, "CAPTURE_IMAGE_TOO_LARGE")
require_unchanged(before, foreground(seat, allow_graphics=True))
capture_id = uuid.uuid4().hex
image_path = output_dir / (capture_id + ".png")
manifest = {"version": 1, "capture_id": capture_id, "source": source,
"screen_type": before.session_type, "node": socket.gethostname(), "uid": os.getuid(),
"width": g.width, "height": g.height,
"sha256": hashlib.sha256(png).hexdigest(), "bytes": len(png),
"path": str(image_path), "timestamp": started,
"completed_at": datetime.now(timezone.utc).isoformat(),
"active_vt": before.active_vt, "session": asdict(before),
"geometry": asdict(g), "backend": backend,
"cursor": ("pixels-present-in-framebuffer" if before.session_type == "tty"
else "hardware-cursor-not-included-by-XGetImage"),
"pixel_source_verified": True, "synthetic": False}
_write_new(image_path, png)
try:
_write_new(output_dir / (capture_id + ".json"),
json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode())
except BaseException:
image_path.unlink(missing_ok=True)
raise
return manifest
def _capture_framebuffer(before: Foreground, device: str, seat: str) -> tuple[bytes, Geometry]:
require(re.fullmatch(r"/dev/fb[0-9]+", device) is not None, "FRAMEBUFFER_DEVICE_INVALID")
try:
fd = os.open(device, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
except OSError:
raise CaptureError("FRAMEBUFFER_UNREADABLE") from None
try:
require(stat.S_ISCHR(os.fstat(fd).st_mode), "FRAMEBUFFER_DEVICE_INVALID")
g = read_geometry(fd)
count = (g.yoffset + g.height) * g.stride
raw = os.pread(fd, count, 0)
require(len(raw) == count, "FRAMEBUFFER_READ_SHORT")
require(read_geometry(fd) == g, "GEOMETRY_CHANGED_DURING_CAPTURE")
require_unchanged(before, foreground(seat, allow_graphics=True))
except OSError:
raise CaptureError("FRAMEBUFFER_READ_FAILED") from None
finally:
os.close(fd)
png = encode_png(decode_framebuffer(raw, g), g.width, g.height, prefer_indexed=True)
return png, g
def _read_private(path: Path, max_bytes: int) -> bytes:
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
try:
s = os.fstat(fd)
require(stat.S_ISREG(s.st_mode) and s.st_uid == os.getuid() and s.st_mode & 0o077 == 0
and s.st_nlink == 1 and s.st_size <= max_bytes, "CAPTURE_ARTIFACT_UNTRUSTED")
with os.fdopen(fd, "rb", closefd=False) as f:
b = f.read(max_bytes + 1)
require(len(b) == s.st_size, "CAPTURE_ARTIFACT_CHANGED")
return b
finally:
os.close(fd)
except OSError:
raise CaptureError("CAPTURE_ARTIFACT_UNAVAILABLE") from None
def read_chunk(output_dir: Path | str, capture_id: str, index: int, *, chunk_bytes: int = 2100,
expected_sha256: str | None = None) -> dict:
require(isinstance(capture_id, str) and CAPTURE_ID.fullmatch(capture_id) is not None,
"CAPTURE_ID_INVALID")
require(type(index) is int and index >= 0 and type(chunk_bytes) is int
and 1 <= chunk_bytes <= MAX_CHUNK_BYTES, "CHUNK_BOUNDS_INVALID")
directory = _directory(output_dir)
try:
m = json.loads(_read_private(directory / (capture_id + ".json"), 16384))
except (json.JSONDecodeError, UnicodeDecodeError):
raise CaptureError("CAPTURE_MANIFEST_INVALID") from None
require(isinstance(m, dict) and m.get("capture_id") == capture_id and m.get("synthetic") is False
and m.get("pixel_source_verified") is True, "CAPTURE_MANIFEST_INVALID")
png = _read_private(directory / (capture_id + ".png"), MAX_IMAGE_BYTES)
digest = hashlib.sha256(png).hexdigest()
require(digest == m.get("sha256") and len(png) == m.get("bytes"), "CAPTURE_SHA256_MISMATCH")
require(expected_sha256 is None or expected_sha256 == digest, "EXPECTED_SHA256_MISMATCH")
total = math.ceil(len(png) / chunk_bytes)
require(index < total, "CHUNK_INDEX_OUT_OF_RANGE")
data = png[index * chunk_bytes:(index + 1) * chunk_bytes]
return {"capture_id": capture_id, "index": index, "total_chunks": total,
"chunk_bytes": len(data), "bytes": len(png), "sha256": digest,
"encoding": "base64", "data": base64.b64encode(data).decode("ascii"),
"more": index + 1 < total}
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("output_dir")
args = parser.parse_args()
try:
print(json.dumps({"ok": True, "result": capture_screen(args.output_dir)}, sort_keys=True))
except CaptureError as exc:
print(json.dumps({"ok": False, "error": exc.code}, sort_keys=True))
raise SystemExit(1)
@@ -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"}
@@ -0,0 +1,267 @@
"""Original FrameBridge real-screen MCP adapter.
The node captures pixels. This adapter validates and transports those bytes;
it never renders terminal text into an image.
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import json
import math
import re
import shlex
import struct
import uuid
from typing import Callable
CHUNK_BYTES = 2100
MAX_IMAGE_BYTES = 32 * 1024 * 1024
MAX_STDOUT_BYTES = 4096
RPC_COMMAND = '/usr/bin/python3 -B -m framebridge_expanded.real_screen.screen_rpc '
HEX_ID = re.compile(r"[0-9a-f]{32}\Z")
SHA = re.compile(r"[0-9a-f]{64}\Z")
NODE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}\Z")
SID = re.compile(r"[A-Za-z0-9_-]{1,100}\Z")
ERROR = re.compile(r"[A-Z0-9_]{1,100}\Z")
READ = {"readOnlyHint": True, "destructiveHint": False,
"idempotentHint": False, "openWorldHint": False}
CHANGE = {**READ, "readOnlyHint": False}
class ScreenError(RuntimeError):
"""A bounded public error suitable for the MCP response."""
def __init__(self, code, details=None):
super().__init__(code)
self.details = details or {}
def require(ok, code):
if not ok:
raise ScreenError(code)
def encode(value):
return json.dumps(value, ensure_ascii=True, sort_keys=True,
separators=(",", ":"), allow_nan=False).encode("ascii")
def valid_reference(value, regex, optional=False):
return type(value) is str and ((optional and value == "") or regex.fullmatch(value) is not None)
class _AuthenticatedReply(dict):
"""A payload tied to the EXEC recipient verified by this client instance.
The Linux hostname in the payload is observed screen identity. The route
alias and UUID come from the existing authenticated executor envelope.
A JSON object cannot supply this local proof or change its capture identity.
"""
_IDENTITY_FIELDS = ("capture_id", "node", "sha256", "session_id", "bytes",
"width", "height", "source", "screen_type",
"full_screen", "authentic_pixels")
def __init__(self, payload, owner, node, node_id):
super().__init__(payload)
self.__owner, self.__node, self.__node_id = owner, node, node_id
self.__identity = tuple(self.get(key) for key in self._IDENTITY_FIELDS)
def bound_to(self, owner, node, node_id=None):
return (self.__owner is owner and self.__node == node
and (node_id is None or self.__node_id == node_id)
and self.__identity == tuple(self.get(key) for key in self._IDENTITY_FIELDS))
@property
def node_id(self):
return self.__node_id
class ScreenClient:
"""Transport only; execution policy and node resolution stay in the host."""
def __init__(self, exec_request: Callable, image_factory: Callable,
resolve_node: Callable | None = None):
self.exec_request = exec_request
self.image_factory = image_factory
self.resolve_node = resolve_node or (lambda node: node.upper())
self._reply_owner = object()
def remote(self, node, request):
payload = base64.b64encode(encode(request)).decode("ascii")
script = RPC_COMMAND + shlex.quote(payload) + "\n"
digest = hashlib.sha256(script.encode("utf-8")).hexdigest()
job_id = "cua-screen-" + uuid.uuid4().hex
outer = self.exec_request(script=script, authorized=True, timeout_s=30,
job_id=job_id, sha256=digest,
include_stdout=True, target=node)
require(type(outer) is dict, "SCREEN_EXEC_FAILED")
require(type(outer.get("exit_code")) in (int, str) and
str(outer["exit_code"]) in ("0", "1"), "SCREEN_EXEC_FAILED")
require(outer.get("target") == node and outer.get("mcp_job_id") == job_id and
outer.get("mcp_script_sha256") == digest, "SCREEN_EXEC_BINDING_INVALID")
try:
node_id = outer.get("node_id")
require(type(node_id) is str, "SCREEN_EXEC_NODE_ID_INVALID")
node_id = str(uuid.UUID(node_id))
except (ValueError, AttributeError):
raise ScreenError("SCREEN_EXEC_NODE_ID_INVALID") from None
stdout = outer.get("stdout")
require(type(stdout) is str and len(stdout.encode("utf-8")) <= MAX_STDOUT_BYTES and
outer.get("stdout_truncated") is not True, "SCREEN_STDOUT_INVALID")
try:
envelope = json.loads(stdout)
except (ValueError, UnicodeError):
raise ScreenError("SCREEN_RESPONSE_INVALID") from None
require(type(envelope) is dict and type(envelope.get("ok")) is bool,
"SCREEN_RESPONSE_INVALID")
if not envelope["ok"]:
code = envelope.get("error")
details = {}
if type(code) is dict:
for name in ("performed", "capture_consumed", "observe_again"):
if type(code.get(name)) is bool:
details[name] = code[name]
if valid_reference(code.get("capture_id"), HEX_ID):
details["capture_id"] = code["capture_id"]
if valid_reference(code.get("cause"), ERROR):
details["cause"] = code["cause"]
code = code.get("code")
raise ScreenError(code if valid_reference(code, ERROR) else "SCREEN_BACKEND_FAILED", details)
require(outer.get("ok") is True and str(outer["exit_code"]) == "0", "SCREEN_EXEC_FAILED")
require(type(envelope.get("result")) is dict, "SCREEN_RESPONSE_INVALID")
return _AuthenticatedReply(envelope["result"], self._reply_owner, node, node_id)
def image(self, node, manifest):
capture_id = manifest.get("capture_id")
total, digest = manifest.get("bytes"), manifest.get("sha256")
require(valid_reference(capture_id, HEX_ID) and valid_reference(digest, SHA),
"SCREEN_MANIFEST_INVALID")
require(type(total) is int and 0 < total <= MAX_IMAGE_BYTES, "SCREEN_IMAGE_SIZE_INVALID")
require(isinstance(manifest, _AuthenticatedReply) and
manifest.bound_to(self._reply_owner, node), "SCREEN_IMAGE_NODE_MISMATCH")
require(valid_reference(manifest.get("node"), NODE), "SCREEN_IMAGE_HOSTNAME_INVALID")
width, height = manifest.get("width"), manifest.get("height")
require(type(width) is int and type(height) is int and
0 < width <= 16384 and 0 < height <= 16384,
"SCREEN_DIMENSIONS_INVALID")
require(manifest.get("source") in ("linux-framebuffer", "x11-root-XGetImage"),
"SCREEN_PIXEL_SOURCE_INVALID")
require(manifest.get("full_screen") is True and manifest.get("authentic_pixels") is True,
"SCREEN_PIXEL_PROVENANCE_INVALID")
parts = []
count = math.ceil(total / CHUNK_BYTES)
for index in range(count):
part = self.remote(node, {"op": "read_chunk", "capture_id": capture_id,
"index": index, "expected_sha256": digest})
require(isinstance(part, _AuthenticatedReply) and
part.bound_to(self._reply_owner, node, manifest.node_id),
"SCREEN_CHUNK_NODE_MISMATCH")
require(part.get("capture_id") == capture_id and type(part.get("index")) is int and
part["index"] == index and type(part.get("total_chunks")) is int and
part["total_chunks"] == count, "SCREEN_CHUNK_BINDING_INVALID")
require(type(part.get("bytes")) is int and part["bytes"] == total and
part.get("sha256") == digest and part.get("encoding") == "base64" and
type(part.get("more")) is bool and part["more"] == (index + 1 < count),
"SCREEN_CHUNK_MANIFEST_INVALID")
text = part.get("data")
require(type(text) is str and len(text) <= 2800, "SCREEN_CHUNK_INVALID")
try:
data = base64.b64decode(text, validate=True)
except (ValueError, UnicodeError, binascii.Error):
raise ScreenError("SCREEN_CHUNK_INVALID") from None
require(len(data) == min(CHUNK_BYTES, total - index * CHUNK_BYTES),
"SCREEN_CHUNK_LENGTH_INVALID")
parts.append(data)
data = b"".join(parts)
require(len(data) == total and hashlib.sha256(data).hexdigest() == digest,
"SCREEN_IMAGE_HASH_MISMATCH")
require(len(data) >= 33 and data[:8] == b"\x89PNG\r\n\x1a\n" and
data[12:16] == b"IHDR" and struct.unpack(">II", data[16:24]) == (width, height),
"SCREEN_PNG_INVALID")
return self.image_factory(data=data, format="png")
def call(self, op, node, authorized, **kwargs):
requested_node = node
try:
require(authorized is True, "AUTHORIZED_TRUE_REQUIRED")
require(valid_reference(node, NODE), "SCREEN_NODE_INVALID")
node = self.resolve_node(node)
require(valid_reference(node, NODE), "SCREEN_NODE_INVALID")
if kwargs.get("session_id"):
require(valid_reference(kwargs["session_id"], SID), "SCREEN_SESSION_INVALID")
else:
kwargs.pop("session_id", None)
if op in ("key", "text", "read_capture"):
require(valid_reference(kwargs.get("capture_id"), HEX_ID), "SCREEN_CAPTURE_ID_REQUIRED")
if op == "key":
require(type(kwargs.get("key")) is str and 0 < len(kwargs["key"]) <= 32,
"SCREEN_KEY_INVALID")
if op == "text":
require(type(kwargs.get("text")) is str and 0 < len(kwargs["text"].encode("utf-8")) <= 4096,
"SCREEN_TEXT_INVALID")
manifest = self.remote(node, {"op": op, **kwargs})
if op == "read_capture":
require(manifest.get("capture_id") == kwargs["capture_id"],
"SCREEN_CAPTURE_REFERENCE_MISMATCH")
image = self.image(node, manifest)
metadata = {"ok": True, "node": node, "requested_node": requested_node,
"node_id": manifest.node_id, "hostname": manifest["node"],
"result": dict(manifest, transport_node=node, node_id=manifest.node_id)}
metadata["result"].pop("path", None)
metadata["result"].pop("image_base64", None)
metadata["result"]["image_mime"] = "image/png"
metadata["result"]["full_screen"] = True
metadata["result"]["authentic_pixels"] = True
return [metadata, image]
except ScreenError as exc:
return {"ok": False, "node": requested_node, "error": str(exc), **exc.details}
except Exception:
return {"ok": False, "node": requested_node, "error": "SCREEN_BACKEND_UNAVAILABLE"}
def resolve_profile_node(alias):
"""Canonicalize only through the enrolled profile registry, never guessed hostnames."""
from ..fleet import resolve_node, FleetError
try:
descriptor = resolve_node(alias)
except FleetError as exc:
raise ScreenError(str(exc)) from None
return "LOCAL" if descriptor["transport"] == "local" else descriptor["alias"]
def register_framebridge_screen_tools(mcp, exec_request, *, image_factory=None,
resolve_node=None):
"""Register real image tools. The host supplies its existing executor."""
if image_factory is None:
from mcp.server.fastmcp import Image
image_factory = Image
client = ScreenClient(exec_request, image_factory, resolve_node)
@mcp.tool(annotations=dict(READ))
def framebridge_screen_observe(node: str, authorized: bool,
session_id: str = ""):
"""Capture the node's entire active screen as real pixels, without rendering text.
To display or redisplay this original in ChatGPT, call framebridge_screen_show
with the returned node and capture_id. Do not take another capture to show it.
"""
return client.call("observe", node, authorized, session_id=session_id)
@mcp.tool(annotations=dict(CHANGE))
def framebridge_screen_key(node: str, capture_id: str, key: str,
authorized: bool, session_id: str = ""):
"""Send a key only to the visible target bound to a previous real capture."""
return client.call("key", node, authorized, capture_id=capture_id,
key=key, session_id=session_id)
@mcp.tool(annotations=dict(CHANGE))
def framebridge_screen_text(node: str, capture_id: str, text: str,
authorized: bool, session_id: str = ""):
"""Type into the visible target bound to a previous real capture, then capture again."""
return client.call("text", node, authorized, capture_id=capture_id,
text=text, session_id=session_id)
return client
@@ -0,0 +1,173 @@
"""Display an already captured, authenticated PNG in MCP Apps-capable clients.
No HTTP image endpoint, upload, credential, external asset, or new capture is used.
The normal ImageContent remains available to clients that do not render MCP Apps.
"""
from __future__ import annotations
import json
from typing import Annotated, Any
from mcp.types import CallToolResult, TextContent
from pydantic import BaseModel
from .screen_frontdoor import READ
TEMPLATE_URI = "ui://framebridge/screen-v1.html"
TOOL_META = {
"ui": {"resourceUri": TEMPLATE_URI},
"openai/outputTemplate": TEMPLATE_URI,
"openai/toolInvocation/invoking": "Loading original screen capture",
"openai/toolInvocation/invoked": "Original screen capture ready",
}
RESOURCE_META = {
"ui": {"prefersBorder": True, "csp": {"connectDomains": [], "resourceDomains": []}},
"openai/widgetDescription": (
"FrameBridge original screen capture viewer. Shows the full saved PNG, "
"its node, capture identifier, dimensions and verified SHA-256. "
"This is a historical capture, not a live screen."
),
"openai/ui": {"availableDisplayModes": ["inline", "fullscreen"]},
}
class ScreenViewResult(BaseModel):
ok: bool
node: str
requested_node: str | None = None
node_id: str | None = None
hostname: str | None = None
result: dict[str, Any] | None = None
error: str | None = None
VIEWER_HTML = r"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>FrameBridge original screen capture</title>
<style>
:root{color-scheme:light dark;font-family:system-ui,sans-serif}
body{margin:0;padding:12px;background:Canvas;color:CanvasText}
header{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}
h1{font-size:16px;margin:0}p{font-size:13px;margin:6px 0}
#status{font-weight:600}#details{overflow-wrap:anywhere;font:12px ui-monospace,monospace;white-space:pre-wrap}
.controls{display:flex;gap:8px;margin:10px 0;flex-wrap:wrap}
button,a{font:inherit;padding:6px 10px;border:1px solid GrayText;border-radius:6px;background:ButtonFace;color:ButtonText;text-decoration:none}
button:disabled,a[aria-disabled=true]{opacity:.5;pointer-events:none}
#viewport{overflow:auto;border:1px solid GrayText;border-radius:6px;max-height:75vh}
#screen{display:block;max-width:100%;height:auto;margin:auto}
#viewport.native #screen{max-width:none;margin:0}
#screen[hidden],#viewport[hidden]{display:none}
</style></head><body>
<header><h1>FrameBridge original screen capture</h1><span>Saved capture · read only</span></header>
<p id="status" role="status">Waiting for the original PNG.</p>
<pre id="details"></pre>
<div class="controls">
<button id="fit" disabled>Fit full screen</button><button id="native" disabled>Original size</button>
<button id="fullscreen" disabled>Fullscreen</button>
<a id="download" aria-disabled="true">Download original PNG</a>
</div>
<div id="viewport" hidden><img id="screen" hidden alt="Full original FrameBridge screen capture"></div>
<script>
"use strict";
const statusEl=document.getElementById("status"),details=document.getElementById("details");
const img=document.getElementById("screen"),viewport=document.getElementById("viewport");
const fit=document.getElementById("fit"),native=document.getElementById("native");
const full=document.getElementById("fullscreen"),download=document.getElementById("download");
let serial=0,active=null,nextId=1;
const pending=new Map();
function post(method,params,id){const m={jsonrpc:"2.0",method,params};if(id!==undefined)m.id=id;window.parent.postMessage(m,"*");}
function request(method,params){const id=nextId++;post(method,params,id);return new Promise((resolve,reject)=>{pending.set(id,{resolve,reject});setTimeout(()=>{if(pending.has(id)){pending.delete(id);reject(new Error("Host bridge unavailable"));}},5000);});}
function error(message){active=null;img.removeAttribute("src");details.textContent="";img.hidden=true;viewport.hidden=true;statusEl.textContent=message;[fit,native,full].forEach(x=>x.disabled=true);download.removeAttribute("href");download.setAttribute("aria-disabled","true");}
function envelope(value){
if(!value||typeof value!=="object")return null;
if(value.structuredContent||value._meta?.framebridgeImage)return value;
for(const name of ["mcp_tool_result","call_tool_result","toolResult","result"]){const item=value[name];if(item&&typeof item==="object"&&(item.structuredContent||item._meta?.framebridgeImage))return item;}
return null;
}
async function render(raw){
const payload=envelope(raw);if(!payload)return;
const n=++serial,meta=payload.structuredContent;
if(!meta?.ok){error("Original capture unavailable: "+String(meta?.error||"unknown error"));return;}
const manifest=meta.result||{},image=payload._meta?.framebridgeImage;
if(!image||image.mimeType!=="image/png"||typeof image.data!=="string"){error("The host did not provide the original PNG to this viewer.");return;}
if(!/^[a-f0-9]{32}$/.test(manifest.capture_id||"")||!/^[a-f0-9]{64}$/.test(manifest.sha256||"")){error("Invalid capture reference.");return;}
const identity=String(meta.node)+" / "+manifest.capture_id+" / "+manifest.sha256;
if(active===identity)return;
try{
statusEl.textContent="Checking original PNG integrity…";
const binary=atob(image.data),bytes=Uint8Array.from(binary,c=>c.charCodeAt(0));
if(bytes.length!==manifest.bytes||bytes.length<33||bytes[0]!==137||bytes[1]!==80||bytes[2]!==78||bytes[3]!==71)throw new Error("PNG length or signature mismatch");
const view=new DataView(bytes.buffer);
if(view.getUint32(16)!==manifest.width||view.getUint32(20)!==manifest.height)throw new Error("PNG dimensions do not match the capture");
if(!globalThis.crypto?.subtle)throw new Error("This host cannot verify SHA-256");
const digest=Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256",bytes)),x=>x.toString(16).padStart(2,"0")).join("");
if(digest!==manifest.sha256)throw new Error("PNG SHA-256 mismatch");
if(n!==serial)return;
img.src="data:image/png;base64,"+image.data;
await img.decode();
if(n!==serial)return;
if(img.naturalWidth!==manifest.width||img.naturalHeight!==manifest.height)throw new Error("Decoded PNG dimensions mismatch");
active=identity;
img.alt="Original full screen of "+String(meta.hostname||meta.node)+", capture "+manifest.capture_id;
img.hidden=false;viewport.hidden=false;viewport.classList.remove("native");
const time=manifest.captured_at||manifest.timestamp||manifest.created_at||"not supplied";
details.textContent="Node: "+String(meta.node)+" ("+String(meta.hostname||"")+")\nCapture: "+manifest.capture_id+"\nCaptured: "+String(time)+"\nOriginal: "+manifest.width+" × "+manifest.height+" · "+manifest.bytes+" bytes\nSHA-256: "+digest;
statusEl.textContent="Original PNG verified. This saved capture does not show subsequent screen changes.";
[fit,native,full].forEach(x=>x.disabled=false);
download.href=img.src;download.download="framebridge-"+manifest.capture_id+".png";download.removeAttribute("aria-disabled");
window.openai?.notifyIntrinsicHeight?.(Math.min(document.body.scrollHeight,900));
}catch(e){if(n===serial)error("Cannot display the original capture: "+String(e?.message||e));}
}
function fromOpenAI(){const api=window.openai;if(!api)return;const out=envelope(api.toolResponseMetadata);if(out)render(out);else if(api.toolOutput)render({structuredContent:api.toolOutput,_meta:api.toolResponseMetadata});}
window.addEventListener("message",event=>{
if(event.source!==window.parent)return;
const m=event.data;if(!m||m.jsonrpc!=="2.0")return;
if(m.id!==undefined&&pending.has(m.id)){const p=pending.get(m.id);pending.delete(m.id);if(m.error)p.reject(m.error);else p.resolve(m.result);return;}
if(m.method==="ui/notifications/tool-result")render(m.params);
if(m.method==="ui/notifications/teardown"){++serial;error("Screen viewer closed.");}
});
window.addEventListener("openai:set_globals",fromOpenAI,{passive:true});
fit.onclick=()=>viewport.classList.remove("native");
native.onclick=()=>viewport.classList.add("native");
full.onclick=()=>{if(window.openai?.requestDisplayMode)window.openai.requestDisplayMode({mode:"fullscreen"});else request("ui/request-display-mode",{mode:"fullscreen"}).catch(()=>{statusEl.textContent="Fullscreen is unavailable. Use Original size to inspect all pixels.";});};
request("ui/initialize",{appInfo:{name:"FrameBridge screen viewer",version:"1.0.0"},appCapabilities:{},protocolVersion:"2026-01-26"}).then(()=>post("ui/notifications/initialized",{})).catch(()=>fromOpenAI());
fromOpenAI();
</script></body></html>"""
def presentation_result(response):
"""Preserve the exact image for the model and pass it to the optional UI."""
if not isinstance(response, list) or len(response) != 2:
metadata = response if isinstance(response, dict) else {"ok": False, "node": "", "error": "SCREEN_PRESENTATION_FAILED"}
return CallToolResult(
content=[TextContent(type="text", text=json.dumps(metadata, sort_keys=True))],
structuredContent=metadata, isError=True,
)
metadata, image = response
content = image.to_image_content()
return CallToolResult(
content=[TextContent(type="text", text=json.dumps(metadata, sort_keys=True)), content],
structuredContent=metadata,
_meta={"framebridgeImage": {"mimeType": content.mimeType, "data": content.data}},
)
def register_screen_presentation(mcp, client):
"""Add an explicit render/replay tool without changing observation semantics."""
@mcp.resource(TEMPLATE_URI, name="FrameBridge original screen",
description="Offline viewer for an authenticated saved screen PNG.",
mime_type="text/html;profile=mcp-app", meta=dict(RESOURCE_META))
def framebridge_screen_viewer():
return VIEWER_HTML
@mcp.tool(annotations=dict(READ), meta=dict(TOOL_META))
def framebridge_screen_show(node: str, capture_id: str, authorized: bool) -> Annotated[CallToolResult, ScreenViewResult]:
"""Display or redisplay a saved original screen PNG, without taking a new capture.
First use framebridge_screen_observe, then pass its node and capture_id here.
This returns the same full PNG bytes and SHA-256, and an optional ChatGPT viewer.
It is a historical read only view; use a fresh observation before controlling a screen.
"""
return presentation_result(client.call("read_capture", node, authorized, capture_id=capture_id))
return framebridge_screen_show
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Original FrameBridge real-screen RPC: actual pixels and bound visible input."""
from __future__ import annotations
import base64
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import sys
import time
from .screen_capture import CaptureError, capture_screen, read_chunk
from .screen_control import ControlError, act, bind_screen, snapshot_binding
MAX_REQUEST = 16384
CAPTURE_ID = re.compile(r"[0-9a-f]{32}\Z")
HEX_SHA = re.compile(r"[0-9a-f]{64}\Z")
class ScreenRpcError(RuntimeError):
def __init__(self, code: str, **details):
self.code = code
self.details = details
super().__init__(code)
def require(condition, code):
if not condition:
raise ScreenRpcError(code)
def encode(value):
return json.dumps(value, sort_keys=True, ensure_ascii=True, separators=(",", ":"), allow_nan=False).encode("ascii")
def state_directory() -> Path:
parent = Path('/run/user') / str(os.getuid())
st = parent.lstat()
require(stat.S_ISDIR(st.st_mode) and st.st_uid == os.getuid() and st.st_mode & 0o077 == 0,
'RUNTIME_DIRECTORY_UNTRUSTED')
profile = Path(os.environ.get('FRAMEBRIDGE_EXPANDED_STATE', str(Path.home() / '.local/share/framebridge-expanded'))).expanduser().absolute()
path = parent / ('framebridge-screen-' + hashlib.sha256(str(profile).encode()).hexdigest()[:16])
try:
path.mkdir(mode=0o700)
except FileExistsError:
pass
st = path.lstat()
require(stat.S_ISDIR(st.st_mode) and st.st_uid == os.getuid() and st.st_mode & 0o077 == 0,
'SCREEN_DIRECTORY_UNTRUSTED')
return path
def checked_read(path: Path, max_bytes=32768):
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
try:
st = os.fstat(fd)
require(stat.S_ISREG(st.st_mode) and st.st_uid == os.getuid() and st.st_mode & 0o077 == 0,
'SCREEN_RECORD_UNTRUSTED')
require(st.st_size <= max_bytes, 'SCREEN_RECORD_TOO_LARGE')
data = os.read(fd, max_bytes + 1)
require(len(data) == st.st_size, 'SCREEN_RECORD_CHANGED')
return json.loads(data)
finally:
os.close(fd)
def write_record(path: Path, value, *, replace=False):
data = encode(value)
temporary = path.with_name(path.name + '.' + str(os.getpid()) + '.tmp')
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW, 0o600)
try:
with os.fdopen(fd, 'wb') as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
if not replace:
require(not path.exists(), 'SCREEN_RECORD_EXISTS')
os.replace(temporary, path)
finally:
try:
temporary.unlink()
except FileNotFoundError:
pass
def public_manifest(manifest):
# Internal path is unnecessary to use or understand a captured screen.
return {k: v for k, v in manifest.items() if k not in {'path', '_action_consumed'}}
def observe(directory: Path, requested_session_id=None):
before = None
before_error = None
try:
before = snapshot_binding()
except ControlError as exc:
before_error = exc.code
manifest = capture_screen(directory)
manifest['full_screen'] = True
manifest['authentic_pixels'] = True
manifest['mime'] = 'image/png'
if manifest.get('screen_type') == 'tty' and before is not None:
manifest = bind_screen(manifest)
require(manifest.get('binding') == before, 'VISIBLE_TARGET_CHANGED_DURING_CAPTURE')
manifest['control_available'] = True
else:
manifest['control_available'] = False
manifest['control_error'] = before_error or 'USE_GRAPHICAL_CUA_FOR_INPUT'
identity = manifest.get('binding') or {
'node': manifest['node'], 'uid': manifest['uid'],
'active_vt': manifest.get('active_vt'), 'session': manifest.get('session'),
'screen_type': manifest.get('screen_type'),
}
manifest['session_id'] = 'screen_' + hashlib.sha256(encode(identity)).hexdigest()[:32]
if requested_session_id:
manifest['requested_session_id'] = requested_session_id
manifest['requested_session_visible'] = requested_session_id == manifest['session_id']
manifest['_action_consumed'] = False
write_record(directory / (manifest['capture_id'] + '.bound.json'), manifest)
return manifest
def load_capture(directory, capture_id):
require(type(capture_id) is str and CAPTURE_ID.fullmatch(capture_id), 'CAPTURE_ID_INVALID')
result = checked_read(directory / (capture_id + '.bound.json'))
require(type(result) is dict, 'CAPTURE_RECORD_INVALID')
require(result.get('capture_id') == capture_id, 'CAPTURE_RECORD_MISMATCH')
return result
def validate_request(request):
require(type(request) is dict, 'REQUEST_INVALID')
op = request.get('op')
allowed = {
'observe': {'op', 'session_id'},
'status': {'op'},
'read_chunk': {'op', 'capture_id', 'index', 'expected_sha256'},
'read_capture': {'op', 'capture_id'},
'key': {'op', 'capture_id', 'session_id', 'key'},
'text': {'op', 'capture_id', 'session_id', 'text'},
}
require(type(op) is str and op in allowed, 'OPERATION_INVALID')
require(not set(request).difference(allowed[op]), 'UNKNOWN_REQUEST_FIELDS')
if 'session_id' in request:
sid = request['session_id']
require(type(sid) is str and len(sid) <= 96 and re.fullmatch(r'[A-Za-z0-9_-]+', sid), 'SESSION_ID_INVALID')
if op == 'read_chunk':
require(type(request.get('index')) is int and request['index'] >= 0, 'CHUNK_INDEX_INVALID')
require(type(request.get('expected_sha256')) is str and HEX_SHA.fullmatch(request['expected_sha256']), 'EXPECTED_SHA256_REQUIRED')
if op == 'key':
require(type(request.get('key')) is str and 0 < len(request['key']) <= 32, 'KEY_INVALID')
if op == 'text':
require(type(request.get('text')) is str and 0 < len(request['text'].encode('utf-8')) <= 4096, 'TEXT_INVALID')
require('\x00' not in request['text'], 'TEXT_INVALID')
return op
def dispatch(request, directory=None):
op = validate_request(request)
directory = directory or state_directory()
if op == 'status':
return {'ok': True, 'result': {'node': os.uname().nodename, 'uid': os.getuid(),
'full_screen': True, 'authentic_pixels': True,
'input_binding': 'foreground-visible-tmux-pane'}}
if op == 'observe':
return {'ok': True, 'result': public_manifest(observe(directory, request.get('session_id')))}
manifest = load_capture(directory, request.get('capture_id'))
if op == 'read_capture':
# Verify the existing artifact without observing or consuming input control.
read_chunk(directory, manifest['capture_id'], 0, expected_sha256=manifest['sha256'])
historical = public_manifest(manifest)
historical.update(historical_capture=True, control_available=False,
control_error='HISTORICAL_CAPTURE_OBSERVE_AGAIN_FOR_INPUT')
return {'ok': True, 'result': historical}
if op == 'read_chunk':
require(manifest['sha256'] == request['expected_sha256'], 'CAPTURE_SHA256_MISMATCH')
chunk = read_chunk(directory, manifest['capture_id'], request['index'],
chunk_bytes=2100, expected_sha256=request['expected_sha256'])
return {'ok': True, 'result': chunk}
if manifest.get('screen_type') == 'x11':
raise ScreenRpcError('USE_GRAPHICAL_CUA_FOR_X11_INPUT')
require(manifest.get('control_available') is True, 'VISIBLE_INPUT_UNAVAILABLE')
require(not manifest.get('_action_consumed'), 'CAPTURE_ALREADY_USED_OBSERVE_AGAIN')
if request.get('session_id'):
require(request['session_id'] == manifest['session_id'], 'SESSION_NOT_VISIBLE')
# Read and verify the immutable PNG before relying on its binding.
read_chunk(directory, manifest['capture_id'], 0, expected_sha256=manifest['sha256'])
# Mark the observation used before input: never replay after an uncertain send.
manifest['_action_consumed'] = True
write_record(directory / (manifest['capture_id'] + '.bound.json'), manifest, replace=True)
try:
result = act(manifest, op, text=request.get('text'), key=request.get('key'))
except ControlError as exc:
raise ScreenRpcError(exc.code, capture_id=manifest['capture_id'],
capture_consumed=True, observe_again=True,
performed=exc.details.get('performed')) from exc
time.sleep(0.08)
try:
after = observe(directory)
except (CaptureError, ControlError, ScreenRpcError, OSError) as exc:
raise ScreenRpcError('INPUT_SENT_OBSERVE_FAILED', performed=True,
capture_id=manifest['capture_id'], observe_again=True,
cause=getattr(exc, 'code', 'SCREEN_IO_FAILED')) from exc
summary = {k: v for k, v in result.items() if k in {'ok', 'performed', 'action', 'input_bytes', 'key', 'input_target'}}
after['action'] = {'kind': op, 'previous_capture_id': manifest['capture_id'], 'result': summary}
return {'ok': True, 'result': public_manifest(after)}
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
try:
require(len(argv) == 1 and len(argv[0]) <= MAX_REQUEST * 2, 'ENCODED_REQUEST_INVALID')
raw = base64.b64decode(argv[0], validate=True)
require(len(raw) <= MAX_REQUEST, 'REQUEST_TOO_LARGE')
request = json.loads(raw)
directory = state_directory()
lock_fd = os.open(directory / '.lock', os.O_WRONLY | os.O_CREAT | os.O_CLOEXEC | os.O_NOFOLLOW, 0o600)
try:
st = os.fstat(lock_fd)
require(stat.S_ISREG(st.st_mode) and st.st_uid == os.getuid() and st.st_mode & 0o077 == 0, 'SCREEN_LOCK_UNTRUSTED')
fcntl.flock(lock_fd, fcntl.LOCK_EX)
reply = dispatch(request, directory)
finally:
os.close(lock_fd)
print(encode(reply).decode('ascii'))
return 0
except (ScreenRpcError, CaptureError, ControlError) as exc:
details = {k: v for k, v in getattr(exc, 'details', {}).items()
if k in {'performed', 'capture_id', 'capture_consumed', 'observe_again', 'cause'}}
print(encode({'ok': False, 'error': {'code': exc.code, **details}}).decode('ascii'))
return 1
except (ValueError, OSError, KeyError, TypeError):
print(encode({'ok': False, 'error': {'code': 'SCREEN_REQUEST_FAILED'}}).decode('ascii'))
return 1
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,197 @@
"""Typed real-screen transport over the existing authenticated admin.exec channel.
Only this explicit request type bypasses the visible administrative FBTERM engine.
The script field is canonical protocol data and a hash binding, never shell code.
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import json
import os
import re
import selectors
import shlex
import signal
import subprocess
import time
from .screen_frontdoor import RPC_COMMAND, MAX_STDOUT_BYTES
from .screen_rpc import MAX_REQUEST, encode, validate_request, ScreenRpcError
REQUEST_TYPE = "screen_rpc"
MAX_TIMEOUT = 30
MAX_STDERR_BYTES = 4096
JOB = re.compile(r"[A-Za-z0-9._-]{1,80}\Z")
FIELDS = {"version", "mode", "authorized", "script", "timeout_s", "job_id",
"sha256", "include_stdout", "request_type"}
class ScreenTransportError(ValueError):
pass
class ScreenProcessError(RuntimeError):
def __init__(self, code, *, started=False):
super().__init__(code)
self.started = started
def need(condition, code):
if not condition:
raise ScreenTransportError(code)
def parse_script(script):
need(type(script) is str and script.startswith(RPC_COMMAND)
and script.endswith("\n"), "SCREEN_RPC_SCRIPT_INVALID")
encoded = script[len(RPC_COMMAND):-1]
need(len(encoded) <= MAX_REQUEST * 2, "SCREEN_RPC_REQUEST_TOO_LARGE")
try:
words = shlex.split(encoded, posix=True)
need(len(words) == 1, "SCREEN_RPC_SCRIPT_INVALID")
raw = base64.b64decode(words[0], validate=True)
need(len(raw) <= MAX_REQUEST, "SCREEN_RPC_REQUEST_TOO_LARGE")
request = json.loads(raw)
validate_request(request)
canonical = encode(request)
need(raw == canonical, "SCREEN_RPC_REQUEST_NONCANONICAL")
argument = base64.b64encode(canonical).decode("ascii")
need(script == RPC_COMMAND + shlex.quote(argument) + "\n",
"SCREEN_RPC_SCRIPT_NONCANONICAL")
return request, argument
except (ValueError, UnicodeError, binascii.Error, ScreenRpcError) as exc:
if isinstance(exc, ScreenTransportError):
raise
raise ScreenTransportError("SCREEN_RPC_REQUEST_INVALID") from None
def validate_payload(payload):
need(type(payload) is dict and set(payload) == FIELDS, "SCREEN_RPC_FIELDS_INVALID")
need(type(payload["version"]) is int and payload["version"] == 1,
"SCREEN_RPC_VERSION_INVALID")
need(payload["mode"] == "EXEC" and payload["request_type"] == REQUEST_TYPE,
"SCREEN_RPC_TYPE_INVALID")
need(payload["authorized"] is True, "AUTHORIZED_TRUE_REQUIRED")
need(type(payload["job_id"]) is str and JOB.fullmatch(payload["job_id"]),
"INVALID_JOB_ID")
need(type(payload["timeout_s"]) is int and 1 <= payload["timeout_s"] <= MAX_TIMEOUT,
"INVALID_SCREEN_TIMEOUT")
need(payload["include_stdout"] is True, "SCREEN_RPC_STDOUT_REQUIRED")
request, argument = parse_script(payload["script"])
digest = hashlib.sha256(payload["script"].encode("utf-8")).hexdigest()
need(type(payload["sha256"]) is str and payload["sha256"] == digest,
"SCRIPT_SHA256_MISMATCH")
return request, argument
def _run_rpc(argument, timeout_s):
"""Bound memory while draining both pipes; bound wall time and child lifetime."""
argv = ["/usr/bin/python3", "-B", "-m",
"framebridge_expanded.real_screen.screen_rpc", argument]
process = None
completed = False
selector = selectors.DefaultSelector()
buffers = {"stdout": bytearray(), "stderr": bytearray()}
deadline = time.monotonic() + timeout_s
try:
process = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, close_fds=True,
start_new_session=True)
selector.register(process.stdout, selectors.EVENT_READ, "stdout")
selector.register(process.stderr, selectors.EVENT_READ, "stderr")
while selector.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ScreenProcessError("SCREEN_RPC_TIMEOUT", started=True)
for item, _ in selector.select(min(remaining, 0.2)):
chunk = os.read(item.fileobj.fileno(), 4096)
if not chunk:
selector.unregister(item.fileobj)
continue
name = item.data
limit = MAX_STDOUT_BYTES if name == "stdout" else MAX_STDERR_BYTES
if len(buffers[name]) + len(chunk) > limit:
raise ScreenProcessError("SCREEN_RPC_OUTPUT_LIMIT", started=True)
buffers[name].extend(chunk)
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ScreenProcessError("SCREEN_RPC_TIMEOUT", started=True)
try:
code = process.wait(timeout=remaining)
except subprocess.TimeoutExpired:
raise ScreenProcessError("SCREEN_RPC_TIMEOUT", started=True) from None
completed = True
return code, bytes(buffers["stdout"]), bytes(buffers["stderr"])
except OSError:
raise ScreenProcessError("SCREEN_RPC_PROCESS_FAILED", started=process is not None) from None
finally:
selector.close()
if process is not None:
if not completed or process.poll() is None:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
pass
if process.stdout is not None:
process.stdout.close()
if process.stderr is not None:
process.stderr.close()
def _response(payload, envelope, code):
return {"version": 1, "ok": envelope["ok"], "mode": "EXEC",
"request_type": REQUEST_TYPE, "job_id": payload["job_id"],
"command_sha256": payload["sha256"], "exit_code": str(code),
"stdout": encode(envelope).decode("ascii") + "\n",
"stdout_truncated": False, "stdout_sanitized": False}
def _failure(payload, request, code, *, started=False):
uncertain = started and request["op"] in {"key", "text"}
error = {"code": code}
if uncertain:
error.update(observe_again=True)
capture = request.get("capture_id")
if type(capture) is str and re.fullmatch(r"[0-9a-f]{32}", capture):
error["capture_id"] = capture
return _response(payload, {"ok": False, "error": error}, 1)
def handle_request(payload, *, runner=None):
request, argument = validate_payload(payload)
runner = _run_rpc if runner is None else runner
try:
code, stdout, stderr = runner(argument, payload["timeout_s"])
except ScreenProcessError as exc:
return _failure(payload, request, str(exc), started=exc.started)
if (type(stdout) is not bytes or type(stderr) is not bytes
or len(stdout) > MAX_STDOUT_BYTES or len(stderr) > MAX_STDERR_BYTES):
return _failure(payload, request, "SCREEN_RPC_OUTPUT_LIMIT", started=True)
try:
envelope = json.loads(stdout)
valid = (type(code) is int and type(envelope) is dict and type(envelope.get("ok")) is bool
and ((code == 0 and envelope["ok"] is True and type(envelope.get("result")) is dict)
or (code == 1 and envelope["ok"] is False and type(envelope.get("error")) is dict))
and not stderr and stdout == encode(envelope) + b"\n")
if not valid:
raise ValueError
except (ValueError, UnicodeError, TypeError):
return _failure(payload, request, "SCREEN_RPC_RESPONSE_INVALID", started=True)
return _response(payload, envelope, code)
def install_adapter_route(adapter):
original = adapter.handle
def handle(payload):
if isinstance(payload, dict) and str(payload.get("request_type", "")).strip().lower() == REQUEST_TYPE:
return handle_request(payload)
return original(payload)
adapter.handle = handle