Fix private production pilot recovery, screen transfer and MCP dispatch
This commit is contained in:
1 parent
f6b14c5210
commit
db5f85c922
18 files changed
+1735
-66
No files matched your search
@@ -32,7 +32,7 @@ import zlib
|
||||
MAX_PIXELS = 4096 * 2160
|
||||
MAX_FRAMEBUFFER_BYTES = 128 * 1024 * 1024
|
||||
MAX_IMAGE_BYTES = 32 * 1024 * 1024
|
||||
MAX_CHUNK_BYTES = 2400
|
||||
MAX_CHUNK_BYTES = 45000
|
||||
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")
|
||||
|
||||
@@ -13,12 +13,17 @@ import math
|
||||
import re
|
||||
import shlex
|
||||
import struct
|
||||
import time
|
||||
import uuid
|
||||
from typing import Callable
|
||||
|
||||
CHUNK_BYTES = 2100
|
||||
MAX_IMAGE_BYTES = 32 * 1024 * 1024
|
||||
MAX_STDOUT_BYTES = 4096
|
||||
# Only the explicit typed screen transport preserves this larger stdout budget.
|
||||
EXPANDED_STDOUT_BYTES = 65536
|
||||
EXPANDED_CHUNK_BYTES = 45000
|
||||
TRANSFER_TIMEOUT_SECONDS = 45
|
||||
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")
|
||||
@@ -28,14 +33,25 @@ ERROR = re.compile(r"[A-Z0-9_]{1,100}\Z")
|
||||
READ = {"readOnlyHint": True, "destructiveHint": False,
|
||||
"idempotentHint": False, "openWorldHint": False}
|
||||
CHANGE = {**READ, "readOnlyHint": False}
|
||||
# These explicit backend checks run before an input capture is consumed. Generic
|
||||
# dispatch failures do not prove whether input was sent, even with valid framing.
|
||||
INPUT_REJECTION_CODES = {
|
||||
"REQUEST_INVALID", "OPERATION_INVALID", "UNKNOWN_REQUEST_FIELDS",
|
||||
"SESSION_ID_INVALID", "KEY_INVALID", "TEXT_INVALID", "CAPTURE_ID_INVALID",
|
||||
"CAPTURE_RECORD_INVALID", "CAPTURE_RECORD_MISMATCH",
|
||||
"USE_GRAPHICAL_CUA_FOR_X11_INPUT", "VISIBLE_INPUT_UNAVAILABLE",
|
||||
"CAPTURE_ALREADY_USED_OBSERVE_AGAIN", "SESSION_NOT_VISIBLE",
|
||||
}
|
||||
|
||||
|
||||
class ScreenError(RuntimeError):
|
||||
"""A bounded public error suitable for the MCP response."""
|
||||
|
||||
def __init__(self, code, details=None):
|
||||
def __init__(self, code, details=None, *, backend_rejection=False):
|
||||
super().__init__(code)
|
||||
self.details = details or {}
|
||||
# Local proof, never accepted from a remote JSON field.
|
||||
self.backend_rejection = backend_rejection
|
||||
|
||||
|
||||
def require(ok, code):
|
||||
@@ -62,7 +78,7 @@ class _AuthenticatedReply(dict):
|
||||
|
||||
_IDENTITY_FIELDS = ("capture_id", "node", "sha256", "session_id", "bytes",
|
||||
"width", "height", "source", "screen_type",
|
||||
"full_screen", "authentic_pixels")
|
||||
"full_screen", "authentic_pixels", "max_chunk_bytes")
|
||||
|
||||
def __init__(self, payload, owner, node, node_id):
|
||||
super().__init__(payload)
|
||||
@@ -80,23 +96,51 @@ class _AuthenticatedReply(dict):
|
||||
|
||||
|
||||
class ScreenClient:
|
||||
"""Transport only; execution policy and node resolution stay in the host."""
|
||||
"""Transport only; execution policy and node resolution stay in the host.
|
||||
|
||||
The monotonic transfer deadline bounds admission of RPCs and successful
|
||||
publication. It is cooperative: the executor can spend its existing bounded
|
||||
transport grace on the final in-flight RPC. Late replies never become a
|
||||
successful image, and no further chunks are requested after the deadline.
|
||||
"""
|
||||
|
||||
def __init__(self, exec_request: Callable, image_factory: Callable,
|
||||
resolve_node: Callable | None = None):
|
||||
resolve_node: Callable | None = None, *,
|
||||
stdout_limit_bytes: int = MAX_STDOUT_BYTES,
|
||||
transfer_timeout_s: int = TRANSFER_TIMEOUT_SECONDS):
|
||||
if (type(stdout_limit_bytes) is not int or
|
||||
stdout_limit_bytes not in (MAX_STDOUT_BYTES, EXPANDED_STDOUT_BYTES)):
|
||||
raise ValueError("unsupported screen stdout budget")
|
||||
if type(transfer_timeout_s) is not int or not 1 <= transfer_timeout_s <= 120:
|
||||
raise ValueError("screen transfer timeout must be 1..120 seconds")
|
||||
self.stdout_limit_bytes = stdout_limit_bytes
|
||||
self.chunk_bytes = (EXPANDED_CHUNK_BYTES
|
||||
if stdout_limit_bytes == EXPANDED_STDOUT_BYTES else CHUNK_BYTES)
|
||||
self.transfer_timeout_s = transfer_timeout_s
|
||||
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):
|
||||
def remote(self, node, request, *, deadline=None, submitted=None):
|
||||
timeout_s = 30
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.monotonic()
|
||||
# The authenticated EXEC protocol accepts whole seconds only. Rounding
|
||||
# down prevents any individual request exceeding the remaining budget.
|
||||
require(remaining >= 1, "SCREEN_TRANSFER_TIMEOUT")
|
||||
timeout_s = min(timeout_s, int(remaining))
|
||||
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,
|
||||
if submitted is not None:
|
||||
submitted["attempted"] = True
|
||||
outer = self.exec_request(script=script, authorized=True, timeout_s=timeout_s,
|
||||
job_id=job_id, sha256=digest,
|
||||
include_stdout=True, target=node)
|
||||
if deadline is not None:
|
||||
require(time.monotonic() < deadline, "SCREEN_TRANSFER_TIMEOUT")
|
||||
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")
|
||||
@@ -109,7 +153,7 @@ class ScreenClient:
|
||||
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
|
||||
require(type(stdout) is str and len(stdout.encode("utf-8")) <= self.stdout_limit_bytes and
|
||||
outer.get("stdout_truncated") is not True, "SCREEN_STDOUT_INVALID")
|
||||
try:
|
||||
envelope = json.loads(stdout)
|
||||
@@ -129,12 +173,19 @@ class ScreenClient:
|
||||
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)
|
||||
valid_code = valid_reference(code, ERROR)
|
||||
raise ScreenError(code if valid_code else "SCREEN_BACKEND_FAILED", details,
|
||||
backend_rejection=(valid_code and outer.get("ok") is False
|
||||
and str(outer["exit_code"]) == "1"
|
||||
and (code in INPUT_REJECTION_CODES
|
||||
or details.get("performed") is False)))
|
||||
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):
|
||||
def image(self, node, manifest, *, deadline=None):
|
||||
if deadline is None:
|
||||
deadline = time.monotonic() + self.transfer_timeout_s
|
||||
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),
|
||||
@@ -151,11 +202,23 @@ class ScreenClient:
|
||||
"SCREEN_PIXEL_SOURCE_INVALID")
|
||||
require(manifest.get("full_screen") is True and manifest.get("authentic_pixels") is True,
|
||||
"SCREEN_PIXEL_PROVENANCE_INVALID")
|
||||
# Capability belongs to this authenticated manifest, never a shared cache.
|
||||
# A new central must retain the old request schema for a frozen receiver.
|
||||
chunk_bytes = CHUNK_BYTES
|
||||
if "max_chunk_bytes" in manifest:
|
||||
capability = manifest["max_chunk_bytes"]
|
||||
require(type(capability) is int and 1 <= capability <= EXPANDED_CHUNK_BYTES,
|
||||
"SCREEN_TRANSFER_CAPABILITY_INVALID")
|
||||
chunk_bytes = min(self.chunk_bytes, capability)
|
||||
parts = []
|
||||
count = math.ceil(total / CHUNK_BYTES)
|
||||
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})
|
||||
request = {"op": "read_chunk", "capture_id": capture_id,
|
||||
"index": index, "expected_sha256": digest}
|
||||
# Omit the extension entirely on constrained legacy transports.
|
||||
if chunk_bytes != CHUNK_BYTES:
|
||||
request["chunk_bytes"] = chunk_bytes
|
||||
part = self.remote(node, request, deadline=deadline)
|
||||
require(isinstance(part, _AuthenticatedReply) and
|
||||
part.bound_to(self._reply_owner, node, manifest.node_id),
|
||||
"SCREEN_CHUNK_NODE_MISMATCH")
|
||||
@@ -167,12 +230,12 @@ class ScreenClient:
|
||||
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")
|
||||
require(type(text) is str and len(text) <= 4 * math.ceil(chunk_bytes / 3), "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),
|
||||
require(len(data) == min(chunk_bytes, total - index * chunk_bytes),
|
||||
"SCREEN_CHUNK_LENGTH_INVALID")
|
||||
parts.append(data)
|
||||
data = b"".join(parts)
|
||||
@@ -181,10 +244,15 @@ class ScreenClient:
|
||||
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")
|
||||
require(time.monotonic() < deadline, "SCREEN_TRANSFER_TIMEOUT")
|
||||
return self.image_factory(data=data, format="png")
|
||||
|
||||
def call(self, op, node, authorized, **kwargs):
|
||||
requested_node = node
|
||||
action_confirmed = False
|
||||
input_reply_received = False
|
||||
submitted = {}
|
||||
deadline = time.monotonic() + self.transfer_timeout_s
|
||||
try:
|
||||
require(authorized is True, "AUTHORIZED_TRUE_REQUIRED")
|
||||
require(valid_reference(node, NODE), "SCREEN_NODE_INVALID")
|
||||
@@ -202,11 +270,20 @@ class ScreenClient:
|
||||
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})
|
||||
manifest = self.remote(node, {"op": op, **kwargs}, deadline=deadline,
|
||||
submitted=submitted)
|
||||
input_reply_received = op in ("key", "text")
|
||||
action = manifest.get("action")
|
||||
summary = action.get("result") if type(action) is dict else None
|
||||
action_confirmed = (op in ("key", "text") and type(action) is dict and
|
||||
action.get("kind") == op and
|
||||
action.get("previous_capture_id") == kwargs["capture_id"] and
|
||||
type(summary) is dict and summary.get("performed") is True)
|
||||
if op == "read_capture":
|
||||
require(manifest.get("capture_id") == kwargs["capture_id"],
|
||||
"SCREEN_CAPTURE_REFERENCE_MISMATCH")
|
||||
image = self.image(node, manifest)
|
||||
image = self.image(node, manifest, deadline=deadline)
|
||||
require(time.monotonic() < deadline, "SCREEN_TRANSFER_TIMEOUT")
|
||||
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)}
|
||||
@@ -217,9 +294,21 @@ class ScreenClient:
|
||||
metadata["result"]["authentic_pixels"] = True
|
||||
return [metadata, image]
|
||||
except ScreenError as exc:
|
||||
if op in ("key", "text") and (input_reply_received or
|
||||
(submitted.get("attempted") and not exc.backend_rejection)):
|
||||
exc.details.setdefault("observe_again", True)
|
||||
if action_confirmed:
|
||||
exc.details.update(performed=True, capture_consumed=True)
|
||||
exc.details.setdefault("capture_id", kwargs["capture_id"])
|
||||
return {"ok": False, "node": requested_node, "error": str(exc), **exc.details}
|
||||
except Exception:
|
||||
return {"ok": False, "node": requested_node, "error": "SCREEN_BACKEND_UNAVAILABLE"}
|
||||
details = {}
|
||||
if op in ("key", "text") and submitted.get("attempted"):
|
||||
details.update(observe_again=True, capture_id=kwargs["capture_id"])
|
||||
if action_confirmed:
|
||||
details.update(performed=True, capture_consumed=True)
|
||||
return {"ok": False, "node": requested_node,
|
||||
"error": "SCREEN_BACKEND_UNAVAILABLE", **details}
|
||||
|
||||
|
||||
def resolve_profile_node(alias):
|
||||
@@ -233,12 +322,16 @@ def resolve_profile_node(alias):
|
||||
|
||||
|
||||
def register_framebridge_screen_tools(mcp, exec_request, *, image_factory=None,
|
||||
resolve_node=None):
|
||||
resolve_node=None,
|
||||
stdout_limit_bytes=MAX_STDOUT_BYTES,
|
||||
transfer_timeout_s=TRANSFER_TIMEOUT_SECONDS):
|
||||
"""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)
|
||||
client = ScreenClient(exec_request, image_factory, resolve_node,
|
||||
stdout_limit_bytes=stdout_limit_bytes,
|
||||
transfer_timeout_s=transfer_timeout_s)
|
||||
|
||||
@mcp.tool(annotations=dict(READ))
|
||||
def framebridge_screen_observe(node: str, authorized: bool,
|
||||
|
||||
@@ -13,7 +13,7 @@ import stat
|
||||
import sys
|
||||
import time
|
||||
|
||||
from .screen_capture import CaptureError, capture_screen, read_chunk
|
||||
from .screen_capture import CaptureError, capture_screen, read_chunk, MAX_CHUNK_BYTES
|
||||
from .screen_control import ControlError, act, bind_screen, snapshot_binding
|
||||
|
||||
MAX_REQUEST = 16384
|
||||
@@ -89,7 +89,10 @@ def write_record(path: Path, value, *, replace=False):
|
||||
|
||||
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'}}
|
||||
result = {k: v for k, v in manifest.items() if k not in {'path', '_action_consumed'}}
|
||||
# An old receiver omits this capability and only accepts the original schema.
|
||||
result['max_chunk_bytes'] = MAX_CHUNK_BYTES
|
||||
return result
|
||||
|
||||
|
||||
def observe(directory: Path, requested_session_id=None):
|
||||
@@ -138,7 +141,7 @@ def validate_request(request):
|
||||
allowed = {
|
||||
'observe': {'op', 'session_id'},
|
||||
'status': {'op'},
|
||||
'read_chunk': {'op', 'capture_id', 'index', 'expected_sha256'},
|
||||
'read_chunk': {'op', 'capture_id', 'index', 'expected_sha256', 'chunk_bytes'},
|
||||
'read_capture': {'op', 'capture_id'},
|
||||
'key': {'op', 'capture_id', 'session_id', 'key'},
|
||||
'text': {'op', 'capture_id', 'session_id', 'text'},
|
||||
@@ -149,6 +152,9 @@ def validate_request(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':
|
||||
if 'chunk_bytes' in request:
|
||||
require(type(request['chunk_bytes']) is int and
|
||||
1 <= request['chunk_bytes'] <= MAX_CHUNK_BYTES, 'CHUNK_BOUNDS_INVALID')
|
||||
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':
|
||||
@@ -179,7 +185,7 @@ def dispatch(request, directory=None):
|
||||
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'])
|
||||
chunk_bytes=request.get('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')
|
||||
|
||||
@@ -17,7 +17,10 @@ import signal
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from .screen_frontdoor import RPC_COMMAND, MAX_STDOUT_BYTES
|
||||
from .screen_frontdoor import RPC_COMMAND, EXPANDED_STDOUT_BYTES
|
||||
|
||||
# This typed route bypasses the legacy 4096-byte administrative sanitizer.
|
||||
MAX_STDOUT_BYTES = EXPANDED_STDOUT_BYTES
|
||||
from .screen_rpc import MAX_REQUEST, encode, validate_request, ScreenRpcError
|
||||
|
||||
REQUEST_TYPE = "screen_rpc"
|
||||
|
||||
Reference in new issue
Block a user