#!/usr/bin/python3 """Manual launcher for the captured FrameBridge Original programs.""" import argparse import hashlib import json import os from pathlib import Path import shutil import signal import stat import socket import subprocess import sys import time PREFIX = Path(__file__).resolve().parents[1] REAL_HOME = Path.home() DEFAULT_STATE = Path(os.environ.get("XDG_STATE_HOME", str(REAL_HOME / ".local/state"))) / "framebridge-original" LAYOUT = json.loads((PREFIX / "layout.json").read_text()) CORE_COMMANDS = ["bash", "tmux", "tee", "flock", "awk", "sha256sum", "timeout", "gnome-terminal", "wmctrl"] def settings(args): state = Path(args.state_dir).expanduser().absolute() home = state / "home" runtime_base = Path("/run/user") / str(os.getuid()) if not runtime_base.is_dir() or runtime_base.stat().st_uid != os.getuid(): runtime_base = Path("/tmp") runtime = runtime_base / ("fbo-" + str(os.getuid()) + "-" + hashlib.sha256(str(state).encode()).hexdigest()[:16]) env = os.environ.copy() authority = env.get("XAUTHORITY") if not authority and (REAL_HOME / ".Xauthority").is_file(): authority = str(REAL_HOME / ".Xauthority") env.update({ "HOME": str(home), "XDG_CONFIG_HOME": str(home / ".config"), "XDG_DATA_HOME": str(home / ".local/share"), "XDG_STATE_HOME": str(home / ".local/state"), "XDG_CACHE_HOME": str(home / ".cache"), "XDG_RUNTIME_DIR": str(runtime), "TMPDIR": str(runtime / "tmp"), "TMUX_TMPDIR": str(runtime / "tmux"), "PYTHONDONTWRITEBYTECODE": "1", "FBTERM_ROOT": str(home / ".local/share/framebridge/fbterm"), "FRAMEBRIDGE_FBTERM_ROOT_OVERRIDE": str(home / ".local/share/framebridge/fbterm"), "FBTERM_SESSION": "framebridge-original-fbterm", "FBTERM_TITLE": "FrameBridge Original", "FRAMEBRIDGE_FBTERM_ROOT": str(home / ".local/share/framebridge/fbterm"), "FRAMEBRIDGE_FBTERM_ENGINE": str(PREFIX / LAYOUT["fbterm_bin"] / "fbterm_engine.py"), "FRAMEBRIDGE_FBTERM_SESSION": "framebridge-original-fbterm", "FRAMEBRIDGE_FBTERM_TITLE": "FrameBridge Original", "FRAMEBRIDGE_EXEC_SESSION": "framebridge-original", "FRAMEBRIDGE_RESCUE_SESSION": "framebridge-original-rescue", "FRAMEBRIDGE_UI_STATE_DIR": str(home / ".local/share/framebridge/ui-state"), "FRAMEBRIDGE_PROGRESS_DIR": str(home / ".local/state/framebridge/progress"), "FRAMEBRIDGE_PROGRESS_LOG": str(home / ".local/state/framebridge/progress/framebridge-progress.log"), "FRAMEBRIDGE_TOOL_ROOT": str(runtime / "adapter"), "FRAMEBRIDGE_NATIVE_HOST": str(PREFIX / LAYOUT["native_host"]), "FRAMEBRIDGE_FRONT_DOOR_HOST": "127.0.0.1", "FRAMEBRIDGE_FRONT_DOOR_PORT": str(args.port), "FRAMEBRIDGE_FLEET_HUB_HOST": "127.0.0.1", "FRAMEBRIDGE_FLEET_HUB_PORT": str(args.hub_port), "FRAMEBRIDGE_HUB_MCP_URL": "http://127.0.0.1:" + str(args.hub_port) + "/mcp", "FRAMEBRIDGE_BROKER_CONTROL": "http://127.0.0.1:" + str(args.broker_port), "FRAMEBRIDGE_ASUS_NODE_ID": "", "FRAMEBRIDGE_GERAMEG_NODE_ID": "", "FRAMEBRIDGE_CLOUD_DSM_SOCKET": str(runtime / "cloud-unconfigured.sock"), "FRAMEBRIDGE_NODE_REGISTRY": str(home / ".config/framebridge/nodes.json"), "FRAMEBRIDGE_ACCOUNT_BINDING": str(home / ".config/framebridge/account-binding.json"), "FRAMEBRIDGE_PAIRED_DISCOVERY": str(home / ".config/framebridge/paired-nodes.json"), "FRAMEBRIDGE_ENABLE_GERAMEG_CUA_TTY": "0", "PYTHONPATH": os.pathsep.join(str(PREFIX / p) for p in [ "original/geranas/FrameBridge-Fleet-Hub/vendor", "original/geranas/FrameBridge-Fleet-Hub/app", "portable"]), }) env.pop("TMUX", None) env.pop("TMUX_PANE", None) if authority: env["XAUTHORITY"] = authority return state, home, env def prepare(args): state, home, env = settings(args) runtime = Path(env["XDG_RUNTIME_DIR"]) try: runtime.mkdir(mode=0o700) except FileExistsError: pass st = runtime.lstat() if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) != 0o700: raise RuntimeError("Runtime directory must be private and owned by this user: " + str(runtime)) for p in [runtime, runtime / "tmp", runtime / "tmux", runtime / "adapter", state, home, state / "run", state / "tmp", state / "tmux", state / "logs", home / ".config/framebridge", home / ".local/share/framebridge/fbterm", home / ".local/share/framebridge/tool-adapter", home / ".local/lib/framebridge-native", home / "Descargas/framebridge_v0_1"]: p.mkdir(parents=True, exist_ok=True) p.chmod(0o700) links = { home / ".local/share/framebridge/fbterm/bin": PREFIX / LAYOUT["fbterm_bin"], home / ".local/share/framebridge/tool-adapter/bin": PREFIX / LAYOUT["adapter_bin"], home / ".local/lib/framebridge-native/host": PREFIX / LAYOUT["native_host_dir"], home / "Descargas/framebridge_v0_1/host": PREFIX / LAYOUT["native_host_dir"], } for dst, src in links.items(): if dst.is_symlink(): if dst.resolve() != src.resolve(): raise RuntimeError("Unexpected link: " + str(dst)) elif dst.exists(): raise RuntimeError("Runtime program path already exists: " + str(dst)) else: dst.symlink_to(src, target_is_directory=True) registry = home / ".config/framebridge/nodes.json" if not registry.exists(): registry.write_text(json.dumps({"nodes": {}}, indent=2) + "\n") registry.chmod(0o600) return state, home, env def birth(pid): try: fields = Path("/proc/" + str(pid) + "/stat").read_text().rsplit(") ", 1)[1].split() if fields[0] in {"Z", "X"}: return None return fields[19] except (OSError, IndexError): return None def live(record): expected = record.get("birth") return expected is not None and birth(record["pid"]) == expected def load_record(state): p = state / "processes.json" if not p.exists(): return {"processes": []} return json.loads(p.read_text()) def stop(args): state, home, env = settings(args) records = load_record(state) for rec in reversed(records.get("processes", [])): if live(rec): os.killpg(rec["pid"], signal.SIGTERM) deadline = time.monotonic() + 5 while any(live(r) for r in records.get("processes", [])) and time.monotonic() < deadline: time.sleep(.1) for rec in records.get("processes", []): if live(rec): os.killpg(rec["pid"], signal.SIGKILL) if Path(env["TMUX_TMPDIR"]).is_dir() and shutil.which("tmux"): subprocess.run(["tmux", "kill-server"], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) (state / "processes.json").unlink(missing_ok=True) print("FrameBridge Original detenido. Estado: " + str(state)) def start(args): missing = [name for name in CORE_COMMANDS if not shutil.which(name)] if missing: raise RuntimeError("Faltan comandos para ejecutar FrameBridge Original: " + ", ".join(missing)) state, home, env = prepare(args) records = load_record(state) if any(live(r) for r in records.get("processes", [])): raise RuntimeError("Este perfil ya está ejecutándose; usa status o stop.") for port in (args.port, args.hub_port): with socket.socket() as probe: probe.bind(("127.0.0.1", port)) commands = [ ("adapter", [sys.executable, str(PREFIX / LAYOUT["adapter"]), "serve"]), ("fleet", [sys.executable, str(PREFIX / "original/geranas/FrameBridge-Fleet-Hub/app/fleet_hub_mcp_server.py")]), ("mcp", [sys.executable, str(PREFIX / "portable/front_door_mcp.py")]), ] record = {"state_dir": str(state), "mcp_url": "http://127.0.0.1:" + str(args.port) + "/mcp", "port": args.port, "hub_port": args.hub_port, "processes": []} try: for role, command in commands: log = (state / "logs" / (role + ".log")).open("ab") proc = subprocess.Popen(command, env=env, cwd=home, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, umask=0o077) log.close() record["processes"].append({"role": role, "pid": proc.pid, "birth": birth(proc.pid)}) (state / "processes.json").write_text(json.dumps(record, indent=2) + "\n") (state / "processes.json").chmod(0o600) deadline = time.monotonic() + 15 while time.monotonic() < deadline: if not all(live(r) for r in record["processes"]): raise RuntimeError("Falló un proceso. Consulta los logs del perfil: " + str(state / "logs")) ready = (Path(env["FRAMEBRIDGE_TOOL_ROOT"]) / "adapter.sock").is_socket() for port in (args.port, args.hub_port): try: with socket.create_connection(("127.0.0.1", port), timeout=.2): pass except OSError: ready = False if ready: print("FrameBridge Original iniciado: " + record["mcp_url"]) print("Conexión a ChatGPT: manual; usa tunnel --help y las instrucciones del paquete.") print("Perfil local: " + str(state)) return time.sleep(.15) raise RuntimeError("No se completó el arranque; consulta los logs del perfil.") except BaseException: stop(args) raise def status(args): state, home, env = settings(args) record = load_record(state) print(json.dumps({"state_dir": str(state), "mcp_url": record.get("mcp_url"), "processes": [{"role": x["role"], "pid": x["pid"], "running": live(x)} for x in record.get("processes", [])]}, indent=2)) def doctor(args): state, home, env = settings(args) required = CORE_COMMANDS gui = ["xdotool", "xwininfo", "xclip", "import", "convert", "identify", "xterm"] imports = subprocess.run([sys.executable, "-c", "import mcp,pydantic_core,cryptography"], env=env, capture_output=True) report = {"python": sys.version.split()[0], "python_abi_expected": "3.13", "mcp_imports_ok": imports.returncode == 0, "core_commands": {x: bool(shutil.which(x)) for x in required}, "cua_commands": {x: bool(shutil.which(x)) for x in gui}, "display": os.environ.get("DISPLAY", ""), "tunnel_sha256": hashlib.sha256((PREFIX / "original/geranas/FrameBridge-MCP-Private/bin/tunnel-client").read_bytes()).hexdigest()} print(json.dumps(report, indent=2)) return 0 if sys.version_info[:2] == (3, 13) and imports.returncode == 0 and all(report["core_commands"].values()) else 1 def main(): parser = argparse.ArgumentParser(description="FrameBridge Original: ejecución local, CUA y conexión manual a ChatGPT.") parser.add_argument("--state-dir", default=str(DEFAULT_STATE)) parser.add_argument("--port", type=int, default=19865) parser.add_argument("--hub-port", type=int, default=19877) parser.add_argument("--broker-port", type=int, default=19878) sub = parser.add_subparsers(dest="command", required=True) for name in ["start", "stop", "status", "doctor"]: sub.add_parser(name) tunnel = sub.add_parser("tunnel", help="Ejecuta el cliente de túnel original; configuración manual independiente.") tunnel.add_argument("arguments", nargs=argparse.REMAINDER) args = parser.parse_args() if args.command == "tunnel": state, home, env = prepare(args) rest = args.arguments if rest[:1] == ["--"]: rest = rest[1:] explicit_refs = {x[4:] for x in rest if x.startswith("env:")} for x in rest: if "=env:" in x: explicit_refs.add(x.split("=env:", 1)[1]) for key in list(env): if (key.startswith(("TUNNEL_CLIENT_", "CONTROL_PLANE_", "OPENAI_", "MCP_", "HEALTH_", "LOG_", "CLOUDFLARED_", "HARPOON_")) or key in {"TUNNEL_ID", "TUNNEL_BIN", "CRED_FILE", "OPEN_WEB_UI", "PID_FILE", "ALLOW_REMOTE_UI", "ADMIN_UI_LOG_BUFFER_EVENTS", "PROXY_CHECK_INTERVAL"}): if key not in explicit_refs: env.pop(key, None) os.execve(str(PREFIX / "original/geranas/FrameBridge-MCP-Private/bin/tunnel-client"), ["tunnel-client"] + rest, env) result = globals()[args.command](args) return result or 0 if __name__ == "__main__": try: sys.exit(main()) except (OSError, ValueError, RuntimeError) as exc: print(str(exc), file=sys.stderr) sys.exit(1)