FrameBridge 0.3.1 community source release
This commit is contained in:
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")
|
||||
@@ -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")
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user