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
@@ -28,6 +28,8 @@ class BrokerError(ValueError):
|
||||
"""A fixed public failure code; never include HTTP bodies or credentials."""
|
||||
|
||||
|
||||
_SETTINGS_LOCK = threading.RLock()
|
||||
|
||||
_state = None
|
||||
_url = None
|
||||
_control_path = None
|
||||
@@ -80,18 +82,22 @@ def validate_endpoint(url):
|
||||
|
||||
def configure(broker_url=None, state_dir=None, control_key_path=None):
|
||||
global _state, _url, _control_path
|
||||
_state = state_path(state_dir)
|
||||
config = _config(_state)
|
||||
port = config.get("ports", {}).get("broker", 20878)
|
||||
_url = validate_endpoint(broker_url or config.get("broker_control_url") or config.get("broker_url")
|
||||
or f"http://127.0.0.1:{port}")
|
||||
_control_path = Path(control_key_path or _state / "broker" / "control.key")
|
||||
# Workers must see one fully validated profile, including explicit changes.
|
||||
with _SETTINGS_LOCK:
|
||||
state = state_path(state_dir)
|
||||
config = _config(state)
|
||||
port = config.get("ports", {}).get("broker", 20878)
|
||||
url = validate_endpoint(broker_url or config.get("broker_control_url") or config.get("broker_url")
|
||||
or f"http://127.0.0.1:{port}")
|
||||
control_path = Path(control_key_path or state / "broker" / "control.key")
|
||||
_state, _url, _control_path = state, url, control_path
|
||||
|
||||
|
||||
def _settings():
|
||||
if _state is None:
|
||||
configure()
|
||||
return _state, _url, _control_path
|
||||
with _SETTINGS_LOCK:
|
||||
if _state is None:
|
||||
configure()
|
||||
return _state, _url, _control_path
|
||||
|
||||
|
||||
def _private_read(path):
|
||||
|
||||
@@ -8,10 +8,13 @@ from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
_BOOTSTRAP_LOCK = threading.RLock()
|
||||
|
||||
JOB_RE = re.compile(r"^[A-Za-z0-9._-]{1,80}$")
|
||||
READ_CAPABILITIES = frozenset({
|
||||
"system.status", "hardware.summary", "storage.inspect",
|
||||
@@ -56,18 +59,20 @@ def original_paths() -> tuple[Path, Path]:
|
||||
|
||||
def bootstrap_original() -> None:
|
||||
"""Extend the original package search path without copying or merging it."""
|
||||
broker_source, fleet_source = original_paths()
|
||||
for path in (fleet_source, broker_source):
|
||||
if str(path) in sys.path:
|
||||
sys.path.remove(str(path))
|
||||
sys.path.insert(0, str(path))
|
||||
import framebridge_linux_chatgpt
|
||||
package_file = Path(framebridge_linux_chatgpt.__file__).resolve()
|
||||
_require(package_file.is_relative_to(broker_source.resolve()), "ORIGINAL_PACKAGE_SHADOWED")
|
||||
for root in (broker_source, fleet_source):
|
||||
path = str(root / "framebridge_linux_chatgpt")
|
||||
if path not in framebridge_linux_chatgpt.__path__:
|
||||
framebridge_linux_chatgpt.__path__.append(path)
|
||||
# Registration now dispatches in workers; protect shared import paths.
|
||||
with _BOOTSTRAP_LOCK:
|
||||
broker_source, fleet_source = original_paths()
|
||||
for path in (fleet_source, broker_source):
|
||||
if str(path) in sys.path:
|
||||
sys.path.remove(str(path))
|
||||
sys.path.insert(0, str(path))
|
||||
import framebridge_linux_chatgpt
|
||||
package_file = Path(framebridge_linux_chatgpt.__file__).resolve()
|
||||
_require(package_file.is_relative_to(broker_source.resolve()), "ORIGINAL_PACKAGE_SHADOWED")
|
||||
for root in (broker_source, fleet_source):
|
||||
path = str(root / "framebridge_linux_chatgpt")
|
||||
if path not in framebridge_linux_chatgpt.__path__:
|
||||
framebridge_linux_chatgpt.__path__.append(path)
|
||||
|
||||
|
||||
def state_directory() -> Path:
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
"""Original FBPAIR identity and outbound protocol for an explicitly enrolled node."""
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import hashlib
|
||||
import http.client
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
import uuid
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
from . import broker
|
||||
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
_RETRY_MAX_SECONDS = 30
|
||||
|
||||
|
||||
class NodeError(ValueError):
|
||||
pass
|
||||
|
||||
@@ -177,6 +186,54 @@ def dispatch_request(request, adapter_request=None, state_dir=None):
|
||||
return {"ok": False, "error": "NODE_DISPATCH_FAILED"}
|
||||
|
||||
|
||||
def _retryable_broker_failure(exc):
|
||||
# post_json intentionally hides transport details from its public error. Its
|
||||
# exception context still distinguishes interruptions from TLS/JSON failures;
|
||||
# never log that context or treat an unknown failure as transient.
|
||||
code = str(exc)
|
||||
if code in {"BROKER_HTTP_REJECTED_502", "BROKER_HTTP_REJECTED_503", "BROKER_HTTP_REJECTED_504"}:
|
||||
return True
|
||||
if code != "BROKER_TRANSPORT_FAILED":
|
||||
return False
|
||||
cause = exc.__context__
|
||||
if isinstance(cause, urllib.error.URLError):
|
||||
cause = cause.reason
|
||||
if isinstance(cause, ssl.SSLError):
|
||||
return False
|
||||
if isinstance(cause, socket.gaierror):
|
||||
return cause.errno == socket.EAI_AGAIN
|
||||
if isinstance(cause, (ConnectionError, TimeoutError, http.client.IncompleteRead)):
|
||||
return True
|
||||
return isinstance(cause, OSError) and cause.errno in {
|
||||
errno.ECONNREFUSED, errno.ECONNRESET, errno.ECONNABORTED, errno.ETIMEDOUT,
|
||||
errno.EPIPE, errno.ENETUNREACH, errno.EHOSTUNREACH, errno.ENETDOWN,
|
||||
}
|
||||
|
||||
|
||||
def _post_with_recovery(url, operation, payload, *, ctx, state, outbound, recover):
|
||||
delay = 1
|
||||
retries = 0
|
||||
while True:
|
||||
# A new signature/nonce is mandatory: the previous attempt may have been
|
||||
# verified even when its response was lost. Do not replay its envelope.
|
||||
envelope = outbound._signed_envelope(ctx, operation, payload)
|
||||
try:
|
||||
result = broker.post_json(url, envelope, state_dir=state)
|
||||
except broker.BrokerError as exc:
|
||||
if not recover or not _retryable_broker_failure(exc):
|
||||
raise
|
||||
retries += 1
|
||||
_LOG.warning("NODE_BROKER_RETRY operation=%s error=%s delay_seconds=%d",
|
||||
operation, str(exc), delay)
|
||||
# Signals and cancellation exceptions are deliberately not caught.
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 2, _RETRY_MAX_SECONDS)
|
||||
else:
|
||||
if retries:
|
||||
_LOG.warning("NODE_BROKER_RECOVERED operation=%s retries=%d", operation, retries)
|
||||
return result
|
||||
|
||||
|
||||
def run(state_dir=None, once=False):
|
||||
state, config, ctx, identity = _context(state_dir)
|
||||
url = broker.validate_endpoint(config.get("broker_url", ""))
|
||||
@@ -187,16 +244,20 @@ def run(state_dir=None, once=False):
|
||||
raise NodeError("PAIRING_BINDING_MISMATCH")
|
||||
outbound = _original("outbound_agent")
|
||||
while True:
|
||||
poll = broker.post_json(url + "/fbpair/v1/node/poll",
|
||||
outbound._signed_envelope(ctx, "node.poll", {}), state_dir=state)
|
||||
poll = _post_with_recovery(url + "/fbpair/v1/node/poll", "node.poll", {},
|
||||
ctx=ctx, state=state, outbound=outbound, recover=not once)
|
||||
if poll.get("ok") is not True:
|
||||
raise NodeError("NODE_POLL_REJECTED")
|
||||
req = poll.get("request")
|
||||
result = {"ok": True, "idle": True}
|
||||
if isinstance(req, dict):
|
||||
value = dispatch_request(req, state_dir=state)
|
||||
posted = broker.post_json(url + "/fbpair/v1/node/result", outbound._signed_envelope(
|
||||
ctx, "node.result", {"request_id": req.get("request_id"), "result": value}), state_dir=state)
|
||||
# Original put_result only records this request's result; repeating
|
||||
# that acknowledgement cannot execute the command. Keep the value
|
||||
# here and never return to dispatch/poll while its delivery is pending.
|
||||
posted = _post_with_recovery(url + "/fbpair/v1/node/result", "node.result",
|
||||
{"request_id": req.get("request_id"), "result": value},
|
||||
ctx=ctx, state=state, outbound=outbound, recover=not once)
|
||||
if posted.get("ok") is not True:
|
||||
raise NodeError("NODE_RESULT_REJECTED")
|
||||
result = {"ok": True, "idle": False, "request_id": req.get("request_id")}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -16,6 +16,7 @@ import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -24,16 +25,20 @@ from .config import load_config, state_path, private_dir
|
||||
PREFIX = Path(os.environ.get("FRAMEBRIDGE_EXPANDED_PREFIX", Path(__file__).resolve().parents[2]))
|
||||
_STOP = False
|
||||
_LEGACY = None
|
||||
_LEGACY_LOCK = threading.RLock()
|
||||
|
||||
def _legacy():
|
||||
global _LEGACY
|
||||
if _LEGACY is None:
|
||||
path = PREFIX / "bin/framebridge-original"
|
||||
loader = importlib.machinery.SourceFileLoader("framebridge_original_launcher", str(path))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
_LEGACY = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(_LEGACY)
|
||||
return _LEGACY
|
||||
# Publish only a fully initialized module; a failed load remains retryable.
|
||||
with _LEGACY_LOCK:
|
||||
if _LEGACY is None:
|
||||
path = PREFIX / "bin/framebridge-original"
|
||||
loader = importlib.machinery.SourceFileLoader("framebridge_original_launcher", str(path))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
_LEGACY = module
|
||||
return _LEGACY
|
||||
|
||||
def _args(state, config):
|
||||
return SimpleNamespace(state_dir=str(state), port=config["ports"]["mcp"],
|
||||
|
||||
Reference in new issue
Block a user