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 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user