Fix private production pilot recovery, screen transfer and MCP dispatch
This commit is contained in:
1 parent
f6b14c5210
commit
db5f85c922
18 files changed
+1713
-44
No files matched your search
@@ -1,5 +1,19 @@
|
|||||||
# FrameBridge release notes
|
# FrameBridge release notes
|
||||||
|
|
||||||
|
## Unreleased — private production pilot fixes
|
||||||
|
|
||||||
|
Private pilot validation remains in progress; these changes are not a public release.
|
||||||
|
|
||||||
|
- Recover from classified transient broker failures with capped backoff and fresh signed nonces. Result acknowledgement retries retain the computed result and never reexecute a command. TLS, authentication and malformed-response failures remain terminal.
|
||||||
|
- Dispatch blocking MCP operations in bounded workers, preserve synchronous internal callbacks, and protect shared initialization. Concurrent generated local job IDs are distinct.
|
||||||
|
- Negotiate larger original-PNG chunks per capture while retaining legacy receivers. Apply a cooperative transfer budget and require fresh observation after uncertain input replies; performed claims require explicit matching backend confirmation.
|
||||||
|
- Bind local real-terminal capture transport to the current node identity.
|
||||||
|
- Keep privileged-helper downgrade revocation tied to the fixed 0.3.2 compatibility floor, allowing private candidates to return to 0.3.2 without revoking grants.
|
||||||
|
|
||||||
|
Validation: 721 integrated source tests passed with zero failures, errors or skips. Frozen 0.3.2 source and signed Debian artifact remained unchanged. Focused tests cover real vendored MCP dispatch, retry/no-replay behavior, frozen screen-receiver compatibility and isolated maintainer-script semantics.
|
||||||
|
|
||||||
|
Known limits: the cooperative capture budget can be exceeded by an already-running lower transport call. A broker restart can reject an unknown in-flight result; a lost poll reply can orphan a request. These cases do not reconstruct or replay commands. Live fleet acceptance remains separate from source-test success.
|
||||||
|
|
||||||
## 0.3.1 — Community Release
|
## 0.3.1 — Community Release
|
||||||
|
|
||||||
Installer: FrameBridge-0.3.1.deb
|
Installer: FrameBridge-0.3.1.deb
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class BrokerError(ValueError):
|
|||||||
"""A fixed public failure code; never include HTTP bodies or credentials."""
|
"""A fixed public failure code; never include HTTP bodies or credentials."""
|
||||||
|
|
||||||
|
|
||||||
|
_SETTINGS_LOCK = threading.RLock()
|
||||||
|
|
||||||
_state = None
|
_state = None
|
||||||
_url = None
|
_url = None
|
||||||
_control_path = None
|
_control_path = None
|
||||||
@@ -80,15 +82,19 @@ def validate_endpoint(url):
|
|||||||
|
|
||||||
def configure(broker_url=None, state_dir=None, control_key_path=None):
|
def configure(broker_url=None, state_dir=None, control_key_path=None):
|
||||||
global _state, _url, _control_path
|
global _state, _url, _control_path
|
||||||
_state = state_path(state_dir)
|
# Workers must see one fully validated profile, including explicit changes.
|
||||||
config = _config(_state)
|
with _SETTINGS_LOCK:
|
||||||
|
state = state_path(state_dir)
|
||||||
|
config = _config(state)
|
||||||
port = config.get("ports", {}).get("broker", 20878)
|
port = config.get("ports", {}).get("broker", 20878)
|
||||||
_url = validate_endpoint(broker_url or config.get("broker_control_url") or config.get("broker_url")
|
url = validate_endpoint(broker_url or config.get("broker_control_url") or config.get("broker_url")
|
||||||
or f"http://127.0.0.1:{port}")
|
or f"http://127.0.0.1:{port}")
|
||||||
_control_path = Path(control_key_path or _state / "broker" / "control.key")
|
control_path = Path(control_key_path or state / "broker" / "control.key")
|
||||||
|
_state, _url, _control_path = state, url, control_path
|
||||||
|
|
||||||
|
|
||||||
def _settings():
|
def _settings():
|
||||||
|
with _SETTINGS_LOCK:
|
||||||
if _state is None:
|
if _state is None:
|
||||||
configure()
|
configure()
|
||||||
return _state, _url, _control_path
|
return _state, _url, _control_path
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ from pathlib import Path
|
|||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
_BOOTSTRAP_LOCK = threading.RLock()
|
||||||
|
|
||||||
JOB_RE = re.compile(r"^[A-Za-z0-9._-]{1,80}$")
|
JOB_RE = re.compile(r"^[A-Za-z0-9._-]{1,80}$")
|
||||||
READ_CAPABILITIES = frozenset({
|
READ_CAPABILITIES = frozenset({
|
||||||
"system.status", "hardware.summary", "storage.inspect",
|
"system.status", "hardware.summary", "storage.inspect",
|
||||||
@@ -56,6 +59,8 @@ def original_paths() -> tuple[Path, Path]:
|
|||||||
|
|
||||||
def bootstrap_original() -> None:
|
def bootstrap_original() -> None:
|
||||||
"""Extend the original package search path without copying or merging it."""
|
"""Extend the original package search path without copying or merging it."""
|
||||||
|
# Registration now dispatches in workers; protect shared import paths.
|
||||||
|
with _BOOTSTRAP_LOCK:
|
||||||
broker_source, fleet_source = original_paths()
|
broker_source, fleet_source = original_paths()
|
||||||
for path in (fleet_source, broker_source):
|
for path in (fleet_source, broker_source):
|
||||||
if str(path) in sys.path:
|
if str(path) in sys.path:
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
"""Original FBPAIR identity and outbound protocol for an explicitly enrolled node."""
|
"""Original FBPAIR identity and outbound protocol for an explicitly enrolled node."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import errno
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import http.client
|
||||||
import importlib
|
import importlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from . import broker
|
from . import broker
|
||||||
|
|
||||||
|
|
||||||
|
_LOG = logging.getLogger(__name__)
|
||||||
|
_RETRY_MAX_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
class NodeError(ValueError):
|
class NodeError(ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -177,6 +186,54 @@ def dispatch_request(request, adapter_request=None, state_dir=None):
|
|||||||
return {"ok": False, "error": "NODE_DISPATCH_FAILED"}
|
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):
|
def run(state_dir=None, once=False):
|
||||||
state, config, ctx, identity = _context(state_dir)
|
state, config, ctx, identity = _context(state_dir)
|
||||||
url = broker.validate_endpoint(config.get("broker_url", ""))
|
url = broker.validate_endpoint(config.get("broker_url", ""))
|
||||||
@@ -187,16 +244,20 @@ def run(state_dir=None, once=False):
|
|||||||
raise NodeError("PAIRING_BINDING_MISMATCH")
|
raise NodeError("PAIRING_BINDING_MISMATCH")
|
||||||
outbound = _original("outbound_agent")
|
outbound = _original("outbound_agent")
|
||||||
while True:
|
while True:
|
||||||
poll = broker.post_json(url + "/fbpair/v1/node/poll",
|
poll = _post_with_recovery(url + "/fbpair/v1/node/poll", "node.poll", {},
|
||||||
outbound._signed_envelope(ctx, "node.poll", {}), state_dir=state)
|
ctx=ctx, state=state, outbound=outbound, recover=not once)
|
||||||
if poll.get("ok") is not True:
|
if poll.get("ok") is not True:
|
||||||
raise NodeError("NODE_POLL_REJECTED")
|
raise NodeError("NODE_POLL_REJECTED")
|
||||||
req = poll.get("request")
|
req = poll.get("request")
|
||||||
result = {"ok": True, "idle": True}
|
result = {"ok": True, "idle": True}
|
||||||
if isinstance(req, dict):
|
if isinstance(req, dict):
|
||||||
value = dispatch_request(req, state_dir=state)
|
value = dispatch_request(req, state_dir=state)
|
||||||
posted = broker.post_json(url + "/fbpair/v1/node/result", outbound._signed_envelope(
|
# Original put_result only records this request's result; repeating
|
||||||
ctx, "node.result", {"request_id": req.get("request_id"), "result": value}), state_dir=state)
|
# 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:
|
if posted.get("ok") is not True:
|
||||||
raise NodeError("NODE_RESULT_REJECTED")
|
raise NodeError("NODE_RESULT_REJECTED")
|
||||||
result = {"ok": True, "idle": False, "request_id": req.get("request_id")}
|
result = {"ok": True, "idle": False, "request_id": req.get("request_id")}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import zlib
|
|||||||
MAX_PIXELS = 4096 * 2160
|
MAX_PIXELS = 4096 * 2160
|
||||||
MAX_FRAMEBUFFER_BYTES = 128 * 1024 * 1024
|
MAX_FRAMEBUFFER_BYTES = 128 * 1024 * 1024
|
||||||
MAX_IMAGE_BYTES = 32 * 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")
|
CAPTURE_ID = re.compile(r"[0-9a-f]{32}\Z")
|
||||||
SESSION_ID = re.compile(r"[A-Za-z0-9_-]{1,64}\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")
|
DISPLAY_ID = re.compile(r":[0-9]{1,5}(?:\.[0-9]{1,3})?\Z")
|
||||||
|
|||||||
@@ -13,12 +13,17 @@ import math
|
|||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import struct
|
import struct
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
CHUNK_BYTES = 2100
|
CHUNK_BYTES = 2100
|
||||||
MAX_IMAGE_BYTES = 32 * 1024 * 1024
|
MAX_IMAGE_BYTES = 32 * 1024 * 1024
|
||||||
MAX_STDOUT_BYTES = 4096
|
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 '
|
RPC_COMMAND = '/usr/bin/python3 -B -m framebridge_expanded.real_screen.screen_rpc '
|
||||||
HEX_ID = re.compile(r"[0-9a-f]{32}\Z")
|
HEX_ID = re.compile(r"[0-9a-f]{32}\Z")
|
||||||
SHA = re.compile(r"[0-9a-f]{64}\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,
|
READ = {"readOnlyHint": True, "destructiveHint": False,
|
||||||
"idempotentHint": False, "openWorldHint": False}
|
"idempotentHint": False, "openWorldHint": False}
|
||||||
CHANGE = {**READ, "readOnlyHint": 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):
|
class ScreenError(RuntimeError):
|
||||||
"""A bounded public error suitable for the MCP response."""
|
"""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)
|
super().__init__(code)
|
||||||
self.details = details or {}
|
self.details = details or {}
|
||||||
|
# Local proof, never accepted from a remote JSON field.
|
||||||
|
self.backend_rejection = backend_rejection
|
||||||
|
|
||||||
|
|
||||||
def require(ok, code):
|
def require(ok, code):
|
||||||
@@ -62,7 +78,7 @@ class _AuthenticatedReply(dict):
|
|||||||
|
|
||||||
_IDENTITY_FIELDS = ("capture_id", "node", "sha256", "session_id", "bytes",
|
_IDENTITY_FIELDS = ("capture_id", "node", "sha256", "session_id", "bytes",
|
||||||
"width", "height", "source", "screen_type",
|
"width", "height", "source", "screen_type",
|
||||||
"full_screen", "authentic_pixels")
|
"full_screen", "authentic_pixels", "max_chunk_bytes")
|
||||||
|
|
||||||
def __init__(self, payload, owner, node, node_id):
|
def __init__(self, payload, owner, node, node_id):
|
||||||
super().__init__(payload)
|
super().__init__(payload)
|
||||||
@@ -80,23 +96,51 @@ class _AuthenticatedReply(dict):
|
|||||||
|
|
||||||
|
|
||||||
class ScreenClient:
|
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,
|
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.exec_request = exec_request
|
||||||
self.image_factory = image_factory
|
self.image_factory = image_factory
|
||||||
self.resolve_node = resolve_node or (lambda node: node.upper())
|
self.resolve_node = resolve_node or (lambda node: node.upper())
|
||||||
self._reply_owner = object()
|
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")
|
payload = base64.b64encode(encode(request)).decode("ascii")
|
||||||
script = RPC_COMMAND + shlex.quote(payload) + "\n"
|
script = RPC_COMMAND + shlex.quote(payload) + "\n"
|
||||||
digest = hashlib.sha256(script.encode("utf-8")).hexdigest()
|
digest = hashlib.sha256(script.encode("utf-8")).hexdigest()
|
||||||
job_id = "cua-screen-" + uuid.uuid4().hex
|
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,
|
job_id=job_id, sha256=digest,
|
||||||
include_stdout=True, target=node)
|
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) is dict, "SCREEN_EXEC_FAILED")
|
||||||
require(type(outer.get("exit_code")) in (int, str) and
|
require(type(outer.get("exit_code")) in (int, str) and
|
||||||
str(outer["exit_code"]) in ("0", "1"), "SCREEN_EXEC_FAILED")
|
str(outer["exit_code"]) in ("0", "1"), "SCREEN_EXEC_FAILED")
|
||||||
@@ -109,7 +153,7 @@ class ScreenClient:
|
|||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
raise ScreenError("SCREEN_EXEC_NODE_ID_INVALID") from None
|
raise ScreenError("SCREEN_EXEC_NODE_ID_INVALID") from None
|
||||||
stdout = outer.get("stdout")
|
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")
|
outer.get("stdout_truncated") is not True, "SCREEN_STDOUT_INVALID")
|
||||||
try:
|
try:
|
||||||
envelope = json.loads(stdout)
|
envelope = json.loads(stdout)
|
||||||
@@ -129,12 +173,19 @@ class ScreenClient:
|
|||||||
if valid_reference(code.get("cause"), ERROR):
|
if valid_reference(code.get("cause"), ERROR):
|
||||||
details["cause"] = code["cause"]
|
details["cause"] = code["cause"]
|
||||||
code = code.get("code")
|
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(outer.get("ok") is True and str(outer["exit_code"]) == "0", "SCREEN_EXEC_FAILED")
|
||||||
require(type(envelope.get("result")) is dict, "SCREEN_RESPONSE_INVALID")
|
require(type(envelope.get("result")) is dict, "SCREEN_RESPONSE_INVALID")
|
||||||
return _AuthenticatedReply(envelope["result"], self._reply_owner, node, node_id)
|
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")
|
capture_id = manifest.get("capture_id")
|
||||||
total, digest = manifest.get("bytes"), manifest.get("sha256")
|
total, digest = manifest.get("bytes"), manifest.get("sha256")
|
||||||
require(valid_reference(capture_id, HEX_ID) and valid_reference(digest, SHA),
|
require(valid_reference(capture_id, HEX_ID) and valid_reference(digest, SHA),
|
||||||
@@ -151,11 +202,23 @@ class ScreenClient:
|
|||||||
"SCREEN_PIXEL_SOURCE_INVALID")
|
"SCREEN_PIXEL_SOURCE_INVALID")
|
||||||
require(manifest.get("full_screen") is True and manifest.get("authentic_pixels") is True,
|
require(manifest.get("full_screen") is True and manifest.get("authentic_pixels") is True,
|
||||||
"SCREEN_PIXEL_PROVENANCE_INVALID")
|
"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 = []
|
parts = []
|
||||||
count = math.ceil(total / CHUNK_BYTES)
|
count = math.ceil(total / chunk_bytes)
|
||||||
for index in range(count):
|
for index in range(count):
|
||||||
part = self.remote(node, {"op": "read_chunk", "capture_id": capture_id,
|
request = {"op": "read_chunk", "capture_id": capture_id,
|
||||||
"index": index, "expected_sha256": digest})
|
"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
|
require(isinstance(part, _AuthenticatedReply) and
|
||||||
part.bound_to(self._reply_owner, node, manifest.node_id),
|
part.bound_to(self._reply_owner, node, manifest.node_id),
|
||||||
"SCREEN_CHUNK_NODE_MISMATCH")
|
"SCREEN_CHUNK_NODE_MISMATCH")
|
||||||
@@ -167,12 +230,12 @@ class ScreenClient:
|
|||||||
type(part.get("more")) is bool and part["more"] == (index + 1 < count),
|
type(part.get("more")) is bool and part["more"] == (index + 1 < count),
|
||||||
"SCREEN_CHUNK_MANIFEST_INVALID")
|
"SCREEN_CHUNK_MANIFEST_INVALID")
|
||||||
text = part.get("data")
|
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:
|
try:
|
||||||
data = base64.b64decode(text, validate=True)
|
data = base64.b64decode(text, validate=True)
|
||||||
except (ValueError, UnicodeError, binascii.Error):
|
except (ValueError, UnicodeError, binascii.Error):
|
||||||
raise ScreenError("SCREEN_CHUNK_INVALID") from None
|
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")
|
"SCREEN_CHUNK_LENGTH_INVALID")
|
||||||
parts.append(data)
|
parts.append(data)
|
||||||
data = b"".join(parts)
|
data = b"".join(parts)
|
||||||
@@ -181,10 +244,15 @@ class ScreenClient:
|
|||||||
require(len(data) >= 33 and data[:8] == b"\x89PNG\r\n\x1a\n" and
|
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),
|
data[12:16] == b"IHDR" and struct.unpack(">II", data[16:24]) == (width, height),
|
||||||
"SCREEN_PNG_INVALID")
|
"SCREEN_PNG_INVALID")
|
||||||
|
require(time.monotonic() < deadline, "SCREEN_TRANSFER_TIMEOUT")
|
||||||
return self.image_factory(data=data, format="png")
|
return self.image_factory(data=data, format="png")
|
||||||
|
|
||||||
def call(self, op, node, authorized, **kwargs):
|
def call(self, op, node, authorized, **kwargs):
|
||||||
requested_node = node
|
requested_node = node
|
||||||
|
action_confirmed = False
|
||||||
|
input_reply_received = False
|
||||||
|
submitted = {}
|
||||||
|
deadline = time.monotonic() + self.transfer_timeout_s
|
||||||
try:
|
try:
|
||||||
require(authorized is True, "AUTHORIZED_TRUE_REQUIRED")
|
require(authorized is True, "AUTHORIZED_TRUE_REQUIRED")
|
||||||
require(valid_reference(node, NODE), "SCREEN_NODE_INVALID")
|
require(valid_reference(node, NODE), "SCREEN_NODE_INVALID")
|
||||||
@@ -202,11 +270,20 @@ class ScreenClient:
|
|||||||
if op == "text":
|
if op == "text":
|
||||||
require(type(kwargs.get("text")) is str and 0 < len(kwargs["text"].encode("utf-8")) <= 4096,
|
require(type(kwargs.get("text")) is str and 0 < len(kwargs["text"].encode("utf-8")) <= 4096,
|
||||||
"SCREEN_TEXT_INVALID")
|
"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":
|
if op == "read_capture":
|
||||||
require(manifest.get("capture_id") == kwargs["capture_id"],
|
require(manifest.get("capture_id") == kwargs["capture_id"],
|
||||||
"SCREEN_CAPTURE_REFERENCE_MISMATCH")
|
"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,
|
metadata = {"ok": True, "node": node, "requested_node": requested_node,
|
||||||
"node_id": manifest.node_id, "hostname": manifest["node"],
|
"node_id": manifest.node_id, "hostname": manifest["node"],
|
||||||
"result": dict(manifest, transport_node=node, node_id=manifest.node_id)}
|
"result": dict(manifest, transport_node=node, node_id=manifest.node_id)}
|
||||||
@@ -217,9 +294,21 @@ class ScreenClient:
|
|||||||
metadata["result"]["authentic_pixels"] = True
|
metadata["result"]["authentic_pixels"] = True
|
||||||
return [metadata, image]
|
return [metadata, image]
|
||||||
except ScreenError as exc:
|
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}
|
return {"ok": False, "node": requested_node, "error": str(exc), **exc.details}
|
||||||
except Exception:
|
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):
|
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,
|
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."""
|
"""Register real image tools. The host supplies its existing executor."""
|
||||||
if image_factory is None:
|
if image_factory is None:
|
||||||
from mcp.server.fastmcp import Image
|
from mcp.server.fastmcp import Image
|
||||||
image_factory = 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))
|
@mcp.tool(annotations=dict(READ))
|
||||||
def framebridge_screen_observe(node: str, authorized: bool,
|
def framebridge_screen_observe(node: str, authorized: bool,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import stat
|
|||||||
import sys
|
import sys
|
||||||
import time
|
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
|
from .screen_control import ControlError, act, bind_screen, snapshot_binding
|
||||||
|
|
||||||
MAX_REQUEST = 16384
|
MAX_REQUEST = 16384
|
||||||
@@ -89,7 +89,10 @@ def write_record(path: Path, value, *, replace=False):
|
|||||||
|
|
||||||
def public_manifest(manifest):
|
def public_manifest(manifest):
|
||||||
# Internal path is unnecessary to use or understand a captured screen.
|
# 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):
|
def observe(directory: Path, requested_session_id=None):
|
||||||
@@ -138,7 +141,7 @@ def validate_request(request):
|
|||||||
allowed = {
|
allowed = {
|
||||||
'observe': {'op', 'session_id'},
|
'observe': {'op', 'session_id'},
|
||||||
'status': {'op'},
|
'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'},
|
'read_capture': {'op', 'capture_id'},
|
||||||
'key': {'op', 'capture_id', 'session_id', 'key'},
|
'key': {'op', 'capture_id', 'session_id', 'key'},
|
||||||
'text': {'op', 'capture_id', 'session_id', 'text'},
|
'text': {'op', 'capture_id', 'session_id', 'text'},
|
||||||
@@ -149,6 +152,9 @@ def validate_request(request):
|
|||||||
sid = request['session_id']
|
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')
|
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 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('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')
|
require(type(request.get('expected_sha256')) is str and HEX_SHA.fullmatch(request['expected_sha256']), 'EXPECTED_SHA256_REQUIRED')
|
||||||
if op == 'key':
|
if op == 'key':
|
||||||
@@ -179,7 +185,7 @@ def dispatch(request, directory=None):
|
|||||||
if op == 'read_chunk':
|
if op == 'read_chunk':
|
||||||
require(manifest['sha256'] == request['expected_sha256'], 'CAPTURE_SHA256_MISMATCH')
|
require(manifest['sha256'] == request['expected_sha256'], 'CAPTURE_SHA256_MISMATCH')
|
||||||
chunk = read_chunk(directory, manifest['capture_id'], request['index'],
|
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}
|
return {'ok': True, 'result': chunk}
|
||||||
if manifest.get('screen_type') == 'x11':
|
if manifest.get('screen_type') == 'x11':
|
||||||
raise ScreenRpcError('USE_GRAPHICAL_CUA_FOR_X11_INPUT')
|
raise ScreenRpcError('USE_GRAPHICAL_CUA_FOR_X11_INPUT')
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import signal
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
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
|
from .screen_rpc import MAX_REQUEST, encode, validate_request, ScreenRpcError
|
||||||
|
|
||||||
REQUEST_TYPE = "screen_rpc"
|
REQUEST_TYPE = "screen_rpc"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import socket
|
|||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -24,15 +25,19 @@ from .config import load_config, state_path, private_dir
|
|||||||
PREFIX = Path(os.environ.get("FRAMEBRIDGE_EXPANDED_PREFIX", Path(__file__).resolve().parents[2]))
|
PREFIX = Path(os.environ.get("FRAMEBRIDGE_EXPANDED_PREFIX", Path(__file__).resolve().parents[2]))
|
||||||
_STOP = False
|
_STOP = False
|
||||||
_LEGACY = None
|
_LEGACY = None
|
||||||
|
_LEGACY_LOCK = threading.RLock()
|
||||||
|
|
||||||
def _legacy():
|
def _legacy():
|
||||||
global _LEGACY
|
global _LEGACY
|
||||||
|
# Publish only a fully initialized module; a failed load remains retryable.
|
||||||
|
with _LEGACY_LOCK:
|
||||||
if _LEGACY is None:
|
if _LEGACY is None:
|
||||||
path = PREFIX / "bin/framebridge-original"
|
path = PREFIX / "bin/framebridge-original"
|
||||||
loader = importlib.machinery.SourceFileLoader("framebridge_original_launcher", str(path))
|
loader = importlib.machinery.SourceFileLoader("framebridge_original_launcher", str(path))
|
||||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
_LEGACY = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
loader.exec_module(_LEGACY)
|
loader.exec_module(module)
|
||||||
|
_LEGACY = module
|
||||||
return _LEGACY
|
return _LEGACY
|
||||||
|
|
||||||
def _args(state, config):
|
def _args(state, config):
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ from build_profiles import CANDIDATE_VERSION, PROFILES, SOURCE_VENDOR, VENDOR_RE
|
|||||||
BASE = Path(__file__).resolve().parents[1]
|
BASE = Path(__file__).resolve().parents[1]
|
||||||
ROOT = BASE.parent
|
ROOT = BASE.parent
|
||||||
VERSION = CANDIDATE_VERSION
|
VERSION = CANDIDATE_VERSION
|
||||||
|
# Oldest package retaining the privileged helper lifecycle. Candidate/release
|
||||||
|
# version bumps must not move this downgrade revocation boundary.
|
||||||
|
PRIVILEGED_HELPER_FLOOR = "0.3.2"
|
||||||
PUBLIC_FILENAME = "FrameBridge-0.3.1.deb"
|
PUBLIC_FILENAME = "FrameBridge-0.3.1.deb"
|
||||||
GPL_SHA256 = "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986"
|
GPL_SHA256 = "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986"
|
||||||
EPOCH = calendar.timegm((2026, 10, 27, 0, 0, 0))
|
EPOCH = calendar.timegm((2026, 10, 27, 0, 0, 0))
|
||||||
@@ -140,7 +143,8 @@ def install_legal_material(docs):
|
|||||||
def maintainer_prerm(version):
|
def maintainer_prerm(version):
|
||||||
"""Revoke helper grants before a downgrade removes the helper's code.
|
"""Revoke helper grants before a downgrade removes the helper's code.
|
||||||
|
|
||||||
Equal-version reinstalls and forward upgrades retain managed credentials.
|
The argument is the fixed helper compatibility floor, not the package version.
|
||||||
|
Targets retaining the helper lifecycle preserve managed credentials.
|
||||||
The fixed installed cleanup module validates the helper's root ownership,
|
The fixed installed cleanup module validates the helper's root ownership,
|
||||||
permissions and ancestors before running its bounded purge-all command.
|
permissions and ancestors before running its bounded purge-all command.
|
||||||
"""
|
"""
|
||||||
@@ -298,7 +302,7 @@ def main(argv=None):
|
|||||||
" Optional virtual displays are explicitly identified as virtual.\n"
|
" Optional virtual displays are explicitly identified as virtual.\n"
|
||||||
" No phone or Owner Device pairing. Services start after connection setup.\n"
|
" No phone or Owner Device pairing. Services start after connection setup.\n"
|
||||||
" Installing the package alone does not activate a connection.\n")
|
" Installing the package alone does not activate a connection.\n")
|
||||||
(control / "prerm").write_text(maintainer_prerm(VERSION))
|
(control / "prerm").write_text(maintainer_prerm(PRIVILEGED_HELPER_FLOOR))
|
||||||
(control / "prerm").chmod(0o755)
|
(control / "prerm").chmod(0o755)
|
||||||
rows = []
|
rows = []
|
||||||
for path in sorted(STAGE.rglob("*")):
|
for path in sorted(STAGE.rglob("*")):
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import base64
|
import base64
|
||||||
|
from functools import partial, wraps
|
||||||
|
import inspect
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -8,6 +10,9 @@ import socket
|
|||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import anyio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mcp import ClientSession
|
from mcp import ClientSession
|
||||||
@@ -55,6 +60,45 @@ mcp = FastMCP(
|
|||||||
mcp._mcp_server.version = PRODUCT_VERSION
|
mcp._mcp_server.version = PRODUCT_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class _BlockingToolRegistrar:
|
||||||
|
"""Offload only MCP dispatch while keeping synchronous callback bindings.
|
||||||
|
|
||||||
|
Use the public SDK decorator; do not change its dispatcher or the functions
|
||||||
|
passed to ScreenClient/TTY as synchronous EXEC callbacks. AnyIO bounds worker
|
||||||
|
concurrency. Waiting cancellation does not replay or undo an operation: a
|
||||||
|
running worker is allowed to finish, including any side effects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, server):
|
||||||
|
self.server = server
|
||||||
|
|
||||||
|
def resource(self, *args, **kwargs):
|
||||||
|
return self.server.resource(*args, **kwargs)
|
||||||
|
|
||||||
|
def tool(self, *args, **kwargs):
|
||||||
|
register = self.server.tool(*args, **kwargs)
|
||||||
|
|
||||||
|
def decorate(function):
|
||||||
|
@wraps(function)
|
||||||
|
async def dispatch(*arguments, **keywords):
|
||||||
|
return await anyio.to_thread.run_sync(
|
||||||
|
partial(function, *arguments, **keywords),
|
||||||
|
abandon_on_cancel=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve future annotations in the defining module, including the
|
||||||
|
# screen presentation's Annotated return type, before registering.
|
||||||
|
dispatch.__signature__ = inspect.signature(function, eval_str=True)
|
||||||
|
register(dispatch)
|
||||||
|
return function
|
||||||
|
|
||||||
|
return decorate
|
||||||
|
|
||||||
|
|
||||||
|
_blocking_tools = _BlockingToolRegistrar(mcp)
|
||||||
|
|
||||||
|
|
||||||
def _registered_operations() -> list[str]:
|
def _registered_operations() -> list[str]:
|
||||||
"""Registered operations are not proof of their current execution readiness."""
|
"""Registered operations are not proof of their current execution readiness."""
|
||||||
return sorted(mcp._tool_manager._tools)
|
return sorted(mcp._tool_manager._tools)
|
||||||
@@ -395,7 +439,7 @@ def _framebridge_exec_geranas_impl(
|
|||||||
if not 1<=timeout_s<=600:
|
if not 1<=timeout_s<=600:
|
||||||
return {"ok":False,"error":"INVALID_TIMEOUT"}
|
return {"ok":False,"error":"INVALID_TIMEOUT"}
|
||||||
if not job_id:
|
if not job_id:
|
||||||
job_id="mcp-"+time.strftime("%Y%m%d-%H%M%S")+"-"+digest[:8]
|
job_id="mcp-"+time.strftime("%Y%m%d-%H%M%S")+"-"+digest[:8]+"-"+uuid.uuid4().hex[:12]
|
||||||
if not JOB_RE.fullmatch(job_id):
|
if not JOB_RE.fullmatch(job_id):
|
||||||
return {"ok":False,"error":"INVALID_JOB_ID"}
|
return {"ok":False,"error":"INVALID_JOB_ID"}
|
||||||
req={
|
req={
|
||||||
@@ -468,7 +512,7 @@ def framebridge_cloud_dsm(action: str, authorized: bool = False, params_json: st
|
|||||||
return _cloud_dsm_rpc({"action":action,"authorized":bool(authorized),"params":params})
|
return _cloud_dsm_rpc({"action":action,"authorized":bool(authorized),"params":params})
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@_blocking_tools.tool()
|
||||||
def framebridge_exec(
|
def framebridge_exec(
|
||||||
script: str,
|
script: str,
|
||||||
authorized: bool,
|
authorized: bool,
|
||||||
@@ -641,7 +685,7 @@ def asus_cua_request(cua: dict, authorized: bool, timeout_s: int, job_id: str) -
|
|||||||
return {"ok":False,"error":"ASUS_CUA_TIMEOUT","request_id":rid}
|
return {"ok":False,"error":"ASUS_CUA_TIMEOUT","request_id":rid}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@_blocking_tools.tool()
|
||||||
def framebridge_cua(
|
def framebridge_cua(
|
||||||
action: str,
|
action: str,
|
||||||
authorized: bool,
|
authorized: bool,
|
||||||
@@ -830,12 +874,15 @@ def _screen_exec_request(
|
|||||||
|
|
||||||
|
|
||||||
from framebridge_expanded.real_screen.screen_frontdoor import register_framebridge_screen_tools, resolve_profile_node
|
from framebridge_expanded.real_screen.screen_frontdoor import register_framebridge_screen_tools, resolve_profile_node
|
||||||
_real_screen_client = register_framebridge_screen_tools(mcp, _screen_exec_request, image_factory=Image, resolve_node=resolve_profile_node)
|
_real_screen_client = register_framebridge_screen_tools(
|
||||||
|
_blocking_tools, _screen_exec_request, image_factory=Image,
|
||||||
|
resolve_node=resolve_profile_node, stdout_limit_bytes=65536, transfer_timeout_s=45,
|
||||||
|
)
|
||||||
from framebridge_expanded.real_screen.screen_presentation import register_screen_presentation
|
from framebridge_expanded.real_screen.screen_presentation import register_screen_presentation
|
||||||
register_screen_presentation(mcp, _real_screen_client)
|
register_screen_presentation(_blocking_tools, _real_screen_client)
|
||||||
|
|
||||||
from framebridge_expanded.tty_mcp import register_tools as register_tty_tools
|
from framebridge_expanded.tty_mcp import register_tools as register_tty_tools
|
||||||
register_tty_tools(mcp, framebridge_exec)
|
register_tty_tools(_blocking_tools, framebridge_exec, screen_client=_real_screen_client)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
from framebridge_expanded.http_server import run_streamable_mcp
|
from framebridge_expanded.http_server import run_streamable_mcp
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,316 @@
|
|||||||
|
"""Portable registration integration through the bundled FastMCP dispatcher.
|
||||||
|
|
||||||
|
Executors are bounded fixtures. No installed adapter, desktop or Fleet node is
|
||||||
|
contacted; these checks establish dispatch and identity wiring, not acceptance.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
from functools import partial, wraps
|
||||||
|
import hashlib
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
WORK = Path(__file__).resolve().parents[1]
|
||||||
|
VENDOR = WORK / "sources/original-geranas/FrameBridge-Fleet-Hub/vendor"
|
||||||
|
sys.path.insert(0, str(WORK / "expanded"))
|
||||||
|
sys.path.insert(0, str(VENDOR))
|
||||||
|
import anyio
|
||||||
|
from mcp.server.fastmcp import FastMCP, Image
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
from framebridge_expanded import tty_mcp
|
||||||
|
from framebridge_expanded.real_screen.screen_frontdoor import register_framebridge_screen_tools
|
||||||
|
from framebridge_expanded.real_screen.screen_presentation import register_screen_presentation
|
||||||
|
from test_screen_frontdoor import Executor, NODE_ID, SESSION_ID
|
||||||
|
|
||||||
|
PORTABLE = WORK / "portable/front_door_mcp.py"
|
||||||
|
|
||||||
|
|
||||||
|
class ChunkExecutor(Executor):
|
||||||
|
"""Honor the typed route's optional larger chunk while retaining bindings."""
|
||||||
|
def __call__(self, **kwargs):
|
||||||
|
outer = super().__call__(**kwargs)
|
||||||
|
request = json.loads(base64.b64decode(shlex.split(kwargs["script"])[-1]))
|
||||||
|
if request["op"] == "read_chunk" and request.get("chunk_bytes"):
|
||||||
|
size, index = request["chunk_bytes"], request["index"]
|
||||||
|
result = json.loads(outer["stdout"])
|
||||||
|
result["result"].update(
|
||||||
|
total_chunks=math.ceil(len(self.data) / size),
|
||||||
|
data=base64.b64encode(self.data[index * size:(index + 1) * size]).decode(),
|
||||||
|
more=(index + 1) * size < len(self.data),
|
||||||
|
)
|
||||||
|
outer["stdout"] = json.dumps(result)
|
||||||
|
return outer
|
||||||
|
|
||||||
|
|
||||||
|
def portable(*, execute=None, screen_execute=None, threaded=True):
|
||||||
|
"""Load the actual registration statements without bootstrap/profile effects."""
|
||||||
|
tree = ast.parse(PORTABLE.read_text())
|
||||||
|
names = {"_BlockingToolRegistrar", "_registered_operations", "framebridge_status",
|
||||||
|
"framebridge_exec", "_screen_exec_request"}
|
||||||
|
assignments = {"_blocking_tools", "_real_screen_client"}
|
||||||
|
registrations = {"register_screen_presentation", "register_tty_tools"}
|
||||||
|
selected = []
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node.name in names:
|
||||||
|
selected.append(node)
|
||||||
|
elif isinstance(node, ast.Assign) and any(
|
||||||
|
isinstance(target, ast.Name) and target.id in assignments for target in node.targets):
|
||||||
|
if not threaded and node.targets[0].id == "_blocking_tools":
|
||||||
|
node.value = ast.Name(id="mcp", ctx=ast.Load())
|
||||||
|
selected.append(node)
|
||||||
|
elif (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
|
||||||
|
and isinstance(node.value.func, ast.Name) and node.value.func.id in registrations):
|
||||||
|
selected.append(node)
|
||||||
|
mcp = FastMCP("dispatch fixture")
|
||||||
|
execute = execute or (lambda *args: {"ok": True})
|
||||||
|
screen_execute = screen_execute or (lambda *args: {"ok": False})
|
||||||
|
namespace = dict(mcp=mcp, anyio=anyio, partial=partial, wraps=wraps, inspect=inspect,
|
||||||
|
ToolAnnotations=ToolAnnotations, Image=Image, socket=socket,
|
||||||
|
ADAPTER_SOCKET=Path("/nonexistent/framebridge-test.sock"),
|
||||||
|
PRODUCT_VERSION="test", GUIDE_SCHEMA=1,
|
||||||
|
_framebridge_exec_geranas_impl=execute, expanded_execute_node=execute,
|
||||||
|
expanded_execute_screen_node=screen_execute,
|
||||||
|
register_framebridge_screen_tools=register_framebridge_screen_tools,
|
||||||
|
resolve_profile_node=lambda target: target.upper(),
|
||||||
|
register_screen_presentation=register_screen_presentation,
|
||||||
|
register_tty_tools=tty_mcp.register_tools)
|
||||||
|
exec(compile(ast.fix_missing_locations(ast.Module(body=selected, type_ignores=[])),
|
||||||
|
str(PORTABLE), "exec"), namespace)
|
||||||
|
return mcp, namespace
|
||||||
|
|
||||||
|
|
||||||
|
def structured(reply):
|
||||||
|
if isinstance(reply, tuple):
|
||||||
|
content, value = reply
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
reply = content
|
||||||
|
if isinstance(reply, dict):
|
||||||
|
return reply
|
||||||
|
return json.loads(next(item.text for item in reply if item.type == "text"))
|
||||||
|
|
||||||
|
|
||||||
|
async def until_set(event):
|
||||||
|
async def poll():
|
||||||
|
while not event.is_set():
|
||||||
|
await asyncio.sleep(.001)
|
||||||
|
await asyncio.wait_for(poll(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchTests(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_actual_sdk_returns_status_while_exec_and_tty_capture_are_running(self):
|
||||||
|
exec_started, capture_started, release = (threading.Event() for _ in range(3))
|
||||||
|
fixture = ChunkExecutor()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def execute(*args):
|
||||||
|
calls.append(args)
|
||||||
|
exec_started.set()
|
||||||
|
release.wait(2)
|
||||||
|
return {"ok": True, "marker": args[0]}
|
||||||
|
|
||||||
|
def screen(alias, script, authorized, timeout_s, job_id, sha256, include_stdout):
|
||||||
|
capture_started.set()
|
||||||
|
release.wait(2)
|
||||||
|
return fixture(target=alias, script=script, authorized=authorized,
|
||||||
|
timeout_s=timeout_s, job_id=job_id, sha256=sha256,
|
||||||
|
include_stdout=include_stdout)
|
||||||
|
|
||||||
|
mcp, _ = portable(execute=execute, screen_execute=screen)
|
||||||
|
slow = asyncio.create_task(mcp.call_tool("framebridge_exec", {
|
||||||
|
"script": "bounded-marker", "authorized": True}))
|
||||||
|
capture = asyncio.create_task(mcp.call_tool("cua_tty_observe", {
|
||||||
|
"session_id": SESSION_ID, "authorized": True}))
|
||||||
|
try:
|
||||||
|
await until_set(exec_started)
|
||||||
|
await until_set(capture_started)
|
||||||
|
status = structured(await asyncio.wait_for(mcp.call_tool("framebridge_status", {}), .3))
|
||||||
|
self.assertTrue(status["ok"])
|
||||||
|
self.assertFalse(slow.done())
|
||||||
|
self.assertFalse(capture.done())
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
exec_result, capture_result = await asyncio.gather(slow, capture)
|
||||||
|
self.assertEqual(structured(exec_result)["marker"], "bounded-marker")
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
self.assertTrue(any(item.type == "image" for item in capture_result))
|
||||||
|
|
||||||
|
async def test_fast_exec_response_is_delivered_before_slow_exec_completes(self):
|
||||||
|
started, release = threading.Event(), threading.Event()
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def execute(*args):
|
||||||
|
marker = args[0]
|
||||||
|
calls.append(marker)
|
||||||
|
if marker == "slow":
|
||||||
|
started.set()
|
||||||
|
release.wait(2)
|
||||||
|
return {"ok": True, "marker": marker}
|
||||||
|
|
||||||
|
mcp, _ = portable(execute=execute)
|
||||||
|
slow = asyncio.create_task(mcp.call_tool("framebridge_exec", {
|
||||||
|
"script": "slow", "authorized": True}))
|
||||||
|
try:
|
||||||
|
await until_set(started)
|
||||||
|
fast = structured(await asyncio.wait_for(mcp.call_tool("framebridge_exec", {
|
||||||
|
"script": "fast", "authorized": True}), .3))
|
||||||
|
self.assertEqual(fast["marker"], "fast")
|
||||||
|
self.assertFalse(slow.done())
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
slow_result = await slow
|
||||||
|
self.assertEqual(structured(slow_result)["marker"], "slow")
|
||||||
|
self.assertCountEqual(calls, ["slow", "fast"])
|
||||||
|
|
||||||
|
async def test_full_portable_import_registers_tools_without_bootstrap_side_effects(self):
|
||||||
|
import importlib.util
|
||||||
|
from framebridge_expanded import fleet
|
||||||
|
spec = importlib.util.spec_from_file_location("frontdoor_dispatch_test", PORTABLE)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
with mock.patch.object(fleet, "bootstrap_original") as bootstrap:
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
bootstrap.assert_called_once_with()
|
||||||
|
names = {tool.name for tool in await module.mcp.list_tools()}
|
||||||
|
self.assertTrue(set(tty_mcp.TOOL_NAMES) <= names)
|
||||||
|
self.assertTrue({"framebridge_exec", "framebridge_cua", "framebridge_screen_show"} <= names)
|
||||||
|
self.assertFalse(inspect.iscoroutinefunction(module.framebridge_exec))
|
||||||
|
|
||||||
|
async def test_schemas_metadata_and_internal_sync_callbacks_are_preserved(self):
|
||||||
|
mcp, namespace = portable()
|
||||||
|
baseline, _ = portable(threaded=False)
|
||||||
|
actual = {tool.name: tool.model_dump() for tool in await mcp.list_tools()}
|
||||||
|
expected = {tool.name: tool.model_dump() for tool in await baseline.list_tools()}
|
||||||
|
self.assertEqual(actual, expected)
|
||||||
|
self.assertFalse(inspect.iscoroutinefunction(namespace["framebridge_exec"]))
|
||||||
|
self.assertFalse(inspect.iscoroutinefunction(namespace["_screen_exec_request"]))
|
||||||
|
self.assertIsInstance(namespace["framebridge_exec"]("marker", True), dict)
|
||||||
|
|
||||||
|
async def test_local_and_remote_tty_capture_use_identity_producing_transport(self):
|
||||||
|
for target in ("LOCAL", "WORKER_1"):
|
||||||
|
with self.subTest(target=target):
|
||||||
|
fixture, calls = ChunkExecutor(), []
|
||||||
|
|
||||||
|
def screen(alias, script, authorized, timeout_s, job_id, sha256, include_stdout):
|
||||||
|
calls.append(alias)
|
||||||
|
return fixture(target=alias, script=script, authorized=authorized,
|
||||||
|
timeout_s=timeout_s, job_id=job_id, sha256=sha256,
|
||||||
|
include_stdout=include_stdout)
|
||||||
|
|
||||||
|
legacy = mock.Mock(side_effect=AssertionError("legacy EXEC must not capture"))
|
||||||
|
mcp, _ = portable(execute=legacy, screen_execute=screen)
|
||||||
|
result = await mcp.call_tool("cua_tty_observe", {
|
||||||
|
"session_id": SESSION_ID, "authorized": True, "target": target})
|
||||||
|
self.assertTrue(any(item.type == "image" for item in result))
|
||||||
|
self.assertEqual(set(calls), {target})
|
||||||
|
self.assertGreaterEqual(len(calls), 2)
|
||||||
|
legacy.assert_not_called()
|
||||||
|
|
||||||
|
async def test_missing_uuid_and_wrong_reply_binding_still_fail_closed(self):
|
||||||
|
for corruption, expected in (("uuid", "SCREEN_EXEC_NODE_ID_INVALID"),
|
||||||
|
("hash", "SCREEN_EXEC_BINDING_INVALID")):
|
||||||
|
with self.subTest(corruption=corruption):
|
||||||
|
fixture = ChunkExecutor()
|
||||||
|
|
||||||
|
def screen(alias, script, authorized, timeout_s, job_id, sha256, include_stdout):
|
||||||
|
result = fixture(target=alias, script=script, authorized=authorized,
|
||||||
|
timeout_s=timeout_s, job_id=job_id, sha256=sha256,
|
||||||
|
include_stdout=include_stdout)
|
||||||
|
if corruption == "uuid":
|
||||||
|
result.pop("node_id")
|
||||||
|
else:
|
||||||
|
result["mcp_script_sha256"] = "0" * 64
|
||||||
|
return result
|
||||||
|
|
||||||
|
mcp, _ = portable(screen_execute=screen)
|
||||||
|
result = structured(await mcp.call_tool("cua_tty_observe", {
|
||||||
|
"session_id": SESSION_ID, "authorized": True}))
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertEqual(result["error"], expected)
|
||||||
|
|
||||||
|
async def test_other_tty_rpcs_also_leave_status_responsive(self):
|
||||||
|
started, release = threading.Event(), threading.Event()
|
||||||
|
mcp, _ = portable()
|
||||||
|
|
||||||
|
def request(*args):
|
||||||
|
started.set()
|
||||||
|
release.wait(2)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
with mock.patch.object(tty_mcp, "_remote_request", side_effect=request):
|
||||||
|
slow = asyncio.create_task(mcp.call_tool("cua_tty_status", {
|
||||||
|
"authorized": True, "target": "WORKER_1"}))
|
||||||
|
try:
|
||||||
|
await until_set(started)
|
||||||
|
self.assertTrue(structured(await asyncio.wait_for(
|
||||||
|
mcp.call_tool("framebridge_status", {}), .3))["ok"])
|
||||||
|
self.assertFalse(slow.done())
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
await slow
|
||||||
|
|
||||||
|
async def test_anyio_cancellation_waits_for_single_worker_without_replay(self):
|
||||||
|
started, release, finished = (threading.Event() for _ in range(3))
|
||||||
|
mcp, namespace = portable()
|
||||||
|
calls, scopes = [], []
|
||||||
|
|
||||||
|
@namespace["_blocking_tools"].tool()
|
||||||
|
def bounded_operation() -> dict:
|
||||||
|
calls.append(1)
|
||||||
|
started.set()
|
||||||
|
release.wait(2)
|
||||||
|
finished.set()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
async def dispatch():
|
||||||
|
with anyio.CancelScope() as scope:
|
||||||
|
scopes.append(scope)
|
||||||
|
await mcp.call_tool("bounded_operation", {})
|
||||||
|
|
||||||
|
async with anyio.create_task_group() as group:
|
||||||
|
group.start_soon(dispatch)
|
||||||
|
await until_set(started)
|
||||||
|
scopes[0].cancel()
|
||||||
|
await anyio.sleep(.02)
|
||||||
|
self.assertFalse(finished.is_set())
|
||||||
|
release.set()
|
||||||
|
self.assertTrue(finished.is_set())
|
||||||
|
self.assertEqual(calls, [1])
|
||||||
|
|
||||||
|
|
||||||
|
class AutomaticJobTests(unittest.TestCase):
|
||||||
|
def test_identical_concurrent_auto_jobs_are_distinct_and_explicit_ids_unchanged(self):
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
tree = ast.parse(PORTABLE.read_text())
|
||||||
|
function = next(node for node in tree.body if isinstance(node, ast.FunctionDef)
|
||||||
|
and node.name == "_framebridge_exec_geranas_impl")
|
||||||
|
namespace = dict(hashlib=hashlib, time=time, uuid=uuid,
|
||||||
|
JOB_RE=re.compile(r"^[A-Za-z0-9._-]{1,80}$"),
|
||||||
|
adapter_request=lambda payload: {"ok": True, "job_id": payload["job_id"]},
|
||||||
|
sanitize_text=lambda text: text)
|
||||||
|
exec(compile(ast.Module(body=[function], type_ignores=[]), str(PORTABLE), "exec"), namespace)
|
||||||
|
execute = namespace["_framebridge_exec_geranas_impl"]
|
||||||
|
with mock.patch.object(time, "strftime", return_value="20261028-000000"):
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||||
|
results = list(pool.map(lambda _: execute("same-command", True), range(16)))
|
||||||
|
self.assertEqual(len({result["job_id"] for result in results}), 16)
|
||||||
|
self.assertTrue(all(namespace["JOB_RE"].fullmatch(result["job_id"]) for result in results))
|
||||||
|
first = execute("same-command", True, job_id="owner-idempotent-id")
|
||||||
|
second = execute("same-command", True, job_id="owner-idempotent-id")
|
||||||
|
self.assertEqual(first["job_id"], "owner-idempotent-id")
|
||||||
|
self.assertEqual(first["job_id"], second["job_id"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"""Deterministic worker initialization races, using only in-memory fixtures."""
|
||||||
|
import ast
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
WORK = Path(__file__).resolve().parents[1]
|
||||||
|
EXPANDED = WORK / "expanded/framebridge_expanded"
|
||||||
|
|
||||||
|
|
||||||
|
def definitions(module, names, namespace):
|
||||||
|
tree = ast.parse((EXPANDED / module).read_text())
|
||||||
|
selected = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in names]
|
||||||
|
exec(compile(ast.Module(body=selected, type_ignores=[]), str(EXPANDED / module), "exec"), namespace)
|
||||||
|
return namespace
|
||||||
|
|
||||||
|
|
||||||
|
class ObservedRLock:
|
||||||
|
"""Signal when the competing worker reaches the lock, without timing guesses."""
|
||||||
|
def __init__(self):
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.waiting = threading.Event()
|
||||||
|
self.watch = None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
if threading.get_ident() == self.watch:
|
||||||
|
self.waiting.set()
|
||||||
|
self.lock.acquire()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
self.lock.release()
|
||||||
|
|
||||||
|
def watched(self, function):
|
||||||
|
self.watch = threading.get_ident()
|
||||||
|
return function()
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerInitializationTests(unittest.TestCase):
|
||||||
|
def test_runtime_publishes_complete_module_once_to_competing_workers(self):
|
||||||
|
started, release = threading.Event(), threading.Event()
|
||||||
|
lock, module = ObservedRLock(), SimpleNamespace()
|
||||||
|
loads = []
|
||||||
|
|
||||||
|
def initialize(target):
|
||||||
|
loads.append(target)
|
||||||
|
started.set()
|
||||||
|
if not release.wait(2):
|
||||||
|
raise AssertionError("fixture load was not released")
|
||||||
|
target.settings = "complete"
|
||||||
|
|
||||||
|
loader = SimpleNamespace(name="fixture", exec_module=initialize)
|
||||||
|
namespace = definitions("runtime.py", {"_legacy"}, dict(
|
||||||
|
_LEGACY=None, _LEGACY_LOCK=lock, PREFIX=Path("/fixture"),
|
||||||
|
importlib=SimpleNamespace(
|
||||||
|
machinery=SimpleNamespace(SourceFileLoader=lambda *args: loader),
|
||||||
|
util=SimpleNamespace(spec_from_loader=lambda *args: object(),
|
||||||
|
module_from_spec=lambda spec: module))))
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
|
first = pool.submit(namespace["_legacy"])
|
||||||
|
try:
|
||||||
|
self.assertTrue(started.wait(1))
|
||||||
|
self.assertIsNone(namespace["_LEGACY"])
|
||||||
|
second = pool.submit(lock.watched, namespace["_legacy"])
|
||||||
|
self.assertTrue(lock.waiting.wait(1))
|
||||||
|
self.assertFalse(second.done())
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
self.assertIs(first.result(1), module)
|
||||||
|
self.assertIs(second.result(1), module)
|
||||||
|
self.assertEqual(module.settings, "complete")
|
||||||
|
self.assertEqual(loads, [module])
|
||||||
|
|
||||||
|
def test_runtime_failed_initialization_remains_retryable(self):
|
||||||
|
attempts = []
|
||||||
|
module = SimpleNamespace()
|
||||||
|
|
||||||
|
def initialize(target):
|
||||||
|
attempts.append(target)
|
||||||
|
if len(attempts) == 1:
|
||||||
|
raise RuntimeError("fixture failure")
|
||||||
|
target.settings = "complete"
|
||||||
|
|
||||||
|
loader = SimpleNamespace(name="fixture", exec_module=initialize)
|
||||||
|
namespace = definitions("runtime.py", {"_legacy"}, dict(
|
||||||
|
_LEGACY=None, _LEGACY_LOCK=threading.RLock(), PREFIX=Path("/fixture"),
|
||||||
|
importlib=SimpleNamespace(
|
||||||
|
machinery=SimpleNamespace(SourceFileLoader=lambda *args: loader),
|
||||||
|
util=SimpleNamespace(spec_from_loader=lambda *args: object(),
|
||||||
|
module_from_spec=lambda spec: module))))
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "fixture failure"):
|
||||||
|
namespace["_legacy"]()
|
||||||
|
self.assertIsNone(namespace["_LEGACY"])
|
||||||
|
self.assertIs(namespace["_legacy"](), module)
|
||||||
|
self.assertEqual(len(attempts), 2)
|
||||||
|
self.assertEqual(module.settings, "complete")
|
||||||
|
|
||||||
|
def test_fleet_bootstrap_serializes_shared_path_check_remove_and_append(self):
|
||||||
|
started, release = threading.Event(), threading.Event()
|
||||||
|
lock = ObservedRLock()
|
||||||
|
broker, fleet = Path("/fixture/broker"), Path("/fixture/fleet")
|
||||||
|
|
||||||
|
class Paths(list):
|
||||||
|
def remove(self, value):
|
||||||
|
if not started.is_set():
|
||||||
|
started.set()
|
||||||
|
if not release.wait(2):
|
||||||
|
raise AssertionError("fixture path update was not released")
|
||||||
|
super().remove(value)
|
||||||
|
|
||||||
|
paths = Paths([str(broker), str(fleet)])
|
||||||
|
package = SimpleNamespace(__file__=str(broker / "framebridge_linux_chatgpt/__init__.py"),
|
||||||
|
__path__=[])
|
||||||
|
namespace = definitions("fleet.py", {"bootstrap_original"}, dict(
|
||||||
|
_BOOTSTRAP_LOCK=lock, original_paths=lambda: (broker, fleet), Path=Path,
|
||||||
|
sys=SimpleNamespace(path=paths),
|
||||||
|
_require=lambda valid, code: self.assertTrue(valid, code)))
|
||||||
|
with mock.patch.dict(sys.modules, {"framebridge_linux_chatgpt": package}):
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
|
first = pool.submit(namespace["bootstrap_original"])
|
||||||
|
try:
|
||||||
|
self.assertTrue(started.wait(1))
|
||||||
|
second = pool.submit(lock.watched, namespace["bootstrap_original"])
|
||||||
|
self.assertTrue(lock.waiting.wait(1))
|
||||||
|
self.assertFalse(second.done())
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
first.result(1)
|
||||||
|
second.result(1)
|
||||||
|
self.assertEqual(paths.count(str(broker)), 1)
|
||||||
|
self.assertEqual(paths.count(str(fleet)), 1)
|
||||||
|
self.assertCountEqual(package.__path__, [str(root / "framebridge_linux_chatgpt")
|
||||||
|
for root in (broker, fleet)])
|
||||||
|
|
||||||
|
|
||||||
|
def broker_namespace(self, config):
|
||||||
|
def validate(url):
|
||||||
|
if url == "invalid-fixture":
|
||||||
|
raise ValueError("fixture endpoint rejected")
|
||||||
|
return url
|
||||||
|
|
||||||
|
return definitions("broker.py", {"configure", "_settings"}, dict(
|
||||||
|
_state=None, _url=None, _control_path=None,
|
||||||
|
_SETTINGS_LOCK=ObservedRLock(), Path=Path,
|
||||||
|
state_path=lambda state=None: Path(state or "/fixture/default"),
|
||||||
|
_config=config, validate_endpoint=validate))
|
||||||
|
|
||||||
|
def test_broker_initial_settings_are_published_as_one_complete_profile(self):
|
||||||
|
started, release = threading.Event(), threading.Event()
|
||||||
|
loads = []
|
||||||
|
|
||||||
|
def config(state):
|
||||||
|
loads.append(state)
|
||||||
|
started.set()
|
||||||
|
if not release.wait(2):
|
||||||
|
raise AssertionError("fixture config was not released")
|
||||||
|
return {"ports": {"broker": 23456}}
|
||||||
|
|
||||||
|
namespace = self.broker_namespace(config)
|
||||||
|
lock = namespace["_SETTINGS_LOCK"]
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
|
first = pool.submit(namespace["_settings"])
|
||||||
|
try:
|
||||||
|
self.assertTrue(started.wait(1))
|
||||||
|
self.assertEqual(tuple(namespace[key] for key in ("_state", "_url", "_control_path")),
|
||||||
|
(None, None, None))
|
||||||
|
second = pool.submit(lock.watched, namespace["_settings"])
|
||||||
|
self.assertTrue(lock.waiting.wait(1))
|
||||||
|
self.assertFalse(second.done())
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
expected = (Path("/fixture/default"), "http://127.0.0.1:23456",
|
||||||
|
Path("/fixture/default/broker/control.key"))
|
||||||
|
self.assertEqual(first.result(1), expected)
|
||||||
|
self.assertEqual(second.result(1), expected)
|
||||||
|
self.assertEqual(loads, [Path("/fixture/default")])
|
||||||
|
|
||||||
|
def test_broker_explicit_profile_change_cannot_expose_mixed_endpoint_or_key_path(self):
|
||||||
|
started, release = threading.Event(), threading.Event()
|
||||||
|
|
||||||
|
def config(state):
|
||||||
|
if state == Path("/fixture/next"):
|
||||||
|
started.set()
|
||||||
|
if not release.wait(2):
|
||||||
|
raise AssertionError("fixture config was not released")
|
||||||
|
return {"ports": {"broker": 23456}}
|
||||||
|
|
||||||
|
namespace = self.broker_namespace(config)
|
||||||
|
previous = namespace["_settings"]()
|
||||||
|
lock = namespace["_SETTINGS_LOCK"]
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
|
first = pool.submit(namespace["configure"], "https://fixture.example:24443",
|
||||||
|
"/fixture/next", "/fixture/next/explicit-control.key")
|
||||||
|
try:
|
||||||
|
self.assertTrue(started.wait(1))
|
||||||
|
self.assertEqual(tuple(namespace[key] for key in ("_state", "_url", "_control_path")),
|
||||||
|
previous)
|
||||||
|
second = pool.submit(lock.watched, namespace["_settings"])
|
||||||
|
self.assertTrue(lock.waiting.wait(1))
|
||||||
|
self.assertFalse(second.done())
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
first.result(1)
|
||||||
|
self.assertEqual(second.result(1), (Path("/fixture/next"),
|
||||||
|
"https://fixture.example:24443", Path("/fixture/next/explicit-control.key")))
|
||||||
|
|
||||||
|
def test_broker_failed_validation_publishes_nothing_and_can_retry(self):
|
||||||
|
namespace = self.broker_namespace(lambda state: {})
|
||||||
|
with self.assertRaisesRegex(ValueError, "fixture endpoint rejected"):
|
||||||
|
namespace["configure"]("invalid-fixture", "/fixture/rejected")
|
||||||
|
self.assertEqual(tuple(namespace[key] for key in ("_state", "_url", "_control_path")),
|
||||||
|
(None, None, None))
|
||||||
|
previous = namespace["_settings"]()
|
||||||
|
with self.assertRaisesRegex(ValueError, "fixture endpoint rejected"):
|
||||||
|
namespace["configure"]("invalid-fixture", "/fixture/rejected")
|
||||||
|
self.assertEqual(namespace["_settings"](), previous)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
"""Socket fault injection against the real FBPAIR broker and enrolled identities."""
|
||||||
|
import errno
|
||||||
|
import hashlib
|
||||||
|
import selectors
|
||||||
|
import shlex
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from framebridge_expanded import broker, node
|
||||||
|
import test_fleet_broker_node as fixtures
|
||||||
|
|
||||||
|
|
||||||
|
class StopLoop(BaseException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NodeRecoveryTests(unittest.TestCase):
|
||||||
|
setUp = fixtures.BrokerNodeTests.setUp
|
||||||
|
tearDown = fixtures.BrokerNodeTests.tearDown
|
||||||
|
make_node = fixtures.BrokerNodeTests.make_node
|
||||||
|
pair = fixtures.BrokerNodeTests.pair
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def disconnect(handler):
|
||||||
|
handler.close_connection = True
|
||||||
|
handler.connection.shutdown(socket.SHUT_RDWR)
|
||||||
|
handler.connection.close()
|
||||||
|
|
||||||
|
def test_transient_poll_disconnects_backoff_cap_and_inventory_recovery(self):
|
||||||
|
state, ident = self.pair("recover-node")
|
||||||
|
node.run(state, once=True)
|
||||||
|
store = self.server.expanded_store
|
||||||
|
store.node_last_seen[ident] = time.time() - 10
|
||||||
|
self.assertFalse(broker.list_confirmed_nodes()[0]["ready"])
|
||||||
|
identity_before = (state / "node/identity.json").read_bytes()
|
||||||
|
original = self.server.RequestHandlerClass.do_POST
|
||||||
|
real_post = broker.post_json
|
||||||
|
attempts, waits, envelopes = [], [], []
|
||||||
|
recovered = []
|
||||||
|
|
||||||
|
def fault(handler):
|
||||||
|
if handler.path.endswith("/node/poll"):
|
||||||
|
attempts.append(1)
|
||||||
|
if len(attempts) <= 8:
|
||||||
|
self.disconnect(handler)
|
||||||
|
return
|
||||||
|
return original(handler)
|
||||||
|
|
||||||
|
def post(url, envelope, **kwargs):
|
||||||
|
if url.endswith("/node/poll"):
|
||||||
|
envelopes.append(envelope)
|
||||||
|
result = real_post(url, envelope, **kwargs)
|
||||||
|
if url.endswith("/node/poll"):
|
||||||
|
recovered.append(True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def pause(seconds):
|
||||||
|
if recovered:
|
||||||
|
raise StopLoop
|
||||||
|
self.assertFalse(store.node_runtime_status(ident)["ready"])
|
||||||
|
waits.append(seconds)
|
||||||
|
|
||||||
|
with mock.patch.object(self.server.RequestHandlerClass, "do_POST", fault), \
|
||||||
|
mock.patch.object(broker, "post_json", side_effect=post), \
|
||||||
|
mock.patch.object(node, "time", wraps=time) as clock, \
|
||||||
|
self.assertLogs(node._LOG, level="WARNING") as logs:
|
||||||
|
clock.sleep.side_effect = pause
|
||||||
|
with self.assertRaises(StopLoop):
|
||||||
|
node.run(state)
|
||||||
|
self.assertEqual(waits, [1, 2, 4, 8, 16, 30, 30, 30])
|
||||||
|
self.assertEqual(len(attempts), 9)
|
||||||
|
self.assertEqual(len({e["nonce"] for e in envelopes}), 9)
|
||||||
|
self.assertTrue(store.node_runtime_status(ident)["ready"])
|
||||||
|
self.assertEqual((state / "node/identity.json").read_bytes(), identity_before)
|
||||||
|
self.assertTrue(any("NODE_BROKER_RECOVERED" in line for line in logs.output))
|
||||||
|
self.assertTrue(all("signature" not in line and "payload" not in line for line in logs.output))
|
||||||
|
|
||||||
|
def test_result_disconnect_before_and_after_acceptance_never_reexecutes(self):
|
||||||
|
state, ident = self.pair("ack-node")
|
||||||
|
marker = self.root / "executed.txt"
|
||||||
|
real_post = broker.post_json
|
||||||
|
original_post = self.server.RequestHandlerClass.do_POST
|
||||||
|
original_send = self.server.RequestHandlerClass.send_json
|
||||||
|
for after_acceptance in (False, True):
|
||||||
|
with self.subTest(after_acceptance=after_acceptance):
|
||||||
|
marker.write_text("")
|
||||||
|
script = "printf x >> " + shlex.quote(str(marker))
|
||||||
|
payload = {"authorized": True, "job_id": "recovery-command", "timeout_s": 10,
|
||||||
|
"script": script, "sha256": hashlib.sha256(script.encode()).hexdigest()}
|
||||||
|
queued, error = self.server.expanded_store.queue_admin(ident, "admin.exec", payload)
|
||||||
|
self.assertIsNone(error)
|
||||||
|
posted, dispatched, waits, dropped, delivered = [], [], [], [], []
|
||||||
|
|
||||||
|
def execute(value):
|
||||||
|
dispatched.append(value["job_id"])
|
||||||
|
subprocess.run(["/bin/sh", "-c", value["script"]], check=True)
|
||||||
|
return {"ok": True, "exit_code": "0"}
|
||||||
|
|
||||||
|
def fault_post(handler):
|
||||||
|
if not after_acceptance and handler.path.endswith("/node/result") and not dropped:
|
||||||
|
dropped.append(True)
|
||||||
|
self.disconnect(handler)
|
||||||
|
return
|
||||||
|
return original_post(handler)
|
||||||
|
|
||||||
|
def fault_send(handler, code, value):
|
||||||
|
if after_acceptance and handler.path.endswith("/node/result") and not dropped:
|
||||||
|
dropped.append(True)
|
||||||
|
self.disconnect(handler)
|
||||||
|
return
|
||||||
|
return original_send(handler, code, value)
|
||||||
|
|
||||||
|
def post(url, envelope, **kwargs):
|
||||||
|
if url.endswith("/node/result"):
|
||||||
|
posted.append(envelope)
|
||||||
|
result = real_post(url, envelope, **kwargs)
|
||||||
|
if url.endswith("/node/result"):
|
||||||
|
delivered.append(True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def pause(seconds):
|
||||||
|
if delivered:
|
||||||
|
raise StopLoop
|
||||||
|
waits.append(seconds)
|
||||||
|
|
||||||
|
with mock.patch.object(self.server.RequestHandlerClass, "do_POST", fault_post), \
|
||||||
|
mock.patch.object(self.server.RequestHandlerClass, "send_json", fault_send), \
|
||||||
|
mock.patch.object(broker, "post_json", side_effect=post), \
|
||||||
|
mock.patch("framebridge_expanded.runtime.adapter_request", side_effect=execute), \
|
||||||
|
mock.patch.object(node, "time", wraps=time) as clock, \
|
||||||
|
self.assertLogs(node._LOG, level="WARNING"):
|
||||||
|
clock.sleep.side_effect = pause
|
||||||
|
with self.assertRaises(StopLoop):
|
||||||
|
node.run(state)
|
||||||
|
self.assertEqual(marker.read_text(), "x")
|
||||||
|
self.assertEqual(dispatched, ["recovery-command"])
|
||||||
|
self.assertEqual(waits, [1])
|
||||||
|
self.assertEqual(len(posted), 2)
|
||||||
|
self.assertNotEqual(posted[0]["nonce"], posted[1]["nonce"])
|
||||||
|
self.assertEqual(posted[0]["payload"], posted[1]["payload"])
|
||||||
|
saved = self.server.expanded_store.get_result(queued["request_id"])
|
||||||
|
self.assertEqual(saved["state"], "complete")
|
||||||
|
self.assertTrue(saved["result"]["ok"])
|
||||||
|
|
||||||
|
def test_broker_restart_after_execution_is_terminal_without_replay(self):
|
||||||
|
state, ident = self.pair("orphan-node")
|
||||||
|
marker = self.root / "orphan-effect.txt"
|
||||||
|
script = "printf x >> " + shlex.quote(str(marker))
|
||||||
|
payload = {"authorized": True, "job_id": "orphan-command", "timeout_s": 10,
|
||||||
|
"script": script, "sha256": hashlib.sha256(script.encode()).hexdigest()}
|
||||||
|
queued, error = self.server.expanded_store.queue_admin(ident, "admin.exec", payload)
|
||||||
|
self.assertIsNone(error)
|
||||||
|
real_post = broker.post_json
|
||||||
|
result_attempts = []
|
||||||
|
|
||||||
|
def execute(value):
|
||||||
|
subprocess.run(["/bin/sh", "-c", value["script"]], check=True)
|
||||||
|
return {"ok": True, "exit_code": "0"}
|
||||||
|
|
||||||
|
def post(url, envelope, **kwargs):
|
||||||
|
if url.endswith("/node/result"):
|
||||||
|
result_attempts.append(1)
|
||||||
|
self.assertEqual(marker.read_text(), "x")
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
self.thread.join(2)
|
||||||
|
self.server = broker.make_server(port=self.port, state_dir=self.central)
|
||||||
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
self.assertIsNone(self.server.expanded_store.get_result(queued["request_id"]))
|
||||||
|
return real_post(url, envelope, **kwargs)
|
||||||
|
|
||||||
|
with mock.patch.object(broker, "post_json", side_effect=post), \
|
||||||
|
mock.patch("framebridge_expanded.runtime.adapter_request", side_effect=execute) as adapter, \
|
||||||
|
mock.patch.object(node, "time", wraps=time) as clock:
|
||||||
|
with self.assertRaisesRegex(broker.BrokerError, "^BROKER_HTTP_REJECTED_400$"):
|
||||||
|
node.run(state)
|
||||||
|
adapter.assert_called_once()
|
||||||
|
clock.sleep.assert_not_called()
|
||||||
|
self.assertEqual(result_attempts, [1])
|
||||||
|
self.assertEqual(marker.read_text(), "x")
|
||||||
|
# A later operator restart may poll, but must never reconstruct/replay
|
||||||
|
# the orphaned command from its lost acknowledgement.
|
||||||
|
self.assertTrue(node.run(state, once=True)["idle"])
|
||||||
|
self.assertEqual(marker.read_text(), "x")
|
||||||
|
|
||||||
|
def test_gateway_failures_recover_without_replaying_signed_envelope(self):
|
||||||
|
state, _ = self.pair("gateway-node")
|
||||||
|
original = self.server.RequestHandlerClass.do_POST
|
||||||
|
codes, waits, successes = [502, 503, 504], [], []
|
||||||
|
|
||||||
|
def gateway(handler):
|
||||||
|
if codes:
|
||||||
|
handler.send_json(codes.pop(0), {"ok": False})
|
||||||
|
return
|
||||||
|
successes.append(True)
|
||||||
|
return original(handler)
|
||||||
|
|
||||||
|
def pause(seconds):
|
||||||
|
if successes:
|
||||||
|
raise StopLoop
|
||||||
|
waits.append(seconds)
|
||||||
|
|
||||||
|
with mock.patch.object(self.server.RequestHandlerClass, "do_POST", gateway), \
|
||||||
|
mock.patch.object(node, "time", wraps=time) as clock, \
|
||||||
|
self.assertLogs(node._LOG, level="WARNING"):
|
||||||
|
clock.sleep.side_effect = pause
|
||||||
|
with self.assertRaises(StopLoop):
|
||||||
|
node.run(state)
|
||||||
|
self.assertEqual(waits, [1, 2, 4])
|
||||||
|
|
||||||
|
def test_auth_and_invalid_json_fail_without_retry(self):
|
||||||
|
state, _ = self.pair("reject-node")
|
||||||
|
for code, body in [(403, b'{"ok":false}'), (500, b'{"ok":false}'),
|
||||||
|
(200, b'not-json'), (200, b'{"ok":false}')]:
|
||||||
|
with self.subTest(code=code, body=body):
|
||||||
|
attempts = []
|
||||||
|
|
||||||
|
def reject(handler):
|
||||||
|
attempts.append(1)
|
||||||
|
handler.send_response(code)
|
||||||
|
handler.send_header("Content-Length", str(len(body)))
|
||||||
|
handler.end_headers()
|
||||||
|
handler.wfile.write(body)
|
||||||
|
|
||||||
|
with mock.patch.object(self.server.RequestHandlerClass, "do_POST", reject), \
|
||||||
|
mock.patch.object(node, "time", wraps=time) as clock:
|
||||||
|
with self.assertRaises((broker.BrokerError, node.NodeError)):
|
||||||
|
node.run(state)
|
||||||
|
clock.sleep.assert_not_called()
|
||||||
|
self.assertEqual(len(attempts), 1)
|
||||||
|
|
||||||
|
def test_tls_and_unknown_transport_causes_are_terminal(self):
|
||||||
|
state, _ = self.pair("tls-node")
|
||||||
|
for cause in [urllib.error.URLError(ssl.SSLCertVerificationError("untrusted test CA")),
|
||||||
|
urllib.error.URLError(socket.gaierror(socket.EAI_NONAME, "invalid host")),
|
||||||
|
OSError(errno.EACCES, "denied"), ValueError("invalid response")]:
|
||||||
|
with self.subTest(cause=type(cause).__name__), \
|
||||||
|
mock.patch("urllib.request.OpenerDirector.open", side_effect=cause) as opened, \
|
||||||
|
mock.patch.object(node, "time", wraps=time) as clock:
|
||||||
|
with self.assertRaises(broker.BrokerError):
|
||||||
|
node.run(state)
|
||||||
|
self.assertEqual(opened.call_count, 1)
|
||||||
|
clock.sleep.assert_not_called()
|
||||||
|
|
||||||
|
def test_once_is_fail_fast_and_retry_wait_propagates_cancellation(self):
|
||||||
|
state, _ = self.pair("cancel-node")
|
||||||
|
attempts = []
|
||||||
|
|
||||||
|
def fault(handler):
|
||||||
|
attempts.append(1)
|
||||||
|
self.disconnect(handler)
|
||||||
|
|
||||||
|
with mock.patch.object(self.server.RequestHandlerClass, "do_POST", fault), \
|
||||||
|
mock.patch.object(node, "time", wraps=time) as clock:
|
||||||
|
with self.assertRaises(broker.BrokerError):
|
||||||
|
node.run(state, once=True)
|
||||||
|
clock.sleep.assert_not_called()
|
||||||
|
clock.sleep.side_effect = KeyboardInterrupt
|
||||||
|
with self.assertLogs(node._LOG, level="WARNING"), self.assertRaises(KeyboardInterrupt):
|
||||||
|
node.run(state)
|
||||||
|
self.assertEqual(len(attempts), 2)
|
||||||
|
clock.sleep.assert_called_once_with(1)
|
||||||
|
|
||||||
|
def test_real_broker_restart_recovers_same_node_process_and_identity(self):
|
||||||
|
state, ident = self.pair("restart-node")
|
||||||
|
identity_before = (state / "node/identity.json").read_bytes()
|
||||||
|
code = "from framebridge_expanded.node import run; run(state_dir=" + repr(str(state)) + ")"
|
||||||
|
child = subprocess.Popen([sys.executable, "-u", "-c", code],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
||||||
|
|
||||||
|
def wait_ready():
|
||||||
|
deadline = time.monotonic() + 8
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
self.assertIsNone(child.poll(), "node exited during recovery")
|
||||||
|
if self.server.expanded_store.node_runtime_status(ident)["ready"]:
|
||||||
|
return
|
||||||
|
threading.Event().wait(0.02)
|
||||||
|
self.fail("node heartbeat did not recover")
|
||||||
|
|
||||||
|
try:
|
||||||
|
wait_ready()
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
self.thread.join(2)
|
||||||
|
with selectors.DefaultSelector() as selector:
|
||||||
|
selector.register(child.stderr, selectors.EVENT_READ)
|
||||||
|
self.assertTrue(selector.select(10), "child did not report broker interruption")
|
||||||
|
self.assertIn("NODE_BROKER_RETRY", child.stderr.readline())
|
||||||
|
self.assertIsNone(child.poll())
|
||||||
|
self.server = broker.make_server(port=self.port, state_dir=self.central)
|
||||||
|
self.assertFalse(self.server.expanded_store.node_runtime_status(ident)["ready"])
|
||||||
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
wait_ready()
|
||||||
|
self.assertEqual((state / "node/identity.json").read_bytes(), identity_before)
|
||||||
|
self.assertEqual(broker.list_confirmed_nodes()[0]["node_id"], ident)
|
||||||
|
finally:
|
||||||
|
child.terminate()
|
||||||
|
try:
|
||||||
|
child.wait(timeout=3)
|
||||||
|
finally:
|
||||||
|
if child.poll() is None:
|
||||||
|
child.kill()
|
||||||
|
child.wait(timeout=3)
|
||||||
|
child.stderr.close()
|
||||||
|
|
||||||
|
def test_sigterm_stops_real_process_during_retry_wait(self):
|
||||||
|
state, _ = self.pair("stop-node")
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
self.thread.join(2)
|
||||||
|
code = "from framebridge_expanded.node import run; run(state_dir=" + repr(str(state)) + ")"
|
||||||
|
child = subprocess.Popen([sys.executable, "-u", "-c", code],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
|
||||||
|
try:
|
||||||
|
with selectors.DefaultSelector() as selector:
|
||||||
|
selector.register(child.stderr, selectors.EVENT_READ)
|
||||||
|
self.assertTrue(selector.select(10), "child did not report retry")
|
||||||
|
self.assertIn("NODE_BROKER_RETRY", child.stderr.readline())
|
||||||
|
started = time.monotonic()
|
||||||
|
child.terminate()
|
||||||
|
self.assertEqual(child.wait(timeout=3), -signal.SIGTERM)
|
||||||
|
self.assertLess(time.monotonic() - started, 3)
|
||||||
|
finally:
|
||||||
|
if child.poll() is None:
|
||||||
|
child.kill()
|
||||||
|
child.wait(timeout=3)
|
||||||
|
child.stderr.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Private package versions retain the fixed privileged-helper rollback floor.
|
||||||
|
|
||||||
|
Only a temporary maintainer script is run. Cleanup Python is replaced with a
|
||||||
|
recording stub; no installed helper, profile or package transaction is touched.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
WORK = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(WORK / "packaging"))
|
||||||
|
import build_expanded
|
||||||
|
|
||||||
|
PRIVATE_VERSION = "0.3.2+private.20261028.1"
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateCandidatePrermTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.control = self.root / "DEBIAN"
|
||||||
|
self.control.mkdir()
|
||||||
|
self.log = self.root / "cleanup-calls.jsonl"
|
||||||
|
self.python_stub = self.root / "record-cleanup"
|
||||||
|
self.python_stub.write_text(
|
||||||
|
"#!/usr/bin/python3\nimport json,sys\nfrom pathlib import Path\n"
|
||||||
|
+ "with Path(" + repr(str(self.log)) + ").open('a') as stream:\n"
|
||||||
|
+ " stream.write(json.dumps(sys.argv[1:])+'\\n')\n")
|
||||||
|
self.python_stub.chmod(0o755)
|
||||||
|
|
||||||
|
def candidate_script(self, package_version=PRIVATE_VERSION):
|
||||||
|
# Execute the real production write expression with an arbitrary package
|
||||||
|
# VERSION, rather than reconstructing its floor selection in the test.
|
||||||
|
tree = ast.parse(Path(build_expanded.__file__).read_text())
|
||||||
|
main = next(node for node in tree.body
|
||||||
|
if isinstance(node, ast.FunctionDef) and node.name == "main")
|
||||||
|
writers = [node for node in ast.walk(main)
|
||||||
|
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
|
||||||
|
and isinstance(node.value.func, ast.Attribute)
|
||||||
|
and node.value.func.attr == "write_text"
|
||||||
|
and node.value.args and isinstance(node.value.args[0], ast.Call)
|
||||||
|
and isinstance(node.value.args[0].func, ast.Name)
|
||||||
|
and node.value.args[0].func.id == "maintainer_prerm"]
|
||||||
|
self.assertEqual(len(writers), 1)
|
||||||
|
namespace = dict(control=self.control, VERSION=package_version,
|
||||||
|
PRIVILEGED_HELPER_FLOOR=build_expanded.PRIVILEGED_HELPER_FLOOR,
|
||||||
|
maintainer_prerm=build_expanded.maintainer_prerm)
|
||||||
|
exec(compile(ast.Module(body=writers, type_ignores=[]), build_expanded.__file__, "exec"), namespace)
|
||||||
|
script = self.control / "prerm"
|
||||||
|
text = script.read_text()
|
||||||
|
self.assertIn('"$2" lt 0.3.2;', text)
|
||||||
|
self.assertNotIn(package_version, text)
|
||||||
|
# The real dpkg is used only for its read-only version validator and
|
||||||
|
# comparator. Every privileged cleanup command is a recording stub.
|
||||||
|
script.write_text(text.replace("/usr/bin/python3", shlex.quote(str(self.python_stub))))
|
||||||
|
return script
|
||||||
|
|
||||||
|
def upgrade_target(self, target, package_version=PRIVATE_VERSION):
|
||||||
|
script = self.candidate_script(package_version)
|
||||||
|
result = subprocess.run(["/bin/sh", str(script), "upgrade", target],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else []
|
||||||
|
|
||||||
|
def test_private_to_official_032_preserves_helper_grants(self):
|
||||||
|
self.assertEqual(self.upgrade_target("0.3.2"), [])
|
||||||
|
|
||||||
|
def test_target_below_032_revokes_helper_grants(self):
|
||||||
|
calls = self.upgrade_target("0.3.1")
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
self.assertEqual(calls[0][:3], ["-I", "-S", "-c"])
|
||||||
|
self.assertIn('["purge_credential_helpers"]()', calls[0][3])
|
||||||
|
|
||||||
|
def test_same_private_version_preserves_helper_grants(self):
|
||||||
|
self.assertEqual(self.upgrade_target(PRIVATE_VERSION), [])
|
||||||
|
|
||||||
|
def test_forward_upgrade_preserves_helper_grants(self):
|
||||||
|
self.assertEqual(self.upgrade_target("0.3.3"), [])
|
||||||
|
|
||||||
|
def test_arbitrary_future_package_version_keeps_the_032_floor(self):
|
||||||
|
self.assertEqual(self.upgrade_target("0.3.2", package_version="9.9.9+private.1"), [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
The bounded executor is a fixture. This is not VM, GUI, TTY or input acceptance.
|
The bounded executor is a fixture. This is not VM, GUI, TTY or input acceptance.
|
||||||
"""
|
"""
|
||||||
import ast
|
import ast
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import shlex
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
@@ -13,9 +17,26 @@ from test_screen_frontdoor import Executor, Image, MCP, NODE_ID
|
|||||||
PORTABLE = Path(__file__).resolve().parents[1] / "portable/front_door_mcp.py"
|
PORTABLE = Path(__file__).resolve().parents[1] / "portable/front_door_mcp.py"
|
||||||
|
|
||||||
|
|
||||||
|
class TypedChunkExecutor(Executor):
|
||||||
|
"""Model the explicitly negotiated typed route without changing legacy fixtures."""
|
||||||
|
def __call__(self, **kwargs):
|
||||||
|
outer = super().__call__(**kwargs)
|
||||||
|
request = json.loads(base64.b64decode(shlex.split(kwargs["script"])[-1]))
|
||||||
|
if request["op"] == "read_chunk" and request.get("chunk_bytes"):
|
||||||
|
size, index = request["chunk_bytes"], request["index"]
|
||||||
|
envelope = json.loads(outer["stdout"])
|
||||||
|
envelope["result"].update(
|
||||||
|
total_chunks=math.ceil(len(self.data) / size),
|
||||||
|
data=base64.b64encode(self.data[index * size:(index + 1) * size]).decode(),
|
||||||
|
more=(index + 1) * size < len(self.data),
|
||||||
|
)
|
||||||
|
outer["stdout"] = json.dumps(envelope)
|
||||||
|
return outer
|
||||||
|
|
||||||
|
|
||||||
class PortableScreenRegistrationTests(unittest.TestCase):
|
class PortableScreenRegistrationTests(unittest.TestCase):
|
||||||
def registration(self, target="LOCAL", corrupt=None):
|
def registration(self, target="LOCAL", corrupt=None):
|
||||||
fixture = Executor(hostname="fb-central-lab" if target == "LOCAL" else "fb-node-lab")
|
fixture = TypedChunkExecutor(hostname="fb-central-lab" if target == "LOCAL" else "fb-node-lab")
|
||||||
calls, legacy_calls = [], []
|
calls, legacy_calls = [], []
|
||||||
|
|
||||||
def expanded(alias, script, authorized, timeout_s=120, job_id="", sha256="", include_stdout=False):
|
def expanded(alias, script, authorized, timeout_s=120, job_id="", sha256="", include_stdout=False):
|
||||||
@@ -51,6 +72,7 @@ class PortableScreenRegistrationTests(unittest.TestCase):
|
|||||||
framebridge_exec=legacy,
|
framebridge_exec=legacy,
|
||||||
register_framebridge_screen_tools=register_framebridge_screen_tools,
|
register_framebridge_screen_tools=register_framebridge_screen_tools,
|
||||||
resolve_profile_node=lambda node: node)
|
resolve_profile_node=lambda node: node)
|
||||||
|
scope["_blocking_tools"] = scope["mcp"] # This fixture calls registrations synchronously.
|
||||||
# Load the definition and registration from production source rather
|
# Load the definition and registration from production source rather
|
||||||
# than recreating the adapter inside this test.
|
# than recreating the adapter inside this test.
|
||||||
module = ast.Module(body=definitions + registrations, type_ignores=[])
|
module = ast.Module(body=definitions + registrations, type_ignores=[])
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
"""Deterministic protocol fixtures only: never observe or control a desktop."""
|
||||||
|
import base64
|
||||||
|
import concurrent.futures
|
||||||
|
import contextlib
|
||||||
|
import hashlib
|
||||||
|
import gzip
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from framebridge_expanded.real_screen import screen_rpc as rpc
|
||||||
|
from framebridge_expanded.real_screen import screen_transport as transport
|
||||||
|
from framebridge_expanded.real_screen.screen_frontdoor import (
|
||||||
|
CHUNK_BYTES, EXPANDED_CHUNK_BYTES, EXPANDED_STDOUT_BYTES,
|
||||||
|
MAX_STDOUT_BYTES, RPC_COMMAND, ScreenClient, ScreenError,
|
||||||
|
)
|
||||||
|
from test_screen_frontdoor import CAPTURE_ID, NODE_ID, Image, png, Executor
|
||||||
|
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
now = 100.0
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocolFixture:
|
||||||
|
"""Saved generated PNG fixture through actual producer, RPC and typed transport.
|
||||||
|
|
||||||
|
The private records model a previously captured artifact for protocol tests;
|
||||||
|
they are generated test pixels and do not certify any real screen observation.
|
||||||
|
"""
|
||||||
|
def __init__(self, directory, width=384, height=256):
|
||||||
|
self.directory = directory
|
||||||
|
self.data = png(width, height)
|
||||||
|
self.manifest = {
|
||||||
|
'capture_id': CAPTURE_ID, 'node': 'test-fixture-host', 'uid': os.getuid(),
|
||||||
|
'source': 'linux-framebuffer', 'screen_type': 'tty', 'active_vt': 'tty1',
|
||||||
|
'bytes': len(self.data), 'sha256': hashlib.sha256(self.data).hexdigest(),
|
||||||
|
'width': width, 'height': height, 'synthetic': False,
|
||||||
|
'pixel_source_verified': True, 'full_screen': True, 'authentic_pixels': True,
|
||||||
|
'session_id': 'screen_fixture', 'requested_session_visible': False,
|
||||||
|
'control_available': True, '_action_consumed': False,
|
||||||
|
'binding': {'window_id': 'fixture-window', 'geometry': [0, 0, width, height]},
|
||||||
|
}
|
||||||
|
path = directory / (CAPTURE_ID + '.png')
|
||||||
|
path.write_bytes(self.data)
|
||||||
|
path.chmod(0o600)
|
||||||
|
for suffix in ('.json', '.bound.json'):
|
||||||
|
rpc.write_record(directory / (CAPTURE_ID + suffix), self.manifest)
|
||||||
|
self.calls, self.reply_sizes = [], []
|
||||||
|
self.rpc, self.transport = rpc, transport
|
||||||
|
self.variant = None
|
||||||
|
self.clock, self.latency = None, 0
|
||||||
|
|
||||||
|
def __call__(self, **kwargs):
|
||||||
|
request = json.loads(base64.b64decode(shlex.split(kwargs['script'])[-1]))
|
||||||
|
self.calls.append((request, kwargs['timeout_s']))
|
||||||
|
if self.clock is not None:
|
||||||
|
self.clock.now += self.latency
|
||||||
|
payload = {key: kwargs[key] for key in (
|
||||||
|
'script', 'authorized', 'timeout_s', 'job_id', 'sha256', 'include_stdout')}
|
||||||
|
payload.update(version=1, mode='EXEC', request_type='screen_rpc')
|
||||||
|
|
||||||
|
def runner(argument, timeout_s):
|
||||||
|
# No real observe/input functions are invoked by this fixture executor.
|
||||||
|
if request['op'] in ('key', 'text'):
|
||||||
|
manifest = self.rpc.public_manifest(self.manifest)
|
||||||
|
manifest['action'] = {'kind': request['op'], 'previous_capture_id': request['capture_id'],
|
||||||
|
'result': {'performed': True}}
|
||||||
|
envelope = {'ok': True, 'result': manifest}
|
||||||
|
else:
|
||||||
|
envelope = self.rpc.dispatch(request, self.directory)
|
||||||
|
if request['op'] == 'read_chunk' and self.variant == 'corrupt':
|
||||||
|
encoded = envelope['result']['data']
|
||||||
|
data = base64.b64decode(encoded)
|
||||||
|
envelope['result']['data'] = base64.b64encode(bytes([data[0] ^ 1]) + data[1:]).decode()
|
||||||
|
return 0, self.rpc.encode(envelope) + b'\n', b''
|
||||||
|
|
||||||
|
outer = self.transport.handle_request(payload, runner=runner)
|
||||||
|
outer.update(target=kwargs['target'], node_id=NODE_ID,
|
||||||
|
mcp_job_id=kwargs['job_id'], mcp_script_sha256=kwargs['sha256'])
|
||||||
|
self.reply_sizes.append(len(outer['stdout'].encode()))
|
||||||
|
if self.variant == 'truncated' and request['op'] == 'read_chunk':
|
||||||
|
outer['stdout_truncated'] = True
|
||||||
|
return outer
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def frozen_receiver(testcase):
|
||||||
|
"""Load the hashed base receiver fixture, including in exported source archives.
|
||||||
|
|
||||||
|
The capsule contains only the five original real-screen modules from the
|
||||||
|
stated immutable revision. Its source hashes and outer digest are verified;
|
||||||
|
no Git checkout, installed receiver, or live desktop is used by this test.
|
||||||
|
"""
|
||||||
|
revision = 'f6b14c52109e0d55b919f179a38c8a4c8f8868f9'
|
||||||
|
path = Path(__file__).resolve().parent / 'fixtures/frozen_screen_receiver_f6b14c52.json.gz'
|
||||||
|
raw = path.read_bytes()
|
||||||
|
testcase.assertEqual(hashlib.sha256(raw).hexdigest(), '47670e1325c0f77fabeea9c69c09923c3fd0ed2463a8300a4940bb5e4bafffc5')
|
||||||
|
capsule = json.loads(gzip.decompress(raw))
|
||||||
|
testcase.assertEqual(capsule['revision'], revision)
|
||||||
|
names = ('screen_frontdoor', 'screen_capture', 'screen_control', 'screen_rpc', 'screen_transport')
|
||||||
|
testcase.assertEqual(set(capsule['files']), set(names))
|
||||||
|
package_name = '_framebridge_frozen_screen_fixture'
|
||||||
|
package = types.ModuleType(package_name)
|
||||||
|
package.__path__ = []
|
||||||
|
with patch.dict(sys.modules, {package_name: package}):
|
||||||
|
modules = {}
|
||||||
|
for name in names:
|
||||||
|
entry = capsule['files'][name]
|
||||||
|
source = entry['source']
|
||||||
|
testcase.assertEqual(hashlib.sha256(source.encode()).hexdigest(), entry['sha256'])
|
||||||
|
full_name = package_name + '.' + name
|
||||||
|
module = types.ModuleType(full_name)
|
||||||
|
module.__package__ = package_name
|
||||||
|
sys.modules[full_name] = module
|
||||||
|
exec(compile(source, revision + ':' + name + '.py', 'exec'), module.__dict__)
|
||||||
|
modules[name] = module
|
||||||
|
yield modules['screen_rpc'], modules['screen_transport']
|
||||||
|
|
||||||
|
|
||||||
|
class ScreenTransferBudgetTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.directory = Path(self.temp.name)
|
||||||
|
self.fixture = ProtocolFixture(self.directory)
|
||||||
|
|
||||||
|
def client(self, **kwargs):
|
||||||
|
return ScreenClient(self.fixture, Image, **kwargs)
|
||||||
|
|
||||||
|
def read(self, client):
|
||||||
|
return client.call('read_capture', 'GERAMEG', True, capture_id=CAPTURE_ID)
|
||||||
|
|
||||||
|
def test_expanded_complete_original_through_producer_rpc_and_transport(self):
|
||||||
|
result = self.read(self.client(stdout_limit_bytes=EXPANDED_STDOUT_BYTES))
|
||||||
|
self.assertEqual(result[1].data, self.fixture.data)
|
||||||
|
self.assertEqual(hashlib.sha256(result[1].data).hexdigest(), self.fixture.manifest['sha256'])
|
||||||
|
self.assertEqual(len(self.fixture.calls), 1 + math.ceil(len(self.fixture.data) / EXPANDED_CHUNK_BYTES))
|
||||||
|
self.assertTrue(all(r['chunk_bytes'] == EXPANDED_CHUNK_BYTES for r, _ in self.fixture.calls[1:]))
|
||||||
|
self.assertLessEqual(max(self.fixture.reply_sizes), EXPANDED_STDOUT_BYTES)
|
||||||
|
self.assertGreater(max(self.fixture.reply_sizes), MAX_STDOUT_BYTES)
|
||||||
|
# A full-sized serialized reply is measured, not estimated from base64 alone.
|
||||||
|
self.assertGreater(max(self.fixture.reply_sizes), 60000)
|
||||||
|
self.assertGreater(EXPANDED_STDOUT_BYTES - max(self.fixture.reply_sizes), 4096)
|
||||||
|
for key in ('capture_id', 'node', 'session_id', 'width', 'height', 'binding', 'requested_session_visible'):
|
||||||
|
self.assertEqual(result[0]['result'][key], self.fixture.manifest[key])
|
||||||
|
self.assertEqual(result[0]['node_id'], NODE_ID)
|
||||||
|
legacy_calls = 1 + math.ceil(len(self.fixture.data) / CHUNK_BYTES)
|
||||||
|
self.assertGreater(legacy_calls, 10 * len(self.fixture.calls))
|
||||||
|
|
||||||
|
def test_legacy_omits_extension_and_stays_within_4096_bytes(self):
|
||||||
|
result = self.read(self.client())
|
||||||
|
self.assertEqual(result[1].data, self.fixture.data)
|
||||||
|
self.assertEqual(len(self.fixture.calls), 1 + math.ceil(len(self.fixture.data) / CHUNK_BYTES))
|
||||||
|
self.assertTrue(all('chunk_bytes' not in r for r, _ in self.fixture.calls))
|
||||||
|
self.assertLessEqual(max(self.fixture.reply_sizes), MAX_STDOUT_BYTES)
|
||||||
|
|
||||||
|
def test_new_central_with_actual_frozen_receiver_uses_legacy_schema(self):
|
||||||
|
with frozen_receiver(self) as (frozen_rpc, frozen_transport):
|
||||||
|
self.fixture.rpc, self.fixture.transport = frozen_rpc, frozen_transport
|
||||||
|
self.assertNotIn('max_chunk_bytes', frozen_rpc.public_manifest(self.fixture.manifest))
|
||||||
|
# Only the capture itself is mocked: all frozen protocol and PNG reads
|
||||||
|
# execute the actual base code. Never access a live desktop in tests.
|
||||||
|
with patch.object(frozen_rpc, 'observe', return_value=self.fixture.manifest):
|
||||||
|
for op, arguments in [('observe', {}), ('read_capture', {'capture_id': CAPTURE_ID})]:
|
||||||
|
with self.subTest(op=op):
|
||||||
|
self.fixture.calls.clear()
|
||||||
|
result = self.client(stdout_limit_bytes=EXPANDED_STDOUT_BYTES).call(
|
||||||
|
op, 'GERAMEG', True, **arguments)
|
||||||
|
self.assertEqual(result[1].data, self.fixture.data)
|
||||||
|
self.assertEqual(result[0]['result']['sha256'], self.fixture.manifest['sha256'])
|
||||||
|
self.assertNotIn('max_chunk_bytes', result[0]['result'])
|
||||||
|
self.assertTrue(all('chunk_bytes' not in request for request, _ in self.fixture.calls))
|
||||||
|
self.assertEqual(len(self.fixture.calls), 1 + math.ceil(len(self.fixture.data) / CHUNK_BYTES))
|
||||||
|
self.assertLessEqual(max(self.fixture.reply_sizes), MAX_STDOUT_BYTES)
|
||||||
|
|
||||||
|
def test_capability_is_exact_bounded_int_and_cannot_expand_transport_budget(self):
|
||||||
|
original = self.fixture.rpc.public_manifest
|
||||||
|
for capability in (True, 0, -1, EXPANDED_CHUNK_BYTES + 1, 1.5, '45000'):
|
||||||
|
with self.subTest(capability=capability), patch.object(
|
||||||
|
self.fixture.rpc, 'public_manifest',
|
||||||
|
side_effect=lambda value: dict(original(value), max_chunk_bytes=capability)):
|
||||||
|
self.fixture.calls.clear()
|
||||||
|
result = self.read(self.client(stdout_limit_bytes=EXPANDED_STDOUT_BYTES))
|
||||||
|
self.assertEqual(result['error'], 'SCREEN_TRANSFER_CAPABILITY_INVALID')
|
||||||
|
self.assertEqual(len(self.fixture.calls), 1)
|
||||||
|
self.fixture.calls.clear()
|
||||||
|
result = self.read(self.client())
|
||||||
|
self.assertEqual(result[1].data, self.fixture.data)
|
||||||
|
self.assertTrue(all('chunk_bytes' not in request for request, _ in self.fixture.calls))
|
||||||
|
self.assertLessEqual(max(self.fixture.reply_sizes), MAX_STDOUT_BYTES)
|
||||||
|
|
||||||
|
def test_ambiguous_submitted_input_failures_require_observe_without_claiming_performed(self):
|
||||||
|
variants = ('non_ok', 'truncated', 'malformed', 'binding')
|
||||||
|
for op, arguments in [('key', {'key': 'ENTER'}), ('text', {'text': 'fixture-only'})]:
|
||||||
|
for variant in variants:
|
||||||
|
with self.subTest(op=op, variant=variant):
|
||||||
|
calls = []
|
||||||
|
fixture = Executor()
|
||||||
|
def execute(**kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
outer = fixture(**kwargs)
|
||||||
|
if variant == 'non_ok':
|
||||||
|
return {'ok': False, 'error': 'SCREEN_RPC_FAILED'}
|
||||||
|
if variant == 'truncated':
|
||||||
|
outer['stdout_truncated'] = True
|
||||||
|
elif variant == 'malformed':
|
||||||
|
outer['stdout'] = '{invalid fixture'
|
||||||
|
elif variant == 'binding':
|
||||||
|
outer['mcp_script_sha256'] = '0' * 64
|
||||||
|
return outer
|
||||||
|
result = ScreenClient(execute, Image).call(
|
||||||
|
op, 'GERAMEG', True, capture_id=CAPTURE_ID, **arguments)
|
||||||
|
self.assertFalse(result['ok'])
|
||||||
|
self.assertTrue(result['observe_again'])
|
||||||
|
self.assertEqual(result['capture_id'], CAPTURE_ID)
|
||||||
|
self.assertNotIn('performed', result)
|
||||||
|
self.assertNotIn('capture_consumed', result)
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
|
||||||
|
def test_unconfirmed_input_image_integrity_failure_does_not_assert_performed(self):
|
||||||
|
for variant in ('dimensions', 'chunk_hash', 'image_hash'):
|
||||||
|
with self.subTest(variant=variant):
|
||||||
|
fixture = Executor(variant)
|
||||||
|
result = ScreenClient(fixture, Image).call(
|
||||||
|
'key', 'GERAMEG', True, capture_id=CAPTURE_ID, key='ENTER')
|
||||||
|
self.assertFalse(result['ok'])
|
||||||
|
self.assertTrue(result['observe_again'])
|
||||||
|
self.assertNotIn('performed', result)
|
||||||
|
self.assertNotIn('capture_consumed', result)
|
||||||
|
self.assertEqual(sum(request['op'] == 'key' for request in fixture.requests), 1)
|
||||||
|
|
||||||
|
def test_generic_rpc_exception_after_consumption_remains_uncertain(self):
|
||||||
|
attempts = []
|
||||||
|
def execute(**kwargs):
|
||||||
|
request = shlex.split(kwargs['script'])[-1]
|
||||||
|
output = io.StringIO()
|
||||||
|
def interrupted(*args, **arguments):
|
||||||
|
attempts.append('fixture-input-attempt')
|
||||||
|
raise OSError('fixture interruption after consuming capture')
|
||||||
|
with patch.object(rpc, 'state_directory', return_value=self.directory), \
|
||||||
|
patch.object(rpc, 'act', side_effect=interrupted), contextlib.redirect_stdout(output):
|
||||||
|
code = rpc.main([request])
|
||||||
|
return {'ok': False, 'exit_code': str(code), 'target': kwargs['target'], 'node_id': NODE_ID,
|
||||||
|
'mcp_job_id': kwargs['job_id'], 'mcp_script_sha256': kwargs['sha256'],
|
||||||
|
'stdout': output.getvalue(), 'stdout_truncated': False}
|
||||||
|
result = ScreenClient(execute, Image).call(
|
||||||
|
'key', 'GERAMEG', True, capture_id=CAPTURE_ID, key='ENTER')
|
||||||
|
self.assertEqual(result['error'], 'SCREEN_REQUEST_FAILED')
|
||||||
|
self.assertTrue(result['observe_again'])
|
||||||
|
self.assertNotIn('performed', result)
|
||||||
|
self.assertNotIn('capture_consumed', result)
|
||||||
|
self.assertEqual(attempts, ['fixture-input-attempt'])
|
||||||
|
self.assertTrue(rpc.load_capture(self.directory, CAPTURE_ID)['_action_consumed'])
|
||||||
|
|
||||||
|
def test_chunk_rejection_after_unconfirmed_input_still_requires_observation(self):
|
||||||
|
fixture = Executor()
|
||||||
|
def execute(**kwargs):
|
||||||
|
result = fixture(**kwargs)
|
||||||
|
if fixture.requests[-1]['op'] == 'read_chunk':
|
||||||
|
result.update(ok=False, exit_code='1', stdout=json.dumps(
|
||||||
|
{'ok': False, 'error': {'code': 'UNKNOWN_REQUEST_FIELDS'}}))
|
||||||
|
return result
|
||||||
|
result = ScreenClient(execute, Image).call(
|
||||||
|
'key', 'GERAMEG', True, capture_id=CAPTURE_ID, key='ENTER')
|
||||||
|
self.assertEqual(result['error'], 'UNKNOWN_REQUEST_FIELDS')
|
||||||
|
self.assertTrue(result['observe_again'])
|
||||||
|
self.assertNotIn('performed', result)
|
||||||
|
self.assertNotIn('capture_consumed', result)
|
||||||
|
self.assertEqual([request['op'] for request in fixture.requests], ['key', 'read_chunk'])
|
||||||
|
|
||||||
|
def test_known_backend_rejection_keeps_original_meaning_without_uncertainty(self):
|
||||||
|
for op, arguments in [('key', {'key': 'ENTER'}), ('text', {'text': 'fixture-only'})]:
|
||||||
|
with self.subTest(op=op):
|
||||||
|
fixture = Executor('error_exit1')
|
||||||
|
result = ScreenClient(fixture, Image).call(
|
||||||
|
op, 'GERAMEG', True, capture_id=CAPTURE_ID, **arguments)
|
||||||
|
self.assertEqual(result['error'], 'SESSION_NOT_VISIBLE')
|
||||||
|
self.assertNotIn('observe_again', result)
|
||||||
|
self.assertNotIn('performed', result)
|
||||||
|
self.assertEqual(len(fixture.requests), 1)
|
||||||
|
|
||||||
|
def test_truncation_and_corruption_never_return_partial_success(self):
|
||||||
|
for variant, error in [('truncated', 'SCREEN_STDOUT_INVALID'), ('corrupt', 'SCREEN_IMAGE_HASH_MISMATCH')]:
|
||||||
|
with self.subTest(variant=variant):
|
||||||
|
self.fixture.variant = variant
|
||||||
|
result = self.read(self.client(stdout_limit_bytes=EXPANDED_STDOUT_BYTES))
|
||||||
|
self.assertIs(result['ok'], False)
|
||||||
|
self.assertEqual(result['error'], error)
|
||||||
|
self.assertNotIn('result', result)
|
||||||
|
|
||||||
|
def test_monotonic_total_deadline_propagates_remaining_whole_seconds(self):
|
||||||
|
clock = Clock()
|
||||||
|
self.fixture.clock, self.fixture.latency = clock, 1.2
|
||||||
|
with patch('framebridge_expanded.real_screen.screen_frontdoor.time.monotonic', clock):
|
||||||
|
result = self.read(self.client(stdout_limit_bytes=EXPANDED_STDOUT_BYTES, transfer_timeout_s=4))
|
||||||
|
self.assertEqual(result['error'], 'SCREEN_TRANSFER_TIMEOUT')
|
||||||
|
self.assertEqual([t for _, t in self.fixture.calls], [4, 2, 1])
|
||||||
|
self.assertLess(clock.now, 104)
|
||||||
|
self.assertNotIn('result', result)
|
||||||
|
|
||||||
|
def test_late_executor_response_cannot_publish_stale_success(self):
|
||||||
|
clock = Clock()
|
||||||
|
self.fixture.clock, self.fixture.latency = clock, 6
|
||||||
|
with patch('framebridge_expanded.real_screen.screen_frontdoor.time.monotonic', clock):
|
||||||
|
result = self.read(self.client(transfer_timeout_s=5))
|
||||||
|
self.assertEqual(result['error'], 'SCREEN_TRANSFER_TIMEOUT')
|
||||||
|
self.assertEqual(len(self.fixture.calls), 1)
|
||||||
|
|
||||||
|
def test_deadline_after_confirmed_input_preserves_no_replay_warning(self):
|
||||||
|
clock = Clock()
|
||||||
|
self.fixture.clock, self.fixture.latency = clock, 1.2
|
||||||
|
with patch('framebridge_expanded.real_screen.screen_frontdoor.time.monotonic', clock):
|
||||||
|
result = self.client(transfer_timeout_s=3).call(
|
||||||
|
'key', 'GERAMEG', True, capture_id=CAPTURE_ID, key='ENTER')
|
||||||
|
self.assertEqual(result['error'], 'SCREEN_TRANSFER_TIMEOUT')
|
||||||
|
self.assertTrue(result['performed'])
|
||||||
|
self.assertTrue(result['capture_consumed'])
|
||||||
|
self.assertTrue(result['observe_again'])
|
||||||
|
self.assertEqual(result['capture_id'], CAPTURE_ID)
|
||||||
|
|
||||||
|
def test_executor_exception_during_input_requires_fresh_observation(self):
|
||||||
|
def failed(**kwargs):
|
||||||
|
raise TimeoutError('test fixture timeout')
|
||||||
|
result = ScreenClient(failed, Image).call(
|
||||||
|
'key', 'GERAMEG', True, capture_id=CAPTURE_ID, key='ENTER')
|
||||||
|
self.assertFalse(result['ok'])
|
||||||
|
self.assertTrue(result['observe_again'])
|
||||||
|
self.assertNotIn('performed', result)
|
||||||
|
self.assertEqual(result['capture_id'], CAPTURE_ID)
|
||||||
|
|
||||||
|
def test_chunk_extension_is_bounded_strict_and_legacy_default_remains(self):
|
||||||
|
request = {'op': 'read_chunk', 'capture_id': CAPTURE_ID, 'index': 0,
|
||||||
|
'expected_sha256': self.fixture.manifest['sha256']}
|
||||||
|
self.assertEqual(rpc.dispatch(request, self.directory)['result']['chunk_bytes'], CHUNK_BYTES)
|
||||||
|
for value in (True, 0, -1, EXPANDED_CHUNK_BYTES + 1, 1.5, '45000'):
|
||||||
|
with self.subTest(value=value), self.assertRaisesRegex(rpc.ScreenRpcError, 'CHUNK_BOUNDS_INVALID'):
|
||||||
|
rpc.validate_request(dict(request, chunk_bytes=value))
|
||||||
|
with self.assertRaisesRegex(rpc.ScreenRpcError, 'UNKNOWN_REQUEST_FIELDS'):
|
||||||
|
rpc.validate_request(dict(request, extra='invalid'))
|
||||||
|
|
||||||
|
def test_client_stdout_budgets_reject_one_byte_over_exact_boundary(self):
|
||||||
|
for budget in (MAX_STDOUT_BYTES, EXPANDED_STDOUT_BYTES):
|
||||||
|
for over in (0, 1):
|
||||||
|
with self.subTest(budget=budget, over=over):
|
||||||
|
def execute(**kwargs):
|
||||||
|
envelope = {'ok': True, 'result': {'padding': ''}}
|
||||||
|
envelope['result']['padding'] = 'x' * (budget + over - len(rpc.encode(envelope)))
|
||||||
|
return {'ok': True, 'exit_code': '0', 'target': kwargs['target'], 'node_id': NODE_ID,
|
||||||
|
'mcp_job_id': kwargs['job_id'], 'mcp_script_sha256': kwargs['sha256'],
|
||||||
|
'stdout': rpc.encode(envelope).decode(), 'stdout_truncated': False}
|
||||||
|
client = ScreenClient(execute, Image, stdout_limit_bytes=budget)
|
||||||
|
if over:
|
||||||
|
with self.assertRaisesRegex(ScreenError, 'SCREEN_STDOUT_INVALID'):
|
||||||
|
client.remote('GERAMEG', {'op': 'status'})
|
||||||
|
else:
|
||||||
|
self.assertIn('padding', client.remote('GERAMEG', {'op': 'status'}))
|
||||||
|
|
||||||
|
def test_typed_transport_exact_serialized_boundary(self):
|
||||||
|
script = RPC_COMMAND + shlex.quote(base64.b64encode(rpc.encode({'op': 'status'})).decode()) + '\n'
|
||||||
|
payload = {'version': 1, 'mode': 'EXEC', 'authorized': True, 'script': script,
|
||||||
|
'timeout_s': 30, 'job_id': 'test-screen-boundary', 'sha256': hashlib.sha256(script.encode()).hexdigest(),
|
||||||
|
'include_stdout': True, 'request_type': 'screen_rpc'}
|
||||||
|
for over in (0, 1):
|
||||||
|
envelope = {'ok': True, 'result': {'padding': ''}}
|
||||||
|
envelope['result']['padding'] = 'x' * (EXPANDED_STDOUT_BYTES + over - len(rpc.encode(envelope)) - 1)
|
||||||
|
stdout = rpc.encode(envelope) + b'\n'
|
||||||
|
self.assertEqual(len(stdout), EXPANDED_STDOUT_BYTES + over)
|
||||||
|
result = transport.handle_request(payload, runner=lambda *args: (0, stdout, b''))
|
||||||
|
self.assertEqual(result['ok'], not over)
|
||||||
|
if over:
|
||||||
|
self.assertIn('SCREEN_RPC_OUTPUT_LIMIT', result['stdout'])
|
||||||
|
|
||||||
|
def test_main_serializes_competing_inputs_and_consumes_capture_once(self):
|
||||||
|
request = base64.b64encode(rpc.encode({'op': 'key', 'capture_id': CAPTURE_ID, 'key': 'ENTER'})).decode()
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
def invoke():
|
||||||
|
barrier.wait(timeout=2)
|
||||||
|
return rpc.main([request])
|
||||||
|
with patch.object(rpc, 'state_directory', return_value=self.directory), \
|
||||||
|
patch.object(rpc, 'act', return_value={'ok': True, 'performed': True}) as act, \
|
||||||
|
patch.object(rpc, 'observe', return_value=dict(self.fixture.manifest)), \
|
||||||
|
patch('builtins.print'), concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
|
outcomes = list(pool.map(lambda _: invoke(), range(2)))
|
||||||
|
self.assertEqual(sorted(outcomes), [0, 1])
|
||||||
|
self.assertEqual(act.call_count, 1)
|
||||||
|
self.assertTrue(rpc.load_capture(self.directory, CAPTURE_ID)['_action_consumed'])
|
||||||
|
|
||||||
|
def test_main_lock_covers_dispatch_in_competing_rpc_entries(self):
|
||||||
|
first_entered, second_entered, release = threading.Event(), threading.Event(), threading.Event()
|
||||||
|
count = 0
|
||||||
|
mutex = threading.Lock()
|
||||||
|
def dispatch(*args):
|
||||||
|
nonlocal count
|
||||||
|
with mutex:
|
||||||
|
count += 1
|
||||||
|
current = count
|
||||||
|
if current == 1:
|
||||||
|
first_entered.set()
|
||||||
|
self.assertTrue(release.wait(2))
|
||||||
|
else:
|
||||||
|
second_entered.set()
|
||||||
|
return {'ok': True, 'result': {}}
|
||||||
|
request = base64.b64encode(rpc.encode({'op': 'status'})).decode()
|
||||||
|
with patch.object(rpc, 'state_directory', return_value=self.directory), \
|
||||||
|
patch.object(rpc, 'dispatch', side_effect=dispatch), patch('builtins.print'), \
|
||||||
|
concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
|
first = pool.submit(rpc.main, [request])
|
||||||
|
try:
|
||||||
|
self.assertTrue(first_entered.wait(2))
|
||||||
|
second = pool.submit(rpc.main, [request])
|
||||||
|
self.assertFalse(second_entered.wait(0.1))
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
self.assertEqual((first.result(timeout=2), second.result(timeout=2)), (0, 0))
|
||||||
|
self.assertTrue(second_entered.is_set())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user