Files
framebridge/source/portable/front_door_mcp.py
T

843 lines
34 KiB
Python

#!/usr/bin/env python3
import base64
import hashlib
import json
import os
import re
import socket
import time
import urllib.error
import urllib.request
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
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]
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})
@mcp.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}
@mcp.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(mcp, _screen_exec_request, image_factory=Image, resolve_node=resolve_profile_node)
from framebridge_expanded.real_screen.screen_presentation import register_screen_presentation
register_screen_presentation(mcp, _real_screen_client)
from framebridge_expanded.tty_mcp import register_tools as register_tty_tools
register_tty_tools(mcp, framebridge_exec)
if __name__ == "__main__":
from framebridge_expanded.http_server import run_streamable_mcp
run_streamable_mcp(mcp)