BangSo/infra/sandboxes/computer/control.py

296 lines
11 KiB
Python
Raw Normal View History

2026-09-01 16:51:19 +00:00
#!/usr/bin/env python3
"""Token-auth desktop control for the Rakazo supervisor."""
import base64
import ctypes
import hmac
import json
import os
import re
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
TOKEN = os.environ.get("RAKAZO_COMPUTER_CONTROL_TOKEN", "")
MAX_BODY_BYTES = 256 * 1024
MAX_ARGV = 32
MAX_ARG_LEN = 16_384
KNOWN_LAUNCH = frozenset(
{
"rakazo-browser",
"xterm",
}
)
CONTROL_TIMEOUT_SEC = 10
LAUNCH_SPAWN_POLL_SEC = 0.2
NATIVE_CAPTURES = {}
NATIVE_LOCK = threading.Lock()
DISPLAY_LOCKS = {}
DISPLAY_LOCKS_GUARD = threading.Lock()
def display_lock(display):
with DISPLAY_LOCKS_GUARD:
lock = DISPLAY_LOCKS.get(display)
if lock is None:
lock = threading.Lock()
DISPLAY_LOCKS[display] = lock
return lock
class NativeCapture:
"""Persistent MIT-SHM frame source with native lossless PNG encoding."""
def __init__(self, display):
library = ctypes.CDLL("/usr/local/lib/librakazo-xcapture.so")
library.rakazo_xcapture_open.argtypes = [ctypes.c_char_p]
library.rakazo_xcapture_open.restype = ctypes.c_void_p
library.rakazo_xcapture_png.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)),
ctypes.POINTER(ctypes.c_size_t),
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_int),
]
library.rakazo_xcapture_png.restype = ctypes.c_int
library.rakazo_xcapture_damage.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_int),
]
library.rakazo_xcapture_damage.restype = ctypes.c_int
library.rakazo_xinput_argv.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.POINTER(ctypes.c_char_p)]
library.rakazo_xinput_argv.restype = ctypes.c_int
context = library.rakazo_xcapture_open(display.encode("utf-8"))
if not context:
raise RuntimeError("MIT-SHM capture is unavailable")
self.library = library
self.context = context
def copy(self):
png = ctypes.POINTER(ctypes.c_ubyte)()
png_size = ctypes.c_size_t()
width, height = ctypes.c_int(), ctypes.c_int()
if self.library.rakazo_xcapture_png(
self.context, ctypes.byref(png), ctypes.byref(png_size), ctypes.byref(width), ctypes.byref(height)
):
raise RuntimeError("MIT-SHM screen capture failed")
damage = (ctypes.c_int(), ctypes.c_int(), ctypes.c_int(), ctypes.c_int())
changed = self.library.rakazo_xcapture_damage(
self.context, *(ctypes.byref(value) for value in damage)
)
return (
ctypes.string_at(png, png_size.value),
width.value,
height.value,
({"x": damage[0].value, "y": damage[1].value, "width": damage[2].value, "height": damage[3].value}
if changed else None),
)
def act(self, argv):
encoded = (ctypes.c_char_p * len(argv))(*(value.encode("utf-8") for value in argv))
return self.library.rakazo_xinput_argv(self.context, len(argv), encoded)
def native_capture(display):
existing = NATIVE_CAPTURES.get(display)
if existing is not None:
return existing
try:
capture = NativeCapture(display)
except (OSError, RuntimeError):
return None
NATIVE_CAPTURES[display] = capture
return capture
def drop_native_capture(display):
NATIVE_CAPTURES.pop(display, None)
def _is_int_string(value):
if not value or value[0] == "-":
return value[1:].isdigit() if len(value) > 1 else False
return value.isdigit()
def allowed_xdotool_argv(argv):
"""Only xdotool forms emitted by containerActionStep / xdotoolCommand."""
if len(argv) < 4 or argv[2] != "xdotool":
return False
op = argv[3]
if op == "key":
return len(argv) == 6 and argv[4] == "--clearmodifiers" and argv[5] != ""
if op == "mousemove":
if len(argv) == 7 and argv[4] == "--" and _is_int_string(argv[5]) and _is_int_string(argv[6]):
return True
return (
len(argv) == 9
and argv[4] == "--"
and _is_int_string(argv[5])
and _is_int_string(argv[6])
and argv[7] in ("mousedown", "click")
and argv[8] in ("1", "3")
)
if op == "mouseup":
return len(argv) == 5 and argv[4] in ("1", "3")
if op == "type":
return len(argv) == 7 and argv[4] == "--clearmodifiers" and argv[5] == "--"
if op == "click":
return (
len(argv) == 7
and argv[4] == "--repeat"
and argv[5].isdigit()
and 1 <= int(argv[5]) <= 20
and argv[6] in ("4", "5")
)
return False
def allowed_control_argv(argv, display):
"""Only supervisor-shaped argv for the locked display."""
if not isinstance(argv, list) or not (3 <= len(argv) <= MAX_ARGV):
return False
if any(not isinstance(value, str) or len(value) > MAX_ARG_LEN or "\0" in value for value in argv):
return False
if argv[0] != "env" or argv[1] != f"DISPLAY={display}":
return False
command = argv[2]
if command == "xdotool":
return allowed_xdotool_argv(argv)
if command == "xdg-open":
return len(argv) == 4
if "/" in command or command not in KNOWN_LAUNCH:
return False
return len(argv) in (3, 4)
def is_long_lived_control(argv):
"""Apps and openers that must not be waited on under display_lock."""
command = argv[2]
return command == "xdg-open" or command in KNOWN_LAUNCH
def run_control_argv(argv, display):
"""Run a fallback control command without holding the lock forever."""
env = {**os.environ, "DISPLAY": display}
if is_long_lived_control(argv):
child = subprocess.Popen(argv, env=env, start_new_session=True)
try:
code = child.wait(timeout=LAUNCH_SPAWN_POLL_SEC)
except subprocess.TimeoutExpired:
threading.Thread(target=child.wait, daemon=True).start()
return
if code:
raise RuntimeError("computer action failed")
return
try:
result = subprocess.run(argv, env=env, timeout=CONTROL_TIMEOUT_SEC)
except subprocess.TimeoutExpired as error:
raise RuntimeError("computer action timed out") from error
if result.returncode:
raise RuntimeError("computer action failed")
def capture(display):
env = {**os.environ, "DISPLAY": display}
def output(argv, fallback=""):
return subprocess.run(argv, env=env, capture_output=True, text=True).stdout.strip() or fallback
geometry = output(["xdotool", "getdisplaygeometry"], "1280 800").split()
cursor = output(["xdotool", "getmouselocation", "--shell"])
window = output(["xdotool", "getactivewindow"])
title = output(["xdotool", "getwindowname", window]) if window else ""
image = None
width = height = damage = None
with NATIVE_LOCK:
source = native_capture(display)
if source:
try:
encoded, width, height, damage = source.copy()
image = subprocess.CompletedProcess([], 0, encoded, b"")
except RuntimeError:
drop_native_capture(display)
if image is None:
image = subprocess.run(
["import", "-define", "png:compression-level=3", "-window", "root", "png:-"],
env=env,
capture_output=True,
)
width, height, damage = (int(geometry[0]), int(geometry[1]), None)
if image.returncode:
raise RuntimeError(image.stderr.decode("utf-8", "replace") or "screen capture failed")
fields = dict(line.split("=", 1) for line in cursor.splitlines() if "=" in line)
return {
"image": base64.b64encode(image.stdout).decode("ascii"),
"mimeType": "image/png",
"width": width,
"height": height,
**({"cursor": {"x": int(fields["X"]), "y": int(fields["Y"])}} if "X" in fields and "Y" in fields else {}),
**({"activeWindow": {"id": window, **({"title": title} if title else {})}} if window else {}),
**({"damage": damage} if damage else {}),
}
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def do_POST(self):
if self.path != "/v1/desktop" or not TOKEN or not hmac.compare_digest(
self.headers.get("Authorization", "").removeprefix("Bearer "), TOKEN
):
self.send_error(401)
return
try:
length = int(self.headers.get("Content-Length", "0"))
if length < 0 or length > MAX_BODY_BYTES:
raise RuntimeError("request body too large")
body = json.loads(self.rfile.read(length))
display = body.get("display", ":1")
if not isinstance(display, str) or not re.fullmatch(r":[0-9]+", display):
raise RuntimeError("invalid display")
with display_lock(display):
for step in body.get("steps", []):
if "waitMs" in step:
time.sleep(max(0, min(int(step["waitMs"]), 5000)) / 1000)
continue
argv = step.get("argv")
if not allowed_control_argv(argv, display):
raise RuntimeError("unsupported computer action")
with NATIVE_LOCK:
source = native_capture(display)
handled = source.act(argv) if source else 0
if handled < 0:
drop_native_capture(display)
handled = 0
if not handled:
run_control_argv(argv, display)
settle_ms = max(0, min(int(body.get("settleMs", 0)), 5000))
if settle_ms:
time.sleep(settle_ms / 1000)
response = {"completed": len(body.get("steps", []))}
if body.get("observe", True):
response["observation"] = capture(display)
encoded = json.dumps(response, separators=(",", ":")).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
except Exception as error:
encoded = json.dumps({"error": str(error)}).encode("utf-8")
self.send_response(500)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", 7070), Handler).serve_forever()