Files

148 lines
7.0 KiB
Python

"""Seven local/remote MCP tools for the real persistent FrameBridge CUA-TTY."""
from __future__ import annotations
import base64
import hashlib
import json
import os
import re
import shlex
import uuid
import zlib
from . import tty
TOOL_NAMES = (
"cua_tty_status", "cua_tty_open", "cua_tty_observe",
"cua_tty_key_send", "cua_tty_text_type", "cua_tty_resize", "cua_tty_close",
)
def _exec_json(execute, target, arguments):
script = "python3 -m framebridge_expanded.tty " + " ".join(shlex.quote(str(x)) for x in arguments)
response = execute(script=script, authorized=True, timeout_s=30,
job_id="tty-rpc-" + uuid.uuid4().hex[:24],
sha256=hashlib.sha256(script.encode()).hexdigest(),
include_stdout=True, target=target)
if type(response) is not dict or response.get("ok") is not True:
return {"ok": False, "error": "TTY_REMOTE_EXEC_FAILED"}
if response.get("stdout_truncated"):
return {"ok": False, "error": "TTY_REMOTE_RESPONSE_TRUNCATED"}
output = response.get("stdout", "")
if type(output) is not str:
return {"ok": False, "error": "TTY_REMOTE_RESPONSE_INVALID"}
try:
value = json.loads(output)
except (ValueError, TypeError):
return {"ok": False, "error": "TTY_REMOTE_RESPONSE_INVALID"}
return value if type(value) is dict else {"ok": False, "error": "TTY_REMOTE_RESPONSE_INVALID"}
def _remote_request(execute, target, payload):
encoded = base64.b64encode(json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode()).decode("ascii")
first = _exec_json(execute, target, ["request", "--payload-b64", encoded, "--wire"])
if first.get("ok") is not True:
return first
try:
ticket = first["ticket"]
digest = first["sha256"]
length = first["bytes"]
count = first["chunks"]
if (first.get("wire") != "zlib-base64-v1" or type(ticket) is not str
or re.fullmatch(r"[a-f0-9]{32}", ticket) is None
or type(digest) is not str or re.fullmatch(r"[a-f0-9]{64}", digest) is None
or type(length) is not int or not 0 < length <= 16777216
or type(count) is not int or not 1 <= count <= 20000):
raise ValueError()
chunks = []
for index in range(count):
part = first if index == 0 else _exec_json(
execute, target, ["read", "--ticket", ticket, "--index", index])
if (part.get("ok") is not True or part.get("ticket") != ticket
or part.get("index") != index or part.get("chunks") != count
or part.get("sha256") != digest or type(part.get("data")) is not str
or len(part["data"]) > 1800):
raise ValueError()
chunks.append(part["data"])
compressed = base64.b64decode("".join(chunks), validate=True)
decoder = zlib.decompressobj()
raw = decoder.decompress(compressed, length + 1)
if (len(raw) != length or not decoder.eof or decoder.unused_data
or decoder.unconsumed_tail or hashlib.sha256(raw).hexdigest() != digest):
raise ValueError()
result = json.loads(raw)
return result if type(result) is dict else {"ok": False, "error": "TTY_REMOTE_RESPONSE_INVALID"}
except (KeyError, ValueError, TypeError, zlib.error):
return {"ok": False, "error": "TTY_REMOTE_RESPONSE_INVALID"}
def register_tools(mcp, exec_request, *, screen_client=None, image_factory=None):
if screen_client is None:
from .real_screen.screen_frontdoor import ScreenClient, resolve_profile_node
if image_factory is None:
from mcp.server.fastmcp import Image
image_factory = Image
screen_client = ScreenClient(exec_request, image_factory, resolve_node=resolve_profile_node)
"""Register tools; exec_request is the existing authorized fleet executor."""
def call(payload, target):
if payload.get("authorized") is not True:
return {"ok": False, "error": "AUTHORIZED_TRUE_REQUIRED"}
if type(target) is not str or not target.strip():
return {"ok": False, "error": "TTY_TARGET_INVALID"}
target = target.strip()
if target.upper() == "LOCAL":
result = tty.request(os.environ.get("FRAMEBRIDGE_EXPANDED_STATE"), payload)
target = "LOCAL"
else:
result = _remote_request(exec_request, target, payload)
result = dict(result)
result["target"] = target
return result
@mcp.tool()
def cua_tty_status(authorized: bool = False, target: str = "LOCAL") -> dict:
"""Report the actual CUA-TTY daemon, Bash sessions and configurable resource limits."""
return call({"op": "status", "authorized": authorized}, target)
@mcp.tool()
def cua_tty_open(authorized: bool = False, program: str = "bash",
rows: int = 24, cols: int = 80, target: str = "LOCAL") -> dict:
"""Open a real persistent Bash console in the profile's private tmux server; no X11 required."""
return call({"op": "open", "authorized": authorized, "program": program,
"rows": rows, "cols": cols}, target)
@mcp.tool()
def cua_tty_observe(session_id: str, authorized: bool = False,
target: str = "LOCAL"):
"""Capture the entire actual active screen and report whether this session is visible."""
return screen_client.call("observe", target, authorized, session_id=session_id)
@mcp.tool()
def cua_tty_key_send(session_id: str, key: str, authorized: bool = False,
target: str = "LOCAL", capture_id: str = ""):
"""Send a key only to the visible TTY target bound to a real capture; use graphical CUA for X11."""
return screen_client.call("key", target, authorized, session_id=session_id,
capture_id=capture_id, key=key)
@mcp.tool()
def cua_tty_text_type(session_id: str, text: str, authorized: bool = False,
target: str = "LOCAL", capture_id: str = ""):
"""Type only into the visible TTY target bound to a real capture; never into a hidden session."""
return screen_client.call("text", target, authorized, session_id=session_id,
capture_id=capture_id, text=text)
@mcp.tool()
def cua_tty_resize(session_id: str, rows: int, cols: int,
authorized: bool = False, target: str = "LOCAL") -> dict:
"""Resize the real PTY/tmux console within the limits reported by cua_tty_status."""
return call({"op": "resize", "authorized": authorized, "session_id": session_id,
"rows": rows, "cols": cols}, target)
@mcp.tool()
def cua_tty_close(session_id: str, authorized: bool = False,
target: str = "LOCAL") -> dict:
"""Close only the selected console owned by this runtime and verify tmux cleanup."""
return call({"op": "close", "authorized": authorized, "session_id": session_id}, target)
return TOOL_NAMES