Files
FrameBridge release automation cdd980e1eb FrameBridge 0.3.4 corresponding-source release
Snapshot assembled only from the signed corresponding-source archive.
Source archive SHA-256: db91a270cdefc4be79056ed38a0b4a16747e1ab130592645eac40fa3809809df
2026-10-29 11:53:40 -04:00

890 lines
36 KiB
Python

#!/usr/bin/env python3
import base64
from functools import partial, wraps
import inspect
import hashlib
import json
import os
import re
import socket
import time
import urllib.error
import urllib.request
import uuid
import anyio
from pathlib import Path
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from mcp.server.fastmcp import FastMCP, Image
from mcp.types import ToolAnnotations
from framebridge_expanded import __version__ as PRODUCT_VERSION
from framebridge_expanded.guide import GUIDE_SCHEMA, get_guide
from framebridge_expanded.fleet import execute_node as expanded_execute_node, cua_node as expanded_cua_node, bootstrap_original
from framebridge_expanded.fleet import execute_screen_node as expanded_execute_screen_node
from framebridge_expanded.runtime import adapter_request as checked_cua_adapter_request
bootstrap_original()
HOME = Path.home()
ADAPTER_SOCKET = Path(os.environ.get("FRAMEBRIDGE_TOOL_ROOT", str(HOME / ".local/share/framebridge/tool-adapter"))) / "adapter.sock"
JOBS_ROOT = HOME / ".local/share/framebridge/fbterm/jobs"
HUB_URL = os.environ.get("FRAMEBRIDGE_HUB_MCP_URL", "http://127.0.0.1:20877/mcp")
BROKER_CONTROL = os.environ.get("FRAMEBRIDGE_BROKER_CONTROL", "http://127.0.0.1:20878")
ASUS_NODE_ID = os.environ.get("FRAMEBRIDGE_ASUS_NODE_ID", "")
GERAMEG_NODE_ID = os.environ.get("FRAMEBRIDGE_GERAMEG_NODE_ID", "")
HOST = os.environ.get("FRAMEBRIDGE_FRONT_DOOR_HOST", "127.0.0.1")
PORT = int(os.environ.get("FRAMEBRIDGE_FRONT_DOOR_PORT", "20865"))
JOB_RE = re.compile(r"^[A-Za-z0-9._-]{1,80}$")
SENSITIVE = re.compile(r"(?i)(secret|token|password|passwd|api[_-]?key|private[_-]?key|cookie|authorization|credential)")
KEY_BEGIN = re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----")
KEY_END = re.compile(r"-----END [A-Z0-9 ]*PRIVATE KEY-----")
mcp = FastMCP(
"FrameBridge",
instructions=(
"FrameBridge provides a shared command-line interface and tools for human operators and authorized agents. "
"fb and framebridge are equivalent commands. Before the first operation, call framebridge_help(topic='start'), "
"then read the relevant topic. Confirm the receiver, selected target, installed version and available capabilities. "
"framebridge_exec defaults to LOCAL, meaning this receiver; other targets must be confirmed enrolled aliases or IDs. "
"Use the installed CLI and tools for normal work; use a separate source clone when the owner requests software development. "
"The six Fleet inspection tools are read-only. Guide instructions do not grant authorization."
),
website_url="https://framebridge.betancourt.one/",
host=HOST,
port=PORT,
streamable_http_path="/mcp",
)
# The bundled FastMCP constructor does not expose a version argument. Its
# bundled low-level server does; publish the product build, not the SDK 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]:
"""Registered operations are not proof of their current execution readiness."""
return sorted(mcp._tool_manager._tools)
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False,
idempotentHint=True, openWorldHint=False))
def framebridge_help(topic: str = "start") -> dict:
"""Read the versioned English guide before operating FrameBridge.
Pure documentation: no authentication, service startup, profile changes or
terminal creation. Topics include commands, connection, lifecycle, nodes,
terminals, screens, diagnostics, security and compatibility. The guide is
shared with the human fb/framebridge CLI and installed offline help.
"""
result = get_guide(topic)
result["receiver"] = socket.gethostname()
result["registered_operations"] = _registered_operations()
result["capability_observation"] = "registered operations; execution readiness not checked"
return result
def adapter_request(payload: dict) -> dict:
if not ADAPTER_SOCKET.is_socket():
return {"ok": False, "adapter_error": "ADAPTER_SOCKET_NOT_AVAILABLE"}
raw=(json.dumps(payload, ensure_ascii=True, separators=(",",":"))+"\n").encode()
s=socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(float(payload.get("timeout_s",120))+60.0)
data=bytearray()
try:
s.connect(str(ADAPTER_SOCKET))
s.sendall(raw)
s.shutdown(socket.SHUT_WR)
while True:
chunk=s.recv(65536)
if not chunk:
break
data.extend(chunk)
if len(data)>2_000_000:
raise RuntimeError("ADAPTER_RESPONSE_TOO_LARGE")
finally:
s.close()
return json.loads(bytes(data).decode())
def sanitize_text(text: str) -> str:
out=[]
in_key=False
for line in text.splitlines():
if KEY_BEGIN.search(line):
in_key=True
out.append("[REDACTED PRIVATE KEY BLOCK]")
continue
if in_key:
if KEY_END.search(line):
in_key=False
continue
if SENSITIVE.search(line) and ("=" in line or ":" in line):
out.append("[REDACTED SENSITIVE LINE]")
else:
out.append(line)
return "\n".join(out)
def stdout_result(job_id: str) -> dict:
p=JOBS_ROOT/job_id/"stdout.bin"
if not p.is_file():
return {"stdout":"","stdout_sanitized":False,"stdout_truncated":False,"stdout_omitted_reason":"NOT_AVAILABLE"}
b=p.read_bytes()
truncated=len(b)>1_048_576
if truncated:
b=b[:1_048_576]
text=sanitize_text(b.decode("utf-8","replace"))
if len(text)>4096:
text=text[:4096]
truncated=True
return {"stdout":text,"stdout_sanitized":True,"stdout_truncated":truncated,"stdout_omitted_reason":None}
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False,
idempotentHint=True, openWorldHint=False))
def framebridge_status() -> dict:
"""Inspect this MCP receiver and registration; use CLI status for lifecycle detail.
A successful query does not certify connection or tool readiness. This query
checks only adapter-socket presence and does not start or repair anything.
"""
present = ADAPTER_SOCKET.is_socket()
return {
"ok": True,
"query_success": True,
"product": "FrameBridge",
"version": PRODUCT_VERSION,
"guide_schema": GUIDE_SCHEMA,
"receiver": socket.gethostname(),
"adapter_socket_present": present,
"adapter_ready": "not_checked",
"mcp_transport": "streamable-http",
"registered_operations": _registered_operations(),
"lifecycle_detail_command": "fb --json status",
"help_topic": "start",
}
def broker_post(path: str, payload: dict, timeout_s: float=10.0) -> dict:
req=urllib.request.Request(
BROKER_CONTROL.rstrip("/")+"/"+path.lstrip("/"),
data=json.dumps(payload,sort_keys=True,separators=(",",":")).encode("utf-8"),
headers={"Content-Type":"application/json","Accept":"application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req,timeout=timeout_s) as resp:
out=json.loads(resp.read(2_000_000).decode("utf-8"))
except urllib.error.HTTPError as exc:
try:
out=json.loads(exc.read(65536).decode("utf-8"))
except Exception:
out={"ok":False,"error":"BROKER_HTTP_"+str(exc.code)}
except Exception as exc:
return {"ok":False,"error":"BROKER_CALL_FAILED","detail":type(exc).__name__+":"+str(exc)[:300]}
return out if isinstance(out,dict) else {"ok":False,"error":"BROKER_RESPONSE_INVALID"}
def asus_exec_request(
script: str,
authorized: bool,
timeout_s: int = 120,
job_id: str = "",
sha256: str = "",
) -> dict:
if authorized is not True:
return {"ok":False,"error":"AUTHORIZED_TRUE_REQUIRED"}
if not isinstance(script,str) or not script:
return {"ok":False,"error":"SCRIPT_REQUIRED"}
raw=script.encode("utf-8")
if len(raw)>262144:
return {"ok":False,"error":"SCRIPT_TOO_LARGE"}
digest=hashlib.sha256(raw).hexdigest()
if sha256 and sha256.lower()!=digest:
return {"ok":False,"error":"SCRIPT_SHA256_MISMATCH","computed_sha256":digest}
try:
timeout_s=int(timeout_s)
except Exception:
return {"ok":False,"error":"INVALID_TIMEOUT"}
if not 1<=timeout_s<=600:
return {"ok":False,"error":"INVALID_TIMEOUT"}
if not job_id:
job_id="asus-mcp-"+time.strftime("%Y%m%d-%H%M%S")+"-"+digest[:8]
if not JOB_RE.fullmatch(job_id):
return {"ok":False,"error":"INVALID_JOB_ID"}
if not ASUS_NODE_ID:
return {"ok":False,"error":"REMOTE_NODE_NOT_CONFIGURED"}
queued=broker_post(
"/fbpair/v1/control/admin-exec",
{
"node_id":ASUS_NODE_ID,
"payload":{
"authorized":True,
"job_id":job_id,
"script":script,
"sha256":digest,
"timeout_s":timeout_s,
},
},
timeout_s=10.0,
)
if queued.get("ok") is not True:
return {"ok":False,"error":"ASUS_EXEC_QUEUE_FAILED","broker":queued}
request_id=str(queued.get("request_id") or "")
if not request_id:
return {"ok":False,"error":"ASUS_EXEC_REQUEST_ID_MISSING"}
deadline=time.monotonic()+timeout_s+75
while time.monotonic()<deadline:
status=broker_post(
"/fbpair/v1/control/result",
{"request_id":request_id},
timeout_s=5.0,
)
if status.get("ok") is not True:
time.sleep(0.25)
continue
state=str(status.get("state") or "")
if state=="complete":
outer=status.get("result") or {}
if not isinstance(outer,dict):
return {"ok":False,"error":"ASUS_EXEC_RESULT_INVALID","request_id":request_id}
if outer.get("ok") is not True:
return {
"ok":False,
"error":"ASUS_EXEC_NODE_FAILED",
"request_id":request_id,
"node_result":outer,
}
value=outer.get("result") or {}
if not isinstance(value,dict):
return {"ok":False,"error":"ASUS_EXEC_ADAPTER_RESULT_INVALID","request_id":request_id}
value=dict(value)
value["mcp_job_id"]=job_id
value["mcp_script_sha256"]=digest
value["broker_request_id"]=request_id
value["target"]="ASUS"
return value
if state in {"expired","failed","denied"}:
return {"ok":False,"error":"ASUS_EXEC_"+state.upper(),"request_id":request_id}
time.sleep(0.25)
return {"ok":False,"error":"ASUS_EXEC_TIMEOUT","request_id":request_id}
def gerameg_exec_request(
script: str,
authorized: bool,
timeout_s: int = 120,
job_id: str = "",
sha256: str = "",
) -> dict:
if authorized is not True:
return {"ok":False,"error":"AUTHORIZED_TRUE_REQUIRED"}
if not isinstance(script,str) or not script:
return {"ok":False,"error":"SCRIPT_REQUIRED"}
raw=script.encode("utf-8")
if len(raw)>262144:
return {"ok":False,"error":"SCRIPT_TOO_LARGE"}
digest=hashlib.sha256(raw).hexdigest()
if sha256 and sha256.lower()!=digest:
return {"ok":False,"error":"SCRIPT_SHA256_MISMATCH","computed_sha256":digest}
try:
timeout_s=int(timeout_s)
except Exception:
return {"ok":False,"error":"INVALID_TIMEOUT"}
if not 1<=timeout_s<=600:
return {"ok":False,"error":"INVALID_TIMEOUT"}
if not job_id:
job_id="gerameg-mcp-"+time.strftime("%Y%m%d-%H%M%S")+"-"+digest[:8]
if not JOB_RE.fullmatch(job_id):
return {"ok":False,"error":"INVALID_JOB_ID"}
if not GERAMEG_NODE_ID:
return {"ok":False,"error":"REMOTE_NODE_NOT_CONFIGURED"}
queued=broker_post(
"/fbpair/v1/control/admin-exec",
{
"node_id":GERAMEG_NODE_ID,
"payload":{
"authorized":True,
"job_id":job_id,
"script":script,
"sha256":digest,
"timeout_s":timeout_s,
},
},
timeout_s=10.0,
)
if queued.get("ok") is not True:
return {"ok":False,"error":"GERAMEG_EXEC_QUEUE_FAILED","broker":queued}
request_id=str(queued.get("request_id") or "")
if not request_id:
return {"ok":False,"error":"GERAMEG_EXEC_REQUEST_ID_MISSING"}
deadline=time.monotonic()+timeout_s+75
while time.monotonic()<deadline:
status=broker_post(
"/fbpair/v1/control/result",
{"request_id":request_id},
timeout_s=5.0,
)
if status.get("ok") is not True:
time.sleep(0.25)
continue
state=str(status.get("state") or "")
if state=="complete":
outer=status.get("result") or {}
if not isinstance(outer,dict):
return {"ok":False,"error":"GERAMEG_EXEC_RESULT_INVALID","request_id":request_id}
if outer.get("ok") is not True:
return {
"ok":False,
"error":"GERAMEG_EXEC_NODE_FAILED",
"request_id":request_id,
"node_result":outer,
}
value=outer.get("result") or {}
if not isinstance(value,dict):
return {"ok":False,"error":"GERAMEG_EXEC_ADAPTER_RESULT_INVALID","request_id":request_id}
value=dict(value)
value["mcp_job_id"]=job_id
value["mcp_script_sha256"]=digest
value["broker_request_id"]=request_id
value["target"]="GERAMEG"
return value
if state in {"expired","failed","denied"}:
return {"ok":False,"error":"GERAMEG_EXEC_"+state.upper(),"request_id":request_id}
time.sleep(0.25)
return {"ok":False,"error":"GERAMEG_EXEC_TIMEOUT","request_id":request_id}
def framebridge_exec_geranas(
script: str,
authorized: bool,
timeout_s: int = 120,
job_id: str = "",
sha256: str = "",
include_stdout: bool = False,
) -> dict:
"""Historical alias for original local FBTERM execution on this receiver."""
return _framebridge_exec_geranas_impl(
script=script,
authorized=authorized,
timeout_s=timeout_s,
job_id=job_id,
sha256=sha256,
include_stdout=include_stdout,
)
def _framebridge_exec_geranas_impl(
script: str,
authorized: bool,
timeout_s: int = 120,
job_id: str = "",
sha256: str = "",
include_stdout: bool = False,
) -> dict:
if authorized is not True:
return {"ok":False,"error":"AUTHORIZED_TRUE_REQUIRED"}
if not isinstance(script,str) or not script:
return {"ok":False,"error":"SCRIPT_REQUIRED"}
raw=script.encode()
if len(raw)>262144:
return {"ok":False,"error":"SCRIPT_TOO_LARGE"}
digest=hashlib.sha256(raw).hexdigest()
if sha256 and sha256.lower()!=digest:
return {"ok":False,"error":"SCRIPT_SHA256_MISMATCH","computed_sha256":digest}
try:
timeout_s=int(timeout_s)
except Exception:
return {"ok":False,"error":"INVALID_TIMEOUT"}
if not 1<=timeout_s<=600:
return {"ok":False,"error":"INVALID_TIMEOUT"}
if not job_id:
job_id="mcp-"+time.strftime("%Y%m%d-%H%M%S")+"-"+digest[:8]+"-"+uuid.uuid4().hex[:12]
if not JOB_RE.fullmatch(job_id):
return {"ok":False,"error":"INVALID_JOB_ID"}
req={
"version":1,
"job_id":job_id,
"mode":"EXEC",
"authorized":True,
"timeout_s":timeout_s,
"sha256":digest,
"script":script,
}
result=adapter_request(req)
result["mcp_job_id"]=job_id
result["mcp_script_sha256"]=digest
if isinstance(result.get("result_final"),str):
result["result_final"]=sanitize_text(result["result_final"])
if include_stdout is True:
result.update(stdout_result(job_id))
else:
result.update({"stdout":"","stdout_sanitized":False,"stdout_truncated":False,"stdout_omitted_reason":"NOT_REQUESTED"})
return result
def _cloud_dsm_rpc(payload: dict) -> dict:
"""Optional private local socket; root-only approval never exposed through MCP."""
import socket as _socket
import json as _json
sock = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM)
sock.settimeout(38)
try:
sock.connect(os.environ.get("FRAMEBRIDGE_CLOUD_DSM_SOCKET", str(HOME / ".local/share/framebridge/dsm/cloud.sock")))
body = (_json.dumps(payload, separators=(",",":"),ensure_ascii=True) + "\n").encode("utf8")
if len(body)>12000:
return {"ok":False,"error":"CLOUD_REQUEST_TOO_LARGE","target":"CLOUD"}
sock.sendall(body)
result = b""
while len(result)<32768 and b"\n" not in result:
part=sock.recv(8192)
if not part:break
result+=part
if b"\n" not in result:
return {"ok":False,"error":"CLOUD_RESPONSE_INCOMPLETE","target":"CLOUD"}
output = _json.loads(result.split(b"\n",1)[0])
if not isinstance(output,dict):
return {"ok":False,"error":"CLOUD_RESPONSE_INVALID","target":"CLOUD"}
output["target"]="CLOUD"
return output
except (OSError,ValueError,UnicodeError) as e:
return {"ok":False,"error":"CLOUD_DSM_UNAVAILABLE",
"error_type":type(e).__name__,"target":"CLOUD"}
finally:
sock.close()
def framebridge_cloud_dsm(action: str, authorized: bool = False, params_json: str = "{}") -> dict:
"""CLOUD XPEnology agentless DSM: persistent agentless DSM with temporary owner 2FA; typed reads and Owner-MFA gated writes.
action: status,system_info,file_station_info,shares,list_folder,users,
create_folder,rename_folder,pending.
params_json: JSON object (no passwords/OTP). Writes require local owner OTP approval.
"""
import json as _json
try:
params = _json.loads(params_json)
if not isinstance(params,dict) or len(params_json)>2000:
return {"ok":False,"error":"CLOUD_PARAMETERS_INVALID"}
except (ValueError,TypeError):
return {"ok":False,"error":"CLOUD_PARAMETERS_JSON_INVALID"}
if action=="approve":
return {"ok":False,"error":"OWNER_APPROVAL_LOCAL_TTY_ONLY"}
return _cloud_dsm_rpc({"action":action,"authorized":bool(authorized),"params":params})
@_blocking_tools.tool()
def framebridge_exec(
script: str,
authorized: bool,
timeout_s: int = 120,
job_id: str = "",
sha256: str = "",
include_stdout: bool = False,
target: str = "LOCAL",
) -> dict:
"""Execute through original local FBTERM or a confirmed Fleet node alias."""
if target == "LOCAL":
result = _framebridge_exec_geranas_impl(script, authorized, timeout_s, job_id, sha256, include_stdout)
if isinstance(result, dict):
result = dict(result)
result["target"] = "LOCAL"
return result
return expanded_execute_node(target, script, authorized, timeout_s, job_id, sha256, include_stdout)
def _cua_frame_fields(frame_output: str) -> dict:
result={}
safe_lines=[]
for line in str(frame_output or "").splitlines():
if "=" not in line:
safe_lines.append(line)
continue
key,value=line.split("=",1)
key=key.strip()
if key=="BASE64":
result["image_base64"]=value.strip()
continue
if key=="MIME":
result["image_mime"]=value.strip()
if key in {
"RESULTADO_FINAL","VISUAL_CAPTURE_GATE","CUA_PROTOCOL_VERSION",
"CHAT_AUTHORIZATION_GATE","CHAT_AUTHORIZATION_MODE","ACTION","SEMANTIC",
"COORD_SPACE","COORD_SPACE_WIDTH","COORD_SPACE_HEIGHT",
"REQUESTED_X","REQUESTED_Y","REQUESTED_X1","REQUESTED_Y1",
"REQUESTED_X2","REQUESTED_Y2","ROOT_X","ROOT_Y","ROOT_X1","ROOT_Y1",
"ROOT_X2","ROOT_Y2","BUTTON","KEY","DIRECTION","CLICKS","CHAR_COUNT",
"TYPE_MODE","WIDTH","HEIGHT","SOURCE_X","SOURCE_Y","SOURCE_WIDTH",
"SOURCE_HEIGHT","DISPLAY_WIDTH","DISPLAY_HEIGHT","CAPTURE_SCOPE",
"COORD_SPACE_DEFAULT","COORD_SPACE_AVAILABLE","WINDOW_X","WINDOW_Y",
"WINDOW_WIDTH","WINDOW_HEIGHT","ACTIVE_WINDOW_ID","ACTIVE_WINDOW_ID_BEFORE",
"ACTIVE_WINDOW_ID_AFTER","WINDOW_TITLE","ACTIVE_WINDOW_TITLE_BEFORE",
"ACTIVE_WINDOW_TITLE_AFTER","CURSOR_X","CURSOR_Y","SHA256","BYTES","MIME",
"EXPECTED_WINDOW_GATE","EXPECTED_SOURCE_GEOMETRY_GATE",
}:
result[key.lower()]=sanitize_text(value.strip())
safe_lines.append(line)
result["frame_output"]=sanitize_text("\n".join(
line for line in safe_lines if not line.startswith("BASE64=")
))
return result
def local_cua_request(cua: dict, authorized: bool, timeout_s: int, job_id: str) -> dict:
if authorized is not True:
return {"ok":False,"error":"AUTHORIZED_TRUE_REQUIRED"}
if not isinstance(cua,dict):
return {"ok":False,"error":"CUA_PAYLOAD_REQUIRED"}
action=str(cua.get("action") or "").upper().strip()
if action not in {"OBSERVE","CLICK","DOUBLE_CLICK","TYPE","KEY","SCROLL","DRAG"}:
return {"ok":False,"error":"CUA_ACTION_UNSUPPORTED"}
try:
timeout_s=int(timeout_s)
except Exception:
return {"ok":False,"error":"INVALID_CUA_TIMEOUT"}
if not 1<=timeout_s<=180:
return {"ok":False,"error":"INVALID_CUA_TIMEOUT"}
if not job_id:
seed=json.dumps({"action":action,"time":time.time_ns()},sort_keys=True).encode()
digest=hashlib.sha256(seed).hexdigest()
job_id="cua-local-"+time.strftime("%Y%m%d-%H%M%S")+"-"+digest[:8]
if not JOB_RE.fullmatch(job_id):
return {"ok":False,"error":"INVALID_JOB_ID"}
result=checked_cua_adapter_request({
"version":1,"mode":"EXEC","request_type":"cua",
"authorized":True,"job_id":job_id,"timeout_s":timeout_s,"cua":cua,
})
if not isinstance(result,dict):
return {"ok":False,"error":"CUA_LOCAL_ADAPTER_RESULT_INVALID","target":"LOCAL"}
value=dict(result)
frame_output=str(value.pop("frame_output",""))
if frame_output:
value.update(_cua_frame_fields(frame_output))
value["target"]="LOCAL"
return value
def dispatch_cua_request(cua: dict, authorized: bool, timeout_s: int, job_id: str, target: str = "LOCAL") -> dict:
"""Reuse the original local CUA contract and route confirmed remote nodes."""
if target == "LOCAL":
return local_cua_request(cua, authorized, timeout_s, job_id)
result = expanded_cua_node(target, cua, authorized, timeout_s, job_id)
if not isinstance(result, dict):
return {"ok":False,"error":"NODE_CUA_RESPONSE_INVALID"}
result = dict(result)
frame_output = str(result.pop("frame_output", ""))
if frame_output:
result.update(_cua_frame_fields(frame_output))
return result
def asus_cua_request(cua: dict, authorized: bool, timeout_s: int, job_id: str) -> dict:
if authorized is not True:
return {"ok":False,"error":"AUTHORIZED_TRUE_REQUIRED"}
if not isinstance(cua,dict):
return {"ok":False,"error":"CUA_PAYLOAD_REQUIRED"}
action=str(cua.get("action") or "").upper().strip()
if action not in {"OBSERVE","CLICK","DOUBLE_CLICK","TYPE","KEY","SCROLL","DRAG"}:
return {"ok":False,"error":"CUA_ACTION_UNSUPPORTED"}
try:
timeout_s=int(timeout_s)
except Exception:
return {"ok":False,"error":"INVALID_CUA_TIMEOUT"}
if not 1<=timeout_s<=180:
return {"ok":False,"error":"INVALID_CUA_TIMEOUT"}
if not job_id:
seed=json.dumps({"action":action,"time":time.time_ns()},sort_keys=True).encode()
digest=hashlib.sha256(seed).hexdigest()
job_id="cua-geranas-"+time.strftime("%Y%m%d-%H%M%S")+"-"+digest[:8]
if not JOB_RE.fullmatch(job_id):
return {"ok":False,"error":"INVALID_JOB_ID"}
if not ASUS_NODE_ID:
return {"ok":False,"error":"REMOTE_NODE_NOT_CONFIGURED"}
queued=broker_post(
"/fbpair/v1/control/admin-cua",
{
"node_id":ASUS_NODE_ID,
"payload":{
"authorized":True,
"job_id":job_id,
"timeout_s":timeout_s,
"cua":cua,
},
},
timeout_s=10.0,
)
if queued.get("ok") is not True:
return {"ok":False,"error":"ASUS_CUA_QUEUE_FAILED","broker":queued}
rid=str(queued.get("request_id") or "")
deadline=time.monotonic()+timeout_s+75
while time.monotonic()<deadline:
status=broker_post("/fbpair/v1/control/result",{"request_id":rid},timeout_s=5.0)
if status.get("ok") is not True:
time.sleep(.2)
continue
state=str(status.get("state") or "")
if state=="complete":
outer=status.get("result") or {}
if not isinstance(outer,dict):
return {"ok":False,"error":"ASUS_CUA_RESULT_INVALID","request_id":rid}
if outer.get("ok") is not True:
return {"ok":False,"error":"ASUS_CUA_NODE_FAILED","request_id":rid,"node_result":outer}
value=outer.get("result") or {}
if not isinstance(value,dict):
return {"ok":False,"error":"ASUS_CUA_ADAPTER_RESULT_INVALID","request_id":rid}
value=dict(value)
frame_output=str(value.pop("frame_output",""))
if frame_output:
value.update(_cua_frame_fields(frame_output))
value["broker_request_id"]=rid
value["target"]="ASUS"
return value
if state in {"expired","failed","denied"}:
return {"ok":False,"error":"ASUS_CUA_"+state.upper(),"request_id":rid}
time.sleep(.2)
return {"ok":False,"error":"ASUS_CUA_TIMEOUT","request_id":rid}
@_blocking_tools.tool()
def framebridge_cua(
action: str,
authorized: bool,
semantic_action: str = "GENERIC",
coord_space: str = "ROOT",
capture_scope: str = "ROOT",
observe_after: bool = False,
observe_scope: str = "WINDOW",
x: int = -1,
y: int = -1,
button: str = "left",
text: str = "",
type_mode: str = "KEYS",
key: str = "",
direction: str = "DOWN",
clicks: int = 3,
x1: int = -1,
y1: int = -1,
x2: int = -1,
y2: int = -1,
steps: int = 8,
duration_ms: int = 320,
preview_width: int = 0,
preview_height: int = 0,
expected_window_id: str = "",
expected_source_width: int = 0,
expected_source_height: int = 0,
timeout_s: int = 60,
job_id: str = "",
target: str = "LOCAL",
):
"""Use the configured graphical display; display_context labels physical or virtual. OBSERVE with capture_scope=node:ALIAS captures that node's actual active physical screen."""
if authorized is not True:
return {"ok":False,"error":"AUTHORIZED_TRUE_REQUIRED"}
action=str(action).upper().strip()
node_scope = str(capture_scope).strip()
if node_scope.lower().startswith("node:"):
if action != "OBSERVE":
return {"ok": False, "error": "NODE_SCREEN_SCOPE_OBSERVE_ONLY", "hint": "Use the bound screen tools for TTY input or the graphical CUA with target for X11 input."}
return _real_screen_client.call("observe", node_scope[5:], True)
if action not in {"OBSERVE","CLICK","DOUBLE_CLICK","TYPE","KEY","SCROLL","DRAG"}:
return {"ok":False,"error":"CUA_ACTION_UNSUPPORTED"}
coord_space=str(coord_space).upper().strip()
capture_scope=str(capture_scope).upper().strip()
observe_scope=str(observe_scope).upper().strip()
if coord_space not in {"ROOT","PREVIEW","WINDOW","WINDOW_PREVIEW"}:
return {"ok":False,"error":"COORDINATE_SPACE_INVALID"}
if capture_scope not in {"ROOT","WINDOW"} or observe_scope not in {"ROOT","WINDOW"}:
return {"ok":False,"error":"CAPTURE_SCOPE_INVALID"}
payload={
"action":action,
"semantic_action":str(semantic_action).upper().strip() or "GENERIC",
"coord_space":coord_space,
"capture_scope":capture_scope,
}
if preview_width>0: payload["preview_width"]=int(preview_width)
if preview_height>0: payload["preview_height"]=int(preview_height)
if expected_window_id: payload["expected_window_id"]=str(expected_window_id)
if expected_source_width>0: payload["expected_source_width"]=int(expected_source_width)
if expected_source_height>0: payload["expected_source_height"]=int(expected_source_height)
if action in {"CLICK","DOUBLE_CLICK"}:
if x<0 or y<0: return {"ok":False,"error":"CLICK_COORDINATES_REQUIRED"}
payload.update({"x":int(x),"y":int(y),"button":str(button).lower()})
elif action=="TYPE":
if not text: return {"ok":False,"error":"TYPE_TEXT_REQUIRED"}
tm=str(type_mode).upper().strip()
if tm not in {"KEYS","CLIPBOARD_EXACT"}: return {"ok":False,"error":"TYPE_MODE_INVALID"}
payload.update({"text":str(text),"type_mode":tm})
elif action=="KEY":
if not key: return {"ok":False,"error":"KEY_REQUIRED"}
payload["key"]=str(key)
elif action=="SCROLL":
payload["direction"]=str(direction).upper()
payload["steps"]=int(steps)
payload["clicks"]=int(clicks)
if x>=0 and y>=0:
payload.update({"x":int(x),"y":int(y)})
elif x>=0 or y>=0:
return {"ok":False,"error":"SCROLL_POINT_INCOMPLETE"}
elif action=="DRAG":
if min(x1,y1,x2,y2)<0: return {"ok":False,"error":"DRAG_COORDINATES_REQUIRED"}
payload.update({
"x1":int(x1),"y1":int(y1),"x2":int(x2),"y2":int(y2),
"button":str(button).lower(),"steps":int(steps),"duration_ms":int(duration_ms),
})
result=dispatch_cua_request(payload,True,timeout_s,job_id,target)
if bool(observe_after) and action!="OBSERVE" and bool(result.get("ok")):
post=dict(payload)
post.clear()
post={"action":"OBSERVE","capture_scope":observe_scope}
if expected_window_id:
post["expected_window_id"]=str(expected_window_id)
post_id=(str(job_id or "cua")[:72]+"-post")[:80]
post_result=dispatch_cua_request(post,True,timeout_s,post_id,target)
result["post_ok"]=bool(post_result.get("ok"))
result["post_resultado_final"]=post_result.get("resultado_final")
for key_name in (
"image_base64","image_mime","source_width","source_height",
"display_width","display_height","capture_scope","active_window_id",
"window_title","sha256","bytes"
):
if key_name in post_result:
result[key_name]=post_result[key_name]
image_b64=result.get("image_base64")
mime=str(result.get("image_mime") or "")
if isinstance(image_b64,str) and image_b64:
try:
data=base64.b64decode(image_b64,validate=True)
fmt="jpeg" if mime=="image/jpeg" else ("png" if mime=="image/png" else None)
if fmt:
meta=dict(result)
meta.pop("image_base64",None)
return [meta, Image(data=data,format=fmt)]
except Exception:
result["image_decode_error"]="INVALID_BASE64"
return result
async def hub_call(tool: str, args: dict) -> dict:
try:
async with streamablehttp_client(HUB_URL) as streams:
async with ClientSession(streams[0], streams[1]) as session:
await session.initialize()
result=await session.call_tool(tool,args)
except Exception as exc:
return {"ok":False,"error":"FLEET_HUB_CALL_FAILED","detail":str(exc)[:500]}
if bool(getattr(result,"isError",False)):
return {"ok":False,"error":"FLEET_HUB_TOOL_ERROR"}
structured=getattr(result,"structuredContent",None)
if isinstance(structured,dict):
return structured
for item in getattr(result,"content",[]):
txt=getattr(item,"text",None)
if isinstance(txt,str):
try:
obj=json.loads(txt)
except Exception:
continue
if isinstance(obj,dict):
return obj
return {"ok":True,"result":obj}
return {"ok":False,"error":"FLEET_HUB_EMPTY_RESULT"}
@mcp.tool()
async def framebridge_fleet_nodes() -> dict:
"""Read enrolled node inventory from this profile hub; no enrollment or changes."""
return await hub_call("framebridge_fleet_nodes",{})
@mcp.tool()
async def framebridge_fleet_node_system_status(node: str) -> dict:
"""Read a confirmed node system status without changing its configuration."""
return await hub_call("framebridge_fleet_node_system_status",{"node":node})
@mcp.tool()
async def framebridge_fleet_node_hardware_summary(node: str) -> dict:
"""Read a confirmed node hardware summary; this operation is observational."""
return await hub_call("framebridge_fleet_node_hardware_summary",{"node":node})
@mcp.tool()
async def framebridge_fleet_node_storage_inspect(node: str) -> dict:
"""Inspect node storage metadata; never partition, format, mount or repair."""
return await hub_call("framebridge_fleet_node_storage_inspect",{"node":node})
@mcp.tool()
async def framebridge_fleet_node_network_inspect(node: str) -> dict:
"""Inspect confirmed node network state; no address, routing or firewall changes."""
return await hub_call("framebridge_fleet_node_network_inspect",{"node":node})
@mcp.tool()
async def framebridge_fleet_node_service_status(node: str, service: str) -> dict:
"""Read a named service status on a confirmed node; never start, stop or enable it."""
return await hub_call("framebridge_fleet_node_service_status",{"node":node,"service":service})
def _screen_exec_request(
*, script: str, authorized: bool, timeout_s: int = 120,
job_id: str = "", sha256: str = "", include_stdout: bool = False,
target: str = "LOCAL",
) -> dict:
"""Keep authenticated Fleet identity without opening an EXEC terminal."""
return expanded_execute_screen_node(target, script, authorized, timeout_s,
job_id, sha256, include_stdout)
from framebridge_expanded.real_screen.screen_frontdoor import register_framebridge_screen_tools, 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
register_screen_presentation(_blocking_tools, _real_screen_client)
from framebridge_expanded.tty_mcp import register_tools as register_tty_tools
register_tty_tools(_blocking_tools, framebridge_exec, screen_client=_real_screen_client)
if __name__ == "__main__":
from framebridge_expanded.http_server import run_streamable_mcp
run_streamable_mcp(mcp)