FrameBridge 0.3.1 community source release

This commit is contained in:
FrameBridge Development committed 2026-10-26 22:09:18 -04:00
commit b23819fff1
1354 files changed
+343716

No files matched your search

@@ -0,0 +1,252 @@
--- original/front_door_mcp.py
+++ portable/front_door_mcp.py
@@ -13,26 +13,28 @@
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from mcp.server.fastmcp import FastMCP, Image
+from framebridge_expanded.fleet import execute_node as expanded_execute_node, cua_node as expanded_cua_node, bootstrap_original
+bootstrap_original()
HOME = Path.home()
-ADAPTER_SOCKET = HOME / ".local/share/framebridge/tool-adapter/adapter.sock"
+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:8877/mcp")
-BROKER_CONTROL = os.environ.get("FRAMEBRIDGE_BROKER_CONTROL", "http://127.0.0.1:28765")
-ASUS_NODE_ID = os.environ.get("FRAMEBRIDGE_ASUS_NODE_ID", "4b4157f9-3c98-4d24-bff4-840825d50951")
-GERAMEG_NODE_ID = os.environ.get("FRAMEBRIDGE_GERAMEG_NODE_ID", "76885f27-da2e-4cca-bbde-6ec981e91e68")
+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", "8865"))
+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 GERANAS Front Door",
+ "FrameBridge Original Front Door",
instructions=(
- "Front Door privado de FrameBridge en GERANAS. "
- "framebridge_exec enruta por target: ASUS y GERAMEG mediante broker_outbound, GERANAS via FBTERM local, autorizado explicitamente; sin target mantiene ASUS. "
+ "Front Door original en el equipo receptor. "
+ "framebridge_exec defaults to LOCAL; other targets are confirmed Fleet aliases or node IDs. "
"Las seis herramientas Fleet son READ-only y se enrutan al Hub local."
),
host=HOST,
@@ -100,7 +102,7 @@
mode=oct(ADAPTER_SOCKET.stat().st_mode & 0o777) if ok else None
return {
"ok":ok,
- "host":"geranas",
+ "host":socket.gethostname(),
"adapter_socket":str(ADAPTER_SOCKET),
"socket_mode":mode,
"mcp_transport":"streamable-http",
@@ -158,6 +160,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",
{
@@ -243,6 +247,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",
{
@@ -310,7 +316,7 @@
sha256: str = "",
include_stdout: bool = False,
) -> dict:
- """Administrative/lab exec on GERANAS local FBTERM."""
+ """Historical alias for original local FBTERM execution on this receiver."""
return _framebridge_exec_geranas_impl(
script=script,
authorized=authorized,
@@ -371,13 +377,13 @@
def _cloud_dsm_rpc(payload: dict) -> dict:
- """Private GERANAS local socket; root-only approval never exposed through MCP."""
+ """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("/run/framebridge-dsm/cloud.sock")
+ 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"}
@@ -428,49 +434,16 @@
job_id: str = "",
sha256: str = "",
include_stdout: bool = False,
- target: str = "ASUS",
+ target: str = "LOCAL",
) -> dict:
- """Generic administrative/lab exec. target=ASUS or GERAMEG uses broker; target=GERANAS uses local FBTERM. Legacy calls without target remain ASUS. CLOUD uses only typed JSON actions, never shell commands."""
- if target == "CLOUD":
- import json as _json
- try:
- payload = _json.loads(script)
- if not isinstance(payload,dict) or len(script)>2000:
- return {"ok":False,"error":"CLOUD_REQUIRES_JSON_ACTION"}
- except (TypeError,ValueError):
- return {"ok":False,"error":"CLOUD_ACCEPTS_ONLY_TYPED_JSON_ACTION_NOT_SHELL"}
- if payload.get("action")=="approve":
- return {"ok":False,"error":"CLOUD_OWNER_APPROVAL_LOCAL_TTY_ONLY"}
- return _cloud_dsm_rpc({"action":payload.get("action"),
- "params":payload.get("params",{}),
- "authorized":bool(authorized)})
- if target == "GERANAS":
- result = _framebridge_exec_geranas_impl(
- script=script,
- authorized=authorized,
- timeout_s=timeout_s,
- job_id=job_id,
- sha256=sha256,
- include_stdout=include_stdout,
- )
+ """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"] = "GERANAS"
+ result["target"] = "LOCAL"
return result
- if target not in ("ASUS", "GERAMEG"):
- return {"ok": False, "error": "UNSUPPORTED_TARGET", "allowed_targets": ["ASUS", "GERANAS", "GERAMEG", "CLOUD"]}
- exec_request = asus_exec_request if target == "ASUS" else gerameg_exec_request
- result=exec_request(
- script=script,
- authorized=authorized,
- timeout_s=timeout_s,
- job_id=job_id,
- sha256=sha256,
- )
- if include_stdout is not True and isinstance(result,dict):
- result=dict(result)
- result.pop("stdout",None)
- return result
+ return expanded_execute_node(target, script, authorized, timeout_s, job_id, sha256, include_stdout)
def _cua_frame_fields(frame_output: str) -> dict:
@@ -510,6 +483,54 @@
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=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"}
@@ -531,6 +552,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",
{
@@ -606,8 +629,9 @@
expected_source_height: int = 0,
timeout_s: int = 60,
job_id: str = "",
+ target: str = "LOCAL",
):
- """CUA R8 on ASUS through GERANAS. Requires explicit per-use chat authorization."""
+ """Original CUA R8 on this receiver through its local Tool Adapter."""
if authorized is not True:
return {"ok":False,"error":"AUTHORIZED_TRUE_REQUIRED"}
action=str(action).upper().strip()
@@ -659,7 +683,7 @@
"button":str(button).lower(),"steps":int(steps),"duration_ms":int(duration_ms),
})
- result=asus_cua_request(payload,True,timeout_s,job_id)
+ 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()
@@ -667,7 +691,7 @@
if expected_window_id:
post["expected_window_id"]=str(expected_window_id)
post_id=(str(job_id or "cua")[:72]+"-post")[:80]
- post_result=asus_cua_request(post,True,timeout_s,post_id)
+ 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 (
@@ -742,8 +766,8 @@
async def framebridge_fleet_node_service_status(node: str, service: str) -> dict:
return await hub_call("framebridge_fleet_node_service_status",{"node":node,"service":service})
-from gerameg_cua_frontdoor import register_tools as register_gerameg_cua_tools
-register_gerameg_cua_tools(mcp, gerameg_exec_request)
+from framebridge_expanded.tty_mcp import register_tools as register_tty_tools
+register_tty_tools(mcp, framebridge_exec)
if __name__ == "__main__":
mcp.run(transport="streamable-http")
+204
View File
@@ -0,0 +1,204 @@
--- original/front_door_mcp.py
+++ portable/front_door_mcp.py
@@ -15,24 +15,24 @@
from mcp.server.fastmcp import FastMCP, Image
HOME = Path.home()
-ADAPTER_SOCKET = HOME / ".local/share/framebridge/tool-adapter/adapter.sock"
+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:8877/mcp")
-BROKER_CONTROL = os.environ.get("FRAMEBRIDGE_BROKER_CONTROL", "http://127.0.0.1:28765")
-ASUS_NODE_ID = os.environ.get("FRAMEBRIDGE_ASUS_NODE_ID", "4b4157f9-3c98-4d24-bff4-840825d50951")
-GERAMEG_NODE_ID = os.environ.get("FRAMEBRIDGE_GERAMEG_NODE_ID", "76885f27-da2e-4cca-bbde-6ec981e91e68")
+HUB_URL = os.environ.get("FRAMEBRIDGE_HUB_MCP_URL", "http://127.0.0.1:19877/mcp")
+BROKER_CONTROL = os.environ.get("FRAMEBRIDGE_BROKER_CONTROL", "http://127.0.0.1:19878")
+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", "8865"))
+PORT = int(os.environ.get("FRAMEBRIDGE_FRONT_DOOR_PORT", "19865"))
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 GERANAS Front Door",
+ "FrameBridge Original Front Door",
instructions=(
- "Front Door privado de FrameBridge en GERANAS. "
- "framebridge_exec enruta por target: ASUS y GERAMEG mediante broker_outbound, GERANAS via FBTERM local, autorizado explicitamente; sin target mantiene ASUS. "
+ "Front Door original en el equipo receptor. "
+ "framebridge_exec ejecuta LOCAL por defecto; ASUS y GERAMEG requieren broker configurado; GERANAS es un alias historico de LOCAL. "
"Las seis herramientas Fleet son READ-only y se enrutan al Hub local."
),
host=HOST,
@@ -100,7 +100,7 @@
mode=oct(ADAPTER_SOCKET.stat().st_mode & 0o777) if ok else None
return {
"ok":ok,
- "host":"geranas",
+ "host":socket.gethostname(),
"adapter_socket":str(ADAPTER_SOCKET),
"socket_mode":mode,
"mcp_transport":"streamable-http",
@@ -158,6 +158,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",
{
@@ -243,6 +245,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",
{
@@ -310,7 +314,7 @@
sha256: str = "",
include_stdout: bool = False,
) -> dict:
- """Administrative/lab exec on GERANAS local FBTERM."""
+ """Historical alias for original local FBTERM execution on this receiver."""
return _framebridge_exec_geranas_impl(
script=script,
authorized=authorized,
@@ -371,13 +375,13 @@
def _cloud_dsm_rpc(payload: dict) -> dict:
- """Private GERANAS local socket; root-only approval never exposed through MCP."""
+ """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("/run/framebridge-dsm/cloud.sock")
+ 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"}
@@ -428,9 +432,15 @@
job_id: str = "",
sha256: str = "",
include_stdout: bool = False,
- target: str = "ASUS",
+ target: str = "LOCAL",
) -> dict:
- """Generic administrative/lab exec. target=ASUS or GERAMEG uses broker; target=GERANAS uses local FBTERM. Legacy calls without target remain ASUS. CLOUD uses only typed JSON actions, never shell commands."""
+ """Original execution on this receiver by default. ASUS/GERAMEG require explicit broker configuration; GERANAS remains a historical local alias. CLOUD accepts typed JSON only."""
+ 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
if target == "CLOUD":
import json as _json
try:
@@ -458,7 +468,7 @@
result["target"] = "GERANAS"
return result
if target not in ("ASUS", "GERAMEG"):
- return {"ok": False, "error": "UNSUPPORTED_TARGET", "allowed_targets": ["ASUS", "GERANAS", "GERAMEG", "CLOUD"]}
+ return {"ok": False, "error": "UNSUPPORTED_TARGET", "allowed_targets": ["LOCAL", "ASUS", "GERANAS", "GERAMEG", "CLOUD"]}
exec_request = asus_exec_request if target == "ASUS" else gerameg_exec_request
result=exec_request(
script=script,
@@ -510,6 +520,40 @@
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=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 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"}
@@ -531,6 +575,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",
{
@@ -607,7 +653,7 @@
timeout_s: int = 60,
job_id: str = "",
):
- """CUA R8 on ASUS through GERANAS. Requires explicit per-use chat authorization."""
+ """Original CUA R8 on this receiver through its local Tool Adapter."""
if authorized is not True:
return {"ok":False,"error":"AUTHORIZED_TRUE_REQUIRED"}
action=str(action).upper().strip()
@@ -659,7 +705,7 @@
"button":str(button).lower(),"steps":int(steps),"duration_ms":int(duration_ms),
})
- result=asus_cua_request(payload,True,timeout_s,job_id)
+ result=local_cua_request(payload,True,timeout_s,job_id)
if bool(observe_after) and action!="OBSERVE" and bool(result.get("ok")):
post=dict(payload)
post.clear()
@@ -667,7 +713,7 @@
if expected_window_id:
post["expected_window_id"]=str(expected_window_id)
post_id=(str(job_id or "cua")[:72]+"-post")[:80]
- post_result=asus_cua_request(post,True,timeout_s,post_id)
+ post_result=local_cua_request(post,True,timeout_s,post_id)
result["post_ok"]=bool(post_result.get("ok"))
result["post_resultado_final"]=post_result.get("resultado_final")
for key_name in (
@@ -742,8 +788,9 @@
async def framebridge_fleet_node_service_status(node: str, service: str) -> dict:
return await hub_call("framebridge_fleet_node_service_status",{"node":node,"service":service})
-from gerameg_cua_frontdoor import register_tools as register_gerameg_cua_tools
-register_gerameg_cua_tools(mcp, gerameg_exec_request)
+if os.environ.get("FRAMEBRIDGE_ENABLE_GERAMEG_CUA_TTY", "") == "1":
+ from gerameg_cua_frontdoor import register_tools as register_gerameg_cua_tools
+ register_gerameg_cua_tools(mcp, gerameg_exec_request)
if __name__ == "__main__":
mcp.run(transport="streamable-http")
+21
View File
@@ -0,0 +1,21 @@
# FrameBridge Original Expanded — portability
PROJECT=FRAMEBRIDGE_ORIGINAL_EXPANDED
STATUS=EXPANDED_PORTABILITY_AND_VALIDATION_SCOPE_DOCUMENTED
DATE=2026-10-21
The supported target is Debian 13 amd64 with Python 3.13. Vendored native Python extensions require that ABI. Other platforms are not certified by this package.
Use the English first-use assistant with framebridge or fb. The full installation, role, manual ChatGPT connection and terminal workflow is in docs/DEBIAN-TUTORIAL.md; installed users can run framebridge tutorial.
The default ports are MCP 20865, fleet hub 20877 and broker 20878. Setup can select different ports. Standalone and central profiles expose local MCP; enrolled nodes are reached through their central instance.
Headless auto mode reuses a usable X11 display or creates a private Xvfb/Openbox/DBus environment for the original graphical tools. Original FBTERM still uses GNOME Terminal and wmctrl. Real CUA-TTY Bash/tmux sessions do not require X.
CUA-TTY real-terminal support is included in Expanded. It uses the configured user's identity and a private local socket; there is no fixed UID or phone requirement. Fleet access retains authenticated transport and explicit node-identity approval. Central setup can generate local TLS material; nodes receive only its public CA certificate and verify the published DER fingerprint.
ChatGPT connection is manual and runs in the foreground. A tunnel ID or supported tunnel-client command is parsed locally; the runtime API key is read with a hidden prompt and kept in process memory only.
Live profile state, keys, enrollment identities and runtime sessions are not release inputs. Do not copy them into an installer or source archive.
Consult docs/VALIDATION.md for actual test coverage. The historical R1 Front Door patch is provenance; it is not the Expanded implementation diff.
+841
View File
@@ -0,0 +1,841 @@
#!/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__":
mcp.run(transport="streamable-http")
+177
View File
@@ -0,0 +1,177 @@
"""Siete herramientas CUA-TTY ligadas a GERAMEG, sobre FBEXEC original."""
from __future__ import annotations
import base64
import hashlib
import json
import re
import shlex
import uuid
RPC_PATH = ".local/lib/framebridge-gerameg-original/cua_tty_rpc.py"
PROTOCOL = "FB-CUA-TTY-RPC/1"
MAX_BYTES = 65536
CHUNK_BYTES = 2100
SID = re.compile(r"[A-Za-z0-9_-]{12,80}\Z")
REPLY_ID = re.compile(r"[0-9a-f]{32}\Z")
SHA = re.compile(r"[0-9a-f]{64}\Z")
KEYS = frozenset({"UP", "DOWN", "LEFT", "RIGHT", "ENTER", "TAB", "ESC", "BACKSPACE", "CTRL_C"})
READ = {"readOnlyHint": True, "destructiveHint": False, "idempotentHint": False, "openWorldHint": False}
CHANGE = {**READ, "readOnlyHint": False}
STOP = {**CHANGE, "destructiveHint": True}
class CuaError(RuntimeError):
pass
def require(ok, code):
if not ok:
raise CuaError(code)
def encode(value):
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("ascii")
def _remote_script(argv):
"""Resolve the helper under the remote execution account's home directory."""
command = shlex.join(["/usr/bin/python3", "-B"])
command += ' "${HOME:?}/' + RPC_PATH + '"'
if argv:
command += " " + shlex.join(argv)
return command + "\n"
def register_tools(mcp, exec_request):
"""exec_request es DI del bootstrap original, nunca un parámetro MCP."""
def remote(argv):
script = _remote_script(argv)
job_id = "cua-gerameg-" + uuid.uuid4().hex
digest = hashlib.sha256(script.encode("utf-8")).hexdigest()
result = exec_request(script=script, authorized=True, timeout_s=15, job_id=job_id, sha256=digest)
require(type(result) is dict and result.get("ok") is True, "CUA_TTY_EXEC_FAILED")
exit_code = result.get("exit_code")
require(type(exit_code) in (int, str) and str(exit_code) == "0", "CUA_TTY_EXEC_FAILED")
require(result.get("target") == "GERAMEG" and result.get("mcp_job_id") == job_id and result.get("mcp_script_sha256") == digest, "CUA_TTY_EXEC_BINDING_INVALID")
stdout = result.get("stdout")
require(type(stdout) is str and len(stdout) <= 4096 and result.get("stdout_truncated") is not True, "CUA_TTY_STDOUT_INVALID")
try:
envelope = json.loads(stdout)
except (ValueError, UnicodeError):
raise CuaError("CUA_TTY_ENVELOPE_INVALID") from None
require(type(envelope) is dict and envelope.get("protocol") == PROTOCOL, "CUA_TTY_ENVELOPE_INVALID")
if envelope.get("ok") is not True:
code = envelope.get("error")
if type(code) is str and re.fullmatch(r"[A-Z0-9_]{1,80}", code):
raise CuaError("CUA_TTY_" + code)
raise CuaError("CUA_TTY_RPC_FAILED")
return envelope
def unpack(envelope):
total, digest = envelope.get("bytes"), envelope.get("sha256")
require(type(total) is int and 0 < total <= MAX_BYTES and type(digest) is str and SHA.fullmatch(digest) is not None, "CUA_TTY_MANIFEST_INVALID")
if envelope.get("kind") == "inline":
text = envelope.get("data_b64")
require(type(text) is str and len(text) <= 3000, "CUA_TTY_RESPONSE_INVALID")
try:
data = base64.b64decode(text, validate=True)
except (ValueError, UnicodeError):
raise CuaError("CUA_TTY_RESPONSE_INVALID") from None
elif envelope.get("kind") == "file":
reply_id = envelope.get("reply_id")
require(type(reply_id) is str and REPLY_ID.fullmatch(reply_id) is not None, "CUA_TTY_REPLY_ID_INVALID")
pieces = []
for offset in range(0, total, CHUNK_BYTES):
part = remote(["read", reply_id, str(offset)])
require(part.get("kind") == "chunk" and part.get("reply_id") == reply_id and type(part.get("offset")) is int and part["offset"] == offset, "CUA_TTY_CHUNK_BINDING_INVALID")
require(type(part.get("bytes")) is int and part["bytes"] == total and part.get("sha256") == digest, "CUA_TTY_CHUNK_MANIFEST_INVALID")
text = part.get("data_b64")
require(type(text) is str and len(text) <= 2800, "CUA_TTY_CHUNK_INVALID")
try:
chunk = base64.b64decode(text, validate=True)
except (ValueError, UnicodeError):
raise CuaError("CUA_TTY_CHUNK_INVALID") from None
expected = min(CHUNK_BYTES, total - offset)
require(type(part.get("length")) is int and part["length"] == expected and len(chunk) == expected, "CUA_TTY_CHUNK_LENGTH_INVALID")
pieces.append(chunk)
data = b"".join(pieces)
else:
raise CuaError("CUA_TTY_ENVELOPE_KIND_INVALID")
require(len(data) == total and hashlib.sha256(data).hexdigest() == digest, "CUA_TTY_RESPONSE_HASH_MISMATCH")
try:
reply = json.loads(data)
except (ValueError, UnicodeError):
raise CuaError("CUA_TTY_RESPONSE_INVALID") from None
require(type(reply) is dict and type(reply.get("ok")) is bool, "CUA_TTY_RESPONSE_INVALID")
return reply, total, digest
def call(op, authorized, **kwargs):
if authorized is not True:
return {"ok": False, "target": "GERAMEG", "error": "AUTHORIZED_TRUE_REQUIRED"}
try:
payload = base64.b64encode(encode({"op": op, **kwargs})).decode("ascii")
reply, total, digest = unpack(remote(["invoke", payload]))
if reply.get("ok") is not True:
error = reply.get("error")
code = error if type(error) is str and re.fullmatch(r"[A-Z0-9_]{1,80}", error) else "CUA_TTY_REQUEST_DENIED"
return {"ok": False, "target": "GERAMEG", "error": code}
require(type(reply.get("result")) is dict, "CUA_TTY_RESPONSE_INVALID")
return {"ok": True, "target": "GERAMEG", "result": reply["result"], "response_bytes": total, "response_sha256": digest}
except CuaError as exc:
return {"ok": False, "target": "GERAMEG", "error": str(exc)}
except Exception:
return {"ok": False, "target": "GERAMEG", "error": "CUA_TTY_BACKEND_UNAVAILABLE"}
def valid_sid(session_id):
return type(session_id) is str and SID.fullmatch(session_id) is not None
def denied(authorized, valid=True, error="CUA_TTY_ARGUMENT_INVALID"):
if authorized is not True:
return {"ok": False, "target": "GERAMEG", "error": "AUTHORIZED_TRUE_REQUIRED"}
if not valid:
return {"ok": False, "target": "GERAMEG", "error": error}
return None
@mcp.tool(annotations=dict(READ))
def framebridge_cua_tty_status(authorized: bool) -> dict:
"""Estado CUA-TTY de GERAMEG; requiere autorización explícita del chat."""
return call("status", authorized)
@mcp.tool(annotations=dict(CHANGE))
def framebridge_cua_tty_open_demo(authorized: bool) -> dict:
"""Abre demo-readonly sin privilegios en GERAMEG; no abre una shell general."""
return call("open_demo", authorized)
@mcp.tool(annotations=dict(READ))
def framebridge_cua_tty_observe(session_id: str, authorized: bool) -> dict:
"""Lee la pantalla textual de una sesión CUA-TTY de GERAMEG."""
return denied(authorized, valid_sid(session_id), "CUA_TTY_SESSION_INVALID") or call("observe", authorized, session_id=session_id)
@mcp.tool(annotations=dict(CHANGE))
def framebridge_cua_tty_resize(session_id: str, rows: int, cols: int, authorized: bool) -> dict:
"""Redimensiona una sesión de GERAMEG dentro de 5–100 filas y 20–220 columnas."""
valid = valid_sid(session_id) and type(rows) is int and type(cols) is int and 5 <= rows <= 100 and 20 <= cols <= 220
return denied(authorized, valid) or call("resize", authorized, session_id=session_id, rows=rows, cols=cols)
@mcp.tool(annotations=dict(STOP))
def framebridge_cua_tty_abort(session_id: str, authorized: bool) -> dict:
"""Cierra exclusivamente la sesión CUA-TTY indicada de GERAMEG."""
return denied(authorized, valid_sid(session_id), "CUA_TTY_SESSION_INVALID") or call("abort", authorized, session_id=session_id)
@mcp.tool(annotations=dict(CHANGE))
def framebridge_cua_tty_key_send(session_id: str, key: str, authorized: bool) -> dict:
"""Envía UP/DOWN/LEFT/RIGHT/ENTER/TAB/ESC/BACKSPACE/CTRL_C en GERAMEG."""
valid = valid_sid(session_id) and type(key) is str and key in KEYS
return denied(authorized, valid) or call("key_send", authorized, session_id=session_id, key=key)
@mcp.tool(annotations=dict(CHANGE))
def framebridge_cua_tty_text_type(session_id: str, text: str, authorized: bool) -> dict:
"""Escribe 1–64 caracteres sin controles en la demo de GERAMEG."""
valid = valid_sid(session_id) and type(text) is str and 1 <= len(text) <= 64 and not any(ord(ch) < 32 or ord(ch) == 127 for ch in text)
if valid:
try:
text.encode("utf-8")
except UnicodeEncodeError:
valid = False
return denied(authorized, valid) or call("text_type", authorized, session_id=session_id, text=text)