Fix private production pilot recovery, screen transfer and MCP dispatch

This commit is contained in:
Gerardo Betancourt committed 2026-10-27 23:54:41 -04:00
1 parent f6b14c5210
commit db5f85c922
18 files changed
+1735 -66

No files matched your search

+15 -9
View File
@@ -28,6 +28,8 @@ class BrokerError(ValueError):
"""A fixed public failure code; never include HTTP bodies or credentials."""
_SETTINGS_LOCK = threading.RLock()
_state = None
_url = None
_control_path = None
@@ -80,18 +82,22 @@ def validate_endpoint(url):
def configure(broker_url=None, state_dir=None, control_key_path=None):
global _state, _url, _control_path
_state = state_path(state_dir)
config = _config(_state)
port = config.get("ports", {}).get("broker", 20878)
_url = validate_endpoint(broker_url or config.get("broker_control_url") or config.get("broker_url")
or f"http://127.0.0.1:{port}")
_control_path = Path(control_key_path or _state / "broker" / "control.key")
# Workers must see one fully validated profile, including explicit changes.
with _SETTINGS_LOCK:
state = state_path(state_dir)
config = _config(state)
port = config.get("ports", {}).get("broker", 20878)
url = validate_endpoint(broker_url or config.get("broker_control_url") or config.get("broker_url")
or f"http://127.0.0.1:{port}")
control_path = Path(control_key_path or state / "broker" / "control.key")
_state, _url, _control_path = state, url, control_path
def _settings():
if _state is None:
configure()
return _state, _url, _control_path
with _SETTINGS_LOCK:
if _state is None:
configure()
return _state, _url, _control_path
def _private_read(path):
+17 -12
View File
@@ -8,10 +8,13 @@ from pathlib import Path
import re
import socket
import sys
import threading
import time
import uuid
from typing import Any
_BOOTSTRAP_LOCK = threading.RLock()
JOB_RE = re.compile(r"^[A-Za-z0-9._-]{1,80}$")
READ_CAPABILITIES = frozenset({
"system.status", "hardware.summary", "storage.inspect",
@@ -56,18 +59,20 @@ def original_paths() -> tuple[Path, Path]:
def bootstrap_original() -> None:
"""Extend the original package search path without copying or merging it."""
broker_source, fleet_source = original_paths()
for path in (fleet_source, broker_source):
if str(path) in sys.path:
sys.path.remove(str(path))
sys.path.insert(0, str(path))
import framebridge_linux_chatgpt
package_file = Path(framebridge_linux_chatgpt.__file__).resolve()
_require(package_file.is_relative_to(broker_source.resolve()), "ORIGINAL_PACKAGE_SHADOWED")
for root in (broker_source, fleet_source):
path = str(root / "framebridge_linux_chatgpt")
if path not in framebridge_linux_chatgpt.__path__:
framebridge_linux_chatgpt.__path__.append(path)
# Registration now dispatches in workers; protect shared import paths.
with _BOOTSTRAP_LOCK:
broker_source, fleet_source = original_paths()
for path in (fleet_source, broker_source):
if str(path) in sys.path:
sys.path.remove(str(path))
sys.path.insert(0, str(path))
import framebridge_linux_chatgpt
package_file = Path(framebridge_linux_chatgpt.__file__).resolve()
_require(package_file.is_relative_to(broker_source.resolve()), "ORIGINAL_PACKAGE_SHADOWED")
for root in (broker_source, fleet_source):
path = str(root / "framebridge_linux_chatgpt")
if path not in framebridge_linux_chatgpt.__path__:
framebridge_linux_chatgpt.__path__.append(path)
def state_directory() -> Path:
+65 -4
View File
@@ -1,18 +1,27 @@
"""Original FBPAIR identity and outbound protocol for an explicitly enrolled node."""
from __future__ import annotations
import errno
import hashlib
import http.client
import importlib
import json
import logging
import os
import socket
import ssl
import time
import uuid
import urllib.error
from pathlib import Path
from . import broker
_LOG = logging.getLogger(__name__)
_RETRY_MAX_SECONDS = 30
class NodeError(ValueError):
pass
@@ -177,6 +186,54 @@ def dispatch_request(request, adapter_request=None, state_dir=None):
return {"ok": False, "error": "NODE_DISPATCH_FAILED"}
def _retryable_broker_failure(exc):
# post_json intentionally hides transport details from its public error. Its
# exception context still distinguishes interruptions from TLS/JSON failures;
# never log that context or treat an unknown failure as transient.
code = str(exc)
if code in {"BROKER_HTTP_REJECTED_502", "BROKER_HTTP_REJECTED_503", "BROKER_HTTP_REJECTED_504"}:
return True
if code != "BROKER_TRANSPORT_FAILED":
return False
cause = exc.__context__
if isinstance(cause, urllib.error.URLError):
cause = cause.reason
if isinstance(cause, ssl.SSLError):
return False
if isinstance(cause, socket.gaierror):
return cause.errno == socket.EAI_AGAIN
if isinstance(cause, (ConnectionError, TimeoutError, http.client.IncompleteRead)):
return True
return isinstance(cause, OSError) and cause.errno in {
errno.ECONNREFUSED, errno.ECONNRESET, errno.ECONNABORTED, errno.ETIMEDOUT,
errno.EPIPE, errno.ENETUNREACH, errno.EHOSTUNREACH, errno.ENETDOWN,
}
def _post_with_recovery(url, operation, payload, *, ctx, state, outbound, recover):
delay = 1
retries = 0
while True:
# A new signature/nonce is mandatory: the previous attempt may have been
# verified even when its response was lost. Do not replay its envelope.
envelope = outbound._signed_envelope(ctx, operation, payload)
try:
result = broker.post_json(url, envelope, state_dir=state)
except broker.BrokerError as exc:
if not recover or not _retryable_broker_failure(exc):
raise
retries += 1
_LOG.warning("NODE_BROKER_RETRY operation=%s error=%s delay_seconds=%d",
operation, str(exc), delay)
# Signals and cancellation exceptions are deliberately not caught.
time.sleep(delay)
delay = min(delay * 2, _RETRY_MAX_SECONDS)
else:
if retries:
_LOG.warning("NODE_BROKER_RECOVERED operation=%s retries=%d", operation, retries)
return result
def run(state_dir=None, once=False):
state, config, ctx, identity = _context(state_dir)
url = broker.validate_endpoint(config.get("broker_url", ""))
@@ -187,16 +244,20 @@ def run(state_dir=None, once=False):
raise NodeError("PAIRING_BINDING_MISMATCH")
outbound = _original("outbound_agent")
while True:
poll = broker.post_json(url + "/fbpair/v1/node/poll",
outbound._signed_envelope(ctx, "node.poll", {}), state_dir=state)
poll = _post_with_recovery(url + "/fbpair/v1/node/poll", "node.poll", {},
ctx=ctx, state=state, outbound=outbound, recover=not once)
if poll.get("ok") is not True:
raise NodeError("NODE_POLL_REJECTED")
req = poll.get("request")
result = {"ok": True, "idle": True}
if isinstance(req, dict):
value = dispatch_request(req, state_dir=state)
posted = broker.post_json(url + "/fbpair/v1/node/result", outbound._signed_envelope(
ctx, "node.result", {"request_id": req.get("request_id"), "result": value}), state_dir=state)
# Original put_result only records this request's result; repeating
# that acknowledgement cannot execute the command. Keep the value
# here and never return to dispatch/poll while its delivery is pending.
posted = _post_with_recovery(url + "/fbpair/v1/node/result", "node.result",
{"request_id": req.get("request_id"), "result": value},
ctx=ctx, state=state, outbound=outbound, recover=not once)
if posted.get("ok") is not True:
raise NodeError("NODE_RESULT_REJECTED")
result = {"ok": True, "idle": False, "request_id": req.get("request_id")}
@@ -32,7 +32,7 @@ import zlib
MAX_PIXELS = 4096 * 2160
MAX_FRAMEBUFFER_BYTES = 128 * 1024 * 1024
MAX_IMAGE_BYTES = 32 * 1024 * 1024
MAX_CHUNK_BYTES = 2400
MAX_CHUNK_BYTES = 45000
CAPTURE_ID = re.compile(r"[0-9a-f]{32}\Z")
SESSION_ID = re.compile(r"[A-Za-z0-9_-]{1,64}\Z")
DISPLAY_ID = re.compile(r":[0-9]{1,5}(?:\.[0-9]{1,3})?\Z")
@@ -13,12 +13,17 @@ import math
import re
import shlex
import struct
import time
import uuid
from typing import Callable
CHUNK_BYTES = 2100
MAX_IMAGE_BYTES = 32 * 1024 * 1024
MAX_STDOUT_BYTES = 4096
# Only the explicit typed screen transport preserves this larger stdout budget.
EXPANDED_STDOUT_BYTES = 65536
EXPANDED_CHUNK_BYTES = 45000
TRANSFER_TIMEOUT_SECONDS = 45
RPC_COMMAND = '/usr/bin/python3 -B -m framebridge_expanded.real_screen.screen_rpc '
HEX_ID = re.compile(r"[0-9a-f]{32}\Z")
SHA = re.compile(r"[0-9a-f]{64}\Z")
@@ -28,14 +33,25 @@ ERROR = re.compile(r"[A-Z0-9_]{1,100}\Z")
READ = {"readOnlyHint": True, "destructiveHint": False,
"idempotentHint": False, "openWorldHint": False}
CHANGE = {**READ, "readOnlyHint": False}
# These explicit backend checks run before an input capture is consumed. Generic
# dispatch failures do not prove whether input was sent, even with valid framing.
INPUT_REJECTION_CODES = {
"REQUEST_INVALID", "OPERATION_INVALID", "UNKNOWN_REQUEST_FIELDS",
"SESSION_ID_INVALID", "KEY_INVALID", "TEXT_INVALID", "CAPTURE_ID_INVALID",
"CAPTURE_RECORD_INVALID", "CAPTURE_RECORD_MISMATCH",
"USE_GRAPHICAL_CUA_FOR_X11_INPUT", "VISIBLE_INPUT_UNAVAILABLE",
"CAPTURE_ALREADY_USED_OBSERVE_AGAIN", "SESSION_NOT_VISIBLE",
}
class ScreenError(RuntimeError):
"""A bounded public error suitable for the MCP response."""
def __init__(self, code, details=None):
def __init__(self, code, details=None, *, backend_rejection=False):
super().__init__(code)
self.details = details or {}
# Local proof, never accepted from a remote JSON field.
self.backend_rejection = backend_rejection
def require(ok, code):
@@ -62,7 +78,7 @@ class _AuthenticatedReply(dict):
_IDENTITY_FIELDS = ("capture_id", "node", "sha256", "session_id", "bytes",
"width", "height", "source", "screen_type",
"full_screen", "authentic_pixels")
"full_screen", "authentic_pixels", "max_chunk_bytes")
def __init__(self, payload, owner, node, node_id):
super().__init__(payload)
@@ -80,23 +96,51 @@ class _AuthenticatedReply(dict):
class ScreenClient:
"""Transport only; execution policy and node resolution stay in the host."""
"""Transport only; execution policy and node resolution stay in the host.
The monotonic transfer deadline bounds admission of RPCs and successful
publication. It is cooperative: the executor can spend its existing bounded
transport grace on the final in-flight RPC. Late replies never become a
successful image, and no further chunks are requested after the deadline.
"""
def __init__(self, exec_request: Callable, image_factory: Callable,
resolve_node: Callable | None = None):
resolve_node: Callable | None = None, *,
stdout_limit_bytes: int = MAX_STDOUT_BYTES,
transfer_timeout_s: int = TRANSFER_TIMEOUT_SECONDS):
if (type(stdout_limit_bytes) is not int or
stdout_limit_bytes not in (MAX_STDOUT_BYTES, EXPANDED_STDOUT_BYTES)):
raise ValueError("unsupported screen stdout budget")
if type(transfer_timeout_s) is not int or not 1 <= transfer_timeout_s <= 120:
raise ValueError("screen transfer timeout must be 1..120 seconds")
self.stdout_limit_bytes = stdout_limit_bytes
self.chunk_bytes = (EXPANDED_CHUNK_BYTES
if stdout_limit_bytes == EXPANDED_STDOUT_BYTES else CHUNK_BYTES)
self.transfer_timeout_s = transfer_timeout_s
self.exec_request = exec_request
self.image_factory = image_factory
self.resolve_node = resolve_node or (lambda node: node.upper())
self._reply_owner = object()
def remote(self, node, request):
def remote(self, node, request, *, deadline=None, submitted=None):
timeout_s = 30
if deadline is not None:
remaining = deadline - time.monotonic()
# The authenticated EXEC protocol accepts whole seconds only. Rounding
# down prevents any individual request exceeding the remaining budget.
require(remaining >= 1, "SCREEN_TRANSFER_TIMEOUT")
timeout_s = min(timeout_s, int(remaining))
payload = base64.b64encode(encode(request)).decode("ascii")
script = RPC_COMMAND + shlex.quote(payload) + "\n"
digest = hashlib.sha256(script.encode("utf-8")).hexdigest()
job_id = "cua-screen-" + uuid.uuid4().hex
outer = self.exec_request(script=script, authorized=True, timeout_s=30,
if submitted is not None:
submitted["attempted"] = True
outer = self.exec_request(script=script, authorized=True, timeout_s=timeout_s,
job_id=job_id, sha256=digest,
include_stdout=True, target=node)
if deadline is not None:
require(time.monotonic() < deadline, "SCREEN_TRANSFER_TIMEOUT")
require(type(outer) is dict, "SCREEN_EXEC_FAILED")
require(type(outer.get("exit_code")) in (int, str) and
str(outer["exit_code"]) in ("0", "1"), "SCREEN_EXEC_FAILED")
@@ -109,7 +153,7 @@ class ScreenClient:
except (ValueError, AttributeError):
raise ScreenError("SCREEN_EXEC_NODE_ID_INVALID") from None
stdout = outer.get("stdout")
require(type(stdout) is str and len(stdout.encode("utf-8")) <= MAX_STDOUT_BYTES and
require(type(stdout) is str and len(stdout.encode("utf-8")) <= self.stdout_limit_bytes and
outer.get("stdout_truncated") is not True, "SCREEN_STDOUT_INVALID")
try:
envelope = json.loads(stdout)
@@ -129,12 +173,19 @@ class ScreenClient:
if valid_reference(code.get("cause"), ERROR):
details["cause"] = code["cause"]
code = code.get("code")
raise ScreenError(code if valid_reference(code, ERROR) else "SCREEN_BACKEND_FAILED", details)
valid_code = valid_reference(code, ERROR)
raise ScreenError(code if valid_code else "SCREEN_BACKEND_FAILED", details,
backend_rejection=(valid_code and outer.get("ok") is False
and str(outer["exit_code"]) == "1"
and (code in INPUT_REJECTION_CODES
or details.get("performed") is False)))
require(outer.get("ok") is True and str(outer["exit_code"]) == "0", "SCREEN_EXEC_FAILED")
require(type(envelope.get("result")) is dict, "SCREEN_RESPONSE_INVALID")
return _AuthenticatedReply(envelope["result"], self._reply_owner, node, node_id)
def image(self, node, manifest):
def image(self, node, manifest, *, deadline=None):
if deadline is None:
deadline = time.monotonic() + self.transfer_timeout_s
capture_id = manifest.get("capture_id")
total, digest = manifest.get("bytes"), manifest.get("sha256")
require(valid_reference(capture_id, HEX_ID) and valid_reference(digest, SHA),
@@ -151,11 +202,23 @@ class ScreenClient:
"SCREEN_PIXEL_SOURCE_INVALID")
require(manifest.get("full_screen") is True and manifest.get("authentic_pixels") is True,
"SCREEN_PIXEL_PROVENANCE_INVALID")
# Capability belongs to this authenticated manifest, never a shared cache.
# A new central must retain the old request schema for a frozen receiver.
chunk_bytes = CHUNK_BYTES
if "max_chunk_bytes" in manifest:
capability = manifest["max_chunk_bytes"]
require(type(capability) is int and 1 <= capability <= EXPANDED_CHUNK_BYTES,
"SCREEN_TRANSFER_CAPABILITY_INVALID")
chunk_bytes = min(self.chunk_bytes, capability)
parts = []
count = math.ceil(total / CHUNK_BYTES)
count = math.ceil(total / chunk_bytes)
for index in range(count):
part = self.remote(node, {"op": "read_chunk", "capture_id": capture_id,
"index": index, "expected_sha256": digest})
request = {"op": "read_chunk", "capture_id": capture_id,
"index": index, "expected_sha256": digest}
# Omit the extension entirely on constrained legacy transports.
if chunk_bytes != CHUNK_BYTES:
request["chunk_bytes"] = chunk_bytes
part = self.remote(node, request, deadline=deadline)
require(isinstance(part, _AuthenticatedReply) and
part.bound_to(self._reply_owner, node, manifest.node_id),
"SCREEN_CHUNK_NODE_MISMATCH")
@@ -167,12 +230,12 @@ class ScreenClient:
type(part.get("more")) is bool and part["more"] == (index + 1 < count),
"SCREEN_CHUNK_MANIFEST_INVALID")
text = part.get("data")
require(type(text) is str and len(text) <= 2800, "SCREEN_CHUNK_INVALID")
require(type(text) is str and len(text) <= 4 * math.ceil(chunk_bytes / 3), "SCREEN_CHUNK_INVALID")
try:
data = base64.b64decode(text, validate=True)
except (ValueError, UnicodeError, binascii.Error):
raise ScreenError("SCREEN_CHUNK_INVALID") from None
require(len(data) == min(CHUNK_BYTES, total - index * CHUNK_BYTES),
require(len(data) == min(chunk_bytes, total - index * chunk_bytes),
"SCREEN_CHUNK_LENGTH_INVALID")
parts.append(data)
data = b"".join(parts)
@@ -181,10 +244,15 @@ class ScreenClient:
require(len(data) >= 33 and data[:8] == b"\x89PNG\r\n\x1a\n" and
data[12:16] == b"IHDR" and struct.unpack(">II", data[16:24]) == (width, height),
"SCREEN_PNG_INVALID")
require(time.monotonic() < deadline, "SCREEN_TRANSFER_TIMEOUT")
return self.image_factory(data=data, format="png")
def call(self, op, node, authorized, **kwargs):
requested_node = node
action_confirmed = False
input_reply_received = False
submitted = {}
deadline = time.monotonic() + self.transfer_timeout_s
try:
require(authorized is True, "AUTHORIZED_TRUE_REQUIRED")
require(valid_reference(node, NODE), "SCREEN_NODE_INVALID")
@@ -202,11 +270,20 @@ class ScreenClient:
if op == "text":
require(type(kwargs.get("text")) is str and 0 < len(kwargs["text"].encode("utf-8")) <= 4096,
"SCREEN_TEXT_INVALID")
manifest = self.remote(node, {"op": op, **kwargs})
manifest = self.remote(node, {"op": op, **kwargs}, deadline=deadline,
submitted=submitted)
input_reply_received = op in ("key", "text")
action = manifest.get("action")
summary = action.get("result") if type(action) is dict else None
action_confirmed = (op in ("key", "text") and type(action) is dict and
action.get("kind") == op and
action.get("previous_capture_id") == kwargs["capture_id"] and
type(summary) is dict and summary.get("performed") is True)
if op == "read_capture":
require(manifest.get("capture_id") == kwargs["capture_id"],
"SCREEN_CAPTURE_REFERENCE_MISMATCH")
image = self.image(node, manifest)
image = self.image(node, manifest, deadline=deadline)
require(time.monotonic() < deadline, "SCREEN_TRANSFER_TIMEOUT")
metadata = {"ok": True, "node": node, "requested_node": requested_node,
"node_id": manifest.node_id, "hostname": manifest["node"],
"result": dict(manifest, transport_node=node, node_id=manifest.node_id)}
@@ -217,9 +294,21 @@ class ScreenClient:
metadata["result"]["authentic_pixels"] = True
return [metadata, image]
except ScreenError as exc:
if op in ("key", "text") and (input_reply_received or
(submitted.get("attempted") and not exc.backend_rejection)):
exc.details.setdefault("observe_again", True)
if action_confirmed:
exc.details.update(performed=True, capture_consumed=True)
exc.details.setdefault("capture_id", kwargs["capture_id"])
return {"ok": False, "node": requested_node, "error": str(exc), **exc.details}
except Exception:
return {"ok": False, "node": requested_node, "error": "SCREEN_BACKEND_UNAVAILABLE"}
details = {}
if op in ("key", "text") and submitted.get("attempted"):
details.update(observe_again=True, capture_id=kwargs["capture_id"])
if action_confirmed:
details.update(performed=True, capture_consumed=True)
return {"ok": False, "node": requested_node,
"error": "SCREEN_BACKEND_UNAVAILABLE", **details}
def resolve_profile_node(alias):
@@ -233,12 +322,16 @@ def resolve_profile_node(alias):
def register_framebridge_screen_tools(mcp, exec_request, *, image_factory=None,
resolve_node=None):
resolve_node=None,
stdout_limit_bytes=MAX_STDOUT_BYTES,
transfer_timeout_s=TRANSFER_TIMEOUT_SECONDS):
"""Register real image tools. The host supplies its existing executor."""
if image_factory is None:
from mcp.server.fastmcp import Image
image_factory = Image
client = ScreenClient(exec_request, image_factory, resolve_node)
client = ScreenClient(exec_request, image_factory, resolve_node,
stdout_limit_bytes=stdout_limit_bytes,
transfer_timeout_s=transfer_timeout_s)
@mcp.tool(annotations=dict(READ))
def framebridge_screen_observe(node: str, authorized: bool,
@@ -13,7 +13,7 @@ import stat
import sys
import time
from .screen_capture import CaptureError, capture_screen, read_chunk
from .screen_capture import CaptureError, capture_screen, read_chunk, MAX_CHUNK_BYTES
from .screen_control import ControlError, act, bind_screen, snapshot_binding
MAX_REQUEST = 16384
@@ -89,7 +89,10 @@ def write_record(path: Path, value, *, replace=False):
def public_manifest(manifest):
# Internal path is unnecessary to use or understand a captured screen.
return {k: v for k, v in manifest.items() if k not in {'path', '_action_consumed'}}
result = {k: v for k, v in manifest.items() if k not in {'path', '_action_consumed'}}
# An old receiver omits this capability and only accepts the original schema.
result['max_chunk_bytes'] = MAX_CHUNK_BYTES
return result
def observe(directory: Path, requested_session_id=None):
@@ -138,7 +141,7 @@ def validate_request(request):
allowed = {
'observe': {'op', 'session_id'},
'status': {'op'},
'read_chunk': {'op', 'capture_id', 'index', 'expected_sha256'},
'read_chunk': {'op', 'capture_id', 'index', 'expected_sha256', 'chunk_bytes'},
'read_capture': {'op', 'capture_id'},
'key': {'op', 'capture_id', 'session_id', 'key'},
'text': {'op', 'capture_id', 'session_id', 'text'},
@@ -149,6 +152,9 @@ def validate_request(request):
sid = request['session_id']
require(type(sid) is str and len(sid) <= 96 and re.fullmatch(r'[A-Za-z0-9_-]+', sid), 'SESSION_ID_INVALID')
if op == 'read_chunk':
if 'chunk_bytes' in request:
require(type(request['chunk_bytes']) is int and
1 <= request['chunk_bytes'] <= MAX_CHUNK_BYTES, 'CHUNK_BOUNDS_INVALID')
require(type(request.get('index')) is int and request['index'] >= 0, 'CHUNK_INDEX_INVALID')
require(type(request.get('expected_sha256')) is str and HEX_SHA.fullmatch(request['expected_sha256']), 'EXPECTED_SHA256_REQUIRED')
if op == 'key':
@@ -179,7 +185,7 @@ def dispatch(request, directory=None):
if op == 'read_chunk':
require(manifest['sha256'] == request['expected_sha256'], 'CAPTURE_SHA256_MISMATCH')
chunk = read_chunk(directory, manifest['capture_id'], request['index'],
chunk_bytes=2100, expected_sha256=request['expected_sha256'])
chunk_bytes=request.get('chunk_bytes', 2100), expected_sha256=request['expected_sha256'])
return {'ok': True, 'result': chunk}
if manifest.get('screen_type') == 'x11':
raise ScreenRpcError('USE_GRAPHICAL_CUA_FOR_X11_INPUT')
@@ -17,7 +17,10 @@ import signal
import subprocess
import time
from .screen_frontdoor import RPC_COMMAND, MAX_STDOUT_BYTES
from .screen_frontdoor import RPC_COMMAND, EXPANDED_STDOUT_BYTES
# This typed route bypasses the legacy 4096-byte administrative sanitizer.
MAX_STDOUT_BYTES = EXPANDED_STDOUT_BYTES
from .screen_rpc import MAX_REQUEST, encode, validate_request, ScreenRpcError
REQUEST_TYPE = "screen_rpc"
@@ -16,6 +16,7 @@ import socket
import stat
import subprocess
import sys
import threading
import time
from pathlib import Path
from types import SimpleNamespace
@@ -24,16 +25,20 @@ from .config import load_config, state_path, private_dir
PREFIX = Path(os.environ.get("FRAMEBRIDGE_EXPANDED_PREFIX", Path(__file__).resolve().parents[2]))
_STOP = False
_LEGACY = None
_LEGACY_LOCK = threading.RLock()
def _legacy():
global _LEGACY
if _LEGACY is None:
path = PREFIX / "bin/framebridge-original"
loader = importlib.machinery.SourceFileLoader("framebridge_original_launcher", str(path))
spec = importlib.util.spec_from_loader(loader.name, loader)
_LEGACY = importlib.util.module_from_spec(spec)
loader.exec_module(_LEGACY)
return _LEGACY
# Publish only a fully initialized module; a failed load remains retryable.
with _LEGACY_LOCK:
if _LEGACY is None:
path = PREFIX / "bin/framebridge-original"
loader = importlib.machinery.SourceFileLoader("framebridge_original_launcher", str(path))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
_LEGACY = module
return _LEGACY
def _args(state, config):
return SimpleNamespace(state_dir=str(state), port=config["ports"]["mcp"],