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 @@
__version__ = "0.3.1"
@@ -0,0 +1,218 @@
"""Real terminals for the existing FrameBridge CUA-TTY Manager.
Transport identity and same-UID socket policy are unchanged. No Owner prompts.
Only this module's tmux sessions are operated on; no existing tty is hijacked.
"""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import os
from pathlib import Path
import re
import subprocess
import time
import uuid
import pwd
import shlex
from .tty_sudo_control import SudoConsoleController, SudoControlError
from framebridge_expanded._captured_cua_tty.core import TerminalDenied
TMUX_SOCKET = None # Runtime socket is supplied by the portable manager.
KEY_NAMES = {'UP':'Up','DOWN':'Down','LEFT':'Left','RIGHT':'Right',
'ENTER':'Enter','RETURN':'Enter','TAB':'Tab','SHIFT_TAB':'BTab','ESC':'Escape',
'BACKSPACE':'BSpace','DELETE':'DC','INSERT':'IC','HOME':'Home','END':'End',
'PAGE_UP':'PPage','PAGE_DOWN':'NPage','SPACE':'Space'}
KEY_NAMES.update({'CTRL_'+c:'C-'+c.lower() for c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'})
KEY_NAMES.update({'F'+str(i):'F'+str(i) for i in range(1,13)})
@dataclass
class RealSession:
session_id: str
name: str
pane: str
born: float
program: str = 'bash'
sequence: int = 0
class RealTerminalMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._real = {}
self._sudo = SudoConsoleController(
Path(self.tmux_socket).parent / 'sudo-consoles', home=self.home,
timeout=self.limits.command_timeout_s,
min_rows=self.limits.min_rows, max_rows=self.limits.max_rows,
min_cols=self.limits.min_cols, max_cols=self.limits.max_cols)
def _tmux(self, *args, data=None, optional=False, session=None):
env={k:v for k,v in os.environ.items() if k not in ('TMUX','TMUX_PANE')}
env.update({'HOME':str(self.home),'USER':pwd.getpwuid(os.getuid()).pw_name,'LOGNAME':pwd.getpwuid(os.getuid()).pw_name,
'LANG':'C.UTF-8','TERM':'xterm-256color','HISTFILE':'/dev/null'})
tmux_socket = getattr(session, '_sudo_record', {}).get('tmux_socket', str(self.tmux_socket))
try:
p=subprocess.run([self.tmux_bin,'-S',tmux_socket,'-f','/dev/null',*args],
input=data,stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=env,timeout=self.limits.command_timeout_s)
except subprocess.TimeoutExpired:
raise TerminalDenied('TMUX_COMMAND_TIMEOUT') from None
except OSError:
raise TerminalDenied('TMUX_NOT_AVAILABLE') from None
if p.returncode and not optional:
raise TerminalDenied('TMUX_COMMAND_FAILED')
return p
def _info(self, session):
keys=['pane_width','pane_height','cursor_x','cursor_y','pane_dead',
'pane_dead_status','pane_pid']
fmt='|'.join('#{'+key+'}' for key in keys)
p=self._tmux('display-message','-p','-t',session.pane,fmt,session=session)
parts=p.stdout.decode('utf-8','replace').rstrip('\n').split('|')
if len(parts)!=len(keys):raise TerminalDenied('TMUX_METADATA_INVALID')
info=dict(zip(keys,parts))
for key in ['pane_current_path','pane_current_command','pane_tty']:
p=self._tmux('display-message','-p','-t',session.pane,'#{'+key+'}',session=session)
info[key]=p.stdout.decode('utf-8','replace').rstrip('\n')
return info
def open(self, program='demo-readonly', rows=24, cols=80):
if program!='bash':
with self._lock:
self.reap()
if len(self._sessions)+len(self._real)>=self.max_sessions:
raise TerminalDenied('SESSION_LIMIT')
return super().open(program,rows,cols)
self._size(rows, cols)
with self._lock:
self.reap()
if len(self._sessions)+len(self._real)>=self.max_sessions:
raise TerminalDenied('SESSION_LIMIT')
sid=uuid.uuid4().hex
record=None
try:
record=self._sudo.launch(sid,cwd=str(self.home),rows=rows,cols=cols,mode='off')
s=RealSession(sid,record['name'],record['pane'],time.monotonic())
s._sudo_record=record
self._real[sid]=s
self._tmux('set-option','-p','-t',s.pane,'remain-on-exit','on',session=s)
self._tmux('set-option','-t',s.name,'status','off',session=s)
self._tmux('set-option','-t',s.name,'history-limit','10000',session=s)
info=self._info(s)
return {'session_id':sid,'program':'bash','state':'OPEN','backend':'tmux',
'real_terminal':True,'tty':info['pane_tty'],'pid':int(info['pane_pid']),
'cwd':info['pane_current_path'],'persistent_between_chat_calls':True,
'sudo_mode':'off','no_new_privs':record['no_new_privs'],
'attach_command':'tmux -S '+shlex.quote(record['tmux_socket'])+' attach-session -t '+shlex.quote(s.name)}
except BaseException:
self._real.pop(sid,None)
if record is not None:
self._sudo.close(record)
raise
def sudo(self, sid, mode):
if mode not in ('on','off','status'):
raise TerminalDenied('TTY_SUDO_MODE_INVALID')
with self._lock:
if sid not in self._real:
raise TerminalDenied('TTY_SUDO_SESSION_NOT_FOUND')
s=self._real[sid]
if mode=='status':
return self._sudo.status(s)
value=self._sudo.set_mode(s,mode)
if value.get('recreated'):
s.name=value['name'];s.pane=value['pane']
s._sudo_record=value
s.sequence+=1
return value
def status(self):
with self._lock:
value=super().status()
sessions=[]
for sid,s in list(self._real.items()):
try:
info=self._info(s)
except TerminalDenied as exc:
# A failed observation is not proof that the console closed.
sessions.append({'session_id':sid,'program':'bash','running':None,
'tty':None,'cwd':None,'command':None,'sudo_mode':'unknown',
'query_error':str(exc)})
continue
try:
sudo_state=self._sudo.status(s)
except SudoControlError as exc:
sudo_state={'mode':'exited' if info['pane_dead']=='1' else 'unknown','error':exc.code}
sessions.append({'session_id':sid,'program':'bash',
'running':info['pane_dead']!='1','tty':info['pane_tty'],
'cwd':info['pane_current_path'],'command':info['pane_current_command'],
'sudo_mode':sudo_state['mode']})
value.update({'active_sessions':value['active_sessions']+len(sessions),
'real_sessions':sessions,'real_terminal_enabled':True,
'real_programs':['bash'],'input_policy':'same-uid-authorized',
'owner_device_connected':False,'owner_signature_required':False,
'input_enabled':'same-uid-authorized','real_idle_timeout_s':None})
return value
def observe(self, sid):
with self._lock:
if sid not in self._real:return super().observe(sid)
s=self._real[sid];info=self._info(s)
text=self._tmux('capture-pane','-p','-t',s.pane,session=s).stdout.decode('utf-8','replace')
lines=text.splitlines()
# The original RPC response has a finite frame size, not an approval gate.
limited=False
import json
while len(json.dumps(lines,ensure_ascii=True).encode())>self.limits.max_screen_bytes:
lines=lines[:-1];limited=True
return {'session_id':sid,'program':'bash','real_terminal':True,'backend':'tmux',
'running':info['pane_dead']!='1','exit_code':int(info['pane_dead_status']) if info['pane_dead_status'] else None,
'cwd':info['pane_current_path'],'current_command':info['pane_current_command'],
'tty':info['pane_tty'],'pid':int(info['pane_pid']),'sequence':s.sequence,
'screen':{'rows':int(info['pane_height']),'cols':int(info['pane_width']),
'cursor':{'row':int(info['cursor_y']),'col':int(info['cursor_x'])},'lines':lines},
'screen_truncated':limited,'screen_sha256':hashlib.sha256(text.encode()).hexdigest()}
def text(self, sid, text, approved_context=None):
with self._lock:
if sid not in self._real:return super().text(sid,text,approved_context=approved_context)
if not isinstance(text,str) or not text or '\x00' in text or len(text.encode())>self.limits.max_text_bytes:
raise TerminalDenied('TEXT_INVALID_OR_TOO_LARGE')
s=self._real[sid];payload=text.encode('utf-8')
self._authorize(s,'text.type',payload,approved_context)
if self._info(s)['pane_dead']=='1':raise TerminalDenied('SESSION_EXITED')
name='fb-input-'+sid
self._tmux('load-buffer','-b',name,'-',data=payload,session=s)
try:self._tmux('paste-buffer','-d','-r','-b',name,'-t',s.pane,session=s)
finally:self._tmux('delete-buffer','-b',name,optional=True,session=s)
s.sequence+=1
return {'session_id':sid,'state':'TYPED','submitted_chars':len(text),'sequence':s.sequence}
def key(self, sid, key, approved_context=None):
with self._lock:
if sid not in self._real:return super().key(sid,key,approved_context=approved_context)
if not isinstance(key,str) or key not in KEY_NAMES:raise TerminalDenied('KEY_NOT_ALLOWLISTED')
s=self._real[sid]
self._authorize(s,'key.send',key.encode(),approved_context)
if self._info(s)['pane_dead']=='1':raise TerminalDenied('SESSION_EXITED')
self._tmux('send-keys','-t',s.pane,KEY_NAMES[key],session=s);s.sequence+=1
return {'session_id':sid,'state':'KEY_SENT','submitted':key,'sequence':s.sequence}
def resize(self, sid, rows, cols):
with self._lock:
if sid not in self._real:return super().resize(sid,rows,cols)
self._size(rows, cols)
self._tmux('resize-window','-t',self._real[sid].pane,'-x',str(cols),'-y',str(rows),session=self._real[sid])
self._real[sid].sequence+=1
return {'session_id':sid,'rows':rows,'cols':cols}
def abort(self, sid):
with self._lock:
if sid not in self._real:return super().abort(sid)
s=self._real[sid]
self._sudo.close(s)
self._real.pop(sid,None)
return {'session_id':sid,'state':'CLOSED','cleanup_verified':True}
def shutdown(self):
with self._lock:
for sid in list(self._real):
try:self.abort(sid)
except (TerminalDenied,SudoControlError):pass
super().shutdown()
@@ -0,0 +1,117 @@
"""Original same-UID adapter with context checks at CUA dispatch."""
from __future__ import annotations
import importlib.util
import base64
import hashlib
import os
from pathlib import Path
import sys
import threading
from . import display_context as display
from .native_host import META
def _blocked(error, may_have_sent=False):
value = {"ok": False, "error": str(error), "mode": "CUA",
"input_may_have_been_sent": bool(may_have_sent),
"do_not_replay": bool(may_have_sent), "result_discarded": True}
if not may_have_sent:
value["performed"] = False
return value
def _successful_observation(native):
if not isinstance(native, dict) or native.get("executed") is not True:
return False
output = native.get("output")
if not isinstance(output, str) or len(output) > 2_000_000:
return False
fields = dict(line.split("=", 1) for line in output.splitlines() if "=" in line)
if (fields.get("RESULTADO_FINAL") != "CUA_VISUAL_OBSERVE_PASS" or fields.get("VISUAL_CAPTURE_GATE") != "PASS" or fields.get("MIME") != "image/jpeg"):
return False
try:
data = base64.b64decode(fields.get("BASE64", ""), validate=True)
return (data.startswith(b"\xff\xd8") and data.endswith(b"\xff\xd9")
and len(data) == int(fields.get("BYTES", "0"))
and hashlib.sha256(data).hexdigest() == fields.get("SHA256"))
except (ValueError, TypeError):
return False
def install_adapter_guard(adapter, *, env=None, checker=None):
env = os.environ if env is None else env
checker = display.check if checker is None else checker
original_handle = adapter.handle_cua_request
original_roundtrip = adapter.native_cua_roundtrip
local = threading.local()
request_lock = threading.RLock()
def native_roundtrip(payload, timeout_s=60):
response = original_roundtrip(payload, timeout_s=timeout_s)
local.native = response
return response
def guarded_handle(request, job_id):
local.native = None
try:
display.prepare(env)
context = checker(env)
except display.DisplayContextError as exc:
return _blocked(exc)
action = str(request.get("cua", {}).get("action", "OBSERVE")).upper().strip()
if isinstance(context, dict) and context.get("needs_observation") and action != "OBSERVE":
result = _blocked("CUA_DISPLAY_CHANGED_OBSERVE_REQUIRED")
result["display_context"] = display.describe(env)
return result
try:
result = original_handle(request, job_id)
except Exception as exc:
# The native child may have acted before a timeout or invalid reply.
# Never invite automatic replay of an uncertain input action.
is_input = str(request.get("cua", {}).get("action", "")).upper() != "OBSERVE"
code = str(exc)
if not code or len(code) > 100 or any(c not in "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_:" for c in code):
code = "CUA_NATIVE_EXECUTION_UNCERTAIN"
return _blocked(code, is_input)
native = local.native or {}
result = dict(result)
for key in META:
if key in native:
result[key] = native[key]
if "display_context" not in result:
result["display_context"] = display.describe(env)
try:
checker(env)
except display.DisplayContextError as exc:
is_input = str(request.get("cua", {}).get("action", "")).upper() != "OBSERVE"
return _blocked(exc, native.get("input_may_have_been_sent", is_input))
if action == "OBSERVE" and result.get("ok") is True and _successful_observation(native):
try:
display.observed(env)
except display.DisplayContextError as exc:
return _blocked(exc)
result["display_context"] = display.describe(env)
return result
def handle(request, job_id):
with request_lock:
return guarded_handle(request, job_id)
adapter.native_cua_roundtrip = native_roundtrip
adapter.handle_cua_request = handle
def main(argv=None):
if list(sys.argv[1:] if argv is None else argv) != ["serve"]:
raise SystemExit("Usage: python3 -m framebridge_expanded.adapter serve")
prefix = Path(os.environ.get("FRAMEBRIDGE_EXPANDED_PREFIX", Path(__file__).resolve().parents[2]))
path = prefix / "original/asus/tool-adapter/bin/framebridge_tool_adapter.py"
if not path.is_file():
path = prefix / "sources/original-asus/tool-adapter/bin/framebridge_tool_adapter.py"
spec = importlib.util.spec_from_file_location("_framebridge_original_adapter", path)
original = importlib.util.module_from_spec(spec)
spec.loader.exec_module(original)
install_adapter_guard(original)
from .real_screen.screen_transport import install_adapter_route
install_adapter_route(original)
original.serve()
if __name__ == "__main__":
main()
@@ -0,0 +1,884 @@
"""Explicit user-service startup with host-bound encrypted systemd credentials."""
from __future__ import annotations
import argparse
import fcntl
import getpass
import hashlib
import io
import json
import os
from pathlib import Path
import pwd
import re
import resource
import secrets
import signal
import stat
import subprocess
import sys
import time
from .config import load_config, save_config, private_dir, state_path, default_config, validate
CREDENTIAL = "chatgpt-runtime-key"
_STOP = False
def _paths(state):
state = state_path(state)
directory = private_dir(private_dir(state) / "autostart")
name = "framebridge-autostart-" + hashlib.sha256(str(state).encode()).hexdigest()[:16] + ".service"
return state, directory, Path.home() / ".config/systemd/user" / name
def _read(path, maximum=131072):
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_mode & 0o077:
raise RuntimeError("Unsafe autostart file ownership or permissions.")
data = os.read(fd, maximum + 1)
if len(data) > maximum:
raise RuntimeError("Autostart file exceeds the size limit.")
return data
finally:
os.close(fd)
def _write(path, data, replace=False):
path = Path(path)
if path.exists() or path.is_symlink():
if not replace:
raise RuntimeError("Refusing to replace an existing autostart file.")
_read(path)
temporary = path.with_name("." + path.name + "." + secrets.token_hex(8))
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
if replace:
os.replace(temporary, path)
else:
os.link(temporary, path, follow_symlinks=False)
finally:
temporary.unlink(missing_ok=True)
def _json(path, value, replace=False):
_write(path, (json.dumps(value, sort_keys=True, indent=2) + "\n").encode(), replace)
def _command(args, *, data=None, timeout=35, check=True):
result = subprocess.run(args, input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
if check and result.returncode:
raise RuntimeError("Autostart command failed: " + Path(args[0]).name + ". No command output was exported.")
return result
def _systemctl(*args, check=True):
return _command(["systemctl", "--user", *args], check=check)
def _quote(value, *, command=False):
value = str(value)
if any(ord(ch) < 32 or ord(ch) == 127 for ch in value):
raise ValueError("Control characters are not allowed in service paths.")
if command:
value = value.replace("$", "$$")
return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%") + '"'
def unit_text(state, encrypted, *, node=False):
module_path = Path(__file__).resolve().parents[1]
executable = [sys.executable, "-B", "-m", "framebridge_expanded.autostart", "_run", "--state", str(state)]
lines = ["# FrameBridge managed autostart v1", "[Unit]", "Description=FrameBridge profile and ChatGPT connection", "StartLimitIntervalSec=0", "ConditionPathExists=" + _quote(module_path / "framebridge_expanded/autostart.py"), "", "[Service]", "Type=simple", "UMask=0077", "LimitCORE=0", "Environment=" + _quote("PYTHONPATH=" + str(module_path)), "Environment=LANG=C.UTF-8", "Environment=LC_ALL=C.UTF-8"]
lines += ["ExecStart=" + " ".join(_quote(x, command=True) for x in executable), "Restart=on-failure", "RestartSec=15", "TimeoutStopSec=25", "KillMode=control-group", "StandardInput=null", "StandardOutput=null", "StandardError=null", "", "[Install]", "WantedBy=default.target", ""]
return "\n".join(lines)
def _linger(user):
directory = Path("/var/lib/systemd/linger")
try:
info = directory.stat()
except FileNotFoundError:
return False
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or directory.is_symlink():
raise RuntimeError("The systemd linger directory is unavailable or unsafe.")
try:
marker = (directory / user).lstat()
except FileNotFoundError:
return False
if not stat.S_ISREG(marker.st_mode) or marker.st_uid != 0:
raise RuntimeError("The systemd linger record is unsafe.")
return True
def _enabled_units():
result = _systemctl("list-unit-files", "--state=enabled", "--no-legend", "--no-pager")
return sorted(line.split()[0] for line in result.stdout.decode(errors="replace").splitlines() if line.split())
def _check_unit_directory(path):
for directory in [Path.home() / ".config", Path.home() / ".config/systemd", path.parent]:
if directory.is_symlink():
raise RuntimeError("User service directories must not be symbolic links.")
directory.mkdir(mode=0o700, exist_ok=True)
info = directory.stat()
if info.st_uid != os.getuid() or info.st_mode & 0o022:
raise RuntimeError("User service directories must be owned by this user and not writable by others.")
def _manifest(directory):
path = directory / "installation.json"
try:
value = json.loads(_read(path))
except FileNotFoundError:
return None
if not isinstance(value, dict) or value.get("schema") != 1:
raise RuntimeError("Unsupported autostart installation record.")
return value
def status(state):
state, directory, unit = _paths(state)
manifest = _manifest(directory)
properties = _systemctl("show", unit.name, "--property=ActiveState,SubState,UnitFileState", check=False)
values = dict(line.split("=", 1) for line in properties.stdout.decode(errors="replace").splitlines() if "=" in line)
from . import runtime, connection_background
result = {"configured": bool(manifest), "unit": unit.name, "service_active": values.get("ActiveState") == "active", "service_state": values.get("ActiveState", "unavailable"), "enabled": values.get("UnitFileState") == "enabled", "linger": _linger(pwd.getpwuid(os.getuid()).pw_name), "encrypted_credential_present": False, "runtime": _safe_runtime_status(runtime, state), "connection": connection_background.status(state)}
if manifest and not manifest.get("node"):
try:
cipher = _read(directory / "credential.cred")
result["encrypted_credential_present"] = hashlib.sha256(cipher).hexdigest() == manifest.get("credential_sha256")
except FileNotFoundError:
pass
try:
result["worker"] = json.loads(_read(directory / "worker-status.json"))
except FileNotFoundError:
result["worker"] = {"state": "not_started"}
result["boot_desired_enabled"] = boot_requested(state)
result["desired_connection"] = "not_configured" if not manifest else ("not_applicable" if manifest.get("node") else ("connected" if connection_requested(state) else "disconnected"))
return result
def _safe_runtime_status(runtime, state):
try:
return runtime.status(state)
except Exception:
return {"running": False, "ready": False, "status_unavailable": True}
def _configuration_action(state, action):
_, directory, _ = _paths(state)
fd = os.open(directory / "configuration.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_mode & 0o077:
raise RuntimeError("Unsafe autostart configuration lock.")
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError("Another autostart configuration is already in progress.") from None
return action(state)
finally:
os.close(fd)
def enable(state):
try:
return _configuration_action(state, _enable)
except (EOFError, KeyboardInterrupt):
print(flush=True)
return {"configured": False, "state": "cancelled"}
def disable(state):
return _configuration_action(state, _disable)
def _committed_connection_status(state):
try:
return status(state)
except (EOFError, KeyboardInterrupt):
# Enabling the service already committed. Cancelling only this status
# read must not suggest that the installed connection was cancelled.
return {"configured": True, "enabled": True, "interrupted_after_commit": True}
def configure_connection(state, public_settings, runtime_key, *, output=None):
"""Install this connection once, using only the supplied hidden in-memory key."""
if not isinstance(runtime_key, str) or not runtime_key or not runtime_key.isascii() or len(runtime_key) > 16384 or any(ord(ch) < 33 or ord(ch) > 126 for ch in runtime_key):
raise ValueError("Enter one nonempty MCP server access key.")
allowed = {"account_label", "tunnel_id", "control_plane_organization_id", "control_plane_url_path"}
if not isinstance(public_settings, dict) or set(public_settings) - allowed:
raise ValueError("Unsupported public connection settings.")
from .tunnel import TunnelSpec
settings = dict(public_settings)
spec = TunnelSpec(settings.get("tunnel_id", ""), settings.get("control_plane_organization_id", ""), settings.get("control_plane_url_path", ""))
label = settings.get("account_label", "")
if not isinstance(label, str) or len(label) > 320 or any(ord(ch) < 32 or ord(ch) == 127 for ch in label):
raise ValueError("Enter a valid account label.")
settings.update(spec.public_settings())
settings["account_label"] = label
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
try:
return _configuration_action(state, lambda selected: _configure_connection(selected, settings, runtime_key))
finally:
runtime_key = ""
def _connection_public_digest(config):
fields = {name: config.get(name, "") for name in ("account_label", "tunnel_id", "control_plane_organization_id", "control_plane_url_path", "role", "node_id")}
fields["mcp_port"] = config["ports"]["mcp"]
return hashlib.sha256(json.dumps(fields, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def resume_connection(state):
"""Resume the verified saved connection without asking for its key again."""
return _configuration_action(state, _resume_connection)
def _resume_connection(state):
if os.geteuid() == 0:
raise RuntimeError("Run connect as the profile owner, not as root.")
state, directory, unit = _paths(state)
config = load_config(state)
record = _manifest(directory)
error = "The saved connection cannot be verified. Run fb connect chatgpt --replace."
if not record or record.get("node") or config["role"] == "node" or record.get("unit") != unit.name:
raise RuntimeError(error)
try:
valid_unit = hashlib.sha256(_read(unit)).hexdigest() == record.get("unit_sha256")
valid_cipher = hashlib.sha256(_read(directory / "credential.cred")).hexdigest() == record.get("credential_sha256")
bound = record.get("connection_settings_sha256")
if bound:
valid_settings = _connection_public_digest(config) == bound
else:
# Older releases only bound the complete profile when converting its desktop setting.
old_bound = record.get("changed_config_sha256")
valid_settings = bool(old_bound) and hashlib.sha256(_read(state / "config.json")).hexdigest() == old_bound
if record.get("headless_changed"):
valid_backup = hashlib.sha256(_read(directory / "config-before.json")).hexdigest() == record.get("config_before_sha256")
else:
valid_backup = True
except (OSError, ValueError):
raise RuntimeError(error) from None
if not (valid_unit and valid_cipher and valid_settings and valid_backup):
raise RuntimeError(error)
from .tunnel import TunnelSpec
TunnelSpec(config["tunnel_id"], config.get("control_plane_organization_id", ""), config.get("control_plane_url_path", ""))
user = pwd.getpwuid(os.getuid()).pw_name
if not _linger(user):
_command(["sudo", "-v"], timeout=180)
_command(["sudo", "-n", "--", "loginctl", "enable-linger", user])
was_manually_stopped = manual_stop_requested(state)
_connection_action(state, lambda _: _set_connection_requested(state, True))
_clear_manual_stop(state)
current = status(state)
local = current.get("runtime", {})
# A live managed worker owns local tools independently of its transport.
# Change its requested connection state instead of restarting those tools.
if current.get("service_active") and local.get("running") and local.get("ready") and current.get("worker", {}).get("state") not in {"failed", "stopped"} and not was_manually_stopped:
if not current.get("enabled") and boot_requested(state) is not False:
_systemctl("enable", unit.name)
return _committed_connection_status(state)
_systemctl("stop", unit.name)
from . import connection_background
stopped = connection_background.disconnect(state)
if stopped.get("ok") is False or stopped.get("alive") or connection_background.status(state).get("alive"):
raise RuntimeError("The previous connection is still stopping. Try connecting again shortly.")
_systemctl("daemon-reload")
if boot_requested(state) is False:
_systemctl("start", unit.name)
else:
_systemctl("enable", "--now", unit.name)
return _committed_connection_status(state)
def _optional_read(path):
try:
return _read(path)
except FileNotFoundError:
return None
def _configure_connection(state, settings, runtime_key):
if os.geteuid() == 0:
raise RuntimeError("Run connect as the profile owner, not as root.")
state, directory, unit = _paths(state)
config_path = state / "config.json"
original_config = _optional_read(config_path)
config = load_config(state) if original_config is not None else default_config()
if config["role"] == "node":
raise RuntimeError("Connect ChatGPT from the central profile.")
config.update(settings)
headless_before = config.get("headless", "auto")
changed_headless = headless_before in ("no", False)
if changed_headless:
config["headless"] = "auto"
validate(config)
config_bytes = (json.dumps(config, indent=2, sort_keys=True) + "\n").encode()
record_path = directory / "installation.json"
blob = directory / "credential.cred"
backup = directory / "config-before.json"
manual_stop = directory / "manual-stop.json"
intent = directory / "connection-intent.json"
targets = (config_path, unit, blob, record_path, backup, manual_stop, intent)
originals = {path: _optional_read(path) for path in targets}
previous = _manifest(directory)
if previous:
if previous.get("node") or previous.get("unit") != unit.name or originals[unit] is None or hashlib.sha256(originals[unit]).hexdigest() != previous.get("unit_sha256"):
raise RuntimeError("The existing service changed outside FrameBridge; nothing was replaced.")
if originals[blob] is None or hashlib.sha256(originals[blob]).hexdigest() != previous.get("credential_sha256"):
raise RuntimeError("The encrypted connection changed outside FrameBridge; nothing was replaced.")
if previous.get("headless_changed") and (originals[backup] is None or hashlib.sha256(originals[backup]).hexdigest() != previous.get("config_before_sha256")):
raise RuntimeError("The profile backup changed outside FrameBridge; nothing was replaced.")
elif any(originals[path] is not None for path in (unit, blob, backup)):
raise RuntimeError("Existing unowned connection files; nothing was replaced.")
_check_unit_directory(unit)
version = _command(["systemd-creds", "--version"]).stdout.decode(errors="replace")
match = re.match(r"systemd (\d+)", version)
if not match or int(match.group(1)) < 256:
raise RuntimeError("Protected connections require systemd 256 or later.")
_command(["sudo", "-v"], timeout=180)
user = pwd.getpwuid(os.getuid()).pw_name
linger_before = _linger(user)
units_before = _enabled_units()
properties = _systemctl("show", unit.name, "--property=ActiveState,UnitFileState", check=False)
values = dict(line.split("=", 1) for line in properties.stdout.decode(errors="replace").splitlines() if "=" in line)
was_enabled = values.get("UnitFileState") == "enabled"
was_active = values.get("ActiveState") in {"active", "activating", "reloading"}
try:
encrypted = _command(["sudo", "-n", "--", "systemd-creds", "encrypt", "--uid=" + str(os.getuid()), "--with-key=host", "--name=" + CREDENTIAL, "-", "-"], data=runtime_key.encode()).stdout
finally:
runtime_key = ""
if len(encrypted) < 80 or len(encrypted) > 131072:
raise RuntimeError("Connection protection did not complete.")
# Recheck the exact originals after authentication/encryption and before any stop.
if any(_optional_read(path) != data for path, data in originals.items()):
raise RuntimeError("The profile changed during connection setup; nothing was replaced.")
text = unit_text(state, blob).encode()
record = dict(previous) if previous else {"schema": 1, "unit": unit.name, "node": False, "linger_before": linger_before, "enabled_units_before": units_before, "headless_before": headless_before, "headless_changed": changed_headless, "config_before_sha256": hashlib.sha256(original_config).hexdigest() if original_config is not None else ""}
backup_bytes = originals[backup]
if changed_headless and not record.get("headless_changed"):
record.update(headless_before=headless_before, headless_changed=True, config_before_sha256=hashlib.sha256(original_config).hexdigest())
backup_bytes = original_config
elif not previous and changed_headless:
backup_bytes = original_config
if record.get("headless_changed"):
# Disabling changes only the desktop preference, preserving the newly selected connection.
restored_profile = dict(config)
restored_profile["headless"] = record["headless_before"]
backup_bytes = (json.dumps(restored_profile, indent=2, sort_keys=True) + "\n").encode()
record["config_before_sha256"] = hashlib.sha256(backup_bytes).hexdigest()
record["changed_config_sha256"] = hashlib.sha256(config_bytes).hexdigest()
record.update(unit_sha256=hashlib.sha256(text).hexdigest(), credential_sha256=hashlib.sha256(encrypted).hexdigest(), connection_settings_sha256=_connection_public_digest(config))
from . import connection_background
background_before = connection_background.status(state)
changed = {}
service_touched = False
manual_connection_stopped = False
def write(path, data):
changed[path] = data
_write(path, data, replace=originals.get(path) is not None)
try:
if previous:
service_touched = True
_systemctl("stop", unit.name)
disconnected = connection_background.disconnect(state)
if disconnected.get("ok") is False or disconnected.get("alive"):
raise RuntimeError("The previous connection could not be stopped safely; nothing was replaced.")
manual_connection_stopped = not previous and background_before.get("alive") is True
if connection_background.status(state).get("alive"):
raise RuntimeError("The previous connection is still stopping; nothing was replaced.")
write(blob, encrypted)
write(unit, text)
write(config_path, config_bytes)
if backup_bytes is not None and backup_bytes != originals[backup]:
write(backup, backup_bytes)
write(record_path, (json.dumps(record, sort_keys=True, indent=2) + "\n").encode())
write(intent, (json.dumps({"schema": 1, "connect": True, "revision": connection_request_revision(state) + 1}, sort_keys=True) + "\n").encode())
_command(["sudo", "-n", "--", "loginctl", "enable-linger", user])
_systemctl("daemon-reload")
service_touched = True
if boot_requested(state) is False:
_systemctl("start", unit.name)
else:
_systemctl("enable", "--now", unit.name)
except BaseException as error:
rollback_failed = False
if service_touched or unit in changed:
try:
rollback_failed = bool(_systemctl("disable", "--now", unit.name, check=False).returncode)
except Exception:
rollback_failed = True
for path, installed in reversed(list(changed.items())):
try:
current = _optional_read(path)
if current == originals[path]:
continue
if current != installed:
rollback_failed = True
continue
if originals[path] is None:
path.unlink()
else:
_write(path, originals[path], replace=True)
except Exception:
rollback_failed = True
try:
if originals[manual_stop] is not None and _optional_read(manual_stop) is None:
_write(manual_stop, originals[manual_stop])
_systemctl("daemon-reload")
if previous:
result = _systemctl("enable" if was_enabled else "disable", unit.name, check=False)
rollback_failed = rollback_failed or bool(result.returncode)
if was_active:
restarted = _systemctl("start", unit.name, check=False)
rollback_failed = rollback_failed or bool(restarted.returncode)
if not linger_before and _enabled_units() == units_before:
restored_linger = _command(["sudo", "-n", "--", "loginctl", "disable-linger", user], check=False)
rollback_failed = rollback_failed or bool(restored_linger.returncode)
except Exception:
rollback_failed = True
if rollback_failed:
raise RuntimeError("Connection setup did not complete, and restoration could not be fully verified. Inspect fb autostart status before retrying.") from None
if manual_connection_stopped:
raise RuntimeError("Connection setup did not complete. The previous manually started connection was stopped; reconnect it using its original key.") from None
raise
return _committed_connection_status(state)
def _enable(state, *, start_now=True, confirm=True):
if os.geteuid() == 0:
raise RuntimeError("Run autostart enable as the profile owner, not as root.")
state, directory, unit = _paths(state)
config = load_config(state)
if not confirm and config["role"] != "node":
raise RuntimeError("Only node startup can be initialized without the connection wizard.")
original_config = _read(state / "config.json")
existing = _manifest(directory)
if existing:
if existing.get("unit") != unit.name or hashlib.sha256(_read(unit)).hexdigest() != existing.get("unit_sha256"):
raise RuntimeError("The service changed outside FrameBridge; refusing to enable it.")
if not existing.get("node") and hashlib.sha256(_read(directory / "credential.cred")).hexdigest() != existing.get("credential_sha256"):
raise RuntimeError("The encrypted credential changed outside FrameBridge; refusing to enable it.")
user = pwd.getpwuid(os.getuid()).pw_name
if not _linger(user):
_command(["sudo", "-v"], timeout=180)
_command(["sudo", "-n", "--", "loginctl", "enable-linger", user])
_set_boot_requested(state, True)
_systemctl("daemon-reload")
if start_now:
_systemctl("enable", "--now", unit.name)
else:
_systemctl("enable", unit.name)
return status(state)
version = _command(["systemd-creds", "--version"]).stdout.decode(errors="replace")
match = re.match(r"systemd (\d+)", version)
if not match or int(match.group(1)) < 256:
raise RuntimeError("Encrypted user credentials require systemd 256 or later.")
node = config["role"] == "node"
if not node and not config.get("tunnel_id"):
raise RuntimeError("Save a tunnel with fb connect chatgpt before enabling automatic startup.")
print("FrameBridge will start automatically when this computer starts.")
print("Available tools are detected automatically.")
if not node:
print("Your connection key will be encrypted on this computer.")
original_headless = config.get("headless", "auto")
changed_headless = original_headless in ("no", False)
if confirm and input("Enable automatic startup? [y/N]: ").strip().lower() not in {"y", "yes"}:
return {"configured": False, "state": "cancelled"}
print("[1/3] Preparing automatic startup...", flush=True)
_check_unit_directory(unit)
if unit.exists() or unit.is_symlink() or (directory / "credential.cred").exists() or (directory / "credential.cred").is_symlink():
raise RuntimeError("Existing unowned service or encrypted credential; nothing was replaced.")
_command(["sudo", "-v"], timeout=180)
user = pwd.getpwuid(os.getuid()).pw_name
linger_before = _linger(user)
units_before = _enabled_units()
encrypted = b""
print("[2/3] Preparing this node..." if node else "[2/3] Protecting the connection key...", flush=True)
if not node:
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
runtime_key = getpass.getpass("Connection key (hidden): ")
if not runtime_key or not runtime_key.isascii() or len(runtime_key) > 16384 or any(ord(ch) < 33 or ord(ch) > 126 for ch in runtime_key):
raise ValueError("The runtime key must be nonempty and contain no whitespace or control characters.")
try:
encrypted = _command(["sudo", "-n", "--", "systemd-creds", "encrypt", "--uid=" + str(os.getuid()), "--with-key=host", "--name=" + CREDENTIAL, "-", "-"], data=runtime_key.encode()).stdout
finally:
runtime_key = ""
if len(encrypted) < 80 or len(encrypted) > 131072:
raise RuntimeError("Credential encryption returned an invalid result.")
blob = directory / "credential.cred"
text = unit_text(state, blob, node=node).encode()
record = {"schema": 1, "unit": unit.name, "unit_sha256": hashlib.sha256(text).hexdigest(), "credential_sha256": hashlib.sha256(encrypted).hexdigest() if encrypted else "", "node": node, "linger_before": linger_before, "enabled_units_before": units_before, "headless_before": original_headless, "headless_changed": changed_headless, "config_before_sha256": hashlib.sha256(original_config).hexdigest()}
created = []
try:
if encrypted:
_write(blob, encrypted); created.append(blob)
_write(unit, text); created.append(unit)
if changed_headless:
if _read(state / "config.json") != original_config:
raise RuntimeError("The profile changed during configuration; no profile settings were overwritten.")
_write(directory / "config-before.json", original_config)
created.append(directory / "config-before.json")
config["headless"] = "auto"
save_config(state, config)
record["changed_config_sha256"] = hashlib.sha256(_read(state / "config.json")).hexdigest()
_json(directory / "installation.json", record); created.append(directory / "installation.json")
print("[3/3] Enabling automatic startup...", flush=True)
_command(["sudo", "-n", "--", "loginctl", "enable-linger", user])
_systemctl("daemon-reload")
if start_now:
_systemctl("enable", "--now", unit.name)
else:
_systemctl("enable", unit.name)
except BaseException:
if unit in created:
_systemctl("disable", "--now", unit.name, check=False)
for path in reversed(created):
try: path.unlink()
except FileNotFoundError: pass
if changed_headless and record.get("changed_config_sha256") == hashlib.sha256(_read(state / "config.json")).hexdigest():
_write(state / "config.json", original_config, replace=True)
_systemctl("daemon-reload", check=False)
if not linger_before:
try:
if _enabled_units() == units_before:
_command(["sudo", "-n", "--", "loginctl", "disable-linger", user], check=False)
except Exception:
pass
raise
return status(state)
def _disable(state):
state, directory, unit = _paths(state)
record = _manifest(directory)
if not record:
return {"configured": False, "state": "already_disabled"}
if record.get("unit") != unit.name or hashlib.sha256(_read(unit)).hexdigest() != record.get("unit_sha256"):
raise RuntimeError("The service changed outside FrameBridge; refusing to remove it.")
blob = directory / "credential.cred"
if not record.get("node") and hashlib.sha256(_read(blob)).hexdigest() != record.get("credential_sha256"):
raise RuntimeError("The encrypted credential changed outside FrameBridge; refusing to remove it.")
backup = None
if record.get("headless_changed"):
backup = _read(directory / "config-before.json")
if hashlib.sha256(backup).hexdigest() != record.get("config_before_sha256"):
raise RuntimeError("The profile backup changed outside FrameBridge; refusing to remove it.")
owned_policy = [directory / "boot-policy.json", directory / "connection-intent.json"]
for policy in owned_policy:
if policy.exists() or policy.is_symlink():
_read(policy)
restore_linger = not record.get("linger_before") and [name for name in _enabled_units() if name != unit.name] == record.get("enabled_units_before")
if restore_linger:
_command(["sudo", "-v"], timeout=180)
_systemctl("disable", "--now", unit.name)
unit.unlink()
if not record.get("node"):
blob.unlink()
config_restored = False
if record.get("headless_changed") and hashlib.sha256(_read(state / "config.json")).hexdigest() == record.get("changed_config_sha256"):
_write(state / "config.json", backup, replace=True)
config_restored = True
_systemctl("daemon-reload")
linger_restored = False
if restore_linger and _enabled_units() == record.get("enabled_units_before"):
user = pwd.getpwuid(os.getuid()).pw_name
_command(["sudo", "-n", "--", "loginctl", "disable-linger", user])
linger_restored = True
if backup is not None:
(directory / "config-before.json").unlink()
for policy in owned_policy:
policy.unlink(missing_ok=True)
(directory / "installation.json").unlink()
return {"configured": False, "state": "disabled", "encrypted_credential_removed": True, "desktop_setting_restored": config_restored, "linger_restored": linger_restored}
def _credential(state):
# Debian 13 systemd 257 cannot load user encrypted credentials directly.
# Its systemd-creds CLI delegates owner-scoped decryption to the OS broker.
_, directory, _ = _paths(state)
record = _manifest(directory)
if not record or record.get("node"):
raise RuntimeError("The encrypted runtime credential is not configured.")
cipher = _read(directory / "credential.cred")
if hashlib.sha256(cipher).hexdigest() != record.get("credential_sha256"):
raise RuntimeError("The encrypted runtime credential changed outside FrameBridge.")
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
result = _command(["systemd-creds", "decrypt", "--user", "--name=" + CREDENTIAL, "-", "-"], data=cipher, timeout=15)
try:
key = result.stdout.decode("ascii")
except UnicodeDecodeError:
raise RuntimeError("The decrypted runtime credential is invalid.") from None
if not key or len(key) > 16384 or any(ord(ch) < 33 or ord(ch) == 127 for ch in key):
raise RuntimeError("The decrypted runtime credential is invalid.")
return key
def stop_for_current_boot(state):
# systemctl stop also cancels an already queued Restart=on-failure job.
# Keep the unit enabled and the encrypted credential for the next boot.
directory = _intent_directory(state)
if directory is None:
return {"configured": False, "state": "not_configured"}
_, _, unit = _paths(state)
record = _manifest(directory)
if record.get("unit") != unit.name or hashlib.sha256(_read(unit)).hexdigest() != record.get("unit_sha256"):
raise RuntimeError("The service changed outside FrameBridge; refusing to stop it.")
_systemctl("stop", unit.name)
return {"configured": True, "state": "stopped_for_current_boot", "autostart_configuration_preserved": True}
def _intent_directory(state):
directory = state_path(state) / "autostart"
if not directory.exists() and not directory.is_symlink():
return None
private_dir(directory)
return directory if _manifest(directory) else None
def mark_manual_stop(state):
directory = _intent_directory(state)
if directory is None:
return False
_json(directory / "manual-stop.json", {"requested": True, "boot_id": Path("/proc/sys/kernel/random/boot_id").read_text().strip()}, replace=True)
return True
def manual_stop_requested(state):
directory = _intent_directory(state)
if directory is None:
return False
try:
record = json.loads(_read(directory / "manual-stop.json"))
except FileNotFoundError:
return False
return isinstance(record, dict) and record.get("requested") is True and record.get("boot_id") == Path("/proc/sys/kernel/random/boot_id").read_text().strip()
def _clear_manual_stop(state):
directory = _intent_directory(state)
if directory is None:
return
path = directory / "manual-stop.json"
try:
_read(path)
except FileNotFoundError:
return
path.unlink()
def boot_requested(state):
"""Explicit boot policy; None retains the normal first-connect default."""
directory = _intent_directory(state)
if directory is None:
return None
try:
value = json.loads(_read(directory / "boot-policy.json"))
except FileNotFoundError:
return None
if type(value) is not dict or value.get("schema") != 1 or type(value.get("enabled")) is not bool:
raise RuntimeError("The saved boot policy cannot be verified.")
return value["enabled"]
def _set_boot_requested(state, enabled):
if type(enabled) is not bool:
raise ValueError("Boot policy must be true or false.")
directory = _intent_directory(state)
if directory is None:
return False
_json(directory / "boot-policy.json", {"schema": 1, "enabled": enabled}, replace=True)
return True
def connection_requested(state):
"""Desired transport state survives worker recovery and reboot until connect."""
directory = _intent_directory(state)
if directory is None:
return True
try:
value = json.loads(_read(directory / "connection-intent.json"))
except FileNotFoundError:
return True
if type(value) is not dict or value.get("schema") != 1 or type(value.get("connect")) is not bool:
raise RuntimeError("The saved connection intent cannot be verified.")
return value["connect"]
def connection_request_revision(state):
directory = _intent_directory(state)
if directory is None:
return 0
try:
value = json.loads(_read(directory / "connection-intent.json"))
except FileNotFoundError:
return 0
revision = value.get("revision", 0) if type(value) is dict else None
if type(revision) is not int or revision < 0:
raise RuntimeError("The saved connection intent cannot be verified.")
return revision
def _set_connection_requested(state, requested):
if type(requested) is not bool:
raise ValueError("Connection intent must be true or false.")
directory = _intent_directory(state)
if directory is None:
return False
_json(directory / "connection-intent.json", {"schema": 1, "connect": requested, "revision": connection_request_revision(state) + 1}, replace=True)
return True
def _connection_action(state, action):
"""Serialize the worker's transport startup with an explicit pause request."""
directory = _intent_directory(state)
if directory is None:
return action(state)
fd = os.open(directory / "connection-action.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_mode & 0o077:
raise RuntimeError("Unsafe connection action lock.")
deadline = time.monotonic() + 20
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
if time.monotonic() >= deadline:
raise RuntimeError("Another connection operation is still finishing. Try again shortly.")
time.sleep(0.05)
return action(state)
finally:
os.close(fd)
def _worker_status(directory, name):
_json(directory / "worker-status.json", {"state": name, "updated_at": time.time()}, replace=True)
def worker(state):
global _STOP
_STOP = False
state, directory, unit = _paths(state)
config = load_config(state)
from . import runtime, connection_background, tunnel
from .display_context import DisplayContextError
def stop(signum, frame):
global _STOP
_STOP = True
old = {s: signal.signal(s, stop) for s in (signal.SIGTERM, signal.SIGINT)}
lockfd = os.open(directory / "worker.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
info = os.fstat(lockfd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_mode & 0o077:
os.close(lockfd)
raise RuntimeError("Unsafe autostart worker lock.")
fcntl.flock(lockfd, fcntl.LOCK_EX | fcntl.LOCK_NB)
connected = False
attempted_connection = False
last_revision = None
next_connection_attempt = 0.0
try:
_clear_manual_stop(state)
while not _STOP:
if manual_stop_requested(state):
return 0
try:
current = runtime.start(state)
if manual_stop_requested(state):
return 0
except DisplayContextError:
if config.get("headless") in ("no", False):
_worker_status(directory, "waiting_for_graphical_login")
time.sleep(3)
continue
raise
if current.get("running") and current.get("ready"):
break
_worker_status(directory, "waiting_for_graphical_login" if config.get("headless") in ("no", False) else "waiting_for_local_services")
time.sleep(3)
if _STOP:
return 0
_worker_status(directory, "local_services_ready")
if config["role"] == "node":
while not _STOP:
if manual_stop_requested(state):
return 0
if not runtime.status(state).get("running"):
return 0 if manual_stop_requested(state) else 1
time.sleep(2)
return 0
while not _STOP:
if manual_stop_requested(state):
return 0
def synchronize_connection(_state):
nonlocal connected, attempted_connection, last_revision, next_connection_attempt
revision = connection_request_revision(state)
if revision != last_revision:
attempted_connection = False
next_connection_attempt = 0.0
last_revision = revision
current = connection_background.status(state)
connected = connected or current.get("alive") is True
if not connection_requested(state):
if current.get("alive"):
stopped = connection_background.disconnect(state)
if stopped.get("ok") is False or stopped.get("alive"):
raise RuntimeError("The paused connection is still stopping.")
_worker_status(directory, "disconnected")
return
if not current.get("alive") and (not attempted_connection or current.get("state") not in ("stopped", "closed")):
if time.monotonic() < next_connection_attempt:
_worker_status(directory, "reconnecting")
return
attempted_connection = True
next_connection_attempt = time.monotonic() + 15
key = _credential(state)
try:
spec = tunnel.TunnelSpec(config["tunnel_id"], config.get("control_plane_organization_id", ""), config.get("control_plane_url_path", ""))
binary = runtime.PREFIX / "original/geranas/FrameBridge-MCP-Private/bin/tunnel-client"
mcp = "http://127.0.0.1:" + str(config["ports"]["mcp"]) + "/mcp"
command = tunnel.build_command(binary, state, mcp, spec)
_worker_status(directory, "connecting")
connected = True
connection_background.start(command, key, state, spec.tunnel_id, mcp, output=io.StringIO(), wait_timeout=1)
finally:
key = ""
current = connection_background.status(state)
if not current.get("alive"):
_worker_status(directory, "disconnected" if current.get("state") in ("stopped", "closed") else "reconnecting")
else:
_worker_status(directory, "connected" if current.get("verified") else "reconnecting")
_connection_action(state, synchronize_connection)
if not runtime.status(state).get("running"):
return 0 if manual_stop_requested(state) else 1
time.sleep(2)
return 0
except Exception:
_worker_status(directory, "failed")
return 1
finally:
if connected:
connection_background.disconnect(state)
runtime.stop(state)
_worker_status(directory, "stopped")
os.close(lockfd)
for sig, handler in old.items():
signal.signal(sig, handler)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=["_run"])
parser.add_argument("--state", required=True)
args = parser.parse_args()
return worker(args.state)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,502 @@
"""Authenticated operator boundary around the unchanged Original FBPAIR broker."""
from __future__ import annotations
import hashlib
import importlib
import importlib.util
import ipaddress
import json
import os
import re
import secrets
import socket
import ssl
import stat
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from functools import lru_cache
from http.server import ThreadingHTTPServer
from pathlib import Path
class BrokerError(ValueError):
"""A fixed public failure code; never include HTTP bodies or credentials."""
_state = None
_url = None
_control_path = None
_JOB = re.compile(r"[A-Za-z0-9._-]{1,80}")
_LABEL = re.compile(r"[A-Za-z0-9._-]{1,64}")
def state_path(state_dir=None):
from .config import state_path as canonical_state
return canonical_state(state_dir or os.environ.get("FRAMEBRIDGE_EXPANDED_STATE")
or os.environ.get("FRAMEBRIDGE_STATE_DIR")).resolve()
def _config(state_dir=None):
p = state_path(state_dir) / "config.json"
if not p.is_file():
return {}
data = json.loads(p.read_text())
if not isinstance(data, dict):
raise BrokerError("CONFIG_INVALID")
return data
def _loopback(host):
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return host == "localhost" and all(ipaddress.ip_address(a[4][0]).is_loopback
for a in socket.getaddrinfo(host, None))
def validate_endpoint(url):
"""Permit verified HTTPS, or literal/local loopback HTTP, without URL credentials."""
if not isinstance(url, str) or len(url) > 2048:
raise BrokerError("BROKER_URL_INVALID")
parsed = urllib.parse.urlsplit(url)
try:
valid_port = parsed.port is None or 1 <= parsed.port <= 65535
except ValueError:
valid_port = False
if (not parsed.hostname or not valid_port or parsed.username is not None
or parsed.password is not None or parsed.query or parsed.fragment
or parsed.scheme not in ("http", "https")
or any(ord(c) < 33 for c in url)):
raise BrokerError("BROKER_URL_INVALID")
if parsed.scheme == "http" and not _loopback(parsed.hostname):
raise BrokerError("BROKER_HTTP_LOOPBACK_REQUIRED")
return url.rstrip("/")
def configure(broker_url=None, state_dir=None, control_key_path=None):
global _state, _url, _control_path
_state = state_path(state_dir)
config = _config(_state)
port = config.get("ports", {}).get("broker", 20878)
_url = validate_endpoint(broker_url or config.get("broker_control_url") or config.get("broker_url")
or f"http://127.0.0.1:{port}")
_control_path = Path(control_key_path or _state / "broker" / "control.key")
def _settings():
if _state is None:
configure()
return _state, _url, _control_path
def _private_read(path):
fd = os.open(str(path), os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or st.st_mode & 0o077:
raise BrokerError("PRIVATE_STATE_PERMISSIONS_INVALID")
data = os.read(fd, 4097)
if len(data) > 4096:
raise BrokerError("PRIVATE_STATE_TOO_LARGE")
return data
finally:
os.close(fd)
def _control_secret(state_dir, create=False):
p = state_path(state_dir) / "broker" / "control.key"
if create:
p.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
st = p.parent.lstat()
if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid() or st.st_mode & 0o077:
raise BrokerError("PRIVATE_CONTROL_DIRECTORY_INVALID")
if not p.exists():
fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
try:
raw = secrets.token_urlsafe(48).encode()
with os.fdopen(fd, "wb") as stream:
stream.write(raw)
stream.flush()
os.fsync(stream.fileno())
except Exception:
raise BrokerError("CONTROL_IDENTITY_CREATE_FAILED") from None
secret = _private_read(p).decode("ascii").strip()
if len(secret) < 40:
raise BrokerError("CONTROL_IDENTITY_INVALID")
return secret
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise BrokerError("BROKER_REDIRECT_BLOCKED")
def post_json(url, value, *, state_dir=None, timeout=10, control=False):
validate_endpoint(url)
config = _config(state_dir)
ca = os.environ.get("FRAMEBRIDGE_TLS_CA") or config.get("tls_ca") or None
context = ssl.create_default_context(cafile=str(ca) if ca else None)
headers = {"Content-Type": "application/json", "Accept": "application/json"}
if control:
_, _, configured = _settings()
headers["Authorization"] = "Bearer " + _private_read(configured).decode("ascii").strip()
request = urllib.request.Request(url, data=json.dumps(value, separators=(",", ":"),
allow_nan=False).encode(),
headers=headers, method="POST")
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect(),
urllib.request.HTTPSHandler(context=context))
try:
with opener.open(request, timeout=timeout) as response:
raw = response.read(20 * 1024 * 1024 + 1)
if len(raw) > 20 * 1024 * 1024:
raise BrokerError("BROKER_RESPONSE_TOO_LARGE")
result = json.loads(raw)
except BrokerError:
raise
except urllib.error.HTTPError as exc:
raise BrokerError("BROKER_HTTP_REJECTED_" + str(exc.code)) from None
except Exception:
raise BrokerError("BROKER_TRANSPORT_FAILED") from None
if not isinstance(result, dict):
raise BrokerError("BROKER_RESPONSE_INVALID")
return result
def _control(path, payload):
state, url, _ = _settings()
return post_json(url + "/fbpair/v1/control/" + path, payload,
state_dir=state, control=True)
def list_confirmed_nodes():
result = _control("nodes", {})
if result.get("ok") is not True or not isinstance(result.get("nodes"), list):
raise BrokerError("BROKER_DISCOVERY_FAILED")
nodes = result["nodes"]
ids, aliases = set(), set()
for item in nodes:
if not isinstance(item, dict):
raise BrokerError("BROKER_DISCOVERY_INVALID")
ident, alias = item.get("node_id"), item.get("alias")
if not isinstance(ident, str) or not isinstance(alias, str):
raise BrokerError("BROKER_DISCOVERY_INVALID")
if ident in ids or alias.casefold() in aliases:
raise BrokerError("BROKER_DISCOVERY_AMBIGUOUS")
ids.add(ident)
aliases.add(alias.casefold())
return nodes
def list_pending():
result = _control("pending", {})
if result.get("ok") is not True:
raise BrokerError("BROKER_PENDING_FAILED")
return result.get("requests", [])
def approve(request_id, expected_node_id, state_dir=None):
if state_dir is not None:
configure(state_dir=state_dir)
return _control("approve", {"request_id": request_id, "expected_node_id": expected_node_id})
def revoke(node_id):
return _control("revoke", {"node_id": node_id})
def request(node_id, capability, args, timeout_s=120):
if type(timeout_s) is not int or not 1 <= timeout_s <= 600:
raise BrokerError("INVALID_TIMEOUT")
if not isinstance(args, dict):
raise BrokerError("ARGS_NOT_OBJECT")
confirmed = {n["node_id"]: n for n in list_confirmed_nodes()}
if node_id not in confirmed:
raise BrokerError("NODE_NOT_CONFIRMED")
if confirmed[node_id].get("ready") is not True:
raise BrokerError("NODE_OFFLINE")
path = {"admin.exec": "admin-exec", "admin.cua": "admin-cua"}.get(capability, "request")
payload = {"node_id": node_id}
if path == "request":
payload.update(capability=capability, args=args)
else:
payload["payload"] = args
created = _control(path, payload)
if created.get("ok") is not True or not created.get("request_id"):
raise BrokerError("BROKER_REQUEST_REJECTED")
rid = created["request_id"]
deadline = time.monotonic() + timeout_s + 10
while time.monotonic() < deadline:
final = _control("result", {"request_id": rid})
if final.get("node_id") != node_id:
raise BrokerError("BROKER_RESULT_NODE_MISMATCH")
if final.get("state") == "complete":
result = final.get("result")
if not isinstance(result, dict):
raise BrokerError("BROKER_RESULT_INVALID")
return dict(result, request_id=rid, node_id=node_id)
if final.get("state") == "expired":
raise BrokerError("BROKER_REQUEST_EXPIRED")
time.sleep(0.2)
raise BrokerError("BROKER_RESULT_TIMEOUT")
@lru_cache(maxsize=1)
def original_modules():
from .fleet import bootstrap_original
bootstrap_original()
root = Path(os.environ.get("FRAMEBRIDGE_BROKER_SOURCE")
or str(Path(os.environ.get("FRAMEBRIDGE_EXPANDED_PREFIX", "/usr/lib/framebridge-expanded"))
/ "original/geranas/FrameBridge-Broker-RC1"))
if not root.exists():
root = Path(__file__).resolve().parents[2] / "sources/original-geranas/FrameBridge-Broker-RC1"
src = root if (root / "framebridge_linux_chatgpt").is_dir() else root / "src"
if not src.is_dir():
raise BrokerError("ORIGINAL_BROKER_SOURCE_UNAVAILABLE")
sys.path.insert(0, str(src)) if str(src) not in sys.path else None
package = importlib.import_module("framebridge_linux_chatgpt")
path = str(src / "framebridge_linux_chatgpt")
if path not in package.__path__:
package.__path__.append(path)
script = (root.parent if root == src else root) / "dev/fbpair_dev_broker.py"
if not script.is_file():
raise BrokerError("ORIGINAL_BROKER_SOURCE_UNAVAILABLE")
spec = importlib.util.spec_from_file_location("_framebridge_expanded_original_broker", script)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _admin_payload(operation, payload):
if not isinstance(payload, dict) or payload.get("authorized") is not True:
raise BrokerError("AUTHORIZED_TRUE_REQUIRED")
value = dict(payload)
timeout = value.get("timeout_s", 120)
if type(timeout) is not int or not 1 <= timeout <= 600:
raise BrokerError("INVALID_TIMEOUT")
job = value.get("job_id", "")
if not isinstance(job, str) or _JOB.fullmatch(job) is None:
raise BrokerError("INVALID_JOB_ID")
if operation == "admin.exec":
script = value.get("script")
if not isinstance(script, str) or not script or len(script.encode()) > 262144:
raise BrokerError("SCRIPT_INVALID")
digest = hashlib.sha256(script.encode()).hexdigest()
if value.get("sha256") != digest:
raise BrokerError("SCRIPT_SHA256_MISMATCH")
elif operation == "admin.cua":
if timeout > 180:
raise BrokerError("INVALID_CUA_TIMEOUT")
cua = value.get("cua")
if not isinstance(cua, dict):
raise BrokerError("CUA_PAYLOAD_INVALID")
if cua.get("action") not in {"OBSERVE", "CLICK", "DOUBLE_CLICK", "TYPE", "KEY", "SCROLL", "DRAG"}:
raise BrokerError("CUA_ACTION_UNSUPPORTED")
# Original admin.cua carries the adapter's cua object, not an EXEC script.
else:
raise BrokerError("OPERATION_NOT_ALLOWED")
value["timeout_s"] = timeout
return value
def make_server(host="127.0.0.1", port=20878, state_dir=None):
"""Build without starting; tests and runtime own the server lifecycle."""
state = state_path(state_dir)
config = _config(state)
if not isinstance(host, str) or not host:
raise BrokerError("BROKER_BIND_INVALID")
tls_cert, tls_key = config.get("tls_cert"), config.get("tls_key")
if not _loopback(host) and not (tls_cert and tls_key):
raise BrokerError("BROKER_REMOTE_BIND_REQUIRES_TLS")
if type(port) is not int or not 1 <= port <= 65535:
raise BrokerError("BROKER_PORT_INVALID")
secret = _control_secret(state, create=True)
original = original_modules()
discovery = state / "broker" / "paired-nodes.json"
public_base = config.get("broker_url") or (f"https://{host}:{port}" if tls_cert else f"http://{host}:{port}")
public_base = validate_endpoint(public_base)
admin_lock = threading.RLock()
class Store(original.Store):
def token(self, pair_id, device_code):
result = super().token(pair_id, device_code)
if result.get("state") == "paired":
result.update(broker_issuer=public_base,
account_binding_id="local-operator-" + hashlib.sha256(secret.encode()).hexdigest()[:24],
scopes=["node.read", "admin.exec", "admin.cua"])
with self.lock:
self.by_pair[pair_id]["receipt"] = dict(result)
return result
def queue_admin(self, node_id, operation, payload):
node = self._paired_node(node_id)
if node is None:
return None, "node_not_paired"
if operation not in node.get("scopes", []):
return None, "node_scope_denied"
try:
value = _admin_payload(operation, payload)
except BrokerError as exc:
return None, str(exc)
rid = secrets.token_hex(16)
item = {"schema": 1, "request_id": rid, "node_id": node_id,
"operation": operation, "payload": value, "created_at": time.time(),
"expires_at": time.time() + value["timeout_s"] + 90}
with self.lock:
self.control_requests[rid] = item
self.control_results[rid] = {"state": "pending", "node_id": node_id}
self.control_queue[node_id].append(rid)
return item, None
store = Store(discovery, public_base)
BaseHandler = original.make_handler(store, public_base)
class Handler(BaseHandler):
server_version = "FrameBridgeOriginal/1"
def do_GET(self):
self.send_json(200 if self.path == "/health" else 404,
{"ok": self.path == "/health", "protocol": "FBPAIR/1"})
def do_POST(self):
if self.path.startswith("/fbpair/v1/control/"):
if not _loopback(str(self.client_address[0])):
self.send_json(403, {"ok": False, "error": "CONTROL_LOOPBACK_REQUIRED"})
return
if not secrets.compare_digest(self.headers.get("Authorization", ""), "Bearer " + secret):
self.send_json(403, {"ok": False, "error": "CONTROL_AUTH_REQUIRED"})
return
try:
body = self.read_json()
if not isinstance(body, dict):
raise BrokerError("REQUEST_INVALID")
with admin_lock:
self._control(body)
except BrokerError as exc:
self.send_json(400, {"ok": False, "error": str(exc)})
except Exception:
self.send_json(500, {"ok": False, "error": "BROKER_INTERNAL_ERROR"})
return
if self.path not in {"/fbpair/v1/device_authorization", "/fbpair/v1/token",
"/fbpair/v1/confirm", "/fbpair/v1/cancel",
"/fbpair/v1/node/poll", "/fbpair/v1/node/result"}:
self.send_json(404, {"ok": False, "error": "ENDPOINT_NOT_AVAILABLE"})
return
super().do_POST()
def _control(self, body):
route = self.path.rsplit("/", 1)[-1]
if route == "pending":
pending = []
for item in list(store.by_pair.values()):
req = item["request"]
if item.get("state") == "pending" and item["expires"] > time.time():
pending.append({"request_id": item["pair_id"], "node_id": req["node_id"],
"node_label": req["node_label"], "state": "pending",
"fingerprint": original.parse_ed25519_public_key_line(
req.get("public_key_ssh", ""))["public_key_sha256"]})
self.send_json(200, {"ok": True, "requests": pending})
elif route == "approve":
rid, expected = body.get("request_id"), body.get("expected_node_id")
item = store.by_pair.get(rid)
if not item or item["request"]["node_id"] != expected:
raise BrokerError("APPROVAL_NODE_MISMATCH")
req = item["request"]
alias = req["node_label"]
reserved = {"local", str(config.get("name", "")).casefold()}
if not isinstance(alias, str) or _LABEL.fullmatch(alias) is None or alias.casefold() in reserved:
raise BrokerError("NODE_ALIAS_RESERVED_OR_INVALID")
if expected == config.get("node_id"):
raise BrokerError("NODE_ID_RESERVED")
for node in store._discovery_records():
if node.get("state") == "paired" and (node.get("node_id") == expected
or str(node.get("node_label", "")).casefold() == alias.casefold()):
raise BrokerError("NODE_ALREADY_REGISTERED")
for other in store.by_pair.values():
if other is item:
continue
if other.get("state") in {"approved", "receipt_issued"} and other["expires"] > time.time():
if (other["request"].get("node_id") == expected
or str(other["request"].get("node_label", "")).casefold() == alias.casefold()):
raise BrokerError("NODE_ALIAS_OR_ID_PENDING")
if not store.approve(item["user_code"]):
raise BrokerError("PAIR_NOT_APPROVABLE")
self.send_json(200, {"ok": True, "request_id": rid, "node_id": expected,
"state": "approved", "approval_method": "authenticated-local-operator"})
elif route == "nodes":
nodes = []
for n in store._discovery_records():
if n.get("state") != "paired":
continue
status = store.node_runtime_status(n["node_id"])
nodes.append({"node_id": n["node_id"], "alias": n["node_label"],
"hostname": n["node_label"], "transport": "broker_outbound",
"ready": status.get("ready") is True, "pairing_state": "paired"})
self.send_json(200, {"ok": True, "nodes": nodes})
elif route == "node-status":
self.send_json(200, store.node_runtime_status(str(body.get("node_id", ""))))
elif route == "revoke":
node_id = body.get("node_id")
records = store._discovery_records()
found = False
for n in records:
if n.get("node_id") == node_id:
n["state"] = "revoked"
found = True
if not found:
raise BrokerError("NODE_NOT_FOUND")
tmp = discovery.with_name(".paired-nodes.revocation.tmp")
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, "w") as stream:
json.dump({"schema": 1, "nodes": records}, stream)
stream.flush()
os.fsync(stream.fileno())
os.replace(tmp, discovery)
self.send_json(200, {"ok": True, "node_id": node_id, "state": "revoked"})
elif route in {"request", "admin-exec", "admin-cua"}:
node_id = str(body.get("node_id", ""))
if route == "request":
item, error = store.queue_read(node_id, body.get("capability"), body.get("args"))
else:
operation = "admin.exec" if route == "admin-exec" else "admin.cua"
item, error = store.queue_admin(node_id, operation, body.get("payload"))
if error:
raise BrokerError(str(error))
self.send_json(200, {"ok": True, "request_id": item["request_id"]})
elif route == "result":
result = store.get_result(str(body.get("request_id", "")))
if result is None:
raise BrokerError("REQUEST_NOT_FOUND")
self.send_json(200, {"ok": True, **result})
else:
self.send_json(404, {"ok": False, "error": "ENDPOINT_NOT_AVAILABLE"})
if ":" in host:
class Server(ThreadingHTTPServer):
address_family = socket.AF_INET6
else:
Server = ThreadingHTTPServer
server = Server((host, port), Handler)
server.daemon_threads = True
server.expanded_store = store
if tls_cert or tls_key:
if not tls_cert or not tls_key:
server.server_close()
raise BrokerError("TLS_CERT_AND_KEY_REQUIRED")
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.load_cert_chain(str(tls_cert), str(tls_key))
server.socket = context.wrap_socket(server.socket, server_side=True)
return server
def serve(host="127.0.0.1", port=20878, state_dir=None):
server = make_server(host, port, state_dir)
try:
server.serve_forever(poll_interval=0.25)
finally:
server.server_close()
+798
View File
@@ -0,0 +1,798 @@
"""English command-line interface for FrameBridge Expanded."""
from __future__ import annotations
import argparse
import copy
import importlib
import json
import os
from pathlib import Path
import re
import socket
import sys
import shlex
from .config import default_config, load_config, save_config
from . import tunnel
_EMAIL = re.compile(r"[^\s@]+@[^\s@]+\.[^\s@]+\Z")
def resolve_state(value=None):
return Path(value or os.environ.get("FRAMEBRIDGE_EXPANDED_STATE")
or os.environ.get("FRAMEBRIDGE_STATE_DIR")
or Path.home() / ".local/share/framebridge-expanded").expanduser().absolute()
def _module(name):
return importlib.import_module("." + name, __package__)
def _show(value):
print(json.dumps(_structured_result(value) if _OUTPUT and _OUTPUT.get("json") else value, ensure_ascii=False, indent=2, default=str))
def _ask(label, default="", *, ask=None):
ask = ask or input
value = ask(label + (f" [{default}]" if default != "" else "") + ": ").strip()
return value if value else default
def _yes(label, default=False, *, ask=None):
answer = _ask(label + (" [Y/n]" if default else " [y/N]"), ask=ask).lower()
return default if not answer else answer in {"y", "yes"}
def _show_autostart_enabled(result):
"""Summarize observed state without treating configuration as readiness."""
enabled = result.get("configured") is True and result.get("enabled") is True
if not enabled:
print("Automatic startup is not enabled. Check: fb status")
return 1
print("Automatic startup enabled.")
service = result.get("service_state", "unavailable")
if service not in {"active", "activating", "deactivating", "inactive", "failed", "reloading", "maintenance", "refreshing"}:
service = "unavailable"
print("Service: " + service)
current = result.get("runtime")
current = current if isinstance(current, dict) else {}
capabilities = current.get("capabilities")
capabilities = capabilities if isinstance(capabilities, dict) else {}
ready = current.get("ready") is True
if ready and all(capabilities.get(name) is True for name in ("fbexec", "fbterm", "cua_tty_module")):
print("Tools: FBEXEC, FBTERM, CUA-TTY ready.")
elif current.get("status_unavailable"):
print("Tools: status unavailable. Check: fb status")
else:
print("Tools: starting.")
context = current.get("display_context")
context = context if isinstance(context, dict) else {}
kind = context.get("kind")
if ready and capabilities.get("graphical_cua") is True and kind in {"physical-x11", "virtual-x11"}:
print("Desktop: " + ("detected; CUA ready." if kind == "physical-x11" else "private; CUA ready."))
elif kind == "text":
print("Desktop: text mode.")
else:
print("Desktop: checking.")
if current.get("role") == "node":
print("Connection: managed by the central computer.")
else:
connection = result.get("connection")
connection = connection if isinstance(connection, dict) else {}
phase = connection.get("state")
if connection.get("verified") is True:
print("Connection: ready.")
elif phase == "retrying":
print("Connection: retrying in the background.")
elif phase in {"starting", "checking", "access", "unverified"}:
print("Connection: checking in the background.")
elif phase in {"error", "rejected"}:
print("Connection: needs attention. Check: fb status connection")
elif phase in {"stopped", "closed"}:
print("Connection: stopped. Check: fb status connection")
else:
print("Connection: checking. Check: fb status connection")
return 0 if result.get("ok") is not False and service not in {"failed", "maintenance"} else 1
def setup(state, *, ask=None):
"""Review and save public settings; never start services or a tunnel."""
state = resolve_state(state)
if (state / "config.json").exists():
current = _module("runtime").status(state)
if current.get("running") or any(p.get("alive") for p in current.get("processes", [])):
raise RuntimeError("Stop this profile before changing setup. Run fb service stop.")
config = copy.deepcopy(load_config(state) if (state / "config.json").exists() else default_config())
print("Set up this computer")
print("standalone: local tools; central: local tools and a node broker; node: joins a central broker.")
config["role"] = _ask("Role (standalone/central/node)", config["role"], ask=ask).lower()
config["name"] = _ask("Computer name", config["name"], ask=ask)
desktop_mode = config.get("headless", "auto")
if desktop_mode == "auto":
print("Desktop: automatic detection.")
elif desktop_mode in ("yes", True):
print("Desktop: existing private desktop setting preserved.")
else:
print("Desktop: existing physical desktop setting preserved.")
ports = {}
for service in ("mcp", "hub", "broker"):
value = _ask(service.upper() + " port", str(config["ports"][service]), ask=ask)
try:
ports[service] = int(value)
except ValueError:
raise ValueError("Service ports must be integers.") from None
config["ports"] = ports
label = _ask("ChatGPT email label (optional; browser login is separate)", config.get("account_label", ""), ask=ask)
if label and not _EMAIL.fullmatch(label):
raise ValueError("Enter an email label or leave it empty.")
config["account_label"] = label
certificate_plan = None
if config["role"] == "central":
print("Loopback is local only. Choose 0.0.0.0 explicitly to accept IPv4 LAN nodes with HTTPS.")
config["broker_listen_host"] = _ask("Broker listen host", config.get("broker_listen_host", "127.0.0.1"), ask=ask)
mode = _ask("TLS certificate setup (generate/existing)", "existing" if config.get("tls_cert") else "generate", ask=ask).lower()
if mode == "generate":
if (state / "tls").exists() or any(config.get(k) for k in ("tls_cert", "tls_key", "tls_ca")):
raise ValueError("This profile already has TLS material. Choose existing certificates or an explicit new profile.")
tls = _module("tls")
host = tls._dns_name(_ask("Certificate DNS name", config["name"].replace("_", "-"), ask=ask))
address = str(tls._address(_ask("Advertised broker IP address", ask=ask)))
certificate_plan = {"host": host, "address": address, "listen_host": config["broker_listen_host"]}
print("A private local CA and server certificate will be created only after confirmation.")
elif mode == "existing":
config["tls_cert"] = _ask("Broker HTTPS certificate file (path only; optional on loopback)", config.get("tls_cert", ""), ask=ask)
config["tls_key"] = _ask("Broker HTTPS private key file (path only)", config.get("tls_key", ""), ask=ask)
config["tls_ca"] = _ask("Trusted CA file (path only; optional)", config.get("tls_ca", ""), ask=ask)
config["broker_url"] = _ask("Advertised broker URL (optional on loopback)", config.get("broker_url", ""), ask=ask)
scheme = "https" if config["tls_cert"] and config["tls_key"] else "http"
config["broker_control_url"] = scheme + "://127.0.0.1:" + str(ports["broker"])
if scheme == "https":
print("Existing certificates must cover 127.0.0.1 or localhost for private local control.")
if config["broker_listen_host"] not in {"localhost", "127.0.0.1", "::1"} and not (config["tls_cert"] and config["tls_key"]):
raise ValueError("A broker listening outside loopback requires HTTPS certificate and key files.")
else:
raise ValueError("Choose generate or existing for TLS certificate setup.")
elif config["role"] == "node":
config["broker_url"] = _ask("Central broker URL (verified HTTPS for remote hosts; may be set later)", config.get("broker_url", ""), ask=ask)
config["tls_ca"] = _ask("Central broker CA file (path only; blank uses system trust)", config.get("tls_ca", ""), ask=ask)
print("Review this profile. TLS paths refer to node transport files; they are not ChatGPT API keys.")
_show(config)
if certificate_plan is not None:
_show({"new_lan_certificate": certificate_plan})
if not _yes("Save this configuration?", True, ask=ask):
print("Configuration was not saved.")
return 0
save_config(state, config)
if certificate_plan is not None:
_show(_module("tls").create_certificates(state, **certificate_plan))
print("Configuration saved. No service or ChatGPT connection was started.")
if config["role"] == "node":
print("Next: framebridge doctor, framebridge nodes enroll, ask the central operator to approve, then framebridge nodes poll.")
print("After approval is confirmed, run fb service start.")
else:
print("Next: fb connect. This starts the managed tools and connection.")
print("Use the same --state-dir option with every command when using a custom profile.")
return 0
def connect_chatgpt(state, *, foreground=False, replace=False):
config_path = Path(state) / "config.json"
config = load_config(state) if config_path.exists() or config_path.is_symlink() else default_config()
if config["role"] == "node":
raise RuntimeError("A node profile does not publish an MCP server. Connect ChatGPT from the central profile.")
runtime = _module("runtime")
port = config["ports"]["mcp"]
expected_url = "http://127.0.0.1:" + str(port) + "/mcp"
binary = Path(runtime.PREFIX) / "original/geranas/FrameBridge-MCP-Private/bin/tunnel-client"
if not binary.is_file():
raise RuntimeError("The packaged tunnel client is missing.")
manifest = Path(state) / "autostart/installation.json"
if not foreground and not replace and (manifest.exists() or manifest.is_symlink()):
return tunnel.resume_managed_connection(state, _module("autostart").resume_connection)
if foreground:
current = runtime.status(state)
if current.get("running") is not True or current.get("ready") is not True or current.get("mcp_url") != expected_url:
raise RuntimeError("This profile's MCP service is not ready. Run fb service start for this profile first.")
def save_public(settings):
config.update(settings)
save_config(state, config)
return tunnel.connect_chatgpt(binary, state, expected_url, config, save_public, foreground=True)
def configure_connection(profile, settings, runtime_key):
current = runtime.status(state) if config_path.is_file() else {"running": False}
if current.get("running") and current.get("mcp_url") != expected_url:
raise RuntimeError("This profile's MCP endpoint does not match its configuration. Run fb doctor.")
if not current.get("running"):
for name in ("mcp", "hub") + (("broker",) if config["role"] == "central" else ()):
try:
with socket.socket() as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
probe.bind(("127.0.0.1", config["ports"][name]))
except OSError:
raise RuntimeError("The local " + name.upper() + " port " + str(config["ports"][name]) + " is already in use. Choose an available port with fb setup.") from None
return _module("autostart").configure_connection(profile, settings, runtime_key)
return tunnel.connect_chatgpt(binary, state, expected_url, config, configure_connection=configure_connection)
def nodes_command(args, state):
config = load_config(state)
if args.node_command == "list":
broker = _module("broker")
broker.configure(None, state_dir=state)
previous = os.environ.get("FRAMEBRIDGE_EXPANDED_STATE")
try:
os.environ["FRAMEBRIDGE_EXPANDED_STATE"] = str(state)
_show(_module("fleet").discover_nodes())
finally:
if previous is None:
os.environ.pop("FRAMEBRIDGE_EXPANDED_STATE", None)
else:
os.environ["FRAMEBRIDGE_EXPANDED_STATE"] = previous
return 0
if args.node_command in {"pending", "approve"}:
broker = _module("broker")
broker.configure(None, state_dir=state)
result = broker.list_pending() if args.node_command == "pending" else broker.approve(args.request_id, args.node_id, state_dir=state)
else:
node = _module("node")
if args.node_command == "enroll":
result = node.enroll(args.central or config.get("broker_url", ""), args.name or config["name"], state_dir=state)
elif args.node_command == "status":
result = node.status(state_dir=state)
elif args.node_command == "poll":
result = node.poll_enrollment(state_dir=state)
else:
result = node.run(state_dir=state, once=args.once)
if result is not None:
_show(result)
return 0
def _target_tty(state, target, payload=None, service=None):
"""Use the existing authenticated node transport; never guess SSH identities."""
if target == "LOCAL":
tty = _module("tty")
return getattr(tty, service)(state) if service else tty.request(state, payload)
previous = os.environ.get("FRAMEBRIDGE_EXPANDED_STATE")
os.environ["FRAMEBRIDGE_EXPANDED_STATE"] = str(state)
try:
fleet = _module("fleet")
wire = _module("tty_mcp")
def execute(**kwargs):
alias = kwargs.pop("target")
return fleet.execute_node(alias, **kwargs)
if service:
return wire._exec_json(execute, target, [service])
return wire._remote_request(execute, target, payload)
finally:
if previous is None:
os.environ.pop("FRAMEBRIDGE_EXPANDED_STATE", None)
else:
os.environ["FRAMEBRIDGE_EXPANDED_STATE"] = previous
def tty_command(args, state):
action = getattr(args, "tty_service_action", None) if args.tty_command == "service" else args.tty_command
target = getattr(args, "node", "LOCAL")
if action in {"start", "stop", "status"}:
result = _target_tty(state, target, service=action)
else:
payload = {"authorized": True}
if action == "list":
payload["op"] = "status"
elif action == "open":
payload.update(op="open", program=args.program, rows=args.rows, cols=args.cols)
elif action == "sudo":
payload.update(op="sudo", session_id=args.session_id, mode=args.sudo_mode)
elif action == "observe":
payload.update(op="observe", session_id=args.session_id)
elif action == "key":
payload.update(op="key.send", session_id=args.session_id, key=args.key)
elif action == "type":
text = args.text if args.text is not None else input("Text to send: ")
payload.update(op="text.type", session_id=args.session_id, text=text)
elif action == "resize":
payload.update(op="resize", session_id=args.session_id, rows=args.rows, cols=args.cols)
else:
payload.update(op="close", session_id=args.session_id)
result = _target_tty(state, target, payload=payload)
_show(result)
return 1 if isinstance(result, dict) and result.get("ok") is False else 0
def tutorial():
runtime = _module("runtime")
candidates = [Path(runtime.PREFIX) / "docs/DEBIAN-TUTORIAL.md",
Path(__file__).resolve().parents[2] / "docs/DEBIAN-TUTORIAL.md"]
for path in candidates:
if path.is_file():
print(path.read_text(encoding="utf-8"))
return 0
raise RuntimeError("The tutorial is not installed. Run framebridge --help.")
_OUTPUT = None
def _structured_result(value):
"""Versioned envelope used only when --json was explicitly requested."""
return {"schema_version": 1, "product": "FrameBridge", "version": "0.3.1",
"command": _OUTPUT.get("command") if _OUTPUT else None,
"ok": not isinstance(value, dict) or value.get("ok") is not False,
"result": value}
def _result_code(value):
return 1 if isinstance(value, dict) and value.get("ok") is False else 0
def _scoped_action(action):
if _OUTPUT and _OUTPUT.get("explicit_state") and action.startswith("fb "):
return "fb --state-dir " + shlex.quote(_OUTPUT["explicit_state"]) + " " + action[3:]
return action
def _interaction_required(action):
action = _scoped_action(action)
result = {"ok": False, "error": "INTERACTION_REQUIRED",
"message": "Complete this action in a normal interactive terminal. Do not send credentials through chat.",
"next_action": action}
if _OUTPUT and _OUTPUT.get("json"):
_show(result)
else:
print("An interactive terminal is required. Next: " + action, file=sys.stderr)
return 2
def _saved_noninteractive_ready(state):
value = _module("lifecycle").status(state)
boot = value.get("boot", {})
return all(boot.get(name) is True for name in
("configured", "encrypted_credential_present", "linger"))
def _runtime_label(value):
if value.get("status_unavailable") or "running" not in value:
return "unavailable"
return "ready" if value.get("ready") is True else ("running" if value.get("running") is True else "stopped")
def _boot_label(value):
if value.get("configured") is False:
return "not configured"
if value.get("configured") is not True or type(value.get("enabled")) is not bool:
return "unavailable"
return "enabled" if value["enabled"] else "disabled"
def _human_status(value, scope=None):
if scope == "connection":
conn = value
print("ChatGPT connection: " + ("connected (verified)" if conn.get("verified") is True else str(conn.get("state", "unavailable"))))
if conn.get("desired_connection"):
print("Connection intent: " + str(conn["desired_connection"]))
return
if scope == "boot":
boot = value
policy = _boot_label(boot)
print("Automatic startup: " + policy)
print("Current service: " + str(boot.get("service_state", "unavailable")))
print("Saved connection material: " + ("present" if boot.get("encrypted_credential_present") is True else ("absent" if boot.get("encrypted_credential_present") is False else "unavailable")))
return
if scope == "runtime":
runtime = value
print("Local tools: " + _runtime_label(runtime))
print("Role: " + str(runtime.get("role", "unavailable")))
return
if scope == "console":
print("Console: " + str(value.get("status", value.get("state", "available" if value.get("panes") else "not open"))))
return
runtime = value.get("runtime", {})
runtime = runtime if isinstance(runtime, dict) else {}
connection = value.get("connection", {})
connection = connection if isinstance(connection, dict) else {}
boot = value.get("boot", {})
boot = boot if isinstance(boot, dict) else {}
print("FrameBridge 0.3.1")
print("Role: " + str(runtime.get("role", "unavailable")))
print("Local tools: " + _runtime_label(runtime))
if runtime.get("role") == "node":
print("ChatGPT connection: provided by the central computer")
else:
print("ChatGPT connection: " + ("connected (verified)" if connection.get("verified") is True else str(connection.get("state", "unavailable"))))
print("Connection intent: " + str(value.get("desired_connection", "unavailable")))
print("Automatic startup: " + _boot_label(boot))
print("Current service: " + str(boot.get("service_state", "unavailable")))
if value.get("ok") is False:
print("Some status information is unavailable. Run fb doctor or inspect fb status --json.")
def _status_command(args, state):
scope = args.scope
if scope == "console":
value = _module("console").status(state, "LOCAL")
else:
aggregate = _module("lifecycle").status(state)
value = aggregate.get(scope, {}) if scope else aggregate
if scope == "connection":
value = dict(value, desired_connection=aggregate.get("desired_connection", "unavailable"))
if args.json:
_show(value)
else:
_human_status(value, scope)
return _result_code(value)
def _doctor_command(args, state):
value = _module("runtime").doctor(state)
if args.json:
_show(value)
else:
print("Diagnostics: " + ("passed" if value.get("ok") else "needs attention"))
print("Debian 13 amd64: " + ("compatible" if value.get("compatible_debian_13_amd64") else "not verified compatible"))
print("Desktop: " + str(value.get("display", "unavailable")))
for item in value.get("missing_commands", []):
print("Missing command: " + str(item))
for item in value.get("issues", []):
print("Issue: " + str(item))
print("This checks prerequisites; use fb status to inspect the current connection.")
return _result_code(value)
def _service_command(args, state):
action = args.service_action
lifecycle = _module("lifecycle")
if action == "credentials":
if not args.confirm:
result = {"ok": False, "error": "CONFIRMATION_REQUIRED",
"message": "This stops and removes the managed connection setup and saved credential. The profile is retained.",
"next_action": _scoped_action("fb service credentials forget --confirm")}
_show(result)
return 2
result = lifecycle.forget_credentials(state)
elif action == "boot":
if args.boot_action == "enable" and (args.json or args.non_interactive):
boot = lifecycle.status(state).get("boot", {})
if not boot.get("configured") or not boot.get("linger"):
return _interaction_required("fb service boot enable")
result = getattr(lifecycle, "boot_" + args.boot_action)(state)
else:
if action == "start" and (args.json or args.non_interactive):
config_path = state / "config.json"
if config_path.is_file() and load_config(state)["role"] == "node":
if not lifecycle.status(state).get("boot", {}).get("configured"):
return _interaction_required("fb service start")
result = getattr(lifecycle, "service_" + action)(state)
if args.json:
_show(result)
elif result.get("ok") is False:
print("Managed operation failed: " + str(result.get("error", result.get("message", "inspect fb status --json"))), file=sys.stderr)
else:
print("Managed " + ("startup policy" if action == "boot" else "service") + ": " + str(result.get("state", result.get("operation", "request completed"))))
if result.get("initial_configuration") is True:
print("Initial node service setup: automatic startup enabled.")
if action == "boot":
print("Current execution and saved connection material are retained.")
elif action == "credentials":
print("The profile and user service persistence are retained.")
if isinstance(result.get("runtime"), dict) and isinstance(result.get("boot"), dict):
_human_status(result)
elif result.get("message"):
print(str(result["message"]))
return _result_code(result)
def _help_command(args):
guide = _module("guide")
topic = "all" if args.command == "tutorial" else args.topic
if args.json:
value = guide.get_guide(topic)
_show(value)
return _result_code(value)
text = guide.render_text(topic)
if sys.stdout.isatty() and sys.stdin.isatty() and not args.non_interactive:
import pydoc
pydoc.pager(text)
else:
print(text)
return 0
def _add_common_options(parser):
# SUPPRESS preserves an option supplied before a nested subcommand.
if not any("--state-dir" in item.option_strings for item in parser._actions):
parser.add_argument("--state-dir", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
parser.add_argument("--json", action="store_true", default=argparse.SUPPRESS,
help="Emit one versioned JSON result; never request interactive input.")
parser.add_argument("--non-interactive", action="store_true", default=argparse.SUPPRESS,
help="Never request interactive input; report when a terminal action is needed.")
for item in parser._actions:
if isinstance(item, argparse._SubParsersAction):
for child in item.choices.values():
_add_common_options(child)
def build_parser():
parser = argparse.ArgumentParser(prog="framebridge",
description="One command-line interface for human operators and authorized agents. fb is the short name for framebridge.",
epilog="Normal first use: fb connect. Advanced setup is optional. Read fb help for the guide and fb help compatibility for older commands.")
parser.add_argument("--state-dir", help="Profile directory; defaults to ~/.local/share/framebridge-expanded.")
parser.set_defaults(json=False, non_interactive=False)
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
connect = sub.add_parser("connect", help="Connect or resume ChatGPT; normal first use needs no separate setup/start.")
connect.add_argument("connection", nargs="?", choices=("chatgpt",), default=None, help=argparse.SUPPRESS)
modes = connect.add_mutually_exclusive_group()
modes.add_argument("--foreground", action="store_true", help="Advanced temporary connection; this terminal remains open.")
modes.add_argument("--replace", action="store_true", help="Replace saved connection settings through hidden local prompts.")
disconnect = sub.add_parser("disconnect", help="Pause ChatGPT until the next connect, including after restart; keep local tools.")
disconnect.add_argument("disconnection", nargs="?", choices=("chatgpt",), default=None, help=argparse.SUPPRESS)
status = sub.add_parser("status", help="Observe local tools, ChatGPT connection and startup policy without changing them.")
status.add_argument("scope", nargs="?", choices=("connection", "runtime", "boot", "console"))
sub.add_parser("doctor", help="Diagnose prerequisites and problems; does not repair or connect.")
sub.add_parser("setup", help="Optional advanced role/profile setup; does not start services or connect.")
help_parser = sub.add_parser("help", help="Read the installed, versioned English guide.")
help_parser.add_argument("topic", nargs="?", default="start", help="Guide topic; use all for the complete reference.")
service = sub.add_parser("service", help="Manage current services separately from future startup policy.")
services = service.add_subparsers(dest="service_action", required=True)
services.add_parser("start", help="Start services; the first node start also prepares automatic startup.")
services.add_parser("stop", help="Stop this profile's managed stack now without changing boot policy.")
boot = services.add_parser("boot", help="Change future startup policy; retain current execution and saved material.")
boot_actions = boot.add_subparsers(dest="boot_action", required=True)
for action in ("enable", "disable"):
boot_actions.add_parser(action)
credentials = services.add_parser("credentials", help="Explicit destructive management of saved connection setup.")
forget = credentials.add_subparsers(dest="credential_action", required=True).add_parser("forget",
help="Stop/remove managed connection setup and saved credential; retain profile and user persistence.")
forget.add_argument("--confirm", action="store_true", help="Explicitly confirm this destructive operation.")
screen = sub.add_parser("screen-access", help="Explicit persistent read-only access to the real local framebuffer.")
ss = screen.add_subparsers(dest="screen_access_command", required=True)
ss.add_parser("status", help="Read the access policy without changing permissions.")
enable = ss.add_parser("enable", help="Administrator opt-in: sudo fb screen-access enable --user NAME.")
enable.add_argument("--user", required=True)
ss.add_parser("disable", help="Revoke only the exact owned unchanged framebuffer ACL.")
certificates = sub.add_parser("certificates", help="Manage profile-scoped private LAN TLS certificates.")
create = certificates.add_subparsers(dest="certificate_command", required=True).add_parser("create",
help="Create a private CA/server certificate without overwriting existing material.")
create.add_argument("--host", required=True, help="Certificate DNS name; localhost is included automatically.")
create.add_argument("--address", required=True, help="Broker IP included in the certificate and advertised by default.")
create.add_argument("--listen-host", help="Explicit bind IP; preserve the saved setting when omitted.")
create.add_argument("--advertise-host", help="Advertise the certificate DNS name instead of its IP.")
nodes = sub.add_parser("nodes", help="Manage node enrollment, approval and agent state.")
ns = nodes.add_subparsers(dest="node_command", required=True)
for name in ("list", "pending", "status", "poll"):
ns.add_parser(name)
approve = ns.add_parser("approve", help="Approve after checking the node identity.")
approve.add_argument("request_id")
approve.add_argument("--node-id", required=True, help="Expected UUID from the pending request.")
enroll = ns.add_parser("enroll", help="Submit enrollment; this does not start the agent.")
enroll.add_argument("--central")
enroll.add_argument("--name")
run = ns.add_parser("run", help="Advanced foreground node agent.")
run.add_argument("--once", action="store_true", help="Process at most one polling cycle.")
console = sub.add_parser("console", help="Open or reattach the shared text Bash/FBTERM work view.")
console.add_argument("--node", default="LOCAL", help="Local alias; remote interactive attachment requires explicit SSH.")
console.add_argument("--mode", choices=("auto", "gui", "tty"), default="auto")
console.add_argument("--inside", action="store_true", help=argparse.SUPPRESS)
console.add_argument("--status", action="store_true", help=argparse.SUPPRESS)
login_flags = console.add_mutually_exclusive_group()
login_flags.add_argument("--enable-login", action="store_const", const="enable", dest="login_action", help=argparse.SUPPRESS)
login_flags.add_argument("--disable-login", action="store_const", const="disable", dest="login_action", help=argparse.SUPPRESS)
login_flags.add_argument("--login-status", action="store_const", const="status", dest="login_action", help=argparse.SUPPRESS)
con_sub = console.add_subparsers(dest="console_command")
login = con_sub.add_parser("login", help="Optional work-view opening after authenticated tty1 login; not service startup.")
login.add_argument("console_login_action", choices=("enable", "status", "disable"))
tty = sub.add_parser("tty", help="Manage persistent Bash sessions; persistence is between calls, not reboots.")
ts = tty.add_subparsers(dest="tty_command", required=True, metavar="ACTION")
tty_service = ts.add_parser("service", help="Manage the terminal daemon.")
tss = tty_service.add_subparsers(dest="tty_service_action", required=True)
for name in ("start", "stop", "status"):
item = tss.add_parser(name)
item.add_argument("--node", default="LOCAL")
for name in ("start", "stop", "status", "list"):
item = ts.add_parser(name, help="List session state." if name == "list" else argparse.SUPPRESS)
item.add_argument("--node", default="LOCAL")
sudo = ts.add_parser("sudo", help="Per-session sudo eligibility; changing mode recreates Bash and loses shell variables.")
modes = sudo.add_subparsers(dest="sudo_mode", required=True)
for mode in ("on", "status", "off"):
item = modes.add_parser(mode)
item.add_argument("session_id")
item.add_argument("--node", default="LOCAL")
op = ts.add_parser("open", help="Create a real Bash session and return its ID.")
op.add_argument("--node", default="LOCAL")
op.add_argument("--program", choices=("bash",), default="bash")
op.add_argument("--rows", type=int, default=24)
op.add_argument("--cols", type=int, default=80)
for name in ("observe", "key", "type", "resize", "close"):
item = ts.add_parser(name, help={"observe":"Read terminal text, not a real screen capture.",
"key":"Send a named key such as ENTER or CTRL_C.", "type":"Type text without adding Enter.",
"resize":"Change terminal dimensions.", "close":"Close this session and its processes; not detachment."}[name])
item.add_argument("session_id")
item.add_argument("--node", default="LOCAL")
if name == "key":
item.add_argument("key")
elif name == "type":
item.add_argument("text", nargs="?")
elif name == "resize":
item.add_argument("--rows", type=int, required=True)
item.add_argument("--cols", type=int, required=True)
# Compatibility parsers retain their actual historical scope.
sub.add_parser("start", help=argparse.SUPPRESS, description="Compatibility: starts only local runtime; use fb service start for the managed stack.")
sub.add_parser("stop", help=argparse.SUPPRESS, description="Compatibility: pauses this boot's managed worker and stops local runtime; startup policy is retained.")
sub.add_parser("tutorial", help=argparse.SUPPRESS, description="Compatibility: read the complete versioned guide; prefer fb help.")
connection = sub.add_parser("connection", help=argparse.SUPPRESS)
connection.add_subparsers(dest="connection_action", required=True).add_parser("status")
autostart = sub.add_parser("autostart", help=argparse.SUPPRESS,
description="Compatibility controls. WARNING: disable removes managed setup and saved connection material. Prefer fb service boot disable for policy only.")
auto = autostart.add_subparsers(dest="autostart_action", required=True)
for action in ("enable", "status", "disable"):
auto.add_parser(action)
# Do not advertise duplicate compatibility spellings in ordinary help.
for group in (sub, ts):
group._choices_actions[:] = [a for a in group._choices_actions if a.help is not argparse.SUPPRESS]
_add_common_options(parser)
return parser
def main(argv=None):
global _OUTPUT
parser = build_parser()
args = parser.parse_args(argv)
previous = _OUTPUT
_OUTPUT = {"json": args.json, "command": args.command,
"explicit_state": str(resolve_state(args.state_dir)) if args.command != "screen-access" and (args.state_dir or os.environ.get("FRAMEBRIDGE_EXPANDED_STATE") or os.environ.get("FRAMEBRIDGE_STATE_DIR")) else None}
try:
return _dispatch(parser, args)
finally:
_OUTPUT = previous
def _dispatch(parser, args):
state = None if args.command == "screen-access" else resolve_state(args.state_dir)
if args.command is None:
if args.json or args.non_interactive or not sys.stdin.isatty():
if args.json:
return _help_command(argparse.Namespace(command="help", topic="start", json=True, non_interactive=True))
parser.print_help()
return 0
print("FrameBridge\n1. Connect\n2. Status\n3. Doctor\n4. Open console\n5. Help\n6. Advanced setup\n0. Exit")
try:
choice = input("Choose an action: ").strip()
except (EOFError, KeyboardInterrupt):
print("\nCancelled.", file=sys.stderr)
return 130
action = {"1":["connect"], "2":["status"], "3":["doctor"], "4":["console"],
"5":["help"], "6":["setup"]}.get(choice)
if choice == "0":
return 0
if action is None:
print("Choose one of the listed actions.", file=sys.stderr)
return 2
return main(["--state-dir", str(state)] + action)
try:
if args.command == "screen-access":
value = _module("screen_access").command(args)
_show(value)
return _result_code(value)
if args.command in {"help", "tutorial"}:
return _help_command(args)
if args.command == "setup":
if args.json or args.non_interactive or not sys.stdin.isatty():
return _interaction_required("fb setup")
return setup(state)
if args.command == "connect":
if args.json or args.non_interactive:
config_path = state / "config.json"
if config_path.is_file() and load_config(state)["role"] == "node":
raise RuntimeError("A node profile does not publish an MCP server. Connect ChatGPT from the central profile.")
manifest = state / "autostart/installation.json"
if args.foreground or args.replace or not manifest.is_file() or not _saved_noninteractive_ready(state):
return _interaction_required("fb connect" + (" --foreground" if args.foreground else (" --replace" if args.replace else "")))
if args.json:
import contextlib
with contextlib.redirect_stdout(sys.stderr):
code = connect_chatgpt(state, foreground=False, replace=False)
value = _module("lifecycle").status(state)
if code:
value = dict(value, ok=False, error="CONNECTION_NOT_READY")
_show(value)
return code
return connect_chatgpt(state, foreground=args.foreground, replace=args.replace)
if args.command == "status":
return _status_command(args, state)
if args.command == "doctor":
return _doctor_command(args, state)
if args.command == "service":
return _service_command(args, state)
if args.command == "connection":
value = _module("connection_background").status(state)
_show(value)
return _result_code(value)
if args.command == "disconnect":
module = "connection_background" if args.disconnection == "chatgpt" else "lifecycle"
result = _module(module).disconnect(state)
if args.json or args.disconnection == "chatgpt":
_show(result)
elif result.get("ok") is False:
print("Connection pause failed: " + str(result.get("error", "inspect fb status connection --json")), file=sys.stderr)
else:
print("ChatGPT connection paused until fb connect. Local tools and saved connection material are retained.")
return _result_code(result)
if args.command == "autostart":
autostart = _module("autostart")
if args.autostart_action == "enable" and (args.json or args.non_interactive):
value = autostart.status(state)
if not value.get("configured") or not value.get("linger"):
return _interaction_required("fb service boot enable")
result = getattr(autostart, args.autostart_action)(state)
if args.json or args.autostart_action == "status":
_show(result)
return _result_code(result)
if args.autostart_action == "enable":
if result.get("state") == "cancelled":
print("Automatic startup was not enabled.")
return 130
return _show_autostart_enabled(result)
print("Automatic startup disabled." if result.get("state") == "disabled" else "No managed automatic startup configuration was found.")
try:
current = autostart.status(state)
except (OSError, ValueError, RuntimeError):
current = {}
service = current.get("service_state", "unavailable")
if service not in {"active", "activating", "deactivating", "inactive", "failed", "reloading", "maintenance", "refreshing"}:
service = "unavailable"
print("Service: " + service)
if result.get("desktop_setting_restored") is False:
print("Current profile settings were preserved; no previous desktop setting was restored.")
if current.get("linger") is True:
print("User service persistence remains enabled.")
return _result_code(result)
if args.command == "certificates":
value = _module("tls").create_certificates(state, args.host, args.address, listen_host=args.listen_host, advertise_host=args.advertise_host)
_show(value)
return _result_code(value)
if args.command == "nodes":
if args.json and args.node_command == "run" and not args.once:
return _interaction_required("fb nodes run")
return nodes_command(args, state)
if args.command == "console":
action = getattr(args, "console_login_action", None) or args.login_action
if getattr(args, "console_login_action", None) and args.login_action:
raise ValueError("Choose one console login command form.")
if args.status and action:
raise ValueError("Choose console status or a login action, not both.")
if action:
if args.node != "LOCAL":
raise ValueError("Console login setup is local; run it on the requested node.")
result = getattr(_module("console_startup"), action)(state)
_show(result)
return _result_code(result)
console = _module("console")
if args.status:
_show(console.status(state, args.node))
return 0
if args.json or args.non_interactive:
return _interaction_required("fb console")
return console.run(state, args.node, inside=args.inside, mode=args.mode)
if args.command == "tty":
if args.tty_command == "type" and args.text is None and (args.json or args.non_interactive):
return _interaction_required("fb tty type SESSION_ID TEXT")
return tty_command(args, state)
if args.command == "stop":
_module("autostart").stop_for_current_boot(state)
value = getattr(_module("runtime"), args.command)(state)
_show(value)
return _result_code(value)
except (EOFError, KeyboardInterrupt):
if args.json:
_show({"ok":False, "error":"CANCELLED"})
else:
print("\nCancelled.", file=sys.stderr)
return 130
except (OSError, ValueError, RuntimeError) as exc:
if args.json:
_show({"ok":False, "error":"COMMAND_FAILED", "message":str(exc)})
else:
print("Error: " + str(exc), file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,99 @@
"""Validated, private profiles for FrameBridge Expanded."""
from __future__ import annotations
import json
import os
import re
import socket
import stat
import tempfile
import uuid
from pathlib import Path
from urllib.parse import urlsplit
SCHEMA_VERSION = 1
ROLES = ("standalone", "central", "node")
DEFAULT_STATE = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share"))) / "framebridge-expanded"
def state_path(state=None):
return Path(state or os.environ.get("FRAMEBRIDGE_EXPANDED_STATE", DEFAULT_STATE)).expanduser().absolute()
def private_dir(path):
path = Path(path)
if path.is_symlink():
raise ValueError("Profile directories must not be symbolic links.")
path.mkdir(mode=0o700, parents=True, exist_ok=True)
mode = path.stat()
if mode.st_uid != os.getuid() or stat.S_IMODE(mode.st_mode) & 0o077:
raise ValueError("Profile directories must belong to the current user and have mode 0700.")
return path
def default_config(role="standalone", name=None):
if role not in ROLES:
raise ValueError("Choose standalone, central, or node.")
name = name or re.sub(r"[^a-z0-9-]", "-", socket.gethostname().lower()).strip("-") or "computer"
return {
"schema_version": SCHEMA_VERSION, "role": role, "name": name,
"node_id": str(uuid.uuid4()),
"ports": {"mcp": 20865, "hub": 20877, "broker": 20878},
"headless": "auto", "broker_listen_host": "127.0.0.1",
"broker_url": "", "tls_ca": "", "tls_cert": "", "tls_key": "",
"account_label": "", "tunnel_id": ""
}
def validate(config):
if not isinstance(config, dict) or config.get("schema_version") != SCHEMA_VERSION:
raise ValueError("Unsupported profile format.")
if config.get("role") not in ROLES:
raise ValueError("Choose standalone, central, or node.")
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,62}", config.get("name", "")):
raise ValueError("The node name must use 1-63 letters, numbers, hyphens, or underscores.")
uuid.UUID(config["node_id"])
ports = config.get("ports", {})
if set(ports) != {"mcp", "hub", "broker"} or any(type(p) is not int or not 1024 <= p <= 65535 for p in ports.values()):
raise ValueError("Ports must be distinct integers between 1024 and 65535.")
if len(set(ports.values())) != len(ports):
raise ValueError("Choose a different port for each service.")
if config.get("headless", "auto") not in ("auto", "yes", "no", True, False):
raise ValueError("Headless mode must be auto, yes, or no.")
for key in ("api_key", "runtime_key", "password", "token", "secret"):
if key in config:
raise ValueError("Runtime credentials must not be stored in profile configuration.")
broker_url = config.get("broker_url", "")
if broker_url:
url = urlsplit(broker_url)
if url.username or url.password or url.query or url.fragment or url.scheme not in ("https", "http") or not url.hostname:
raise ValueError("Use a broker URL without credentials, query parameters, or fragments.")
if url.scheme == "http" and url.hostname not in ("localhost", "127.0.0.1", "::1"):
raise ValueError("A remote broker requires verified HTTPS.")
return config
def load_config(state=None):
state = state_path(state)
path = state / "config.json"
if not path.is_file():
raise ValueError("This profile is not configured. Run framebridge setup first.")
if path.is_symlink():
raise ValueError("Profile configuration must not be a symbolic link.")
st = path.stat()
if st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) & 0o077:
raise ValueError("Profile configuration must belong to the current user and have mode 0600.")
return validate(json.loads(path.read_text(encoding="utf-8")))
def save_config(state, config):
validate(config)
state = private_dir(state_path(state))
target = state / "config.json"
if target.is_symlink():
raise ValueError("Profile configuration must not be a symbolic link.")
fd, temporary = tempfile.mkstemp(prefix=".config-", dir=state)
try:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump(config, stream, indent=2, sort_keys=True)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, target)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
return config
@@ -0,0 +1,358 @@
"""Profile-scoped detached ChatGPT supervision without a persistent credential."""
from __future__ import annotations
import fcntl
import json
import os
import resource
from pathlib import Path
import signal
import stat
import subprocess
import sys
import tempfile
import threading
import time
from .config import private_dir, state_path
_BOOTSTRAP = "import sys;sys.path.insert(0,sys.argv[1]);from framebridge_expanded.connection_background import worker_main;worker_main(sys.argv[2],int(sys.argv[3]))"
_PACKAGE_PARENT = str(Path(__file__).resolve().parents[1])
_STATES = {"starting", "checking", "access", "ready", "retrying", "rejected", "unverified", "error", "stopped", "closed"}
_MESSAGES = {
"starting": "[1/3] Starting the background tunnel client.",
"checking": "[2/3] Verifying the local MCP service.",
"access": "[3/3] Verifying tunnel access and authenticated polling.",
"ready": "[OK] Secure tunnel connected; local MCP service verified.",
"retrying": "[RETRYING] Waiting for current verified readiness; check the private connection log.",
"rejected": "[ERROR] Tunnel access was rejected; check the runtime key and tunnel permissions.",
"unverified": "[NOT READY] The connection is not verified yet; it continues retrying in the background.",
"error": "[ERROR] Background tunnel stopped or could not be verified; check the private connection log.",
"stopped": "[STOPPED] No background ChatGPT connection is running.",
"closed": "[CLOSED] The background tunnel client stopped.",
}
def _directory(state, create=True):
root = state_path(state)
if create:
private_dir(root)
return private_dir(root / "connection")
path = root / "connection"
if not path.exists() and not path.is_symlink():
return None
for item in (root, path):
st = item.lstat()
if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) & 0o077:
raise RuntimeError("The connection directory is not private and owned by this user.")
return path
def _checked_fd(path, flags, create=False):
fd = os.open(path, flags | os.O_NOFOLLOW | os.O_NONBLOCK | (os.O_CREAT if create else 0), 0o600)
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1 or st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) != 0o600:
raise RuntimeError("The connection state file is not a private regular file.")
return fd
except BaseException:
os.close(fd)
raise
def _read(path):
try:
fd = _checked_fd(path, os.O_RDONLY)
except FileNotFoundError:
return {}
try:
raw = os.read(fd, 32769)
if len(raw) > 32768:
raise RuntimeError("The connection status is too large.")
data = json.loads(raw)
if not isinstance(data, dict):
raise ValueError()
return data
except (ValueError, UnicodeError):
raise RuntimeError("The connection status cannot be read safely.") from None
finally:
os.close(fd)
def _write(path, data):
if path.exists() or path.is_symlink():
fd = _checked_fd(path, os.O_RDONLY)
os.close(fd)
fd, name = tempfile.mkstemp(prefix=".status-", dir=path.parent)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump(data, stream, sort_keys=True)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
finally:
try:
os.unlink(name)
except FileNotFoundError:
pass
def _supervisor_command(state, lock_fd):
return [sys.executable, "-c", _BOOTSTRAP, _PACKAGE_PARENT, str(state_path(state)), str(lock_fd)]
def _identity(pid, state, lock_fd):
proc = Path("/proc") / str(pid)
fields = (proc / "stat").read_text().split(") ", 1)[1].split()
return {"pid": pid, "uid": proc.stat().st_uid, "start_ticks": int(fields[19]),
"boot_id": Path("/proc/sys/kernel/random/boot_id").read_text().strip(),
"session_id": int(fields[3]), "process_group": int(fields[2]),
"state_dir": str(state_path(state)), "lock_fd": lock_fd}
def _valid_identity(record, state):
try:
pid = record["pid"]
lock_fd = record["lock_fd"]
if type(pid) is not int or pid < 2 or type(lock_fd) is not int or lock_fd < 3:
return False
current = _identity(pid, state, lock_fd)
if any(current[k] != record.get(k) for k in current):
return False
if current["uid"] != os.getuid() or current["session_id"] != pid or current["process_group"] != pid:
return False
proc = Path("/proc") / str(pid)
if (proc / "stat").read_text().split(") ", 1)[1].split()[0] == "Z":
return False
if os.path.realpath(proc / "exe") != os.path.realpath(sys.executable):
return False
argv = (proc / "cmdline").read_bytes().split(b"\0")[:-1]
if argv != [os.fsencode(x) for x in _supervisor_command(state, lock_fd)]:
return False
lock = _directory(state, False) / "owner.lock"
return os.stat(proc / "fd" / str(lock_fd)).st_ino == lock.stat().st_ino and os.stat(proc / "fd" / str(lock_fd)).st_dev == lock.stat().st_dev
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return False
def status(state):
directory = _directory(state, False)
record = _read(directory / "status.json") if directory else {}
alive = bool(record) and _valid_identity(record, state)
phase = record.get("state", "stopped")
if not isinstance(phase, str) or phase not in _STATES:
phase = "error"
if not alive and phase not in {"error", "closed", "stopped"}:
phase = "stopped"
checked = record.get("verified_at")
verified = alive and phase == "ready" and type(checked) in (int, float) and 0 <= time.time() - checked <= 15
if alive and phase == "ready" and not verified:
phase = "retrying"
return {"ok": True, "alive": alive, "verified": verified, "state": phase,
"message": _MESSAGES[phase],
"pid": record.get("pid") if alive else None,
"log": str(state_path(state) / "logs/chatgpt/connection.log")}
def disconnect(state, *, timeout=16):
directory = _directory(state, False)
record = _read(directory / "status.json") if directory else {}
if not record or not _valid_identity(record, state):
return {"ok": True, "alive": False, "state": "stopped", "message": "No owned background tunnel is running."}
if not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"):
return {"ok": False, "alive": True, "state": "error", "message": "Safe process signaling is unavailable; the connection was left unchanged."}
try:
fd = os.pidfd_open(record["pid"])
except ProcessLookupError:
return {"ok": True, "alive": False, "state": "stopped"}
try:
if not _valid_identity(record, state):
return {"ok": False, "alive": False, "state": "error", "message": "Connection identity changed; no signal was sent."}
try:
signal.pidfd_send_signal(fd, signal.SIGINT)
except ProcessLookupError:
pass
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if not _valid_identity(record, state):
return {"ok": True, "alive": False, "state": "stopped", "message": "Background ChatGPT connection stopped."}
time.sleep(0.1)
return {"ok": False, "alive": True, "state": "error", "message": "Connection cleanup is still running; local services were left unchanged."}
finally:
os.close(fd)
class _StatusOutput:
"""Progress destination owned by the worker, with no parent pipe or terminal."""
def __init__(self, path, identity):
self.path = path
self.record = dict(identity)
self.pending = ""
self.change("starting", "[1/3] Starting the background tunnel client.")
def isatty(self):
return False
def flush(self):
pass
def change(self, state, message):
self.record.update(state=state, message=message, updated_at=time.time())
_write(self.path, self.record)
def observe(self, state):
self.record["checked_at"] = time.time()
self.record["verified_at"] = time.time() if state == "ready" else None
_write(self.path, self.record)
def write(self, text):
self.pending += text
while "\n" in self.pending:
line, self.pending = self.pending.split("\n", 1)
phase = next((phase for token, phase in [("[1/3]", "starting"), ("[2/3]", "checking"), ("[3/3]", "access"), ("[OK]", "ready"), ("[RETRYING]", "retrying"), ("[NOT READY]", "unverified"), ("[ERROR]", "error"), ("[STOPPED]", "stopped"), ("[CLOSED]", "closed")] if line.startswith(token)), None)
if phase:
self.change(phase, line)
if len(self.pending) > 4096:
self.pending = ""
return len(text)
def worker_main(state, lock_fd):
"""Read one bounded anonymous-pipe payload; the credential is never a file."""
os.umask(0o077)
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
payload = credential = None
sink = None
rc = 1
try:
directory = _directory(state)
held = os.fstat(lock_fd)
owned = (directory / "owner.lock").stat()
if (held.st_dev, held.st_ino) != (owned.st_dev, owned.st_ino):
return
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
raw = sys.stdin.buffer.read(65537)
if len(raw) > 65536:
return
payload = json.loads(raw)
raw = None
credential = payload.pop("credential")
command = payload["command"]
if not isinstance(credential, str) or not credential or any(c.isspace() for c in credential) or len(credential) > 16384:
return
if not isinstance(command, list) or not command or any(not isinstance(v, str) or "\0" in v for v in command):
return
sink = _StatusOutput(directory / "status.json", _identity(os.getpid(), state, lock_fd))
from .tunnel import run_foreground
rc = run_foreground(command, credential, state=state, output=sink, observe=sink.observe)
except BaseException:
rc = 1
finally:
credential = payload = None
if sink is not None:
try:
sink.change("stopped" if rc in (0, 130) else "error", "[STOPPED] Background ChatGPT connection stopped." if rc in (0, 130) else "[ERROR] Background tunnel stopped; check the private connection log.")
except Exception:
pass
try:
os.close(lock_fd)
except OSError:
pass
def start(command, credential, state, expected_tunnel=None, expected_mcp=None, *, output=None, wait_timeout=95):
"""Detach the owned supervisor, then wait for verified readiness or a clear result."""
output = output or sys.stdout
directory = _directory(state)
lock_fd = _checked_fd(directory / "owner.lock", os.O_RDWR, create=True)
child = None
try:
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
current = status(state)
output.write("A ChatGPT connection already owns this profile. Use framebridge connection status.\n")
return 0 if current["verified"] else 1
# No earlier status may be mistaken for this new supervisor.
old = directory / "status.json"
if old.exists() or old.is_symlink():
check = _checked_fd(old, os.O_RDONLY)
os.close(check)
old.unlink()
env = dict(os.environ)
from .tunnel import KEY_ENV, _RUNTIME_PREFIXES, _RUNTIME_NAMES
for name in list(env):
if name.startswith(_RUNTIME_PREFIXES) or name in _RUNTIME_NAMES:
env.pop(name, None)
child = subprocess.Popen(_supervisor_command(state, lock_fd), stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
pass_fds=(lock_fd,), close_fds=True, start_new_session=True, env=env)
threading.Thread(target=child.wait, daemon=True, name="framebridge-tunnel-reaper").start()
try:
payload = json.dumps({"credential": credential, "command": list(command)}).encode()
if len(payload) > 65536:
raise ValueError("The connection input is too large.")
child.stdin.write(payload)
child.stdin.close()
payload = None
except (OSError, ValueError):
child.stdin.close()
output.write("[ERROR] The background connection could not receive its private input.\n")
return 1
finally:
credential = None
# Closing, rather than LOCK_UN, preserves the child's inherited flock.
os.close(lock_fd)
lock_fd = None
deadline = time.monotonic() + wait_timeout
last = None
while time.monotonic() < deadline:
current = status(state)
if current["alive"] and current["message"] != last:
output.write(current["message"] + "\n")
output.flush()
last = current["message"]
if current["verified"]:
output.write("The connection is running in the background; this terminal can be closed.\n")
output.write("Use framebridge connection status or framebridge disconnect chatgpt.\n")
output.write("Select this tunnel in ChatGPT. A successful tool call verifies that connection.\n")
return 0
if child.poll() is not None:
output.write("[ERROR] The background tunnel stopped before verified readiness. Check the private connection log.\n")
return 1
time.sleep(0.2)
output.write("[NOT READY] Verification is still pending. The connection continues retrying in the background; use framebridge connection status.\n")
return 1
except KeyboardInterrupt:
if child is not None:
result = disconnect(state)
# The Popen child remains ours even before it has published status.
if child.poll() is None:
try:
child.send_signal(signal.SIGINT)
child.wait(timeout=16)
except (OSError, subprocess.TimeoutExpired):
result = {"ok": False}
if not result["ok"]:
output.write("[ERROR] Check framebridge connection status before reconnecting.\n")
return 130
finally:
credential = None
if lock_fd is not None:
os.close(lock_fd)
def run_foreground(command, credential, state, *, output=None):
"""Retain explicit diagnostic mode, exclusive with detached connections."""
directory = _directory(state)
fd = _checked_fd(directory / "owner.lock", os.O_RDWR, create=True)
try:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError("A ChatGPT connection already owns this profile. Disconnect it before using foreground mode.") from None
from .tunnel import run_foreground as run
return run(command, credential, state=state, output=output)
finally:
os.close(fd)
@@ -0,0 +1,358 @@
"""Private connection logs and evidence-based terminal status.
This module monitors the packaged tunnel client. A ready tunnel is not evidence
that the intended ChatGPT account has completed consent or made a tool call.
"""
from __future__ import annotations
import datetime
import fcntl
import http.client
import ipaddress
import json
import math
import os
from pathlib import Path
import re
import selectors
import stat
import threading
import time
import uuid
import unicodedata
from urllib.parse import urlsplit
MAX_LINE = 64 * 1024
MAX_LOG = 2 * 1024 * 1024
LOG_COPIES = 3
POLL_FRESH_SECONDS = 75
_ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))")
class ConnectionMonitorError(RuntimeError):
"""A safe public error; details belong only in sanitized logs."""
def _private_directory(path):
path = Path(path).absolute()
for part in [path, *path.parents]:
if part.is_symlink():
raise ConnectionMonitorError("The private connection directory is unsafe.")
try:
path.mkdir(mode=0o700)
except FileExistsError:
pass
info = path.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
raise ConnectionMonitorError("The private connection directory is unsafe.")
return path
def redact(text, secret):
"""Normalize terminal controls before redacting and persisting a record."""
text = _ANSI.sub("", str(text))
text = "".join(c for c in text if c in "\n\t" or not unicodedata.category(c).startswith("C"))
if secret:
text = text.replace(secret, "[REDACTED]")
# A child may exit after emitting only a credential prefix.
tail = text.rstrip("\r\n")
suffix = text[len(tail):]
for length in range(min(len(secret) - 1, len(tail)), 7, -1):
if tail.endswith(secret[:length]):
text = tail[:-length] + "[REDACTED]" + suffix
break
text = re.sub(r"\bsk-[A-Za-z0-9_-]{8,}", "[REDACTED]", text)
# Preserve a useful proxy/server location without credential-bearing userinfo.
text = re.sub(r"(?i)(\b[a-z][a-z0-9+.-]*://)[^/\s?#]*@", r"\1[REDACTED]@", text)
# Conservative whole-tail redaction prevents partial Cookie/password leaks.
text = re.sub(r"(?im)(\b(?:authorization|proxy-authorization|api[_-]?key|access[_-]?token|"
r"refresh[_-]?token|password|passwd|cookie|set-cookie)\b[\"']?\s*[:=]\s*).*$",
r"\1[REDACTED]", text)
return text
class PrivateLog:
"""A bounded rotating log, protected against symlinks and shared files."""
def __init__(self, state, secret, *, max_bytes=MAX_LOG):
root = _private_directory(state)
root = _private_directory(root / "logs")
self.directory = _private_directory(root / "chatgpt")
self.path = self.directory / "connection.log"
self.secret = secret
self.max_bytes = max_bytes
self.directory_fd = os.open(self.directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
self.lock_fd = self._open("connection.lock", os.O_RDWR | os.O_CREAT)
except Exception:
os.close(self.directory_fd)
raise
self.mutex = threading.Lock()
def _open(self, name, flags):
fd = os.open(name, flags | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK, 0o600, dir_fd=self.directory_fd)
info = os.fstat(fd)
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
or info.st_nlink != 1 or info.st_mode & 0o077):
os.close(fd)
raise ConnectionMonitorError("The private connection log is unsafe.")
return fd
def _validate_existing(self, name):
try:
fd = self._open(name, os.O_RDONLY)
except FileNotFoundError:
return False
os.close(fd)
return True
def write(self, text):
clean = redact(str(text), self.secret)
# All callers provide a complete bounded logical record.
if len(clean.encode("utf-8")) > MAX_LINE * 4:
clean = "[Oversized log record omitted]"
stamp = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
data = (stamp + " " + clean.rstrip("\r\n") + "\n").encode("utf-8", errors="replace")
with self.mutex:
deadline = time.monotonic() + 1.5
while True:
try:
fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
if time.monotonic() >= deadline:
raise ConnectionMonitorError("The private connection log is busy.") from None
time.sleep(0.02)
try:
fd = self._open("connection.log", os.O_WRONLY | os.O_APPEND | os.O_CREAT)
try:
size = os.fstat(fd).st_size
finally:
os.close(fd)
if size + len(data) > self.max_bytes:
existing = {i: self._validate_existing("connection.log" + ("." + str(i) if i else ""))
for i in range(LOG_COPIES)}
if existing[LOG_COPIES - 1]:
os.unlink("connection.log." + str(LOG_COPIES - 1), dir_fd=self.directory_fd)
for i in range(LOG_COPIES - 2, -1, -1):
if existing[i]:
os.rename("connection.log" + ("." + str(i) if i else ""),
"connection.log." + str(i + 1),
src_dir_fd=self.directory_fd, dst_dir_fd=self.directory_fd)
fd = self._open("connection.log", os.O_WRONLY | os.O_APPEND | os.O_CREAT)
try:
view = memoryview(data)
while view:
count = os.write(fd, view)
if count <= 0:
raise OSError("Short private log write")
view = view[count:]
finally:
os.close(fd)
finally:
fcntl.flock(self.lock_fd, fcntl.LOCK_UN)
def close(self):
self.secret = None
for name in ("lock_fd", "directory_fd"):
fd = getattr(self, name, None)
if fd is not None:
os.close(fd)
setattr(self, name, None)
class Attempt:
def __init__(self, state):
root = _private_directory(state)
self.root = _private_directory(root / "connection-runtime")
self.directory = _private_directory(self.root / uuid.uuid4().hex)
self.health_file = self.directory / "health.url"
def close(self):
# Remove only this attempt's own known file, never a symlink or unknown data.
try:
info = self.health_file.lstat()
if stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid() and info.st_nlink == 1:
self.health_file.unlink()
except FileNotFoundError:
pass
try:
self.directory.rmdir()
except OSError:
pass
def health_endpoint(path):
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK)
try:
info = os.fstat(fd)
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1
or info.st_mode & 0o022 or not 0 < info.st_size <= 512):
raise ConnectionMonitorError("The tunnel health endpoint is unsafe.")
text = os.read(fd, 513).decode("ascii").strip()
finally:
os.close(fd)
value = urlsplit(text)
try:
address = ipaddress.ip_address(value.hostname or "")
port = value.port
except ValueError:
raise ConnectionMonitorError("The tunnel health endpoint is unsafe.") from None
if (value.scheme != "http" or not address.is_loopback or not port or "%" in text
or value.username or value.password or value.path not in ("", "/")
or value.query or value.fragment):
raise ConnectionMonitorError("The tunnel health endpoint is unsafe.")
return str(address), port
def _get(endpoint, path, limit):
# Direct literal-loopback HTTP: no DNS, inherited proxy, or redirects.
connection = http.client.HTTPConnection(*endpoint, timeout=0.6)
try:
connection.request("GET", path, headers={"Connection": "close"})
response = connection.getresponse()
body = response.read(limit + 1)
if len(body) > limit:
raise ConnectionMonitorError("The tunnel health response is too large.")
return response.status, body
finally:
connection.close()
def probe(health_file, expected_tunnel, expected_mcp, started, *, now=None):
"""Return a public state and private detail from the pinned client contract."""
now = time.time() if now is None else now
try:
endpoint = health_endpoint(health_file)
except FileNotFoundError:
return "starting", "Waiting for the tunnel health endpoint."
except (OSError, ValueError, ConnectionMonitorError):
return "checking", "The tunnel health endpoint is not available or valid."
try:
code, body = _get(endpoint, "/readyz", 8192)
message = body.decode("utf-8", errors="replace").strip()
if code != 200 or message != "ready":
return "checking", "MCP readiness is pending: " + message
code, body = _get(endpoint, "/api/status", 65536)
if code != 200:
return "checking", "Tunnel status is not available."
status = json.loads(body)
if not isinstance(status, dict):
return "checking", "Tunnel status has an unexpected format."
if (status.get("control_plane_tunnel_id") != expected_tunnel
or status.get("mcp_server_url") != expected_mcp):
return "checking", "Tunnel status does not match this profile."
created = datetime.datetime.fromisoformat(status["started_at"].replace("Z", "+00:00")).timestamp()
if not started - 5 <= created <= now + 5:
return "checking", "Tunnel status belongs to a different startup."
metadata = status.get("tunnel_metadata") or {}
if not isinstance(metadata, dict):
return "checking", "Tunnel metadata has an unexpected format."
if status.get("tunnel_metadata_error"):
detail = str(status["tunnel_metadata_error"])
if re.search(r"\b(?:401|403|unauthorized|forbidden)\b", detail, re.I):
return "rejected", "Tunnel access was rejected: " + detail
return "access", "Tunnel metadata request failed: " + detail
if metadata.get("ID") != expected_tunnel:
return "access", "Waiting for authenticated tunnel metadata."
code, body = _get(endpoint, "/metrics", 256 * 1024)
if code != 200:
return "access", "Waiting for authenticated polling evidence."
values = []
for line in body.decode("ascii", errors="replace").splitlines():
if line.startswith("#"):
continue
match = re.fullmatch(r"commands_poll_last_successful_timestamp_seconds(?:\{[^\n]*\})?\s+([0-9.eE+-]+)(?:\s+\d+)?", line)
if match:
values.append(float(match.group(1)))
if not values or not all(math.isfinite(v) for v in values):
return "access", "Waiting for authenticated polling evidence."
recent = max(values)
if recent < started - 5 or recent > now + 5 or now - recent > POLL_FRESH_SECONDS:
return "access", "A successful authenticated poll has not been confirmed recently."
return "ready", "Local MCP initialization, tunnel identity, metadata and recent authenticated polling verified."
except (OSError, ValueError, KeyError, TypeError, http.client.HTTPException, ConnectionMonitorError):
return "checking", "The tunnel health check is temporarily unavailable."
class Progress:
def __init__(self, output):
self.output = output
self.tty = bool(getattr(output, "isatty", lambda: False)())
self.state = None
self.message = ""
self.width = 0
self.index = 0
def change(self, state, message):
if self.state == state and self.message == message:
return
self.clear()
self.state, self.message = state, message
self.output.write(message + "\n")
self.output.flush()
def clear(self):
if self.tty and self.width:
self.output.write("\r" + " " * self.width + "\r")
self.width = 0
def tick(self):
if self.tty and self.state not in {"ready", "closed", "error", "rejected", "unverified"}:
text = "Connecting " + "|/-\\"[self.index % 4] + " Press Ctrl+C to cancel."
self.index += 1
self.width = max(self.width, len(text))
self.output.write("\r" + text.ljust(self.width))
self.output.flush()
def capture_output(stream, log, errors, stop):
"""Drain binary output without ever forwarding it to the terminal."""
buffer = bytearray()
dropping = False
try:
fd = stream.fileno()
selector = selectors.DefaultSelector()
selector.register(fd, selectors.EVENT_READ)
while True:
if not selector.select(timeout=0.1):
if stop.is_set():
break
continue
chunk = os.read(fd, 4096)
if not chunk:
break
for part in chunk.splitlines(keepends=True):
ending = part.endswith(b"\n") or part.endswith(b"\r")
if not dropping:
if len(buffer) + len(part) > MAX_LINE:
buffer.clear()
dropping = True
else:
buffer.extend(part)
if ending:
if dropping:
log.write("[Oversized client output line omitted]")
else:
log.write(buffer.decode("utf-8", errors="replace"))
buffer.clear()
dropping = False
if buffer and not dropping:
log.write(buffer.decode("utf-8", errors="replace"))
elif dropping:
log.write("[Oversized client output line omitted]")
except Exception:
errors.append("Connection output could not be written to its private log.")
finally:
if "selector" in locals():
selector.close()
if stop.is_set():
# If the main thread timed out waiting for draining, this thread
# remains the owner of its live stream/log until it finishes.
try:
stream.close()
except Exception:
errors.append("Connection output stream cleanup failed.")
try:
log.close()
except Exception:
errors.append("Private connection log cleanup failed.")
@@ -0,0 +1,256 @@
"""Local authenticated Bash/FBTERM console, shared across desktop and TTY clients."""
from __future__ import annotations
import argparse
import hashlib
import os
from pathlib import Path
import pwd
import re
import shutil
import socket
import stat
import subprocess
import sys
from .config import load_config, private_dir, state_path
_NODE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}\Z")
def _node(state, value):
value = value or "LOCAL"
if type(value) is not str or _NODE.fullmatch(value) is None:
raise ValueError("CONSOLE_NODE_INVALID")
host = socket.gethostname()
names = {"local", "localhost", host.lower(), host.split(".")[0].lower()}
try:
config = load_config(state)
except (ValueError, OSError):
config = {}
name = config.get("name")
if isinstance(name, str):
names.add(name.lower())
if value.lower() not in names:
raise RuntimeError(
"REMOTE_CONSOLE_SSH_CONFIGURATION_REQUIRED: run fb console on the target node, "
"or use your explicitly configured SSH destination. Remote EXEC, CUA and TTY "
"operations remain available through the enrolled broker.")
return re.sub(r"[^A-Za-z0-9_-]", "_", host)[:64] or "NODE"
def _private_runtime(path):
path = Path(path)
info = path.lstat()
if (not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode)
or info.st_uid != os.getuid() or info.st_mode & 0o077):
raise RuntimeError("CONSOLE_RUNTIME_DIRECTORY_UNTRUSTED")
return path
def context(state, node="LOCAL", *, create=False):
state = state_path(state)
label = _node(state, node)
key = hashlib.sha256(str(state).encode()).hexdigest()[:20]
runtime = _private_runtime(Path("/run/user") / str(os.getuid()))
root = runtime / "framebridge-expanded-console"
profile = root / key
if create:
private_dir(state)
for path in (root, profile):
path.mkdir(mode=0o700, exist_ok=True)
_private_runtime(path)
else:
for path in (root, profile):
if path.exists() or path.is_symlink():
_private_runtime(path)
return {
"state": state, "node": label, "runtime": runtime,
"socket": profile / "console.sock",
"session": "FB-" + label + "-" + key[:10],
"jobs_root": state / "home/.local/share/framebridge/fbterm",
}
def _properties(text):
return dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
def _active_graphical_display(env=None):
"""Auto GUI requires the actual active local X11 session, never a guessed Xvfb."""
env = os.environ if env is None else env
display = env.get("DISPLAY", "")
sid = env.get("XDG_SESSION_ID", "")
if (re.fullmatch(r":[0-9]+(?:\.[0-9]+)?", display) is None
or re.fullmatch(r"[A-Za-z0-9_-]{1,80}", sid) is None):
return False
try:
result = subprocess.run([
"loginctl", "show-session", sid, "--no-pager",
"-p", "User", "-p", "Type", "-p", "Active", "-p", "Remote",
"-p", "Display", "-p", "VTNr",
], capture_output=True, text=True, timeout=5)
if result.returncode:
return False
fields = _properties(result.stdout)
vt = fields.get("VTNr", "")
if not vt.isdigit() or int(vt) < 1:
return False
active = Path("/sys/class/tty/tty0/active").read_text().strip()
return (fields.get("User") == str(os.getuid())
and fields.get("Type") == "x11"
and fields.get("Active") == "yes"
and fields.get("Remote") == "no"
and fields.get("Display", "").split(".")[0] == display.split(".")[0]
and active == "tty" + str(int(vt)))
except (OSError, subprocess.SubprocessError):
return False
def _environment(ctx):
env = os.environ.copy()
real_home = pwd.getpwuid(os.getuid()).pw_dir
env.update({
"HOME": real_home,
"XDG_RUNTIME_DIR": str(ctx["runtime"]),
"FRAMEBRIDGE_EXPANDED_STATE": str(ctx["state"]),
"FRAMEBRIDGE_FBTERM_ROOT": str(ctx["jobs_root"]),
"FRAMEBRIDGE_FBTERM_ROOT_OVERRIDE": str(ctx["jobs_root"]),
"FBTERM_ROOT": str(ctx["jobs_root"]),
"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8",
})
# The installed command adjusts sys.path only in its own process. Make the
# new graphical-terminal process resolve this same installed module.
package_root = str(Path(__file__).resolve().parent.parent)
env["PYTHONPATH"] = os.pathsep.join(
[package_root] + ([env["PYTHONPATH"]] if env.get("PYTHONPATH") else []))
env.pop("TMUX", None)
env.pop("TMUX_PANE", None)
return env
def _socket_exists(sock):
try:
info = sock.lstat()
except FileNotFoundError:
return False
if (not stat.S_ISSOCK(info.st_mode) or stat.S_ISLNK(info.st_mode)
or info.st_uid != os.getuid() or info.st_mode & 0o077):
raise RuntimeError("CONSOLE_SOCKET_UNTRUSTED")
return True
def status(state, node="LOCAL"):
ctx = context(state, node)
sock = ctx["socket"]
value = {"ok": True, "node": ctx["node"], "socket": str(sock),
"session": ctx["session"], "jobs_root": str(ctx["jobs_root"]),
"exists": False, "panes": []}
if not _socket_exists(sock):
return value
try:
result = subprocess.run([
"/usr/bin/tmux", "-S", str(sock), "list-panes",
"-t", "=" + ctx["session"],
"-F", "#{pane_id}|#{pane_pid}|#{pane_current_command}|#{pane_width}|#{pane_height}|#{@framebridge_role}",
], capture_output=True, text=True, timeout=5)
except (OSError, subprocess.SubprocessError):
value.update(ok=False, error="CONSOLE_TMUX_STATUS_FAILED")
return value
if result.returncode:
value.update(ok=False, error="CONSOLE_TMUX_STATUS_FAILED")
return value
value["exists"] = True
for line in result.stdout.splitlines():
fields = line.split("|")
if len(fields) != 6:
raise RuntimeError("CONSOLE_TMUX_STATUS_INVALID")
pane, pid, command, width, height, role = fields
if not re.fullmatch(r"%[0-9]+", pane) or not all(x.isdigit() for x in [pid, width, height]):
raise RuntimeError("CONSOLE_TMUX_STATUS_INVALID")
value["panes"].append({"pane": pane, "pid": int(pid), "command": command,
"cols": int(width), "rows": int(height), "role": role})
return value
def _prevent_recursive_attach(ctx, env):
"""Reject attaching a console from one of its own panes before reflow."""
marker = os.environ.get("TMUX", "")
if not marker:
return
parts = marker.rsplit(",", 2)
try:
same_socket = Path(parts[0]).resolve() == ctx["socket"].resolve()
except (OSError, RuntimeError):
raise RuntimeError("CONSOLE_TMUX_CONTEXT_UNVERIFIED") from None
if not same_socket:
return
pane = os.environ.get("TMUX_PANE", "")
if (len(parts) != 3 or not parts[1].isdigit() or not parts[2].isdigit()
or re.fullmatch(r"%[0-9]+", pane) is None):
raise RuntimeError("CONSOLE_TMUX_CONTEXT_UNVERIFIED")
try:
result = subprocess.run([
"/usr/bin/tmux", "-S", str(ctx["socket"]), "list-panes", "-s",
"-t", "=" + ctx["session"], "-F", "#{pane_id}",
], env=env, capture_output=True, text=True, timeout=5)
except (OSError, subprocess.SubprocessError):
raise RuntimeError("CONSOLE_TMUX_CONTEXT_UNVERIFIED") from None
panes = result.stdout.splitlines() if isinstance(result.stdout, str) else []
if (result.returncode or not panes
or any(re.fullmatch(r"%[0-9]+", value) is None for value in panes)):
raise RuntimeError("CONSOLE_TMUX_CONTEXT_UNVERIFIED")
if pane in panes:
raise RuntimeError(
"CONSOLE_ALREADY_ATTACHED: This terminal is already inside this "
"FrameBridge console. Use its existing shell or open a separate terminal.")
def run(state, node="LOCAL", *, inside=False, mode="auto"):
if mode not in {"auto", "gui", "tty"}:
raise ValueError("CONSOLE_MODE_INVALID")
ctx = context(state, node, create=True)
env = _environment(ctx)
_socket_exists(ctx["socket"])
ui = Path(__file__).resolve().parent / "console_ui/framebridge_console.py"
if not ui.is_file():
raise RuntimeError("CONSOLE_COMPONENT_NOT_INSTALLED")
graphical = _active_graphical_display(env)
if not inside and mode != "tty" and (mode == "gui" or graphical):
if not graphical:
raise RuntimeError("CONSOLE_ACTIVE_X11_DESKTOP_REQUIRED")
terminal = shutil.which("gnome-terminal")
if not terminal:
raise RuntimeError("CONSOLE_GRAPHICAL_TERMINAL_UNAVAILABLE")
try:
result = subprocess.run([
terminal, "--window", "--maximize",
"--title=FrameBridge Console | " + ctx["node"], "--",
sys.executable, "-m", "framebridge_expanded.console",
"--state-dir", str(ctx["state"]), "--node", node, "--inside", "--mode", "gui",
], env=env, capture_output=True, text=True, timeout=15)
except (OSError, subprocess.SubprocessError):
raise RuntimeError("CONSOLE_GRAPHICAL_TERMINAL_FAILED") from None
if result.returncode:
raise RuntimeError("CONSOLE_GRAPHICAL_TERMINAL_FAILED")
return 0
if not os.isatty(0):
raise RuntimeError("CONSOLE_INTERACTIVE_TERMINAL_REQUIRED")
_prevent_recursive_attach(ctx, env)
try:
result = subprocess.run([
sys.executable, str(ui), "--node", ctx["node"],
"--socket", str(ctx["socket"]), "--session", ctx["session"] ,
"--layout", "horizontal" if inside else "vertical", "--apply",
], env=env, capture_output=True, text=True, timeout=20)
except (OSError, subprocess.SubprocessError):
raise RuntimeError("CONSOLE_LAYOUT_FAILED") from None
if result.returncode:
raise RuntimeError("CONSOLE_LAYOUT_FAILED")
os.execve("/usr/bin/tmux", [
"tmux", "-S", str(ctx["socket"]), "attach-session", "-f", "ignore-size",
"-t", "=" + ctx["session"],
], env)
return 0
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--state-dir")
parser.add_argument("--node", default="LOCAL")
parser.add_argument("--inside", action="store_true")
parser.add_argument("--mode", choices=["auto", "gui", "tty"], default="auto")
args = parser.parse_args(argv)
return run(state_path(args.state_dir), args.node, inside=args.inside, mode=args.mode)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,390 @@
"""Explicit, reversible console startup after an authenticated tty1 Bash login.
Only the caller's owned Bash startup files are changed. No login, sudo policy,
system service, desktop autostart, or authentication configuration is altered.
"""
from contextlib import contextmanager
from datetime import datetime, timezone
import errno
import fcntl
import hashlib
import json
import os
from pathlib import Path
import pwd
import re
import shlex
import stat
import tempfile
import uuid
STARTUP_NAMES = (".bash_profile", ".bash_login", ".profile")
BEGIN = b"# BEGIN FRAMEBRIDGE CONSOLE LOGIN v1 "
END = b"# END FRAMEBRIDGE CONSOLE LOGIN v1 "
FB_EXECUTABLE = "/usr/bin/fb"
class ConsoleStartupError(ValueError):
pass
def _need(value, code):
if not value:
raise ConsoleStartupError(code)
def _sha(data):
return hashlib.sha256(data).hexdigest()
def _absolute(value):
try:
return Path(os.path.abspath(os.path.expanduser(os.fspath(value))))
except (TypeError, ValueError):
raise ConsoleStartupError("CONSOLE_LOGIN_PATH_INVALID") from None
def _no_symlink_parents(path):
for part in [path, *path.parents]:
if part.is_symlink():
raise ConsoleStartupError("CONSOLE_LOGIN_SYMLINK_REFUSED")
def _paths(state, home):
uid = os.getuid()
_need(uid != 0 and os.geteuid() == uid, "CONSOLE_LOGIN_USER_REQUIRED")
home = _absolute(home if home is not None else pwd.getpwuid(uid).pw_dir)
state = _absolute(state)
_no_symlink_parents(home)
_no_symlink_parents(state)
try:
info = home.lstat()
except OSError:
raise ConsoleStartupError("CONSOLE_LOGIN_HOME_UNAVAILABLE") from None
_need(stat.S_ISDIR(info.st_mode) and info.st_uid == uid
and not info.st_mode & 0o022, "CONSOLE_LOGIN_HOME_NOT_OWNED")
if state.exists():
_private_dir(state, create=False)
return state, home
def _private_dir(path, create=True):
_no_symlink_parents(path)
if create:
path.mkdir(mode=0o700, parents=True, exist_ok=True)
info = path.lstat()
_need(stat.S_ISDIR(info.st_mode) and info.st_uid == os.getuid()
and stat.S_IMODE(info.st_mode) == 0o700,
"CONSOLE_LOGIN_STATE_NOT_PRIVATE")
def _snapshot(path):
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except FileNotFoundError:
return None
except OSError as error:
if error.errno == errno.ELOOP:
raise ConsoleStartupError("CONSOLE_LOGIN_SYMLINK_REFUSED") from None
raise
try:
info = os.fstat(fd)
_need(stat.S_ISREG(info.st_mode), "CONSOLE_LOGIN_FILE_NOT_REGULAR")
_need(info.st_uid == os.getuid(), "CONSOLE_LOGIN_FILE_NOT_OWNED")
with os.fdopen(fd, "rb") as stream:
fd = None
data = stream.read()
return {"data": data, "sha256": _sha(data),
"mode": stat.S_IMODE(info.st_mode), "gid": info.st_gid,
"atime_ns": info.st_atime_ns, "mtime_ns": info.st_mtime_ns}
finally:
if fd is not None:
os.close(fd)
def _startup_files(home):
return {name: _snapshot(home / name) for name in STARTUP_NAMES}
def _effective(snapshots):
return next((name for name in STARTUP_NAMES if snapshots[name] is not None), ".profile")
def _markers(snapshots):
found = []
for name, snapshot in snapshots.items():
if snapshot is None:
continue
for line in snapshot["data"].splitlines():
if line.startswith((b"# BEGIN FRAMEBRIDGE CONSOLE LOGIN",
b"# END FRAMEBRIDGE CONSOLE LOGIN")):
found.append((name, line))
return found
@contextmanager
def _home_lock(home, create):
path = home / ".framebridge-console-login.lock"
flags = os.O_RDWR | os.O_NOFOLLOW | (os.O_CREAT if create else 0)
try:
fd = os.open(path, flags, 0o600)
except FileNotFoundError:
yield
return
except OSError as error:
if error.errno == errno.ELOOP:
raise ConsoleStartupError("CONSOLE_LOGIN_SYMLINK_REFUSED") from None
raise
try:
info = os.fstat(fd)
_need(stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid()
and stat.S_IMODE(info.st_mode) == 0o600, "CONSOLE_LOGIN_LOCK_UNTRUSTED")
fcntl.flock(fd, fcntl.LOCK_EX if create else fcntl.LOCK_SH)
yield
finally:
os.close(fd)
def _atomic_write(path, data, mode=0o600, gid=None, new=False):
fd, temporary = tempfile.mkstemp(prefix=".fb-console-login-", dir=path.parent)
try:
with os.fdopen(fd, "wb") as stream:
if gid is not None and os.fstat(stream.fileno()).st_gid != gid:
os.fchown(stream.fileno(), -1, gid)
os.fchmod(stream.fileno(), mode)
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
if new:
os.link(temporary, path, follow_symlinks=False)
else:
os.replace(temporary, path)
finally:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
def _profile_id(state):
return _sha(os.fsencode(str(state)))
def _read_manifest(state, home):
directory = state / "console-login"
path = directory / "manifest.json"
if not path.exists() and not path.is_symlink():
return None
_private_dir(state, create=False)
_private_dir(directory, create=False)
snapshot = _snapshot(path)
_need(snapshot is not None and snapshot["mode"] == 0o600,
"CONSOLE_LOGIN_MANIFEST_UNTRUSTED")
try:
value = json.loads(snapshot["data"])
valid = (type(value) is dict and value["schema"] == 1
and value["state"] == str(state) and value["home"] == str(home)
and value["profile_id"] == _profile_id(state)
and value["startup_name"] in STARTUP_NAMES
and value["phase"] in {"PREPARED", "ENABLED", "DISABLED"}
and type(value["existed_before"]) is bool
and type(value["before_mode"]) is int
and 0 <= value["before_mode"] <= 0o7777
and type(value["before_gid"]) is int
and value["before_gid"] >= 0
and re.fullmatch(r"[a-f0-9]{32}", value["transaction"]) is not None
and re.fullmatch(r"[a-f0-9]{64}", value["after_sha256"]) is not None)
if value["existed_before"]:
valid = (valid and re.fullmatch(r"[a-f0-9]{64}", value["before_sha256"]) is not None
and value["backup_name"] == "backup-" + value["transaction"] + ".bin")
else:
valid = valid and value["before_sha256"] is None and value["backup_name"] is None
_need(valid, "CONSOLE_LOGIN_MANIFEST_INVALID")
return value
except (KeyError, TypeError, ValueError):
raise ConsoleStartupError("CONSOLE_LOGIN_MANIFEST_INVALID") from None
def _write_manifest(directory, record):
_atomic_write(directory / "manifest.json",
(json.dumps(record, indent=2) + "\n").encode())
def _known_original(snapshot, record):
if not record["existed_before"]:
return snapshot is None
return (snapshot is not None and snapshot["sha256"] == record["before_sha256"]
and snapshot["mode"] == record["before_mode"]
and snapshot["gid"] == record["before_gid"])
def _known_installed(snapshot, record):
return (snapshot is not None and snapshot["sha256"] == record["after_sha256"]
and snapshot["mode"] == record["before_mode"]
and snapshot["gid"] == record["before_gid"])
def _hook(state, profile_id):
# Adjacent literals keep shell parameter expansion in the generated script.
guard = (
'if [ -n "$' '{BASH_VERSION-}" ] && (shopt -q login_shell 2>/dev/null) '
'&& [ -z "$' '{SSH_TTY-}" ] && [ -z "$' '{TMUX-}" ] '
'&& [ "$(tty 2>/dev/null)" = /dev/tty1 ] '
'&& [ -x ' + shlex.quote(FB_EXECUTABLE) + ' ]; then\n'
)
return (BEGIN.decode() + profile_id + "\n" + guard
+ " " + shlex.quote(FB_EXECUTABLE) + " --state-dir " + shlex.quote(str(state))
+ " console --mode tty || printf '%s\\n' "
+ "'FrameBridge console did not start; continuing this login.' >&2\n"
+ "fi\n" + END.decode() + profile_id + "\n").encode()
def _result(record, *, enabled, changed=False, **extra):
return dict({"ok": True, "enabled": enabled, "changed": changed,
"startup_file": str(Path(record["home"]) / record["startup_name"]),
"profile_id": record["profile_id"], "phase": record["phase"],
"before_sha256": record["before_sha256"],
"after_sha256": record["after_sha256"]}, **extra)
def _enable(state, home):
state, home = _paths(state, home)
with _home_lock(home, create=True):
snapshots = _startup_files(home)
record = _read_manifest(state, home)
if record and record["phase"] != "DISABLED":
installed = snapshots[record["startup_name"]]
if _known_installed(installed, record):
_need(_effective(snapshots) == record["startup_name"],
"CONSOLE_LOGIN_STARTUP_PRIORITY_CHANGED")
expected = [(record["startup_name"], BEGIN + record["profile_id"].encode()),
(record["startup_name"], END + record["profile_id"].encode())]
_need(_markers(snapshots) == expected, "CONSOLE_LOGIN_FOREIGN_BLOCK")
if record["phase"] == "PREPARED":
record["phase"] = "ENABLED"
_write_manifest(state / "console-login", record)
return _result(record, enabled=True)
_need(record["phase"] == "PREPARED" and _known_original(installed, record),
"CONSOLE_LOGIN_STARTUP_CHANGED")
record["phase"] = "DISABLED"
_write_manifest(state / "console-login", record)
markers = _markers(snapshots)
if markers:
own = _profile_id(state).encode()
code = ("CONSOLE_LOGIN_UNTRACKED_BLOCK"
if any(own in line for _, line in markers)
else "CONSOLE_LOGIN_OTHER_PROFILE_ENABLED")
raise ConsoleStartupError(code)
_private_dir(state)
directory = state / "console-login"
_private_dir(directory)
if record:
history = directory / ("history-" + record["transaction"] + ".json")
_atomic_write(history, (json.dumps(record, indent=2) + "\n").encode(), new=True)
name = _effective(snapshots)
before = snapshots[name]
data = before["data"] if before else b""
profile_id = _profile_id(state)
after = data + (b"\n" if data and not data.endswith(b"\n") else b"") + _hook(state, profile_id)
transaction = uuid.uuid4().hex
record = {
"schema": 1, "transaction": transaction, "phase": "PREPARED",
"created_utc": datetime.now(timezone.utc).isoformat(),
"state": str(state), "home": str(home), "profile_id": profile_id,
"startup_name": name, "existed_before": before is not None,
"before_sha256": before["sha256"] if before else None,
"before_mode": before["mode"] if before else 0o600,
"before_gid": before["gid"] if before else os.getgid(),
"before_atime_ns": before["atime_ns"] if before else None,
"before_mtime_ns": before["mtime_ns"] if before else None,
"backup_name": "backup-" + transaction + ".bin" if before else None,
"after_sha256": _sha(after),
}
if before:
_atomic_write(directory / record["backup_name"], data, new=True)
backup = _snapshot(directory / record["backup_name"])
_need(backup["sha256"] == record["before_sha256"], "CONSOLE_LOGIN_BACKUP_MISMATCH")
_write_manifest(directory, record)
_need(_known_original(_snapshot(home / name), record), "CONSOLE_LOGIN_STARTUP_CHANGED")
_atomic_write(home / name, after, record["before_mode"], record["before_gid"], new=before is None)
_need(_known_installed(_snapshot(home / name), record), "CONSOLE_LOGIN_WRITE_MISMATCH")
record["phase"] = "ENABLED"
_write_manifest(directory, record)
return _result(record, enabled=True, changed=True)
def _disable(state, home):
state, home = _paths(state, home)
with _home_lock(home, create=True):
record = _read_manifest(state, home)
if record is None:
return {"ok": True, "enabled": False, "changed": False, "phase": "NOT_CONFIGURED"}
if record["phase"] == "DISABLED":
return _result(record, enabled=False)
path = home / record["startup_name"]
current = _snapshot(path)
directory = state / "console-login"
if record["phase"] == "PREPARED" and _known_original(current, record):
record["phase"] = "DISABLED"
_write_manifest(directory, record)
return _result(record, enabled=False)
_need(_known_installed(current, record), "CONSOLE_LOGIN_STARTUP_CHANGED")
if record["existed_before"]:
backup = _snapshot(directory / record["backup_name"])
_need(backup is not None and backup["mode"] == 0o600
and backup["sha256"] == record["before_sha256"],
"CONSOLE_LOGIN_BACKUP_MISMATCH")
_need(_known_installed(_snapshot(path), record), "CONSOLE_LOGIN_STARTUP_CHANGED")
_atomic_write(path, backup["data"], record["before_mode"], record["before_gid"])
os.utime(path, ns=(record["before_atime_ns"], record["before_mtime_ns"]))
_need(_known_original(_snapshot(path), record), "CONSOLE_LOGIN_RESTORE_MISMATCH")
else:
_need(_known_installed(_snapshot(path), record), "CONSOLE_LOGIN_STARTUP_CHANGED")
path.unlink()
record["phase"] = "DISABLED"
_write_manifest(directory, record)
return _result(record, enabled=False, changed=True)
def _status(state, home):
state, home = _paths(state, home)
with _home_lock(home, create=False):
snapshots = _startup_files(home)
record = _read_manifest(state, home)
if record is None:
return {"ok": True, "enabled": False, "changed": False,
"phase": "NOT_CONFIGURED", "startup_file": str(home / _effective(snapshots)),
"other_hook_present": bool(_markers(snapshots))}
if record["phase"] == "DISABLED":
return _result(record, enabled=False)
current = snapshots[record["startup_name"]]
if record["phase"] == "PREPARED" and _known_original(current, record):
return _result(record, enabled=False, recovery_pending=True)
_need(_known_installed(current, record), "CONSOLE_LOGIN_STARTUP_CHANGED")
_need(_effective(snapshots) == record["startup_name"],
"CONSOLE_LOGIN_STARTUP_PRIORITY_CHANGED")
expected = [(record["startup_name"], BEGIN + record["profile_id"].encode()),
(record["startup_name"], END + record["profile_id"].encode())]
_need(_markers(snapshots) == expected, "CONSOLE_LOGIN_FOREIGN_BLOCK")
return _result(record, enabled=True, recovery_pending=record["phase"] == "PREPARED")
def _call(function, state, home):
try:
return function(state, home)
except ConsoleStartupError as error:
return {"ok": False, "error": str(error)}
except (OSError, ValueError, TypeError, KeyError):
return {"ok": False, "error": "CONSOLE_LOGIN_IO_ERROR"}
def enable(state, *, home=None):
return _call(_enable, state, home)
def disable(state, *, home=None):
return _call(_disable, state, home)
def status(state, *, home=None):
return _call(_status, state, home)
@@ -0,0 +1 @@
"""Accepted two-pane console display components."""
@@ -0,0 +1,464 @@
#!/usr/bin/env python3
"""Read-only FBTERM dashboard for a real tmux pane (Python standard library).
The display is an operational summary, not a screenshot or an execution engine.
Job files remain authoritative. No lifecycle recovery or job mutation is done.
"""
from __future__ import annotations
import argparse
from collections import OrderedDict
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import datetime
import curses
import heapq
import json
import math
import os
from pathlib import Path
import re
import socket
import stat
import textwrap
import time
VERSION = "1.0"
MAX_FILE = 32768
MAX_TAIL = 12288
RECENT_LIMIT = 40
CACHE_LIMIT = 2048
ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))")
SENSITIVE = re.compile(
r"(?i)(password|passwd|passphrase|secret|token|bearer|authorization|cookie|"
r"api[ _.-]?key|private[ _.-]?key|client[ _.-]?secret|oauth|"
r"ssh-(?:rsa|ed25519)|BEGIN [A-Z ]*PRIVATE KEY|://[^ /]+:[^ /]+@)"
)
BLOB = re.compile(r"[A-Za-z0-9+/_=-]{160,}")
ENCODED_LINE = re.compile(r"\s*[A-Za-z0-9+/]{48,}={0,2}\s*\Z")
IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,159}\Z")
TRANSPORT = "[transport payload omitted; raw output retained]"
REDACTED = "[potentially sensitive output hidden]"
def ascii_text(value):
"""Discard terminal controls and non-ASCII glyphs for the physical VT."""
text = ANSI.sub("", str(value)).replace("\r", " ")
return "".join(c for c in text if 32 <= ord(c) < 127)
def safe_line(value):
text = ascii_text(value)
if SENSITIVE.search(text):
return REDACTED
low = text.lower()
if (BLOB.search(text) or ENCODED_LINE.fullmatch(text)
or "data:image/" in low or "base64" in low
or '"capture_id"' in low or '"data"' in low and '"encoding"' in low):
return TRANSPORT
return text[:400] + (" [truncated]" if len(text) > 400 else "")
def read_small(path, limit=MAX_FILE):
"""Bound reads and reject symlinks/special files (including stdout pipes)."""
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
raise ValueError("not a bounded regular file")
return os.read(fd, limit + 1)
finally:
os.close(fd)
def read_exit(job):
try:
if job.is_symlink():
return None
value = read_small(job / "exit.code", 32).decode("ascii").strip()
return int(value) if re.fullmatch(r"-?[0-9]{1,4}", value) else None
except (OSError, ValueError, UnicodeError):
return None
def worker_alive(token, proc_root=Path("/proc")):
"""Three-valued PID identity check: true, false or unavailable."""
if not isinstance(token, dict) or type(token.get("pid")) is not int or token["pid"] <= 0:
return None
try:
raw = (proc_root / str(token["pid"]) / "stat").read_text()
fields = raw[raw.rfind(")") + 2:].split()
boot = (proc_root / "sys/kernel/random/boot_id").read_text().strip()
if not all(k in token for k in ("start_ticks", "boot")):
return None
return (fields[0] != "Z" and fields[19] == str(token["start_ticks"])
and boot == token["boot"])
except FileNotFoundError:
return False
except (OSError, IndexError, ValueError):
return None
def number(value):
return float(value) if type(value) in (int, float) and math.isfinite(value) else None
@dataclass(frozen=True)
class Job:
name: str
updated: float
state: str
exit_code: int | None = None
started: float | None = None
finished: float | None = None
recorded: bool = False
def duration(self, now):
if self.started is None:
return "--"
stop = self.finished if self.finished is not None else now if self.state == "RUNNING" else None
if stop is None or stop < self.started:
return "--"
seconds = int(stop - self.started)
if seconds >= 3600:
return f"{seconds // 3600}h{seconds % 3600 // 60:02}m"
if seconds >= 60:
return f"{seconds // 60}m{seconds % 60:02}s"
return f"{seconds}s"
def describe(name, receipt, job_path, updated, proc_root=Path("/proc")):
rc = read_exit(job_path)
if receipt is None:
return Job(name, updated, "EXIT" if rc is not None else "UNKNOWN", rc)
if (not isinstance(receipt, dict) or receipt.get("schema") != 4
or receipt.get("job_id") != name):
return Job(name, updated, "INVALID", rc)
phase = receipt.get("phase")
started = number(receipt.get("started_at"))
finished = number(receipt.get("finished_at"))
if phase == "COMPLETED":
recorded = (rc is not None and type(receipt.get("exit_code")) is int
and rc == receipt["exit_code"] and finished is not None)
return Job(name, updated, ("DONE" if rc == 0 else "FAILED") if recorded else "MISMATCH",
rc, started, finished, recorded)
if phase == "RUNNING":
live = worker_alive(receipt.get("worker"), proc_root)
state = "RUNNING" if live is True else "STALE" if live is False else "UNCONFIRMED"
return Job(name, updated, state, rc, started)
if phase in ("RESERVED", "ARMED"):
return Job(name, updated, phase, rc, started, finished)
if phase == "INTERRUPTED":
return Job(name, updated, "INTERRUPTED", rc, started, finished)
return Job(name, updated, "INVALID", rc, started, finished)
class Catalogue:
"""Stream the full census; keep bounded metadata and bounded output tails."""
def __init__(self, root, proc_root=Path("/proc")):
self.root = Path(root)
self.proc_root = Path(proc_root)
self.cache = OrderedDict()
self.recent = []
self.counts = {}
self.total = 0
self.errors = 0
self.last_scan = None
self.output_cache = (None, [])
def receipt(self, path):
info = path.lstat()
stamp = (info.st_ino, info.st_mtime_ns, info.st_size)
key = str(path)
if key in self.cache and self.cache[key][0] == stamp:
self.cache.move_to_end(key)
return self.cache[key][1]
try:
data = json.loads(read_small(path))
except (OSError, ValueError, UnicodeError):
data = {}
self.cache[key] = (stamp, data)
self.cache.move_to_end(key)
while len(self.cache) > CACHE_LIMIT:
self.cache.popitem(last=False)
return data
def scan(self):
counts, newest, total, errors = {}, [], 0, 0
receipts = self.root / "receipts"
jobs = self.root / "jobs"
def accept(job):
nonlocal total
total += 1
counts[job.state] = counts.get(job.state, 0) + 1
# A stable tie breaker means equal mtimes cannot compare Job objects.
key = (job.updated, job.name, job)
if len(newest) < RECENT_LIMIT:
heapq.heappush(newest, key)
elif key[:2] > newest[0][:2]:
heapq.heapreplace(newest, key)
# Enumerate every receipt, not only recent directories. Older running
# jobs are counted using PID identity even when newer jobs have arrived.
try:
try:
receipt_entries = os.scandir(receipts)
except FileNotFoundError:
# Legacy producers need no receipts directory. Only opening
# this optional source may be absent; entry errors still count.
receipt_entries = nullcontext(())
with receipt_entries as entries:
for entry in entries:
if not entry.name.endswith(".json") or not entry.is_file(follow_symlinks=False):
continue
name = entry.name[:-5]
if not IDENTIFIER.fullmatch(name):
continue
try:
path = Path(entry.path)
info = entry.stat(follow_symlinks=False)
accept(describe(name, self.receipt(path), jobs / name, info.st_mtime,
self.proc_root))
except OSError:
errors += 1
except OSError:
errors += 1
# Legacy jobs with an exit file are useful, but never assumed running.
try:
with os.scandir(jobs) as entries:
for entry in entries:
if not IDENTIFIER.fullmatch(entry.name) or not entry.is_dir(follow_symlinks=False):
continue
if (receipts / (entry.name + ".json")).exists():
continue
try:
accept(describe(entry.name, None, Path(entry.path),
entry.stat(follow_symlinks=False).st_mtime, self.proc_root))
except OSError:
errors += 1
except OSError:
errors += 1
self.recent = [row[2] for row in sorted(newest, reverse=True)]
self.counts, self.total, self.errors = counts, total, errors
self.last_scan = time.time()
def output(self, name):
if not IDENTIFIER.fullmatch(name):
return []
job = self.root / "jobs" / name
if job.is_symlink():
return []
stamps = []
for stream in ("stdout.bin", "stderr.bin"):
try:
info = (job / stream).lstat()
stamps.append((info.st_ino, info.st_mtime_ns, info.st_ctime_ns, info.st_size))
except OSError:
stamps.append(None)
cache_key = (name, *stamps)
if self.output_cache[0] == cache_key:
return self.output_cache[1]
lines = []
transport_seen = False
for stream, prefix in (("stdout.bin", "OUT"), ("stderr.bin", "ERR")):
try:
fd = os.open(job / stream, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode):
continue
start = max(0, info.st_size - MAX_TAIL)
os.lseek(fd, start, os.SEEK_SET)
data = os.read(fd, MAX_TAIL)
finally:
os.close(fd)
chunks = data.decode("utf-8", errors="replace").splitlines()
if start and chunks:
chunks.pop(0) # Never expose a partial potentially sensitive line.
# Multiline credential blocks must not leak short base64 lines.
secret_block = False
for text in chunks[-80:]:
upper = text.upper()
if "-----BEGIN " in upper and "KEY-----" in upper:
secret_block = True
if secret_block:
line = REDACTED
if "-----END " in upper and "KEY-----" in upper:
secret_block = False
else:
line = safe_line(text)
if line == TRANSPORT:
if transport_seen:
continue
transport_seen = True
if line.strip() and (not lines or lines[-1] != (prefix, line)):
lines.append((prefix, line))
except (OSError, ValueError):
continue
lines = lines[-100:]
self.output_cache = (cache_key, lines)
return lines
def put(screen, row, text, attr=0):
h, w = screen.getmaxyx()
if 0 <= row < h and w > 1:
try:
screen.addnstr(row, 0, ascii_text(text).ljust(w - 1), w - 1, attr)
except (curses.error, ValueError):
pass
def recent_label(job, width):
# Date-heavy execution IDs are shortened from the left; their unique suffix
# and observed time remain visible, while the complete ID is shown below.
name = job.name if len(job.name) <= width else ".." + job.name[-max(1, width - 2):]
return name
def draw(screen, catalogue, node, selection=0, follow=True, help_open=False):
screen.erase()
h, w = screen.getmaxyx()
now = time.time()
title = curses.A_REVERSE | curses.A_BOLD
accent = curses.color_pair(1) | curses.A_BOLD if curses.has_colors() else curses.A_BOLD
good = curses.color_pair(2) if curses.has_colors() else curses.A_NORMAL
warning = curses.color_pair(3) if curses.has_colors() else curses.A_BOLD
bad = curses.color_pair(4) if curses.has_colors() else curses.A_BOLD
put(screen, 0, f" FBTERM / ACTIVITY | {node}", title)
clock = datetime.now().astimezone().strftime("%H:%M:%S %Z")
running = catalogue.counts.get("RUNNING", 0)
pending = sum(catalogue.counts.get(k, 0) for k in ("RESERVED", "ARMED"))
uncertain = sum(catalogue.counts.get(k, 0) for k in ("UNKNOWN", "STALE", "UNCONFIRMED", "INVALID", "MISMATCH"))
put(screen, 1, f" {clock} | RUN {running} WAIT {pending} CHECK {uncertain}", accent)
if h < 10 or w < 35:
put(screen, 2, f" {catalogue.total} jobs | {'LIVE' if follow else 'REVIEW'}", good)
put(screen, 3, " Ctrl+B z: zoom | Ctrl+B o: focus")
return screen.refresh()
if help_open:
help_lines = [
" MONITOR HELP", "", " j/k or Up/Down : review recent jobs",
" f : follow newest job", " ? : close this help", "",
" TMUX", " Ctrl+B then o : switch pane",
" Ctrl+B then arrow: choose pane", " Ctrl+B then z : zoom / restore", "",
" RUNNING: worker PID, birth and boot match.",
" DONE/FAILED: receipt and exit agree.",
" EXIT: legacy exit file, no receipt.",
" STALE/CHECK: activity cannot be confirmed.", "",
" Output is a filtered, bounded tail.",
" Raw stdout/stderr remain in job files.",
" Output hashes are not checked by this UI.",
" q does not close the monitor.",
]
for row, text in enumerate(help_lines, 3):
if row < h - 2:
put(screen, row, text)
put(screen, h - 1, " ?: close help | Ctrl+B o: Bash", title)
return screen.refresh()
put(screen, 2, f" {'LIVE' if follow else 'REVIEW'} | {catalogue.total} jobs | receipts + process identity", good)
if catalogue.errors:
put(screen, 3, f" Source unavailable/read errors: {catalogue.errors}", warning)
else:
put(screen, 3, " Recent jobs / recorded results", accent)
list_count = min(8 if h >= 40 else 5 if h >= 25 else 2, max(1, h // 6))
start_index = max(0, selection - list_count + 1)
rows = catalogue.recent[start_index:start_index + list_count]
for index, job in enumerate(rows):
selected = start_index + index == selection
rc = f" rc={job.exit_code}" if job.exit_code is not None else ""
state = f"{job.state}{rc} {job.duration(now)}"
name_width = max(8, w - len(state) - 5)
line = f"{'>' if selected else ' '} {recent_label(job, name_width):<{name_width}} {state}"
color = bad if job.state in ("FAILED", "MISMATCH", "INVALID") else accent if selected else good
put(screen, 4 + index, line, color)
detail_row = 5 + list_count
put(screen, detail_row - 1, " " + "-" * max(0, w - 3))
if catalogue.recent:
job = catalogue.recent[min(selection, len(catalogue.recent) - 1)]
wrapped = textwrap.wrap(" Job: " + job.name, width=max(1, w - 2))[:2]
for line in wrapped:
put(screen, detail_row, line, accent)
detail_row += 1
lines = catalogue.output(job.name)
available = max(0, h - detail_row - 4)
wrapped_output = []
for prefix, line in lines:
for part in textwrap.wrap(prefix + " " + line, width=max(1, w - 3)) or [prefix]:
wrapped_output.append((prefix, " " + part))
if not wrapped_output:
wrapped_output = [("OUT", " No readable output recorded.")]
for index, (prefix, text) in enumerate(wrapped_output[-available:] if available else []):
put(screen, detail_row + index, text, warning if prefix == "ERR" else curses.A_NORMAL)
else:
put(screen, detail_row, " Waiting for recorded FBTERM jobs.", good)
put(screen, h - 3, " " + "-" * max(0, w - 3))
put(screen, h - 2, " j/k: review | f: live | ?: help", good)
put(screen, h - 1, " Ctrl+B o: focus | Ctrl+B z: zoom", title)
screen.refresh()
def main_screen(screen, root, node):
try:
curses.curs_set(0)
except curses.error:
pass
screen.timeout(200)
screen.keypad(True)
if curses.has_colors():
curses.start_color()
try:
curses.use_default_colors()
except curses.error:
pass
for pair, fg in ((1, curses.COLOR_CYAN), (2, curses.COLOR_GREEN),
(3, curses.COLOR_YELLOW), (4, curses.COLOR_RED)):
try:
curses.init_pair(pair, fg, -1)
except curses.error:
pass
catalogue = Catalogue(root)
previous = 0.0
selection, follow, help_open = 0, True, False
selected_name = None
while True:
now = time.monotonic()
if now - previous >= 1.5:
catalogue.scan()
previous = now
if follow:
selection = 0
elif selected_name:
selection = next((i for i, j in enumerate(catalogue.recent) if j.name == selected_name),
min(selection, max(0, len(catalogue.recent) - 1)))
draw(screen, catalogue, node, selection, follow, help_open)
ch = screen.getch()
if ch in (ord("j"), curses.KEY_DOWN):
selection = min(selection + 1, max(0, len(catalogue.recent) - 1))
follow = False
elif ch in (ord("k"), curses.KEY_UP):
selection = max(0, selection - 1)
follow = False
elif ch in (ord("f"), ord("F")):
selection, follow = 0, True
elif ch == ord("?"):
help_open = not help_open
selected_name = catalogue.recent[selection].name if catalogue.recent else None
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(os.environ.get(
"FRAMEBRIDGE_FBTERM_ROOT", str(Path.home() / ".local/share/framebridge/fbterm"))))
parser.add_argument("--node", default=os.environ.get("FRAMEBRIDGE_NODE", socket.gethostname()))
parser.add_argument("--version", action="version", version=VERSION)
args = parser.parse_args()
node = ascii_text(args.node)[:64]
try:
curses.wrapper(main_screen, args.root, node)
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""FrameBridge's two-pane physical console, using the existing user's tmux.
No login, privilege, service or desktop configuration is changed here.
The normal entry point is called by an existing authenticated tty1 login.
"""
from __future__ import annotations
import argparse
import fcntl
import getpass
import json
import os
from pathlib import Path
import re
import shlex
import socket
import stat
import subprocess
import sys
import time
HERE = Path(__file__).resolve().parent
def arguments():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--node', default=os.environ.get('FRAMEBRIDGE_NODE', socket.gethostname()))
p.add_argument('--socket')
p.add_argument('--session')
p.add_argument('--apply', action='store_true', help='Set up without attaching a client')
p.add_argument('--probe', action='store_true', help='Read current pane metadata only')
p.add_argument('--reflow', action='store_true', help='Resize existing panes without taking focus')
p.add_argument('--refresh-monitor', action='store_true')
p.add_argument('--layout', choices=('auto','horizontal','vertical'), default='auto')
p.add_argument('--monitor', action='store_true', help=argparse.SUPPRESS)
a = p.parse_args()
a.node = re.sub(r'[^A-Za-z0-9_-]', '_', a.node)[:64] or 'NODE'
a.socket = a.socket or str(Path('/run/user') / str(os.getuid()) / (a.node.lower() + '-tty1') / 'console.sock')
a.session = a.session or a.node.upper() + '-FBTERM'
if not re.fullmatch(r'[A-Za-z0-9_-]{1,90}', a.session):
p.error('Session name must contain only letters, digits, underscore and hyphen')
return a
class Console:
def __init__(self, args):
self.a = args
self.session_target = args.session
self.window = '=' + args.session + ':0'
self.env = {k:v for k,v in os.environ.items() if k not in ('TMUX', 'TMUX_PANE')}
self.env['LC_ALL'] = 'C.UTF-8'
def tmux(self, *args, check=True):
r = subprocess.run(['/usr/bin/tmux', '-S', self.a.socket, *args],
env=self.env, capture_output=True, text=True, timeout=8)
if check and r.returncode:
raise RuntimeError('tmux ' + args[0] + ': ' + r.stderr.strip())
return r.stdout.strip()
def exists(self):
r = subprocess.run(['/usr/bin/tmux', '-S', self.a.socket, 'has-session', '-t', '=' + self.a.session],
env=self.env, capture_output=True, timeout=5)
if r.returncode == 0:
matches=[line.rsplit('|',1)[1] for line in self.tmux('list-sessions','-F','#{session_name}|#{session_id}').splitlines()
if line.rsplit('|',1)[0]==self.a.session]
if len(matches)!=1:
raise RuntimeError('Console session identity is ambiguous')
self.session_target=matches[0]
windows=self.tmux('list-windows','-t',self.session_target,'-F','#{window_id}').splitlines()
if len(windows)!=1:
raise RuntimeError('Use a dedicated console session containing exactly one window')
self.window=windows[0]
return True
return False
def panes(self):
fmt = '|'.join('#{' + f + '}' for f in ('pane_id','pane_pid','pane_current_command',
'pane_left','pane_top','pane_width','pane_height','pane_active','pane_dead',
'@framebridge_role'))
rows = []
for line in self.tmux('list-panes', '-t', self.window, '-F', fmt).splitlines():
ident,pid,command,left,top,width,height,active,dead,role = line.split('|')
rows.append(dict(id=ident,pid=int(pid),command=command,left=int(left),top=int(top),
width=int(width),height=int(height),active=active=='1',dead=dead=='1',role=role))
return rows
def monitor_command(self):
return shlex.join(['/usr/bin/python3', str(HERE / 'framebridge_console.py'), '--monitor', '--node', self.a.node])
def locate(self, rows):
shell = next((p for p in rows if p['role']=='shell'), None)
monitor = next((p for p in rows if p['role']=='monitor'), None)
# Adopt only the known original dashboard, never an arbitrary Python pane.
for p in rows:
if p['role']:
continue
try:
cmd = Path('/proc', str(p['pid']), 'cmdline').read_bytes().split(b'\0')
names = {Path(x.decode(errors='replace')).name for x in cmd if x}
except OSError:
names = set()
if not monitor and ('fbterm_live.py' in names or 'fbterm_dashboard.py' in names):
monitor = p
elif not shell and p['command']=='bash' and not p['dead']:
shell = p
return shell, monitor
def layout(self, shell, monitor):
active=next((p['id'] for p in self.panes() if p['active']),None)
width,height = map(int, self.tmux('display-message','-p','-t',self.window,
'#{window_width} #{window_height}').split())
mode=getattr(self.a,'layout','auto')
horizontal=mode=='horizontal' or (mode=='auto' and width>=160)
if horizontal:
self.tmux('select-layout','-t',self.window,'even-horizontal')
current={p['id']:p for p in self.panes()}
if current[monitor['id']]['left']>current[shell['id']]['left']:
self.tmux('swap-pane','-d','-s',shell['id'],'-t',monitor['id'])
monitor_width=max(2,min(max(2,width-21),max(24,round((width-1)*0.30))))
self.tmux('resize-pane','-t',monitor['id'],'-x',str(monitor_width))
else:
self.tmux('select-layout','-t',self.window,'even-vertical')
current={p['id']:p for p in self.panes()}
if current[monitor['id']]['top']>current[shell['id']]['top']:
self.tmux('swap-pane','-d','-s',shell['id'],'-t',monitor['id'])
self.tmux('resize-pane','-t',monitor['id'],'-y',str(max(2,min(8,(height-3)//3))))
if active:
self.tmux('select-pane','-t',active)
def apply(self):
existed=self.exists()
if not existed:
parent=Path(self.a.socket).parent
parent.mkdir(mode=0o700,parents=True,exist_ok=True)
st=parent.stat()
if st.st_uid!=os.getuid() or st.st_mode & 0o022:
raise RuntimeError('Console socket directory is not private to this user')
size=os.get_terminal_size(0) if os.isatty(0) else os.terminal_size((240,67))
self.session_target,self.window=self.tmux('new-session','-d','-P','-F','#{session_id}|#{window_id}',
'-x',str(size.columns),'-y',str(size.lines),
'-s',self.a.session,'-n','Console','-c',str(Path.home()),'/bin/bash --login').split('|')
rows=self.panes()
shell,monitor=self.locate(rows)
if self.a.reflow:
if shell and monitor and len(rows)==2:
# Zoom is an explicit user choice; never undo it in a resize hook.
if self.tmux('display-message','-p','-t',self.window,'#{window_zoomed_flag}')!='1':
self.layout(shell,monitor)
return
if not shell or shell['dead']:
raise RuntimeError('Normal Bash pane is absent; refusing to replace another program')
if len(rows)>2 or (len(rows)==2 and not monitor):
raise RuntimeError('Unexpected console panes; refusing to modify them')
original_pid=shell['pid']
previous_focus=next((p['id'] for p in rows if p['active']),shell['id'])
previous_zoom=self.tmux('display-message','-p','-t',self.window,'#{window_zoomed_flag}')=='1'
if not monitor:
ident=self.tmux('split-window','-d','-h','-l','30%','-t',shell['id'],'-P','-F','#{pane_id}',self.monitor_command())
monitor=next(p for p in self.panes() if p['id']==ident)
else:
try:
cmd=Path('/proc',str(monitor['pid']),'cmdline').read_bytes()
except OSError:
cmd=b''
if self.a.refresh_monitor or monitor['dead'] or b'framebridge_console.py' not in cmd:
self.tmux('respawn-pane','-k','-t',monitor['id'],self.monitor_command())
for role,pane in [('shell',shell),('monitor',monitor)]:
self.tmux('set-option','-p','-t',pane['id'],'@framebridge_role',role)
self.tmux('set-option','-p','-t',monitor['id'],'remain-on-exit','on')
self.tmux('select-pane','-t',shell['id'],'-T','SHELL | '+self.a.node+' | '+getpass.getuser())
self.tmux('select-pane','-t',monitor['id'],'-T','FBTERM | ACTIVITY')
self.tmux('set-window-option','-t',self.window,'automatic-rename','off')
self.tmux('rename-window','-t',self.window,'Console')
for key,value in [('pane-border-status','top'),('pane-border-style','fg=blue'),
('pane-active-border-style','fg=cyan'),
('pane-border-format',' #{?pane_active,>, } #{pane_title} '),
('window-status-format',''),('window-status-current-format','')]:
self.tmux('set-window-option','-t',self.window,key,value)
for key,value in [('status','on'),('status-style','bg=blue,fg=white'),
('status-left','#[bold] FRAMEBRIDGE #[default] | '+self.a.node+' | '),
('status-left-length','80'),('status-right-length','110'),
('status-right',' Ctrl+B, then o: switch | z: zoom | %H:%M %Z ')]:
self.tmux('set-option','-t',self.session_target,key,value)
self.tmux('set-option','-t',self.session_target,'mouse','off')
# This server is dedicated to FrameBridge; do not alter global tmux configuration.
for key in ('z','C-z','Z'):
self.tmux('bind-key','-T','prefix',key,'resize-pane','-Z')
# Reattachment must preserve the user's zoom and pane focus.
if not previous_zoom:
self.layout(shell,monitor)
command=shlex.join(['/usr/bin/python3',str(HERE/'framebridge_console.py'),'--node',self.a.node,
'--socket',self.a.socket,'--session',self.a.session,
'--layout',getattr(self.a,'layout','auto'),'--reflow'])
self.tmux('set-hook','-t',self.session_target,'client-resized[90]','run-shell -b '+shlex.quote(command))
self.tmux('set-hook','-t',self.session_target,'client-attached[90]','run-shell -b '+shlex.quote(command))
self.tmux('select-window','-t',self.window)
selected=previous_focus if existed else shell['id']
self.tmux('select-pane','-t',selected)
if previous_zoom and self.tmux('display-message','-p','-t',self.window,'#{window_zoomed_flag}')!='1':
self.tmux('resize-pane','-Z','-t',selected)
current=next(p for p in self.panes() if p['id']==shell['id'])
if current['pid']!=original_pid:
raise RuntimeError('Bash identity changed unexpectedly')
def supervise(node):
while True:
try:
result=subprocess.run(['/usr/bin/python3','-u',str(HERE/'fbterm_dashboard.py'),'--node',node])
print('\r\nFBTERM monitor stopped (exit '+str(result.returncode)+'). Restarting in 2 seconds.',flush=True)
print('Ctrl+B o switches to the shell.',flush=True)
time.sleep(2)
except KeyboardInterrupt:
print('\r\nFBTERM monitor restarting. Ctrl+B o switches to the shell.',flush=True)
time.sleep(1)
def main():
args=arguments()
if args.monitor:
supervise(args.node)
return 0
console=Console(args)
if args.probe:
print(json.dumps({'node':args.node,'socket':args.socket,'session':args.session,
'panes':console.panes() if console.exists() else [],'login':'existing-authenticated-login'}))
return 0
if not (args.apply or args.reflow):
if not os.isatty(0) or os.ttyname(0)!='/dev/tty1' or os.environ.get('TMUX') or os.environ.get('SSH_TTY'):
print('Open this console from an authenticated tty1 login. --probe is read-only.',file=sys.stderr)
return 1
parent=Path(args.socket).parent
parent.mkdir(mode=0o700,parents=True,exist_ok=True)
info=parent.stat()
if info.st_uid!=os.getuid() or info.st_mode & 0o022:
raise RuntimeError('Console socket directory is not private to this user')
fd=os.open(parent/'console-layout.lock',os.O_CREAT|os.O_RDWR|os.O_CLOEXEC|os.O_NOFOLLOW,0o600)
try:
info=os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid!=os.getuid():
raise RuntimeError('Invalid console layout lock')
fcntl.flock(fd,fcntl.LOCK_EX)
console.apply()
finally:
os.close(fd)
if args.apply:
print(json.dumps({'node':args.node,'status':'READY','panes':console.panes()}))
elif not args.reflow:
os.execve('/usr/bin/tmux',['tmux','-S',args.socket,'attach-session','-t',console.session_target],console.env)
return 0
if __name__=='__main__':
try:
raise SystemExit(main())
except (RuntimeError,OSError,subprocess.TimeoutExpired) as e:
print('FrameBridge console: '+str(e),file=sys.stderr)
raise SystemExit(1)
@@ -0,0 +1,156 @@
"""Bind graphical CUA to a verified physical X11 session or labelled virtual display."""
from __future__ import annotations
import json
import shutil
from .real_screen.screen_capture import CaptureError, foreground, _x11_context
BINDING = "FRAMEBRIDGE_DISPLAY_BINDING"
GRAPHICAL_COMMANDS = ("wmctrl", "xdotool", "xwininfo", "xclip")
VIRTUAL_COMMANDS = ("Xvfb", "openbox", "xauth")
def missing_graphical_commands(env):
path = env.get("PATH")
missing = [name for name in GRAPHICAL_COMMANDS if not shutil.which(name, path=path)]
if not (shutil.which("magick", path=path) or shutil.which("convert", path=path)):
missing.append("imagemagick")
return missing
def bind_text(env, profile, *, reason="PHYSICAL_X11_SESSION_UNAVAILABLE"):
context = {"kind": "text", "physical": False, "explicit": False,
"selection": "auto", "profile": str(profile), "display": "", "reason": reason}
for name in ("DISPLAY", "XAUTHORITY", "GDK_BACKEND"):
env.pop(name, None)
env["XDG_SESSION_TYPE"] = "tty"
env[BINDING] = json.dumps(context, sort_keys=True, separators=(",", ":"))
return context
FIELDS = ("session_id", "uid", "leader", "active_vt", "seat", "vtnr", "display")
class DisplayContextError(RuntimeError):
pass
def _physical():
current = foreground(allow_graphics=True)
if current.session_type != "x11":
raise DisplayContextError("PHYSICAL_X11_SESSION_UNAVAILABLE")
display, authority = _x11_context(current, None, None)
return {
"kind": "physical-x11", "physical": True,
"session_id": current.session_id, "uid": current.uid,
"leader": current.leader, "active_vt": current.active_vt,
"seat": current.seat, "vtnr": current.vtnr, "display": display,
"authority": authority,
}
def describe(env):
try:
context = json.loads(env.get(BINDING, "{}"))
except (TypeError, ValueError):
return {"kind": "unavailable", "physical": False, "error": "DISPLAY_BINDING_INVALID"}
if not isinstance(context, dict):
return {"kind": "unavailable", "physical": False, "error": "DISPLAY_BINDING_INVALID"}
return {k: v for k, v in context.items() if k != "authority"}
def configure(env, headless, profile, *, refresh=False):
profile = str(profile)
try:
previous = json.loads(env.get(BINDING, "{}"))
except (TypeError, ValueError):
previous = {}
if not isinstance(previous, dict):
previous = {}
# The manager and worker share the same immutable initial binding.
mode_compatible = ((headless in ("yes", True) and previous.get("kind") == "virtual-x11" and previous.get("explicit") is True)
or (headless in ("no", False) and previous.get("kind") == "physical-x11")
or (headless == "auto" and previous.get("kind") in ("physical-x11", "text") and previous.get("selection") == "auto"))
if not refresh and previous.get("profile") == profile and previous.get("kind") in ("physical-x11", "virtual-x11", "text") and mode_compatible:
return env
explicit = headless in ("yes", True)
if explicit:
context = {"kind": "virtual-x11", "physical": False, "explicit": True,
"display": env.get("DISPLAY", "")}
else:
try:
context = _physical()
if headless == "auto" and missing_graphical_commands(env):
bind_text(env, profile, reason="GRAPHICAL_CUA_TOOLS_UNAVAILABLE")
return env
env["DISPLAY"] = context["display"]
env["XDG_SESSION_TYPE"] = "x11"
env["GDK_BACKEND"] = "x11"
if context.get("authority"):
env["XAUTHORITY"] = context["authority"]
else:
env.pop("XAUTHORITY", None)
except (CaptureError, DisplayContextError, OSError) as exc:
code = getattr(exc, "code", str(exc)) or "PHYSICAL_X11_SESSION_UNAVAILABLE"
if headless == "auto":
bind_text(env, profile, reason=code)
return env
context = {"kind": "unavailable", "physical": False, "explicit": False,
"display": "", "reason": code}
context["selection"] = "yes" if explicit else ("auto" if headless == "auto" else "no")
context["profile"] = profile
env[BINDING] = json.dumps(context, sort_keys=True, separators=(",", ":"))
return env
def bind_virtual(env, profile, *, explicit):
context = {"kind": "virtual-x11", "physical": False, "explicit": bool(explicit),
"profile": str(profile), "display": env.get("DISPLAY", "")}
env[BINDING] = json.dumps(context, sort_keys=True, separators=(",", ":"))
return context
def prepare(env):
"""Select the current real desktop once per request; guards then pin it."""
try:
previous = json.loads(env.get(BINDING, "{}"))
except (TypeError, ValueError):
return
if isinstance(previous, dict) and previous.get("selection") == "auto":
configure(env, "auto", previous.get("profile", ""), refresh=True)
current = json.loads(env[BINDING])
changed = current.get("kind") != previous.get("kind") or any(current.get(k) != previous.get(k) for k in FIELDS)
if previous.get("needs_observation") or changed:
current["needs_observation"] = True
env[BINDING] = json.dumps(current, sort_keys=True, separators=(",", ":"))
def observed(env):
current = check(env)
if current.get("needs_observation"):
current = dict(current)
current.pop("needs_observation", None)
env[BINDING] = json.dumps(current, sort_keys=True, separators=(",", ":"))
def check(env):
try:
expected = json.loads(env.get(BINDING, "{}"))
except (TypeError, ValueError):
raise DisplayContextError("DISPLAY_BINDING_INVALID") from None
if not isinstance(expected, dict):
raise DisplayContextError("DISPLAY_BINDING_INVALID")
if expected.get("kind") == "physical-x11":
try:
current = _physical()
except (CaptureError, OSError, DisplayContextError):
raise DisplayContextError("CUA_FOREGROUND_SESSION_CHANGED") from None
if any(current.get(k) != expected.get(k) for k in FIELDS):
raise DisplayContextError("CUA_FOREGROUND_SESSION_CHANGED")
if env.get("DISPLAY") != expected.get("display"):
raise DisplayContextError("CUA_DISPLAY_BINDING_MISMATCH")
elif expected.get("kind") == "virtual-x11":
if not expected.get("display") or env.get("DISPLAY") != expected["display"]:
raise DisplayContextError("VIRTUAL_DISPLAY_BINDING_MISMATCH")
else:
raise DisplayContextError("CUA_DISPLAY_UNAVAILABLE")
return expected
def private_display_required(env, headless, available):
physical = describe(env).get("kind") == "physical-x11"
if headless in ("no", False) and (not physical or not available):
raise DisplayContextError("PHYSICAL_X11_UNAVAILABLE_NO_VIRTUAL_FALLBACK")
return headless in ("yes", True)
@@ -0,0 +1,340 @@
"""Original Fleet providers and generic confirmed-node routing."""
from __future__ import annotations
import argparse
import hashlib
import os
from pathlib import Path
import re
import socket
import sys
import time
import uuid
from typing import Any
JOB_RE = re.compile(r"^[A-Za-z0-9._-]{1,80}$")
READ_CAPABILITIES = frozenset({
"system.status", "hardware.summary", "storage.inspect",
"network.inspect", "service.status",
})
class FleetError(ValueError):
"""Stable public error codes only."""
def _require(condition: object, code: str) -> None:
if not condition:
raise FleetError(code)
def original_paths() -> tuple[Path, Path]:
root = Path(__file__).resolve().parents[2]
choices = [
("FRAMEBRIDGE_BROKER_SOURCE", "FrameBridge-Broker-RC1/src"),
("FRAMEBRIDGE_FLEET_SOURCE", "FrameBridge-Fleet-Hub/app"),
]
result = []
for variable, suffix in choices:
configured = os.environ.get(variable)
candidates = [Path(configured)] if configured else [
root / "original/geranas" / suffix,
root / "sources/original-geranas" / suffix,
]
normalized = []
for candidate in candidates:
nested = candidate / ("src" if variable == "FRAMEBRIDGE_BROKER_SOURCE" else "app")
if (candidate / "framebridge_linux_chatgpt").is_dir():
normalized.append(candidate)
elif (nested / "framebridge_linux_chatgpt").is_dir():
normalized.append(nested)
path = next(iter(normalized), None)
_require(path is not None, "ORIGINAL_MODULES_UNAVAILABLE")
result.append(path)
return result[0], result[1]
def bootstrap_original() -> None:
"""Extend the original package search path without copying or merging it."""
broker_source, fleet_source = original_paths()
for path in (fleet_source, broker_source):
if str(path) in sys.path:
sys.path.remove(str(path))
sys.path.insert(0, str(path))
import framebridge_linux_chatgpt
package_file = Path(framebridge_linux_chatgpt.__file__).resolve()
_require(package_file.is_relative_to(broker_source.resolve()), "ORIGINAL_PACKAGE_SHADOWED")
for root in (broker_source, fleet_source):
path = str(root / "framebridge_linux_chatgpt")
if path not in framebridge_linux_chatgpt.__path__:
framebridge_linux_chatgpt.__path__.append(path)
def state_directory() -> Path:
raw = os.environ.get("FRAMEBRIDGE_EXPANDED_STATE") or os.environ.get("FRAMEBRIDGE_STATE_DIR")
_require(bool(raw), "EXPANDED_STATE_REQUIRED")
return Path(raw).expanduser()
def local_config() -> dict[str, Any]:
from .config import load_config
config = load_config(state_directory())
_require(type(config) is dict, "PROFILE_CONFIG_INVALID")
_require(config.get("role") in ("standalone", "central", "node"), "PROFILE_ROLE_INVALID")
_require(type(config.get("name")) is str and JOB_RE.fullmatch(config["name"]), "PROFILE_NAME_INVALID")
try:
uuid.UUID(str(config["node_id"]))
except (KeyError, ValueError, TypeError):
raise FleetError("PROFILE_NODE_ID_INVALID") from None
return config
def _local_descriptor(config: dict[str, Any]) -> dict[str, Any]:
bootstrap_original()
from framebridge_linux_chatgpt.seed_capabilities import execute_capability
_require(callable(execute_capability), "LOCAL_READ_PROVIDER_UNAVAILABLE")
return {
"alias": config["name"], "node_id": str(config["node_id"]),
"hostname": socket.gethostname(), "transport": "local",
"target": "LOCAL", "capability_profile": "original-readonly",
"source": "local", "operational_transport_ready": True,
}
def _remote_descriptors(config: dict[str, Any]) -> list[dict[str, Any]]:
if config["role"] != "central":
return []
from . import broker
items = broker.list_confirmed_nodes()
_require(type(items) is list, "BROKER_NODE_LIST_INVALID")
result = []
for item in items:
_require(type(item) is dict, "BROKER_NODE_INVALID")
alias = item.get("alias")
node_id = item.get("node_id")
_require(type(alias) is str and JOB_RE.fullmatch(alias), "BROKER_NODE_ALIAS_INVALID")
try:
uuid.UUID(str(node_id))
except (ValueError, TypeError):
raise FleetError("BROKER_NODE_ID_INVALID") from None
result.append({
"alias": alias, "node_id": str(node_id),
"hostname": str(item.get("hostname") or alias),
"transport": "broker_outbound", "target": alias,
"capability_profile": "original-readonly",
"source": "confirmed_pairing",
"operational_transport_ready": item.get("ready") is True,
})
return result
def descriptors() -> list[dict[str, Any]]:
config = local_config()
result = [_local_descriptor(config), *_remote_descriptors(config)]
namespace: set[str] = set()
for item in result:
for value in (item["alias"], item["node_id"]):
_require(value not in namespace and (value != "LOCAL" or item["transport"] == "local"),
"FLEET_IDENTITY_COLLISION")
namespace.add(value)
return result
def discover_nodes() -> dict[str, Any]:
try:
nodes = descriptors()
return {"ok": True, "nodes": sorted(nodes, key=lambda item: item["alias"]),
"sources": {"local": 1, "confirmed_pairing": len(nodes) - 1}}
except Exception as exc:
return {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "FLEET_DISCOVERY_FAILED"}
def resolve_node(alias: str) -> dict[str, Any]:
_require(type(alias) is str and JOB_RE.fullmatch(alias), "NODE_ALIAS_INVALID")
nodes = descriptors()
if alias == "LOCAL":
return next(item for item in nodes if item["transport"] == "local")
matches = [item for item in nodes if alias in (item["alias"], item["node_id"])]
_require(len(matches) == 1, "NODE_NOT_FOUND")
return matches[0]
def _normalize_read(capability: str, args: dict[str, Any]) -> dict[str, Any]:
bootstrap_original()
from framebridge_linux_chatgpt.fleet_routing import ReadCapability, normalize_read_args
_require(capability in READ_CAPABILITIES, "FLEET_READ_CAPABILITY_DENIED")
try:
return normalize_read_args(ReadCapability(capability), args)
except (TypeError, ValueError):
raise FleetError("FLEET_READ_ARGUMENTS_INVALID") from None
def read_local(capability: str, args: dict[str, Any]) -> dict[str, Any]:
normalized = _normalize_read(capability, args)
from framebridge_linux_chatgpt.seed_capabilities import execute_capability
data = execute_capability(capability, normalized)
_require(type(data) is dict, "LOCAL_READ_RESPONSE_INVALID")
if data.get("ok") is False:
return data
return {"ok": True, "capability": capability, "result": data}
def node_read(alias: str, capability: str, args: dict[str, Any]) -> dict[str, Any]:
try:
normalized = _normalize_read(capability, args)
node = resolve_node(alias)
if node["transport"] == "local":
result = read_local(capability, normalized)
else:
_require(node["operational_transport_ready"], "NODE_OFFLINE")
from . import broker
result = broker.request(node["node_id"], capability, normalized, timeout_s=30)
_require(type(result) is dict, "NODE_READ_RESPONSE_INVALID")
response = dict(result)
response.update(node=node["alias"], hostname=node["hostname"], transport=node["transport"])
return response
except Exception as exc:
return {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "NODE_READ_FAILED"}
def _exec_payload(script: str, authorized: bool, timeout_s: int, job_id: str,
sha256: str, include_stdout: bool) -> dict[str, Any]:
_require(authorized is True, "AUTHORIZED_TRUE_REQUIRED")
_require(type(script) is str and bool(script), "SCRIPT_REQUIRED")
data = script.encode("utf-8")
_require(len(data) <= 262144, "SCRIPT_TOO_LARGE")
digest = hashlib.sha256(data).hexdigest()
_require(not sha256 or (type(sha256) is str and sha256.lower() == digest), "SCRIPT_SHA256_MISMATCH")
try:
timeout = int(timeout_s)
except (TypeError, ValueError):
raise FleetError("INVALID_TIMEOUT") from None
_require(1 <= timeout <= 600, "INVALID_TIMEOUT")
job = job_id or "node-mcp-" + time.strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:12]
_require(type(job) is str and JOB_RE.fullmatch(job), "INVALID_JOB_ID")
return {"version": 1, "mode": "EXEC", "authorized": True, "script": script,
"timeout_s": timeout, "job_id": job, "sha256": digest,
"include_stdout": include_stdout is True}
def execute_node(alias: str, script: str, authorized: bool, timeout_s: int = 120,
job_id: str = "", sha256: str = "", include_stdout: bool = False) -> dict[str, Any]:
try:
payload = _exec_payload(script, authorized, timeout_s, job_id, sha256, include_stdout)
node = resolve_node(alias)
if node["transport"] == "local":
from .runtime import adapter_request
result = adapter_request(payload)
else:
_require(node["operational_transport_ready"], "NODE_OFFLINE")
from . import broker
result = broker.request(node["node_id"], "admin.exec", payload, timeout_s=payload["timeout_s"])
_require(type(result) is dict, "NODE_EXEC_RESPONSE_INVALID")
response = dict(result)
if response.get("ok") is True:
_require(response.get("mcp_job_id", response.get("job_id")) == payload["job_id"],
"NODE_EXEC_JOB_BINDING_INVALID")
_require(response.get("mcp_script_sha256", response.get("command_sha256")) == payload["sha256"],
"NODE_EXEC_HASH_BINDING_INVALID")
response.update(target="LOCAL" if node["transport"] == "local" else node["alias"],
node_id=node["node_id"], mcp_job_id=payload["job_id"],
mcp_script_sha256=payload["sha256"])
if include_stdout is not True:
response.pop("stdout", None)
return response
except Exception as exc:
return {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "NODE_EXEC_FAILED"}
def execute_screen_node(alias: str, script: str, authorized: bool, timeout_s: int = 30,
job_id: str = "", sha256: str = "", include_stdout: bool = False) -> dict[str, Any]:
"""Use the same confirmed-node authority with an explicit non-shell screen operation."""
from .real_screen.screen_transport import REQUEST_TYPE, validate_payload, ScreenTransportError
try:
_require(type(timeout_s) is int and 1 <= timeout_s <= 30, "INVALID_SCREEN_TIMEOUT")
payload = _exec_payload(script, authorized, timeout_s, job_id, sha256, include_stdout)
payload["request_type"] = REQUEST_TYPE
validate_payload(payload)
node = resolve_node(alias)
if node["transport"] == "local":
from .runtime import adapter_request
result = adapter_request(payload)
else:
_require(node["operational_transport_ready"], "NODE_OFFLINE")
from . import broker
result = broker.request(node["node_id"], "admin.exec", payload, timeout_s=payload["timeout_s"])
_require(type(result) is dict, "NODE_EXEC_RESPONSE_INVALID")
response = dict(result)
if response.get("ok") is True:
_require(response.get("mcp_job_id", response.get("job_id")) == payload["job_id"],
"NODE_EXEC_JOB_BINDING_INVALID")
_require(response.get("mcp_script_sha256", response.get("command_sha256")) == payload["sha256"],
"NODE_EXEC_HASH_BINDING_INVALID")
if response.get("adapter_error") == "ValueError:UNSUPPORTED_REQUEST_TYPE":
# An old adapter rejects this type before EXEC. Never retry it as shell code.
from .real_screen.screen_rpc import encode
response.update(ok=False, exit_code="1", stdout_truncated=False,
stdout=encode({"ok": False, "error": {"code": "SCREEN_RPC_UNSUPPORTED_ON_NODE"}}).decode("ascii") + "\n")
response.update(target="LOCAL" if node["transport"] == "local" else node["alias"],
node_id=node["node_id"], mcp_job_id=payload["job_id"],
mcp_script_sha256=payload["sha256"])
return response
except Exception as exc:
return {"ok": False, "error": str(exc) if isinstance(exc, (FleetError, ScreenTransportError)) else "SCREEN_RPC_FAILED"}
def cua_node(alias: str, payload: dict[str, Any], authorized: bool,
timeout_s: int = 60, job_id: str = "") -> dict[str, Any]:
try:
_require(authorized is True, "AUTHORIZED_TRUE_REQUIRED")
_require(type(payload) is dict, "CUA_PAYLOAD_REQUIRED")
_require(payload.get("action") in {"OBSERVE", "CLICK", "DOUBLE_CLICK", "TYPE", "KEY", "SCROLL", "DRAG"},
"CUA_ACTION_UNSUPPORTED")
try:
timeout = int(timeout_s)
except (TypeError, ValueError):
raise FleetError("INVALID_CUA_TIMEOUT") from None
_require(1 <= timeout <= 180, "INVALID_CUA_TIMEOUT")
job = job_id or "cua-node-" + uuid.uuid4().hex
_require(type(job) is str and JOB_RE.fullmatch(job), "INVALID_JOB_ID")
request = {"version": 1, "mode": "EXEC", "request_type": "cua",
"authorized": True, "timeout_s": timeout, "job_id": job, "cua": dict(payload)}
node = resolve_node(alias)
if node["transport"] == "local":
from .runtime import adapter_request
result = adapter_request(request)
else:
_require(node["operational_transport_ready"], "NODE_OFFLINE")
from . import broker
result = broker.request(node["node_id"], "admin.cua", request, timeout_s=timeout)
_require(type(result) is dict, "NODE_CUA_RESPONSE_INVALID")
response = dict(result)
if response.get("ok") is True:
_require(response.get("job_id") == job, "NODE_CUA_JOB_BINDING_INVALID")
response.update(target="LOCAL" if node["transport"] == "local" else node["alias"], node_id=node["node_id"])
return response
except Exception as exc:
return {"ok": False, "error": str(exc) if isinstance(exc, FleetError) else "NODE_CUA_FAILED"}
def main() -> None:
parser = argparse.ArgumentParser(description="Original six-tool Fleet READ Hub")
parser.add_argument("command", choices=("serve",), nargs="?", default="serve")
parser.parse_args()
bootstrap_original()
from mcp.server.fastmcp import FastMCP
from framebridge_linux_chatgpt.fleet_hub import FleetHub
from framebridge_linux_chatgpt.fleet_mcp_surface import register_fleet_read_tools
host = os.environ.get("FRAMEBRIDGE_FLEET_HUB_HOST", "127.0.0.1")
_require(host in ("127.0.0.1", "::1"), "FLEET_HUB_HOST_INVALID")
port = int(os.environ.get("FRAMEBRIDGE_FLEET_HUB_PORT", "20877"))
_require(1024 <= port <= 65535, "FLEET_HUB_PORT_INVALID")
server = FastMCP("FrameBridge Original Expanded Fleet", host=host, port=port,
streamable_http_path="/mcp")
hub = FleetHub(discover_nodes, node_read)
register_fleet_read_tools(server, hub)
server.run(transport="streamable-http")
if __name__ == "__main__":
main()
@@ -0,0 +1,63 @@
"""Versioned, read-only English guide shared by CLI, MCP and website documentation."""
from __future__ import annotations
import copy
import hashlib
import json
from . import __version__
PRODUCT = "FrameBridge"
GUIDE_SCHEMA = 1
from .guide_data import GUIDE as _GUIDE
def topics():
"""Return the bounded public guide-topic names."""
return tuple(_GUIDE)
def _digest(value):
raw = json.dumps(value, sort_keys=True, ensure_ascii=True, separators=(",", ":")).encode()
return hashlib.sha256(raw).hexdigest()
def get_guide(topic="start"):
"""Read static help without touching profiles, credentials, processes or network."""
if not isinstance(topic, str) or len(topic) > 64:
return {"ok": False, "error": "HELP_TOPIC_INVALID", "topics": list(topics())}
topic = topic.strip().lower()
if topic == "all":
sections = []
for name, entry in _GUIDE.items():
sections.append({"heading": entry["title"], "body": "Guide topic: " + name, "commands": []})
sections.extend(copy.deepcopy(entry["sections"]))
title = "FrameBridge complete command guide"
elif topic in _GUIDE:
entry = _GUIDE[topic]
title, sections = entry["title"], copy.deepcopy(entry["sections"])
else:
return {"ok": False, "error": "HELP_TOPIC_UNKNOWN", "topic": topic, "topics": list(topics()),
"product": PRODUCT, "version": __version__, "guide_schema": GUIDE_SCHEMA}
value = {"ok": True, "product": PRODUCT, "version": __version__, "guide_schema": GUIDE_SCHEMA,
"topic": topic, "title": title, "sections": sections, "topics": list(topics()),
"read_only": True, "capabilities_verified": False}
value["guide_sha256"] = _digest(value)
return value
def render_text(topic="start"):
"""Render the same guide as a navigable offline text reference."""
guide = get_guide(topic)
if not guide["ok"]:
raise ValueError("Unknown help topic. Choose: " + ", ".join(topics()))
lines = [guide["title"], "FrameBridge " + guide["version"] + " | guide schema " + str(GUIDE_SCHEMA), ""]
for section in guide["sections"]:
lines.extend([section["heading"], section["body"], ""])
lines.extend(" " + command for command in section["commands"])
if section["commands"]:
lines.append("")
if topic != "all":
lines.append("More help: fb help " + " | ".join(topics()))
return "\n".join(lines).rstrip() + "\n"
def export_guide():
"""Return the versioned static export used to generate website documentation."""
value = {"product": PRODUCT, "version": __version__, "guide_schema": GUIDE_SCHEMA,
"topics": {name: get_guide(name) for name in topics()}}
value["guide_sha256"] = _digest(value)
return value
@@ -0,0 +1,430 @@
"""Shared English guide content. Regenerate command docs after editing."""
GUIDE = {'start': {'title': 'Start with FrameBridge',
'sections': [{'heading': 'One interface for people and agents',
'body': 'FrameBridge provides tools and a shared command-line interface '
'for human operators and authorized agents. fb is the short name '
'for framebridge; both run the same program. The tools run on '
'your Linux computers. The ChatGPT model remains hosted. Read '
'this guide for the installed version before operating; confirm '
'the receiver, target and available capabilities. Use the '
'installed CLI and tools for normal work. Edit software only when '
'the owner explicitly requests development, using a separate '
'source clone and reviewable changes rather than rewriting the '
'installed program.',
'commands': ['fb help', 'fb status', 'fb --json status']},
{'heading': 'Install and connect',
'body': 'On a supported Debian system, install the downloaded package '
'with the package manager. Normal first connection does not '
'require a separate setup, doctor or start sequence. Run connect '
'in a normal interactive terminal. Advanced setup is optional for '
'selecting central/node roles and public settings.',
'commands': ['sudo apt install ./FrameBridge-0.3.1.deb', 'fb connect']},
{'heading': 'Updating and removal',
'body': 'Install an official update through the normal package manager. '
'Normal package removal stops package-owned managed processes and '
'retains the profile and saved encrypted connection material for '
'deliberate reuse; it is not a credential-forgetting operation. '
'Use the separately documented explicit forget action first if '
'that is your intended outcome. Do not assume purge erases '
'user-profile data.',
'commands': ['sudo dpkg --remove framebridge-original-expanded']},
{'heading': 'Human authentication',
'body': 'The normal connection asks for an optional account label, a '
'hidden tunnel ID and a hidden MCP server access key. The label '
'is metadata, not proof of ChatGPT login. Browser login/consent '
'and normal operating-system authentication remain human actions '
'in the normal interface. Never put credentials in chat, command '
'arguments, documentation or example files. A successful managed '
'connection returns to the prompt and can run independently of '
'that terminal. Reboot only when an actual system change requires '
'it.',
'commands': []},
{'heading': 'Choose the right guide',
'body': 'Use commands for the reference, connection and lifecycle for '
'state changes, nodes for central/node work, terminals for '
'persistent Bash, and screens for authentic CUA evidence. Query '
'success alone does not mean the connection is ready. The guide '
'describes registered operations; use status/doctor to '
'investigate current state.',
'commands': ['fb help commands',
'fb help connection',
'fb help lifecycle']}]},
'commands': {'title': 'Command reference',
'sections': [{'heading': 'Everyday commands',
'body': 'connect establishes or resumes the managed ChatGPT '
'connection. disconnect pauses that connection until an '
'explicit connect, including across recovery and reboot. '
'status reports runtime, connection, intended connection state '
'and boot policy. doctor diagnoses prerequisites/problems. '
'setup edits advanced stopped-profile settings. console opens '
'the text work view. help reads this versioned reference.',
'commands': ['fb connect',
'fb disconnect',
'fb status',
'fb status connection',
'fb status runtime',
'fb status boot',
'fb status console',
'fb doctor',
'fb setup',
'fb console',
'fb help']},
{'heading': 'Services and boot',
'body': 'For an already configured managed profile, service start/stop '
'control the current stack symmetrically. Boot enable/disable '
'changes future startup policy without stopping current work '
'or forgetting the saved credential. First node provisioning '
'is an explicit exception described in lifecycle. Without '
'managed setup, non-node service start starts local tools '
'only; normal first connection should use connect. Read status '
'before and after changes. The separate credentials forget '
'action is destructive and requires explicit confirmation.',
'commands': ['fb service start',
'fb service stop',
'fb service boot enable',
'fb service boot disable']},
{'heading': 'Console and specialized operations',
'body': 'console login manages optional work-view opening after an '
'authenticated tty1 login; it does not enable automatic login. '
'tty service manages the terminal daemon; tty list lists '
'sessions. nodes, certificates and screen-access retain their '
'domain-specific subcommands. Use --help at the relevant '
'command for its exact arguments. Avoid compatibility aliases '
'in new automation.',
'commands': ['fb console login status',
'fb tty service status',
'fb tty list',
'fb nodes --help',
'fb certificates --help',
'fb screen-access --help']},
{'heading': 'Human and structured output',
'body': 'Use explicit commands in automation rather than the '
'interactive no-argument menu. --json selects structured '
'output for an agent; examine operation success, readiness and '
'state separately. Read errors and the exit status. If human '
'interaction is required, complete it in the normal '
'terminal/interface rather than attempting to supply a secret '
'through the agent. CLI JSON uses an envelope with '
'schema_version, product, version, command, ok and result. '
'Read runtime, connection and boot truth inside result; '
'envelope ok denotes request success. Exit 0 means successful '
'query/operation, not necessarily a connected transport; exit '
'1 reports operation/diagnostic failure, exit 2 invalid '
'arguments or required human interaction/confirmation, and '
'exit 130 cancellation.',
'commands': ['framebridge --json status',
'fb --json status connection']}]},
'connection': {'title': 'Connect and disconnect ChatGPT',
'sections': [{'heading': 'Normal managed connection',
'body': 'connect creates the default standalone profile when absent '
'and establishes managed startup. It asks for initial human '
'input in the terminal, checks progress, reports readiness '
'or an actionable failure, and returns to the prompt. Valid '
'saved setup can be resumed without repeating the initial '
'fields. The MCP access key is not an OpenAI API key. Keep '
"the browser's ChatGPT account login and consent separate "
'from the optional local account label.',
'commands': ['fb connect', 'fb status connection']},
{'heading': 'Saved credentials and next boot',
'body': 'Managed startup uses protected, host-bound encrypted '
'connection material. The normal terminal does not have to '
'remain open. This is a persistence feature, not a promise '
'that an account, network or tunnel service can never fail. '
'Connection intent and boot policy are separate: enabled '
'startup runs the stack at boot, while an intentional '
'disconnected state remains disconnected until connect.',
'commands': ['fb status boot', 'fb status']},
{'heading': 'Intentional disconnect',
'body': 'disconnect keeps local tools and saved material, but '
'records the intention to stay disconnected across worker '
'recovery and reboot. Explicit connect clears that pause. '
'Check the returned state rather than interpreting a '
'successful query as an active transport.',
'commands': ['fb disconnect', 'fb status connection']},
{'heading': 'Replacement and temporary foreground mode',
'body': 'Use connect --replace only when deliberately replacing the '
'account label/tunnel/access configuration. The advanced '
'--foreground mode is temporary, needs a ready local runtime '
'and occupies its terminal until interrupted; it is not the '
'normal managed workflow. Do not use advanced mode merely to '
'recover a normal saved connection.',
'commands': []}]},
'lifecycle': {'title': 'Services, connection intent and boot policy',
'sections': [{'heading': 'Separate states',
'body': 'FrameBridge distinguishes actual local runtime, desired '
'connection, verified transport, boot enablement and saved '
'credential presence. A healthy runtime can be intentionally '
'disconnected; a service can be active while disabled for the '
'next boot. Read the labelled status fields together.',
'commands': ['fb status', 'fb --json status']},
{'heading': 'Current managed stack',
'body': 'For an already configured managed profile, service start and '
'service stop use the same current-stack boundary without '
'changing boot enablement. Stop ends current managed work and '
'transport but keeps profile, boot policy and saved material. '
'Startup on the next boot follows the retained policy and '
'intended connection state. A non-node profile without '
'managed setup starts only local tools. Stopping a terminal '
'service or session can interrupt its running work.',
'commands': ['fb service start', 'fb service stop']},
{'heading': 'Future boot policy',
'body': 'Once managed setup exists, service boot enable/disable '
'changes unit enablement only. It does not immediately stop '
'or start current work and does not delete saved connection '
'material. Initial node provisioning is described separately '
'below. Explicitly forgetting saved material is a separate '
'destructive operation. Legacy autostart disable has its old '
'broader destructive behavior; use the canonical service boot '
'commands for new workflows.',
'commands': ['fb service boot enable',
'fb service boot disable',
'fb status boot']},
{'heading': 'First managed startup for a node',
'body': 'After advanced node setup and the required '
'enrollment/approval, a node without a managed installation '
'can run service start in a normal terminal. This first call '
'creates managed startup, enables future boot and starts the '
'node now; the result explicitly reports '
'initial_configuration and boot_policy_changed. '
'Alternatively, first service boot enable prepares and '
'enables the node unit without starting current work. Initial '
'preparation can require normal owner authentication and is '
'not silently performed by an unattended JSON request. Once '
'configured, the ordinary start/stop and boot-policy-only '
'contracts apply. A node does not need its own ChatGPT '
'tunnel.',
'commands': ['fb service start',
'fb service boot enable',
'fb status boot']},
{'heading': 'Console opening',
'body': 'Opening console is a view operation, separate from Linux '
'service startup. Optional console login enable/disable '
'changes the owned startup hook after an authenticated tty1 '
'Bash login. This hook does not bypass normal login. Review '
"the command's scope before changing it.",
'commands': ['fb console login status']}]},
'nodes': {'title': 'Central and node operation',
'sections': [{'heading': 'Roles',
'body': 'Standalone provides local tools. Central adds the broker for '
'enrolled nodes. A node joins one selected central instance and '
'uses its ChatGPT connection; it does not need another ChatGPT '
'tunnel or phone pairing. Advanced setup selects the role. '
'Preserve explicit authenticated node transport and real identity '
'approval.',
'commands': ['fb setup', 'fb nodes --help']},
{'heading': 'Enrollment is not availability',
'body': 'Enrollment submission, central approval, node receipt of '
'authorization and a running node agent are separate stages. '
'status reports what it actually knows; an unchecked agent is not '
'evidence that the node is online. Central list/pending/approve '
'and node enroll/poll/status/run have role-specific purposes. '
'poll can complete saved authorization; it is not a purely '
'observational operation. After enrollment, consult the lifecycle '
'topic for first managed node startup and its explicit initial '
'boot-policy effect.',
'commands': ['fb nodes list', 'fb nodes pending', 'fb nodes status']},
{'heading': 'Targets',
'body': 'LOCAL means the actual receiving machine. Use enrolled, '
'confirmed aliases or IDs and inspect the selected target before '
'work. Do not guess SSH identities or use historical lab '
'hostnames as defaults. CLI tty --node can use the authenticated '
'node transport. The interactive console command is local; run it '
'at the destination or use an explicitly configured SSH '
'destination.',
'commands': []},
{'heading': 'Transport trust',
'body': 'certificates create manages a private central/node transport CA, '
'not a public website certificate or ChatGPT account '
'authentication. Private keys stay protected on the central host; '
'distribute only the required public trust material. Off-loopback '
'broker use requires correctly configured verified TLS. Do not '
'recreate trust merely to work around a connection symptom.',
'commands': ['fb certificates --help']}]},
'terminals': {'title': 'Persistent Bash terminals',
'sections': [{'heading': 'Service and sessions',
'body': "tty service controls the profile's terminal daemon. tty open "
'creates a real Bash session and returns its identifier. Use '
'that identifier for subsequent observations and input. The '
'same Bash preserves directory, variables and work between '
'requests while the session/service remains alive. It does '
'not preserve running processes across an operating-system '
"reboot. Tool jobs use a profile-specific HOME; a job's home "
'directory can therefore differ from the human login home. '
"Commands retain the connecting account's normal "
'operating-system permissions.',
'commands': ['fb tty service status', 'fb tty list', 'fb tty open']},
{'heading': 'Observe, type and keys',
'body': 'tty observe returns terminal transcript text, cursor, '
'dimensions and process state; it is not an authentic '
'full-screen image. tty type sends text without automatically '
'adding Enter, though supplied newlines can affect execution. '
'tty key accepts exact uppercase names such as ENTER and '
'CTRL_C. Replace session arguments with an identifier '
'returned by open/list; never guess one.',
'commands': ['fb tty key --help',
'fb tty type --help',
'fb tty observe --help']},
{'heading': 'Lifetime and closing',
'body': 'Default real idle expiry is disabled, but a profile can '
'configure a timeout. Global status/list does not refresh '
"each session's per-session activity. close destroys a "
'session rather than merely separating a viewer. Graceful '
'service stop attempts to close its sessions; elevated or '
'unverifiable process state can prevent cleanup. Inspect the '
'outcome instead of assuming that all child processes '
'disappeared.',
'commands': ['fb tty close --help', 'fb tty resize --help']},
{'heading': 'Sudo mode',
'body': "A session's sudo on/off controls normal privilege "
'eligibility; it does not log in as root or bypass '
'operating-system authentication. Changing the mode recreates '
'Bash, preserving directory and dimensions but not shell '
'variables or internal state. Authentication stays in the '
'normal human terminal/interface. Never send a password '
'through type or chat.',
'commands': ['fb tty sudo --help']},
{'heading': 'Work console panels',
'body': 'console combines an ordinary Bash pane with a read-only '
'FBTERM activity dashboard. The dashboard is not a second '
'interactive shell. The tmux prefix is Ctrl+B: release both '
'keys, then press lowercase z to enlarge/restore a pane, or '
'lowercase o to switch panes. The release accepts common '
'zoom-key variants within its dedicated console. Closing a '
'viewer normally leaves its tmux session alive; leaving Bash, '
'killing its server or rebooting is different. In a desktop '
'terminal the shared work view is horizontal with FBTERM on '
'the left and Bash on the right. Explicit TTY mode is '
'vertical with a short FBTERM monitor above Bash. The layout '
'belongs to the shared tmux window and follows the last '
'requested view mode; it is not isolated per viewer.',
'commands': ['fb console', 'fb status console']}]},
'screens': {'title': 'Authentic screen observation and input',
'sections': [{'heading': 'Text transcripts and pixels',
'body': 'A CLI tty transcript describes a terminal and explicitly does '
'not claim authentic pixels. MCP real-screen CUA-TTY and '
'graphical screen tools operate through the real screen client '
'and capture-linked interaction. Use those supported '
'observation tools when authentic complete screen evidence is '
'required. Never present a reconstructed terminal, animation or '
'partial invented view as a real screenshot.',
'commands': []},
{'heading': 'Automatic graphical discovery',
'body': 'The installer and control console stay text interfaces in both '
'TTY and a desktop terminal. Compatible graphical CUA is '
'discovered automatically; availability depends on the actual '
'display/session and permissions. A loaded module is not proof '
'that real pixel capture or input succeeded. New profiles use '
'adaptive auto mode: without a verified active X11 session the '
'runtime provides FBEXEC, FBTERM and CUA-TTY in text mode, '
'without requiring a graphical login or automatically creating '
'a private desktop. Explicit virtual-desktop settings remain '
'advanced options and are labelled separately from '
'physical-screen evidence.',
'commands': ['fb status', 'fb doctor']},
{'heading': 'Frame-buffer read access',
'body': 'screen-access is a narrow, explicit administrative permission '
'for reading the real frame buffer. It does not by itself '
'enable graphical CUA, keyboard/mouse control or arbitrary VT '
'switching. Persistent read access can remain effective after '
"logout or a VT switch; review the command's displayed scope "
'and use normal local administrator authentication.',
'commands': ['fb screen-access status']},
{'heading': 'Shared desktop',
'body': 'A graphical desktop shares focus, keyboard and mouse. Use one '
'controlling agent per desktop at a time and avoid overlapping '
'work. This is an operating recommendation, not a claim of '
'certified automatic isolation. Independent non-overlapping '
'execution jobs may be possible; do not infer isolation or '
'physical parallel execution from that alone.',
'commands': []}]},
'diagnostics': {'title': 'Status and diagnosis',
'sections': [{'heading': 'Inspect before acting',
'body': 'status summarizes current runtime, connection, desired '
'state and boot policy. A query completing successfully is '
'distinct from the components being ready. Connection '
'verification has a freshness boundary; retrying or unknown '
'must not be treated as connected. Focused status uses the '
'same underlying state model.',
'commands': ['fb status',
'fb status connection',
'fb status runtime',
'fb status boot']},
{'heading': 'Doctor',
'body': 'doctor diagnoses prerequisites and reports findings with '
'suggested next steps. It is not an implicit repair, and it '
'is not a substitute for checking actual live connection '
'state. Inspect the diagnosis and relevant private '
'application logs before considering configuration changes.',
'commands': ['fb doctor']},
{'heading': 'Logs and progress',
'body': 'Normal connection presents concise progress and a final '
'result, then returns to the prompt. Detailed diagnostics '
'belong in protected application logs. Share only the '
'minimum relevant redacted result; never expose '
'credentials, private keys, browser session data or '
'unrelated personal paths. Avoid rerunning setup, '
'enrollment or broad lifecycle tests merely because a view '
'is confusing.',
'commands': ['fb help connection', 'fb help lifecycle']}]},
'security': {'title': 'Authorization, distribution and safe operation',
'sections': [{'heading': 'Authorized scope',
'body': 'An agent can use the documented CLI and MCP tools within the '
"user's authorized scope. Reading this guide does not grant "
'permission. Verify the receiver and selected target. Normal '
'human terminal/browser authentication remains separate; do '
'not request credentials in chat or invent phone/Owner-TTY '
'enrollment requirements. Normal use should operate through '
'the installed CLI and tools, not edit or rebuild installed '
'FrameBridge program files. If the owner explicitly requests '
'development, use a separate source clone, make reviewable '
'changes, and do not present a modified build as an official '
'release. This operating guidance does not restrict GNU GPL '
'freedoms or require private forks to submit changes upstream.',
'commands': []},
{'heading': 'Official releases',
'body': 'The source is distributed under GNU GPLv3-only terms and the '
'applicable third-party notices. Verify official release '
'checksums and published signatures/repository metadata before '
'installing. Signature verification identifies the publisher '
'and detects alteration; it does not prohibit lawful modified '
'versions. Official releases are reviewed through the '
"project's release process.",
'commands': []},
{'heading': 'Side effects',
'body': 'Read status before service stop, credential forgetting, node '
'approval, startup-hook changes or administrative screen '
'permission changes. Explain material scope and consequences '
"and follow the user's authorization. A sensitive operation "
'must report the actual result, not just that a query '
'succeeded. Keep real captures complete and distinguish '
'observations from hypotheses.',
'commands': []}]},
'compatibility': {'title': 'Compatibility with earlier commands',
'sections': [{'heading': 'Preferred spelling',
'body': 'Use the short canonical interface in new documentation '
'and automation. fb and framebridge remain equivalent. '
'Older connect chatgpt, disconnect chatgpt and connection '
'status spellings are compatibility routes. New status '
'scopes replace the need to learn an extra connection '
'noun.',
'commands': ['fb connect',
'fb disconnect',
'fb status connection']},
{'heading': 'Legacy lifecycle behavior',
'body': 'Older root start/stop and autostart commands are '
'retained for compatibility where documented, but their '
'historical scopes can differ from the canonical service '
'controls. In particular, legacy autostart disable '
'removes managed setup and saved encrypted material; it '
'is not an alias for boot-policy-only disable. Read the '
"command's help before using a legacy script.",
'commands': ['fb help lifecycle']},
{'heading': 'Versioned help',
'body': 'This guide describes the installed product and guide '
'schema. Read the matching release notes for any semantic '
'migration. The CLI help, MCP guide and static web '
'documentation are generated from the same reviewed guide '
'data; a cached manual from an earlier build can describe '
'a different workflow.',
'commands': ['fb help compatibility']}]}}
@@ -0,0 +1,202 @@
"""Explicit lifecycle scopes shared by human and agent command interfaces."""
from __future__ import annotations
import hashlib
import os
import pwd
import time
from . import autostart, connection_background, runtime
from .config import load_config, state_path
def _owned_setup(state):
state, directory, unit = autostart._paths(state)
record = autostart._manifest(directory)
if record and (record.get("unit") != unit.name or hashlib.sha256(autostart._read(unit)).hexdigest() != record.get("unit_sha256")):
raise RuntimeError("The managed service changed outside FrameBridge; no lifecycle change was made.")
return state, directory, unit, record
def _runtime_stopped(result):
if type(result) is not dict or result.get("ok") is False or result.get("status_unavailable") or result.get("running") is not False:
return False
processes = result.get("processes", [])
return type(processes) is list and all(type(item) is dict and item.get("alive") is False for item in processes)
def _settled_runtime_stop(state, result):
"""Observe bounded asynchronous cleanup without treating query-ok as stopped."""
if _runtime_stopped(result):
return result
deadline = time.monotonic() + 2
while time.monotonic() < deadline:
time.sleep(0.05)
result = runtime.status(state)
if _runtime_stopped(result):
break
return result
def status(state):
"""One observation model: actual tools/transport, intended transport and boot."""
state = state_path(state)
configured = (state / "config.json").is_file()
config = load_config(state) if configured else {}
local = autostart._safe_runtime_status(runtime, state) if configured else {"running": False, "ready": False, "configured": False}
connection = connection_background.status(state)
directory = state / "autostart"
if directory.exists() or directory.is_symlink():
startup = autostart.status(state)
else:
startup = {"configured": False, "enabled": False, "service_active": False, "service_state": "not_configured", "linger": None, "encrypted_credential_present": False}
boot = {key: startup.get(key) for key in ("configured", "enabled", "service_active", "service_state", "linger", "encrypted_credential_present", "unit")}
boot["desired_enabled"] = startup.get("boot_desired_enabled")
if config.get("role") == "node":
desired = "not_applicable"
elif not boot["configured"]:
desired = "not_configured"
else:
desired = "connected" if autostart.connection_requested(state) else "disconnected"
return {"ok": True, "configured": configured, "role": config.get("role"), "name": config.get("name"),
"runtime": local, "connection": connection, "boot": boot,
"desired_connection": desired,
"disconnect_scope": "until_explicit_connect",
"next_boot_connection": "paused" if desired == "disconnected" else ("requested" if desired == "connected" else "not_applicable"),
"worker": startup.get("worker", {"state": "not_configured"})}
def service_start(state):
"""Start the configured stack now without changing its boot enablement."""
def action(value):
state, directory, unit, record = _owned_setup(value)
if not record and load_config(state)["role"] == "node":
autostart._enable(state, start_now=True, confirm=False)
return {"ok": True, "state": "start_requested", "scope": "managed_stack",
"initial_configuration": True, "boot_policy_changed": True,
"boot_default_enabled": True, "status": status(state)}
if not record:
result = runtime.start(state)
return {"ok": bool(result.get("running") and result.get("ready")), "state": "started" if result.get("running") and result.get("ready") else "not_ready",
"scope": "local_tools", "boot_policy_changed": False, "runtime": result}
autostart._clear_manual_stop(state)
autostart._systemctl("start", unit.name)
return {"ok": True, "state": "start_requested", "scope": "managed_stack", "boot_policy_changed": False, "status": status(state)}
return autostart._configuration_action(state, action)
def service_stop(state):
"""Stop current tools and transport; retain desired transport and boot policy."""
def action(value):
state, directory, unit, record = _owned_setup(value)
if record:
autostart.mark_manual_stop(state)
autostart._systemctl("stop", unit.name)
connection = connection_background.disconnect(state)
local = _settled_runtime_stop(state, runtime.stop(state))
success = connection.get("ok") is not False and not connection.get("alive") and _runtime_stopped(local)
return {"ok": success, "state": "stopped" if success else "stop_incomplete",
"scope": "managed_stack" if record else "local_tools", "boot_policy_changed": False, "saved_material_preserved": True,
"connection_intent_preserved": True, "connection": connection, "runtime": local}
return autostart._configuration_action(state, action)
def _boot(state, enabled):
def action(value):
state, directory, unit, record = _owned_setup(value)
initialized = False
if not record:
if enabled and load_config(state)["role"] == "node":
autostart._enable(state, start_now=False, confirm=False)
state, directory, unit, record = _owned_setup(state)
initialized = True
else:
raise RuntimeError("Managed startup is not configured. Use fb connect, or fb service start for a node profile.")
if enabled and not autostart._linger(pwd.getpwuid(os.getuid()).pw_name):
raise RuntimeError("OWNER_AUTHENTICATION_REQUIRED: run fb connect in a normal terminal, or fb autostart enable for a node, to enable startup after logout.")
policy = directory / "boot-policy.json"
before = autostart._optional_read(policy)
autostart._set_boot_requested(state, enabled)
installed = autostart._read(policy)
try:
autostart._systemctl("enable" if enabled else "disable", unit.name)
except BaseException:
if autostart._optional_read(policy) == installed:
if before is None:
policy.unlink()
else:
autostart._write(policy, before, replace=True)
raise
return {"ok": True, "state": "boot_enabled" if enabled else "boot_disabled",
"boot_policy_changed": True, "current_execution_unchanged": True, "initial_configuration": initialized,
"saved_material_preserved": True, "status": status(state)}
return autostart._configuration_action(state, action)
def boot_enable(state):
return _boot(state, True)
def boot_disable(state):
return _boot(state, False)
def disconnect(state):
"""Persist a pause until explicit connect, including worker recovery/reboot."""
def action(value):
def pause(inner):
autostart._set_connection_requested(inner, False)
result = connection_background.disconnect(inner)
return {"ok": result.get("ok") is not False and not result.get("alive"),
"state": "disconnected" if result.get("ok") is not False and not result.get("alive") else "disconnect_incomplete",
"desired_connection": "disconnected",
"scope": "until_explicit_connect", "local_tools_preserved": True,
"saved_material_preserved": True, "boot_policy_changed": False,
"connection": result}
return autostart._connection_action(value, pause)
return autostart._configuration_action(state, action)
def forget_credentials(state):
"""Remove the managed connection setup/material; retain profile and linger.
The command front end requires explicit confirmation before invoking this
destructive operation. Legacy autostart disable retains its old contract.
"""
def action(value):
state, directory, unit, record = _owned_setup(value)
if not record:
return {"ok": True, "state": "already_forgotten", "profile_preserved": True}
if record.get("node"):
raise RuntimeError("This node profile has no ChatGPT credential to forget.")
blob = directory / "credential.cred"
if hashlib.sha256(autostart._read(blob)).hexdigest() != record.get("credential_sha256"):
raise RuntimeError("The encrypted credential changed outside FrameBridge; nothing was forgotten.")
backup = directory / "config-before.json"
if record.get("headless_changed"):
if hashlib.sha256(autostart._read(backup)).hexdigest() != record.get("config_before_sha256"):
raise RuntimeError("The profile backup changed outside FrameBridge; nothing was forgotten.")
owned_metadata = [directory / "connection-intent.json", directory / "manual-stop.json", directory / "boot-policy.json"]
for path in owned_metadata:
if path.exists() or path.is_symlink():
autostart._read(path)
autostart.mark_manual_stop(state)
autostart._systemctl("disable", "--now", unit.name)
connection = connection_background.disconnect(state)
if connection.get("ok") is False or connection.get("alive"):
raise RuntimeError("The connection is still stopping; its saved material was retained.")
local = _settled_runtime_stop(state, runtime.stop(state))
if not _runtime_stopped(local):
raise RuntimeError("Local tools are still stopping; the saved connection material was retained.")
blob.unlink()
unit.unlink()
if record.get("headless_changed"):
backup.unlink()
for path in owned_metadata:
path.unlink(missing_ok=True)
(directory / "installation.json").unlink()
autostart._systemctl("daemon-reload")
return {"ok": True, "state": "forgotten", "encrypted_credential_removed": True,
"managed_configuration_removed": True, "profile_preserved": True,
"linger_preserved": True, "boot_enabled": False}
return autostart._configuration_action(state, action)
@@ -0,0 +1,111 @@
"""Preserve the captured native protocol while guarding actual X11 dispatch."""
from __future__ import annotations
import importlib.util
import json
import os
from pathlib import Path
import sys
from . import display_context as display
META = ("input_may_have_been_sent", "do_not_replay", "performed", "display_context")
def _input_primitive(argv):
args = list(argv)
if len(args) < 2 or Path(str(args[0])).name != "xdotool":
return False
return args[1] not in {"getactivewindow", "getdisplaygeometry", "getmouselocation",
"getwindowgeometry", "getwindowname", "getwindowpid",
"search", "version", "help"}
def install_cua_guard(cua, *, env=None, checker=None):
env = os.environ if env is None else env
checker = display.check if checker is None else checker
original_run = cua._run
original_handle = cua.handle_cua_message
state = {"active": False, "input_started": False, "failed_code": ""}
def verify():
try:
checker(env)
except display.DisplayContextError as exc:
state["failed_code"] = str(exc)
raise cua.CuaTransportError(str(exc), str(exc)) from None
def guard_run(run, track_input):
def guarded(argv, timeout=15):
# The captured pointer helper calls its base runner after an internal
# query. Guard that final runner too, immediately before subprocess.
verify()
if track_input and _input_primitive(argv):
state["input_started"] = True
return run(argv, timeout=timeout)
return guarded
primitive_run = getattr(cua, "_run_v3_base", None)
if primitive_run is not None:
cua._run_v3_base = guard_run(primitive_run, True)
guarded_run = guard_run(original_run, primitive_run is None)
def guarded_handle(message):
state.update(active=True, input_started=False, failed_code="")
verify()
result = original_handle(message)
verify()
return result
cua._run = guarded_run
cua.handle_cua_message = guarded_handle
return state
def decorate_response(response, state, env):
if not state.get("active") or not isinstance(response, dict):
return response
response = dict(response)
may_have_sent = bool(state.get("input_started"))
response["display_context"] = display.describe(env)
response["input_may_have_been_sent"] = may_have_sent
response["do_not_replay"] = bool(may_have_sent and
(state.get("failed_code") or not response.get("executed")))
if state.get("failed_code"):
details = {"ok": False, "error_code": state["failed_code"],
"input_may_have_been_sent": may_have_sent,
"do_not_replay": may_have_sent, "result_discarded": True}
if not may_have_sent:
details["performed"] = False
response.update(executed=False, exit_code=52, output=json.dumps(details),
error=json.dumps(details))
if may_have_sent:
response.pop("performed", None)
else:
response["performed"] = False
elif response.get("executed"):
response["performed"] = may_have_sent
elif not may_have_sent:
response["performed"] = False
state["active"] = False
return response
def _load(name, path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
def main():
prefix = Path(os.environ.get("FRAMEBRIDGE_EXPANDED_PREFIX", Path(__file__).resolve().parents[2]))
host = prefix / "original/asus/framebridge-native/host/framebridge_host.py"
if not host.is_file():
host = prefix / "sources/original-asus/framebridge-native/host/framebridge_host.py"
if not host.is_file():
raise SystemExit("CAPTURED_NATIVE_HOST_NOT_FOUND")
sys.path.insert(0, str(host.parent))
cua = _load("framebridge_cua_linux", host.with_name("framebridge_cua_linux.py"))
state = install_cua_guard(cua)
original = _load("_framebridge_original_native_host", host)
send = original.send_message
original.send_message = lambda response: send(decorate_response(response, state, os.environ))
original.main()
if __name__ == "__main__":
main()
@@ -0,0 +1,166 @@
"""Read-only network diagnostics, independent of a desktop applet's manager view."""
from __future__ import annotations
import json
import os
import re
import stat
from pathlib import Path
import shutil
import subprocess
_IFACE = re.compile(r"[A-Za-z0-9_.:@-]{1,100}\Z")
def _command(argv, *, timeout=4):
try:
result = subprocess.run(argv, capture_output=True, text=True, timeout=timeout,
env={**os.environ, "LC_ALL": "C", "LANG": "C"})
return result.returncode, result.stdout
except (OSError, subprocess.SubprocessError):
return None, ""
def _json(argv):
code, text = _command(argv)
if code != 0:
return None
try:
value = json.loads(text)
except (ValueError, TypeError):
return None
return value if type(value) is list else None
def _active(service):
code, text = _command(["systemctl", "is-active", service])
return code == 0 and text.strip() == "active"
def _available(name):
return bool(shutil.which(name) or any(
(Path(directory) / name).is_file() and os.access(Path(directory) / name, os.X_OK)
for directory in ["/usr/sbin", "/sbin", "/usr/bin", "/bin"]))
def _ifupdown(state_file=Path("/run/network/ifstate")):
"""Read active interface names from bounded state, never credential/config files."""
try:
fd = os.open(state_file, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
info = os.fstat(fd)
if (not stat.S_ISREG(info.st_mode) or info.st_uid not in {0, os.getuid()}
or info.st_mode & 0o022 or info.st_size > 65536):
return set()
raw = os.read(fd, 65537)
finally:
os.close(fd)
if len(raw) > 65536:
return set()
text = raw.decode("ascii")
except (OSError, UnicodeError):
return set()
names = set()
for line in text.splitlines():
if "=" not in line:
continue
name, mapped = line.split("=", 1)
if _IFACE.fullmatch(name) and _IFACE.fullmatch(mapped):
names.add(name)
return names
def _nm_devices():
"""Only device metadata, never connection profiles, SSIDs or saved secrets."""
code, text = _command([
"nmcli", "-t", "-f", "GENERAL.DEVICE,GENERAL.STATE,GENERAL.NM-MANAGED", "device", "show",
])
if code != 0:
return None
devices = {}
current = None
for line in text.splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
# nmcli escapes colons/backslashes in terse output. Linux interface
# names containing either are excluded rather than guessed.
if key == "GENERAL.DEVICE":
current = value if _IFACE.fullmatch(value) and "\\" not in value else None
if current:
devices[current] = {"managed": None, "state": ""}
elif current and key == "GENERAL.STATE":
devices[current]["state"] = value[:120]
elif current and key == "GENERAL.NM-MANAGED":
devices[current]["managed"] = {"yes": True, "no": False}.get(value.strip().lower())
return devices
def summarize(addresses, routes, nm_devices=None, ifupdown=None, managers=None):
"""Separate observed kernel connectivity from network-manager ownership."""
nm_devices = {} if nm_devices is None else nm_devices
ifupdown = set() if ifupdown is None else set(ifupdown)
managers = {} if managers is None else managers
defaults = []
for family, rows in routes.items():
for row in rows or []:
name = row.get("dev")
if row.get("dst") != "default" or not isinstance(name, str) or _IFACE.fullmatch(name) is None:
continue
defaults.append({"family": family, "interface": name,
"gateway": row.get("gateway"), "metric": row.get("metric")})
interfaces = []
for row in addresses or []:
name = row.get("ifname")
if not isinstance(name, str) or _IFACE.fullmatch(name) is None:
continue
flags = row.get("flags") if isinstance(row.get("flags"), list) else []
addresses_public = []
for address in row.get("addr_info", []):
if (isinstance(address, dict) and address.get("family") in {"inet", "inet6"}
and isinstance(address.get("local"), str)):
addresses_public.append({"family": address["family"], "address": address["local"],
"prefixlen": address.get("prefixlen"),
"scope": address.get("scope")})
nm = nm_devices.get(name, {})
managed = nm.get("managed")
if managed is True:
owner = "NetworkManager"
elif name in ifupdown:
owner = "ifupdown"
else:
owner = "unverified"
default = any(route["interface"] == name for route in defaults)
link = name != "lo" and "UP" in flags and row.get("operstate") in {"UP", "UNKNOWN"}
usable_address = any(a["scope"] == "global" for a in addresses_public)
interfaces.append({
"name": name, "link_up": link, "operstate": row.get("operstate"),
"addresses": addresses_public, "has_default_route": default,
"manager": owner, "networkmanager_managed": managed,
"networkmanager_state": nm.get("state", ""),
"local_route_ready": bool(link and usable_address and default),
})
ready = any(item["local_route_ready"] for item in interfaces)
warnings = []
for item in interfaces:
if item["local_route_ready"] and item["networkmanager_managed"] is False:
warnings.append({
"code": "CONNECTED_INTERFACE_NOT_MANAGED_BY_NETWORKMANAGER",
"interface": item["name"],
"message": "A NetworkManager desktop applet may not represent this working interface.",
})
return {"ok": True, "interfaces": interfaces, "default_routes": defaults,
"managers": managers, "connectivity": "LINK_ADDRESS_DEFAULT_ROUTE" if ready else "NOT_ESTABLISHED",
"internet_verified": False,
"internet_note": "Link, address and route checks do not verify DNS or Internet access.",
"warnings": warnings}
def inspect():
addresses = _json(["ip", "-j", "address", "show"])
routes4 = _json(["ip", "-j", "-4", "route", "show", "default"])
routes6 = _json(["ip", "-j", "-6", "route", "show", "default"])
nm = _nm_devices() if shutil.which("nmcli") else None
managers = {
"NetworkManager": {"available": bool(shutil.which("nmcli")), "active": _active("NetworkManager.service")},
"ifupdown": {"available": _available("ifquery"), "active": _active("networking.service")},
"systemd-networkd": {"active": _active("systemd-networkd.service")},
}
active = _ifupdown() if managers["ifupdown"]["active"] else set()
value = summarize(addresses, {"ipv4": routes4, "ipv6": routes6}, nm, active, managers)
if addresses is None or (routes4 is None and routes6 is None):
value.update(ok=False, error="NETWORK_KERNEL_STATUS_UNAVAILABLE")
if nm is None and managers["NetworkManager"]["available"]:
value["warnings"].append({"code": "NETWORKMANAGER_STATUS_UNAVAILABLE"})
return value
@@ -0,0 +1,205 @@
"""Original FBPAIR identity and outbound protocol for an explicitly enrolled node."""
from __future__ import annotations
import hashlib
import importlib
import json
import os
import socket
import time
import uuid
from pathlib import Path
from . import broker
class NodeError(ValueError):
pass
def _original(name):
broker.original_modules()
return importlib.import_module("framebridge_linux_chatgpt." + name)
def _write_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
temporary = path.with_name("." + path.name + "." + uuid.uuid4().hex + ".tmp")
fd = os.open(str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
try:
with os.fdopen(fd, "w") as stream:
json.dump(value, stream, indent=2, sort_keys=True, allow_nan=False)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
if temporary.exists():
temporary.unlink()
def _context(state_dir=None, create=False, node_label=None):
state = broker.state_path(state_dir)
config = broker._config(state)
label = node_label or config.get("name") or socket.gethostname()
if not isinstance(label, str) or broker._LABEL.fullmatch(label) is None or label.casefold() == "local":
raise NodeError("NODE_LABEL_INVALID")
ctx = _original("seed_core").SeedContext(state_dir=state / "node", config_dir=state / "node/config")
identity = ctx.load_identity()
if identity:
node_id = identity.get("node_id")
if config.get("node_id") and config["node_id"] != node_id:
raise NodeError("NODE_IDENTITY_CONFIG_MISMATCH")
if create and identity.get("hostname") != label:
raise NodeError("NODE_IDENTITY_LABEL_MISMATCH")
elif create:
node_id = config.get("node_id") or str(uuid.uuid4())
try:
if str(uuid.UUID(node_id)) != node_id:
raise ValueError
except (ValueError, TypeError, AttributeError):
raise NodeError("NODE_ID_INVALID") from None
crypto = _original("node_identity").ensure_node_keypair(ctx.state_dir,
comment="framebridge-" + node_id[:8])
identity = {"schema": 2, "node_id": node_id, "node_id_short": node_id.replace("-", "")[:8],
"node_host_id": crypto["host_id"], "node_public_jwk": crypto["public_jwk"],
"node_public_key_sha256": crypto["public_key_sha256"], "hostname": label,
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
_write_json(ctx.identity_path, identity)
else:
raise NodeError("NODE_IDENTITY_MISSING")
if create and (config.get("node_id") != node_id or config.get("name") != label):
config.update(schema=1, node_id=node_id, name=label)
_write_json(state / "config.json", config)
return state, config, ctx, identity
def _poster(state):
def post(url, value, *, timeout=10):
return broker.post_json(url, value, state_dir=state, timeout=timeout)
return post
def enroll(broker_url, node_label, state_dir=None):
url = broker.validate_endpoint(broker_url)
state, config, ctx, identity = _context(state_dir, create=True, node_label=node_label)
store = _original("pairing").PairingStore(ctx.state_dir)
existing = store.load()
if existing.get("state") in {"pair_requested", "paired"}:
raise NodeError("NODE_ENROLLMENT_ALREADY_EXISTS")
manifest = _original("seed_capabilities").list_capabilities()
digest = hashlib.sha256(json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
result = _original("pairing_client").start_pairing(
state_dir=ctx.state_dir, node_id=identity["node_id"], node_label=node_label,
node_host_id=identity["node_host_id"], public_jwk=identity["node_public_jwk"],
provider="chatgpt", capability_manifest_sha256=digest, broker_base_url=url,
allow_insecure_dev=url.startswith("http://"), poster=_poster(state))
config["broker_url"] = url
_write_json(state / "config.json", config)
return dict(result, node_id=identity["node_id"], node_label=node_label,
fingerprint=identity["node_public_key_sha256"])
def poll_enrollment(state_dir=None):
state, config, ctx, identity = _context(state_dir)
url = broker.validate_endpoint(config.get("broker_url", ""))
store = _original("pairing").PairingStore(ctx.state_dir)
pairing = store.load()
if pairing.get("broker_base_url") and pairing["broker_base_url"].rstrip("/") != url:
raise NodeError("PAIRING_BROKER_CONFIG_MISMATCH")
if pairing.get("state") == "paired" and pairing.get("broker_confirm_required") is not True:
return status(state)
result = _original("pairing_client").poll_pairing(
state_dir=ctx.state_dir, allow_insecure_dev=url.startswith("http://"), poster=_poster(state))
pairing = store.load()
if pairing.get("state") == "paired":
_write_json(ctx.config_path, {"schema": 1, "provider": "chatgpt", "pairing": {
"state": "paired", "broker_base_url": url,
"broker_confirm_required": pairing.get("broker_confirm_required") is True}})
return dict(result, node_id=identity["node_id"])
def status(state_dir=None):
try:
state, config, ctx, identity = _context(state_dir)
except NodeError as exc:
return {"ok": False, "error": str(exc), "state": "not_enrolled"}
pairing = _original("pairing").PairingStore(ctx.state_dir).load()
return {"ok": True, "node_id": identity["node_id"], "node_label": identity["hostname"],
"fingerprint": identity["node_public_key_sha256"],
"state": pairing.get("state", "not_paired"),
"broker_confirm_required": pairing.get("broker_confirm_required") is True,
"broker_url": config.get("broker_url"), "agent_state": "not_checked"}
def dispatch_request(request, adapter_request=None, state_dir=None):
"""Recheck binding and authorization before invoking the real local Original adapter."""
if not isinstance(request, dict):
return {"ok": False, "error": "REQUEST_INVALID"}
try:
_, _, _, identity = _context(state_dir)
if request.get("node_id") != identity["node_id"]:
raise NodeError("REQUEST_NODE_MISMATCH")
expires = request.get("expires_at")
if isinstance(expires, bool) or not isinstance(expires, (int, float)) or not expires > time.time():
raise NodeError("REQUEST_EXPIRED")
rid = request.get("request_id")
if not isinstance(rid, str) or not rid or len(rid) > 80:
raise NodeError("REQUEST_ID_INVALID")
operation = request.get("operation")
if operation:
payload = broker._admin_payload(operation, request.get("payload"))
payload = dict(payload, version=1, mode="EXEC")
if operation == "admin.cua":
payload["request_type"] = "cua"
if adapter_request is None:
from .runtime import adapter_request
result = adapter_request(payload)
if not isinstance(result, dict):
raise NodeError("ADAPTER_RESPONSE_INVALID")
result = dict(result, mcp_job_id=payload["job_id"])
if operation == "admin.exec":
result["mcp_script_sha256"] = payload["sha256"]
if payload.get("include_stdout") is not True:
result.update(stdout="", stdout_sanitized=False, stdout_truncated=False,
stdout_omitted_reason="NOT_REQUESTED")
return result
cap, args = request.get("capability"), request.get("args")
if cap not in broker.original_modules().READ_CAPS or not isinstance(args, dict):
raise NodeError("CAPABILITY_NOT_ALLOWED")
value = _original("seed_capabilities").execute_capability(cap, args)
if isinstance(value, dict) and value.get("ok") is False:
return {"ok": False, "capability": cap, "error": "READ_CAPABILITY_FAILED"}
return {"ok": True, "capability": cap, "result": value}
except (NodeError, broker.BrokerError) as exc:
return {"ok": False, "error": str(exc)}
except Exception:
return {"ok": False, "error": "NODE_DISPATCH_FAILED"}
def run(state_dir=None, once=False):
state, config, ctx, identity = _context(state_dir)
url = broker.validate_endpoint(config.get("broker_url", ""))
pairing = _original("pairing").PairingStore(ctx.state_dir).load()
if pairing.get("state") != "paired" or pairing.get("broker_confirm_required") is True:
raise NodeError("NODE_NOT_CONFIRMED")
if pairing.get("node_id") != identity["node_id"] or pairing.get("broker_base_url", "").rstrip("/") != url:
raise NodeError("PAIRING_BINDING_MISMATCH")
outbound = _original("outbound_agent")
while True:
poll = broker.post_json(url + "/fbpair/v1/node/poll",
outbound._signed_envelope(ctx, "node.poll", {}), state_dir=state)
if poll.get("ok") is not True:
raise NodeError("NODE_POLL_REJECTED")
req = poll.get("request")
result = {"ok": True, "idle": True}
if isinstance(req, dict):
value = dispatch_request(req, state_dir=state)
posted = broker.post_json(url + "/fbpair/v1/node/result", outbound._signed_envelope(
ctx, "node.result", {"request_id": req.get("request_id"), "result": value}), state_dir=state)
if posted.get("ok") is not True:
raise NodeError("NODE_RESULT_REJECTED")
result = {"ok": True, "idle": False, "request_id": req.get("request_id")}
if once:
return result
time.sleep(1)
@@ -0,0 +1 @@
"""Actual active-screen capture and bound visible terminal input."""
@@ -0,0 +1,575 @@
#!/usr/bin/env python3
"""Capture actual Linux VT or X11 root pixels, never rendered terminal text.
Active X11 sessions use XGetImage, and Wayland fails explicitly. The framebuffer
backend is never used as a substitute for an active graphical session.
"""
from __future__ import annotations
import array
import base64
import ctypes
import ctypes.util
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
import fcntl
import hashlib
import json
import math
import os
from pathlib import Path
import pwd
import re
import socket
import stat
import struct
import subprocess
import sys
import threading
import uuid
import zlib
MAX_PIXELS = 4096 * 2160
MAX_FRAMEBUFFER_BYTES = 128 * 1024 * 1024
MAX_IMAGE_BYTES = 32 * 1024 * 1024
MAX_CHUNK_BYTES = 2400
CAPTURE_ID = re.compile(r"[0-9a-f]{32}\Z")
SESSION_ID = re.compile(r"[A-Za-z0-9_-]{1,64}\Z")
DISPLAY_ID = re.compile(r":[0-9]{1,5}(?:\.[0-9]{1,3})?\Z")
_X11_LOCK = threading.RLock()
FBIOGET_VSCREENINFO = 0x4600
FBIOGET_FSCREENINFO = 0x4602
class CaptureError(RuntimeError):
def __init__(self, code: str):
self.code = code
super().__init__(code)
def require(condition: bool, code: str) -> None:
if not condition:
raise CaptureError(code)
@dataclass(frozen=True)
class Foreground:
active_vt: str
session_id: str
uid: int
leader: int
session_type: str
tty: str
seat: str
vtnr: int
display: str = ""
def choose_foreground(active_vt: str, sessions: list[dict], seat: str) -> Foreground:
"""Require an identifiable active text session on the actual foreground VT."""
require(re.fullmatch(r"tty[1-9][0-9]*", active_vt) is not None, "ACTIVE_VT_UNVERIFIED")
active = [s for s in sessions if s.get("Seat") == seat and s.get("Active") == "yes"
and s.get("Remote") == "no"]
require(not any(s.get("Type") in {"x11", "wayland"} for s in active),
"GRAPHICAL_CAPTURE_REQUIRED")
matching = [s for s in active if s.get("Type") == "tty" and s.get("TTY") == active_vt
and s.get("VTNr") == active_vt[3:]]
require(len(matching) == 1, "FOREGROUND_SESSION_UNVERIFIED")
s = matching[0]
try:
result = Foreground(active_vt, s["Id"], int(s["User"]), int(s["Leader"]),
s["Type"], s["TTY"], s["Seat"], int(s["VTNr"]))
except (KeyError, TypeError, ValueError):
raise CaptureError("FOREGROUND_SESSION_INVALID") from None
require(SESSION_ID.fullmatch(result.session_id) is not None and result.leader > 0,
"FOREGROUND_SESSION_INVALID")
return result
def choose_display_foreground(active_vt: str, sessions: list[dict], seat: str) -> Foreground:
active = [s for s in sessions if s.get("Seat") == seat and s.get("Active") == "yes"
and s.get("Remote") == "no"]
require(len(active) == 1, "FOREGROUND_SESSION_UNVERIFIED")
s = active[0]
if s.get("Type") == "tty":
return choose_foreground(active_vt, sessions, seat)
require(s.get("Type") != "wayland", "WAYLAND_CAPTURE_UNSUPPORTED")
require(s.get("Type") == "x11", "SCREEN_TYPE_UNSUPPORTED")
# LightDM may leave TTY empty while supplying the actual numeric VT.
# Missing metadata or an explicit contradictory TTY is still rejected.
require(re.fullmatch(r"tty[1-9][0-9]*", active_vt) is not None
and s.get("VTNr") == active_vt[3:] and s.get("TTY") in ("", active_vt),
"GRAPHICAL_VT_UNVERIFIED")
require(DISPLAY_ID.fullmatch(s.get("Display", "")) is not None, "X11_DISPLAY_UNVERIFIED")
try:
f = Foreground(active_vt, s["Id"], int(s["User"]), int(s["Leader"]), "x11",
s["TTY"], s["Seat"], int(s["VTNr"]), s["Display"])
except (KeyError, ValueError, TypeError):
raise CaptureError("FOREGROUND_SESSION_INVALID") from None
require(f.uid == os.getuid() and f.leader > 0 and SESSION_ID.fullmatch(f.session_id) is not None,
"X11_SESSION_IDENTITY_MISMATCH")
return f
def foreground(seat: str = "seat0", *, allow_graphics: bool = False) -> Foreground:
def run(args: list[str]) -> str:
try:
p = subprocess.run(args, check=False, capture_output=True, timeout=4,
text=True, env={**os.environ, "LC_ALL": "C"})
except (OSError, subprocess.TimeoutExpired):
raise CaptureError("SESSION_QUERY_FAILED") from None
require(p.returncode == 0 and len(p.stdout) <= 65536, "SESSION_QUERY_FAILED")
return p.stdout
ids = []
for line in run(["loginctl", "list-sessions", "--no-legend", "--no-pager"]).splitlines():
if line.strip():
sid = line.split()[0]
require(SESSION_ID.fullmatch(sid) is not None, "SESSION_QUERY_INVALID")
ids.append(sid)
require(len(ids) <= 32, "SESSION_COUNT_EXCEEDED")
fields = ["Id", "Active", "Type", "Seat", "VTNr", "TTY", "User", "Leader", "Remote", "Display"]
sessions = []
for sid in ids:
args = ["loginctl", "show-session", sid, "--no-pager"]
for key in fields:
args += ["-p", key]
sessions.append(dict(line.split("=", 1) for line in run(args).splitlines() if "=" in line))
try:
active_vt = Path("/sys/class/tty/tty0/active").read_text().strip()
except OSError:
raise CaptureError("ACTIVE_VT_UNAVAILABLE") from None
return (choose_display_foreground(active_vt, sessions, seat) if allow_graphics
else choose_foreground(active_vt, sessions, seat))
class _Fix(ctypes.Structure):
_fields_ = [("id", ctypes.c_char * 16), ("smem_start", ctypes.c_ulong),
("smem_len", ctypes.c_uint32), ("type", ctypes.c_uint32),
("type_aux", ctypes.c_uint32), ("visual", ctypes.c_uint32),
("xpanstep", ctypes.c_uint16), ("ypanstep", ctypes.c_uint16),
("ywrapstep", ctypes.c_uint16), ("line_length", ctypes.c_uint32),
("mmio_start", ctypes.c_ulong), ("mmio_len", ctypes.c_uint32),
("accel", ctypes.c_uint32), ("capabilities", ctypes.c_uint16),
("reserved", ctypes.c_uint16 * 2)]
@dataclass(frozen=True)
class Geometry:
width: int
height: int
virtual_width: int
virtual_height: int
xoffset: int
yoffset: int
stride: int
bpp: int
red: tuple[int, int]
green: tuple[int, int]
blue: tuple[int, int]
memory_bytes: int
driver: str = ""
byteorder: str = sys.byteorder
def validate_geometry(g: Geometry) -> None:
require(0 < g.width <= 4096 and 0 < g.height <= 2160 and
g.width * g.height <= MAX_PIXELS, "SCREEN_SIZE_UNSUPPORTED")
require(g.bpp in {16, 24, 32} and g.byteorder in {"little", "big"}, "PIXEL_FORMAT_UNSUPPORTED")
pixel_bytes = g.bpp // 8
require(g.virtual_width >= g.width and g.virtual_height >= g.height and
0 <= g.xoffset <= g.virtual_width - g.width and
0 <= g.yoffset <= g.virtual_height - g.height, "VIEWPORT_INVALID")
require(g.stride >= g.virtual_width * pixel_bytes and
0 < g.memory_bytes <= MAX_FRAMEBUFFER_BYTES and
(g.yoffset + g.height) * g.stride <= g.memory_bytes, "FRAMEBUFFER_LAYOUT_INVALID")
masks = []
for offset, length in (g.red, g.green, g.blue):
require(1 <= length <= 8 and 0 <= offset and offset + length <= g.bpp,
"COLOR_LAYOUT_UNSUPPORTED")
masks.append(((1 << length) - 1) << offset)
require(not any(a & b for i, a in enumerate(masks) for b in masks[i + 1:]),
"COLOR_LAYOUT_OVERLAP")
def read_geometry(fd: int) -> Geometry:
var = array.array("I", [0] * 40)
require(var.itemsize == 4, "HOST_ABI_UNSUPPORTED")
fixed_buffer = bytearray(ctypes.sizeof(_Fix))
try:
fcntl.ioctl(fd, FBIOGET_VSCREENINFO, var, True)
fcntl.ioctl(fd, FBIOGET_FSCREENINFO, fixed_buffer, True)
except OSError:
raise CaptureError("FRAMEBUFFER_INFO_UNAVAILABLE") from None
fixed = _Fix.from_buffer_copy(fixed_buffer)
require(fixed.type == 0 and fixed.visual == 2 and var[7] == 0 and var[20] == 0
and var[34] == 0 and all(var[i] == 0 for i in (10, 13, 16)),
"FRAMEBUFFER_FORMAT_UNSUPPORTED")
g = Geometry(var[0], var[1], var[2], var[3], var[4], var[5], fixed.line_length,
var[6], (var[8], var[9]), (var[11], var[12]), (var[14], var[15]),
fixed.smem_len, bytes(fixed.id).decode("ascii", "replace"))
validate_geometry(g)
return g
def decode_framebuffer(raw: bytes, g: Geometry) -> bytes:
"""Losslessly select visible pixels and decode the kernel-reported RGB fields."""
validate_geometry(g)
end = (g.yoffset + g.height - 1) * g.stride + (g.xoffset + g.width) * (g.bpp // 8)
require(len(raw) >= end, "FRAMEBUFFER_READ_SHORT")
pixel_bytes = g.bpp // 8
output = bytearray(g.width * g.height * 3)
byte_fields = g.bpp in {24, 32} and all(n == 8 and off % 8 == 0
for off, n in (g.red, g.green, g.blue))
for y in range(g.height):
start = (y + g.yoffset) * g.stride + g.xoffset * pixel_bytes
row = raw[start:start + g.width * pixel_bytes]
dst = y * g.width * 3
if byte_fields:
for component, (off, _) in enumerate((g.red, g.green, g.blue)):
byte = off // 8 if g.byteorder == "little" else pixel_bytes - 1 - off // 8
output[dst + component:dst + g.width * 3:3] = row[byte::pixel_bytes]
else:
for x in range(g.width):
value = int.from_bytes(row[x * pixel_bytes:(x + 1) * pixel_bytes], g.byteorder)
for component, (off, n) in enumerate((g.red, g.green, g.blue)):
mask = (1 << n) - 1
output[dst + x * 3 + component] = (((value >> off) & mask) * 255 + mask // 2) // mask
return bytes(output)
def encode_png(rgb: bytes, width: int, height: int, *, prefer_indexed: bool = False) -> bytes:
require(len(rgb) == width * height * 3 and width > 0 and height > 0, "PIXEL_LENGTH_INVALID")
def chunk(kind: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xffffffff)
# A palette changes only PNG storage, never pixels. Stop the palette attempt
# as soon as the 257th exact RGB value appears, retaining true RGB for photos.
palette = {}
indexes = None
if prefer_indexed:
indexes = bytearray(width * height)
for pixel, start in enumerate(range(0, len(rgb), 3)):
color = rgb[start:start + 3]
index = palette.get(color)
if index is None:
if len(palette) == 256:
indexes = None
break
index = len(palette)
palette[color] = index
indexes[pixel] = index
if indexes is not None:
depth = 1 if len(palette) <= 2 else 2 if len(palette) <= 4 else 4 if len(palette) <= 16 else 8
per_byte = 8 // depth
compressor = zlib.compressobj(9)
rows = []
for y in range(height):
source = indexes[y * width:(y + 1) * width]
if depth == 8:
packed = source
else:
packed = bytearray((width * depth + 7) // 8)
for x, value in enumerate(source):
packed[x // per_byte] |= value << (8 - depth * ((x % per_byte) + 1))
rows.append(compressor.compress(b"\0" + packed))
rows.append(compressor.flush())
return (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, depth, 3, 0, 0, 0))
+ chunk(b"PLTE", b"".join(palette)) + chunk(b"IDAT", b"".join(rows))
+ chunk(b"IEND", b""))
# RGB pixels are stored exactly; no fonts, labels, scaling or other overlays.
compressor = zlib.compressobj(9)
rows = []
for y in range(height):
rows.append(compressor.compress(b"\0" + rgb[y * width * 3:(y + 1) * width * 3]))
rows.append(compressor.flush())
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", b"".join(rows)) + chunk(b"IEND", b""))
def _directory(output_dir: Path | str) -> Path:
path = Path(output_dir).absolute()
path.mkdir(parents=True, mode=0o700, exist_ok=True)
s = path.lstat()
require(stat.S_ISDIR(s.st_mode) and not path.is_symlink() and s.st_uid == os.getuid()
and s.st_mode & 0o077 == 0, "CAPTURE_DIRECTORY_UNTRUSTED")
return path
def _write_new(path: Path, data: bytes) -> None:
try:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
except OSError:
raise CaptureError("CAPTURE_ARTIFACT_CREATE_FAILED") from None
with os.fdopen(fd, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
def require_unchanged(before: Foreground, after: Foreground) -> None:
require(before == after, "FOREGROUND_CHANGED_DURING_CAPTURE")
class _XImage(ctypes.Structure):
_fields_ = [("width", ctypes.c_int), ("height", ctypes.c_int), ("xoffset", ctypes.c_int),
("format", ctypes.c_int), ("data", ctypes.c_void_p), ("byte_order", ctypes.c_int),
("bitmap_unit", ctypes.c_int), ("bitmap_bit_order", ctypes.c_int),
("bitmap_pad", ctypes.c_int), ("depth", ctypes.c_int),
("bytes_per_line", ctypes.c_int), ("bits_per_pixel", ctypes.c_int),
("red_mask", ctypes.c_ulong), ("green_mask", ctypes.c_ulong),
("blue_mask", ctypes.c_ulong), ("obdata", ctypes.c_void_p),
("functions", ctypes.c_void_p * 6)]
def _mask_field(mask: int) -> tuple[int, int]:
require(mask > 0, "X11_COLOR_MASK_INVALID")
offset = (mask & -mask).bit_length() - 1
shifted = mask >> offset
require(shifted & (shifted + 1) == 0, "X11_COLOR_MASK_INVALID")
return offset, shifted.bit_length()
def _x11_leader_environment(f: Foreground, display: str) -> dict[str, str]:
"""Read the user leader's two context fields, never a display manager's env."""
try:
leader = Path("/proc") / str(f.leader)
owner = leader.stat().st_uid
require(owner in {0, f.uid}, "X11_LEADER_IDENTITY_MISMATCH")
if owner != f.uid:
# LightDM's session leader belongs to root. It is not the desktop
# user and its authentication context must not be read or reused.
return {}
environment = (leader / "environ").read_bytes()
require(len(environment) <= 1024 * 1024, "X11_CONTEXT_TOO_LARGE")
values = {}
for item in environment.split(b"\0"):
if b"=" not in item:
continue
name, value = item.split(b"=", 1)
if name in {b"DISPLAY", b"XAUTHORITY"}:
values[name.decode()] = value.decode("utf-8", "strict")
require(values.get("DISPLAY", display) == display, "X11_DISPLAY_IDENTITY_MISMATCH")
return values
except (OSError, UnicodeDecodeError):
return {}
def _x11_context(f: Foreground, display: str | None, xauthority: str | None) -> tuple[str, str | None]:
require(f.session_type == "x11" and f.uid == os.getuid(), "X11_SESSION_IDENTITY_MISMATCH")
display = display or f.display
require(isinstance(display, str) and DISPLAY_ID.fullmatch(display) is not None
and display == f.display, "X11_DISPLAY_IDENTITY_MISMATCH")
if xauthority is None:
# Use the matching process context or the user leader's whitelist.
# Authentication bytes are never inspected, logged or persisted here.
if os.environ.get("DISPLAY") == display:
xauthority = os.environ.get("XAUTHORITY")
if xauthority is None:
xauthority = _x11_leader_environment(f, display).get("XAUTHORITY")
if xauthority is None:
# Resolve the verified user's canonical home, not a caller's HOME.
# Xlib's normal fallback is made explicit and validated below.
try:
account = pwd.getpwuid(f.uid)
except KeyError:
raise CaptureError("X11_AUTHORITY_UNAVAILABLE") from None
require(account.pw_uid == f.uid and Path(account.pw_dir).is_absolute(),
"X11_AUTHORITY_PATH_INVALID")
xauthority = str(Path(account.pw_dir) / ".Xauthority")
if xauthority is not None:
require(isinstance(xauthority, str) and len(xauthority) <= 4096 and "\0" not in xauthority,
"X11_AUTHORITY_PATH_INVALID")
path = Path(xauthority)
try:
s = path.lstat()
except OSError:
raise CaptureError("X11_AUTHORITY_UNAVAILABLE") from None
require(path.is_absolute() and stat.S_ISREG(s.st_mode) and s.st_uid == f.uid
and s.st_mode & 0o077 == 0, "X11_AUTHORITY_UNTRUSTED")
return display, xauthority
def capture_x11_pixels(f: Foreground, *, display: str | None = None,
xauthority: str | None = None) -> tuple[bytes, Geometry, dict]:
display, xauthority = _x11_context(f, display, xauthority)
library = ctypes.util.find_library("X11")
require(library is not None, "X11_LIBRARY_UNAVAILABLE")
with _X11_LOCK:
try:
x = ctypes.CDLL(library)
x.XOpenDisplay.argtypes = [ctypes.c_char_p]; x.XOpenDisplay.restype = ctypes.c_void_p
x.XCloseDisplay.argtypes = [ctypes.c_void_p]
x.XDefaultRootWindow.argtypes = [ctypes.c_void_p]; x.XDefaultRootWindow.restype = ctypes.c_ulong
x.XGetGeometry.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.c_int), ctypes.POINTER(ctypes.c_int),
*([ctypes.POINTER(ctypes.c_uint)] * 4)]
x.XGetGeometry.restype = ctypes.c_int
x.XGetImage.argtypes = [ctypes.c_void_p, ctypes.c_ulong, ctypes.c_int, ctypes.c_int,
ctypes.c_uint, ctypes.c_uint, ctypes.c_ulong, ctypes.c_int]
x.XGetImage.restype = ctypes.POINTER(_XImage)
x.XDestroyImage.argtypes = [ctypes.POINTER(_XImage)]
x.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int]
except (OSError, AttributeError):
raise CaptureError("X11_LIBRARY_UNAVAILABLE") from None
old_authority = os.environ.get("XAUTHORITY")
try:
if xauthority is not None:
os.environ["XAUTHORITY"] = xauthority
else:
os.environ.pop("XAUTHORITY", None)
connection = x.XOpenDisplay(display.encode("ascii"))
finally:
if old_authority is None:
os.environ.pop("XAUTHORITY", None)
else:
os.environ["XAUTHORITY"] = old_authority
require(bool(connection), "X11_DISPLAY_UNREADABLE")
image = None
try:
root = x.XDefaultRootWindow(connection)
def dimensions() -> tuple[int, int, int]:
rr = ctypes.c_ulong(); px = ctypes.c_int(); py = ctypes.c_int()
width = ctypes.c_uint(); height = ctypes.c_uint(); border = ctypes.c_uint(); depth = ctypes.c_uint()
ok = x.XGetGeometry(connection, root, ctypes.byref(rr), ctypes.byref(px), ctypes.byref(py),
ctypes.byref(width), ctypes.byref(height), ctypes.byref(border), ctypes.byref(depth))
require(bool(ok), "X11_GEOMETRY_UNAVAILABLE")
require(0 < width.value <= 4096 and 0 < height.value <= 2160
and width.value * height.value <= MAX_PIXELS, "SCREEN_SIZE_UNSUPPORTED")
return width.value, height.value, depth.value
width, height, depth = dimensions()
x.XSync(connection, 0)
image = x.XGetImage(connection, root, 0, 0, width, height, ctypes.c_ulong(-1).value, 2)
require(bool(image), "X11_IMAGE_UNAVAILABLE")
info = image.contents
require(info.width == width and info.height == height and info.xoffset == 0
and info.format == 2 and info.byte_order in {0, 1} and bool(info.data),
"X11_IMAGE_FORMAT_UNSUPPORTED")
count = info.bytes_per_line * height
g = Geometry(width, height, width, height, 0, 0, info.bytes_per_line, info.bits_per_pixel,
_mask_field(info.red_mask), _mask_field(info.green_mask), _mask_field(info.blue_mask),
count, "X11-XGetImage", "little" if info.byte_order == 0 else "big")
validate_geometry(g)
raw = ctypes.string_at(info.data, count)
require(dimensions() == (width, height, depth), "GEOMETRY_CHANGED_DURING_CAPTURE")
return decode_framebuffer(raw, g), g, {"display": display, "root_window": root, "depth": depth}
finally:
if image:
x.XDestroyImage(image)
x.XCloseDisplay(connection)
def capture_screen(output_dir: Path | str, *, device: str = "/dev/fb0", seat: str = "seat0",
display: str | None = None, xauthority: str | None = None) -> dict:
"""Capture immutable real screen pixels or fail; never substitute a text render."""
output_dir = _directory(output_dir)
before = foreground(seat, allow_graphics=True)
started = datetime.now(timezone.utc).isoformat()
if before.session_type == "x11":
rgb, g, backend = capture_x11_pixels(before, display=display, xauthority=xauthority)
require_unchanged(before, foreground(seat, allow_graphics=True))
png = encode_png(rgb, g.width, g.height)
source = "x11-root-XGetImage"
else:
png, g = _capture_framebuffer(before, device, seat)
backend = {}
source = "linux-framebuffer"
require(len(png) <= MAX_IMAGE_BYTES, "CAPTURE_IMAGE_TOO_LARGE")
require_unchanged(before, foreground(seat, allow_graphics=True))
capture_id = uuid.uuid4().hex
image_path = output_dir / (capture_id + ".png")
manifest = {"version": 1, "capture_id": capture_id, "source": source,
"screen_type": before.session_type, "node": socket.gethostname(), "uid": os.getuid(),
"width": g.width, "height": g.height,
"sha256": hashlib.sha256(png).hexdigest(), "bytes": len(png),
"path": str(image_path), "timestamp": started,
"completed_at": datetime.now(timezone.utc).isoformat(),
"active_vt": before.active_vt, "session": asdict(before),
"geometry": asdict(g), "backend": backend,
"cursor": ("pixels-present-in-framebuffer" if before.session_type == "tty"
else "hardware-cursor-not-included-by-XGetImage"),
"pixel_source_verified": True, "synthetic": False}
_write_new(image_path, png)
try:
_write_new(output_dir / (capture_id + ".json"),
json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode())
except BaseException:
image_path.unlink(missing_ok=True)
raise
return manifest
def _capture_framebuffer(before: Foreground, device: str, seat: str) -> tuple[bytes, Geometry]:
require(re.fullmatch(r"/dev/fb[0-9]+", device) is not None, "FRAMEBUFFER_DEVICE_INVALID")
try:
fd = os.open(device, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
except OSError:
raise CaptureError("FRAMEBUFFER_UNREADABLE") from None
try:
require(stat.S_ISCHR(os.fstat(fd).st_mode), "FRAMEBUFFER_DEVICE_INVALID")
g = read_geometry(fd)
count = (g.yoffset + g.height) * g.stride
raw = os.pread(fd, count, 0)
require(len(raw) == count, "FRAMEBUFFER_READ_SHORT")
require(read_geometry(fd) == g, "GEOMETRY_CHANGED_DURING_CAPTURE")
require_unchanged(before, foreground(seat, allow_graphics=True))
except OSError:
raise CaptureError("FRAMEBUFFER_READ_FAILED") from None
finally:
os.close(fd)
png = encode_png(decode_framebuffer(raw, g), g.width, g.height, prefer_indexed=True)
return png, g
def _read_private(path: Path, max_bytes: int) -> bytes:
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
try:
s = os.fstat(fd)
require(stat.S_ISREG(s.st_mode) and s.st_uid == os.getuid() and s.st_mode & 0o077 == 0
and s.st_nlink == 1 and s.st_size <= max_bytes, "CAPTURE_ARTIFACT_UNTRUSTED")
with os.fdopen(fd, "rb", closefd=False) as f:
b = f.read(max_bytes + 1)
require(len(b) == s.st_size, "CAPTURE_ARTIFACT_CHANGED")
return b
finally:
os.close(fd)
except OSError:
raise CaptureError("CAPTURE_ARTIFACT_UNAVAILABLE") from None
def read_chunk(output_dir: Path | str, capture_id: str, index: int, *, chunk_bytes: int = 2100,
expected_sha256: str | None = None) -> dict:
require(isinstance(capture_id, str) and CAPTURE_ID.fullmatch(capture_id) is not None,
"CAPTURE_ID_INVALID")
require(type(index) is int and index >= 0 and type(chunk_bytes) is int
and 1 <= chunk_bytes <= MAX_CHUNK_BYTES, "CHUNK_BOUNDS_INVALID")
directory = _directory(output_dir)
try:
m = json.loads(_read_private(directory / (capture_id + ".json"), 16384))
except (json.JSONDecodeError, UnicodeDecodeError):
raise CaptureError("CAPTURE_MANIFEST_INVALID") from None
require(isinstance(m, dict) and m.get("capture_id") == capture_id and m.get("synthetic") is False
and m.get("pixel_source_verified") is True, "CAPTURE_MANIFEST_INVALID")
png = _read_private(directory / (capture_id + ".png"), MAX_IMAGE_BYTES)
digest = hashlib.sha256(png).hexdigest()
require(digest == m.get("sha256") and len(png) == m.get("bytes"), "CAPTURE_SHA256_MISMATCH")
require(expected_sha256 is None or expected_sha256 == digest, "EXPECTED_SHA256_MISMATCH")
total = math.ceil(len(png) / chunk_bytes)
require(index < total, "CHUNK_INDEX_OUT_OF_RANGE")
data = png[index * chunk_bytes:(index + 1) * chunk_bytes]
return {"capture_id": capture_id, "index": index, "total_chunks": total,
"chunk_bytes": len(data), "bytes": len(png), "sha256": digest,
"encoding": "base64", "data": base64.b64encode(data).decode("ascii"),
"more": index + 1 < total}
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("output_dir")
args = parser.parse_args()
try:
print(json.dumps({"ok": True, "result": capture_screen(args.output_dir)}, sort_keys=True))
except CaptureError as exc:
print(json.dumps({"ok": False, "error": exc.code}, sort_keys=True))
raise SystemExit(1)
@@ -0,0 +1,301 @@
"""Bind real framebuffer observations to the visible, owned tmux terminal.
This module never selects a window, pane or VT. Input is permitted only to the
pane that was active in the client on the observed foreground VT. Capture files
and manifests are stored by the caller in its private, immutable capture store.
"""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import time
from datetime import datetime, timezone
class ScreenControlError(RuntimeError):
def __init__(self, code, **details):
super().__init__(code)
self.code = code
self.details = details
ControlError = ScreenControlError
KEYS = {
"ENTER": "Enter", "RETURN": "Enter", "TAB": "Tab", "SHIFT_TAB": "BTab",
"ESC": "Escape", "BACKSPACE": "BSpace", "DELETE": "DC", "INSERT": "IC",
"UP": "Up", "DOWN": "Down", "LEFT": "Left", "RIGHT": "Right",
"HOME": "Home", "END": "End", "PAGE_UP": "PPage", "PAGE_DOWN": "NPage",
"SPACE": "Space", "CTRL_C": "C-c", "CTRL_D": "C-d", "CTRL_Z": "C-z",
"CTRL_A": "C-a", "CTRL_E": "C-e", "CTRL_L": "C-l", "CTRL_U": "C-u",
"CTRL_K": "C-k", "CTRL_R": "C-r", "CTRL_W": "C-w",
**{"F" + str(n): "F" + str(n) for n in range(1, 13)},
}
def _active_vt():
value = Path("/sys/class/tty/tty0/active").read_text().strip()
if re.fullmatch(r"tty[1-9][0-9]*", value) is None:
raise ScreenControlError("SCREEN_ACTIVE_VT_INVALID")
return value
def _proc(pid, *, tmux=False):
p = Path("/proc") / str(int(pid))
if p.stat().st_uid != os.getuid():
raise ScreenControlError("SCREEN_PROCESS_OWNER_MISMATCH")
raw = (p / "stat").read_text()
fields = raw[raw.rfind(")") + 2:].split()
if tmux and os.path.realpath(p / "exe") != os.path.realpath("/usr/bin/tmux"):
raise ScreenControlError("SCREEN_TMUX_PROCESS_MISMATCH")
return {"pid": int(pid), "start_ticks": int(fields[19]),
"uid": p.stat().st_uid}
def _socket_identity(path):
"""Reject symlinks and paths outside the owned runtime directories."""
p = Path(path)
if not p.is_absolute() or ".." in p.parts:
raise ScreenControlError("SCREEN_SOCKET_PATH_UNTRUSTED")
roots = [Path("/run/user") / str(os.getuid()),
Path("/tmp") / ("tmux-" + str(os.getuid()))]
root = next((r for r in roots if p.is_relative_to(r)), None)
if root is None:
raise ScreenControlError("SCREEN_SOCKET_PATH_UNTRUSTED")
current = root
for part in ("", *p.relative_to(root).parts):
if part:
current /= part
st = current.lstat()
if stat.S_ISLNK(st.st_mode) or st.st_uid != os.getuid():
raise ScreenControlError("SCREEN_SOCKET_OWNER_MISMATCH")
if current != p:
if not stat.S_ISDIR(st.st_mode) or stat.S_IMODE(st.st_mode) & 0o022:
raise ScreenControlError("SCREEN_SOCKET_PARENT_UNTRUSTED")
elif not stat.S_ISSOCK(st.st_mode):
raise ScreenControlError("SCREEN_SOCKET_NOT_SOCKET")
return {"path": str(p), "device": st.st_dev, "inode": st.st_ino,
"uid": st.st_uid}
def _server_sockets():
"""Only probe sockets actually held by a same-UID tmux server process."""
unix = {}
for line in Path("/proc/net/unix").read_text().splitlines()[1:]:
fields = line.split(maxsplit=7)
if len(fields) == 8 and fields[7].startswith("/"):
unix[fields[6]] = fields[7]
found = []
for p in Path("/proc").iterdir():
if not p.name.isdigit():
continue
try:
if p.stat().st_uid != os.getuid() or (p / "comm").read_text().strip() != "tmux: server":
continue
process = _proc(int(p.name), tmux=True)
for fd in (p / "fd").iterdir():
target = os.readlink(fd)
match = re.fullmatch(r"socket:\[([0-9]+)\]", target)
if match and match[1] in unix:
try:
identity = _socket_identity(unix[match[1]])
except (OSError, ScreenControlError):
continue
found.append((identity, process))
if _proc(int(p.name), tmux=True) != process:
raise ScreenControlError("SCREEN_TMUX_SERVER_CHANGED")
except (FileNotFoundError, PermissionError, ProcessLookupError):
continue
unique = {(s[0]["path"], s[1]["pid"]): s for s in found}
if len(unique) > 32:
raise ScreenControlError("SCREEN_TMUX_SERVER_LIMIT")
return list(unique.values())
def _tmux(socket_path, *args):
env = {k: v for k, v in os.environ.items() if k not in ("TMUX", "TMUX_PANE")}
env.update({"LC_ALL": "C.UTF-8", "TERM": "xterm-256color"})
try:
result = subprocess.run(["/usr/bin/tmux", "-S", socket_path, *args],
capture_output=True, text=True, env=env, timeout=4)
except (OSError, subprocess.TimeoutExpired) as exc:
raise ScreenControlError("SCREEN_TMUX_COMMAND_FAILED") from exc
if result.returncode or len(result.stdout.encode()) > 65536:
raise ScreenControlError("SCREEN_TMUX_COMMAND_FAILED")
return result.stdout
def _rows(value, size):
out = []
for line in value.splitlines():
fields = line.split("|")
if len(fields) != size:
raise ScreenControlError("SCREEN_TMUX_METADATA_INVALID")
out.append(fields)
return out
def snapshot_binding():
"""Read-only snapshot of the active visible terminal; never fall back hidden."""
vt = _active_vt()
tty = "/dev/" + vt
matches = []
client_format = "|".join(["#{client_pid}", "#{client_tty}", "#{session_id}",
"#{client_width}", "#{client_height}", "#{client_control_mode}"])
pane_format = "|".join(["#{session_id}", "#{window_id}", "#{window_active}",
"#{pane_id}", "#{pane_pid}", "#{pane_tty}", "#{pane_active}",
"#{pane_left}", "#{pane_top}", "#{pane_width}", "#{pane_height}",
"#{window_width}", "#{window_height}", "#{pane_dead}",
"#{window_zoomed_flag}", "#{pane_in_mode}"])
for socket_identity, server in _server_sockets():
path = socket_identity["path"]
try:
clients = [c for c in _rows(_tmux(path, "list-clients", "-F", client_format), 6)
if c[1] == tty and c[5] == "0"]
if not clients:
continue
panes = _rows(_tmux(path, "list-panes", "-a", "-F", pane_format), 16)
for client in clients:
visible = [p for p in panes if p[0] == client[2] and p[2] == "1"
and p[6] == "1" and p[13] == "0"]
if len(visible) != 1:
raise ScreenControlError("SCREEN_VISIBLE_PANE_AMBIGUOUS")
p = visible[0]
binding = {"version": 1, "backend": "visible-tmux", "node": os.uname().nodename,
"uid": os.getuid(), "active_vt": vt, "client_tty": tty,
"socket": socket_identity, "server": server,
"client": _proc(int(client[0]), tmux=True),
"session_id": p[0], "window_id": p[1], "pane_id": p[3],
"pane": _proc(int(p[4])), "pane_tty": p[5],
"client_size": [int(client[3]), int(client[4])],
"window_size": [int(p[11]), int(p[12])],
"pane_geometry": [int(x) for x in p[7:11]],
"window_zoomed": p[14] == "1", "pane_in_mode": p[15] == "1"}
if _socket_identity(path) != socket_identity or _proc(server["pid"], tmux=True) != server:
raise ScreenControlError("SCREEN_TMUX_SERVER_CHANGED")
matches.append(binding)
except ScreenControlError as exc:
if exc.code == "SCREEN_TMUX_COMMAND_FAILED":
continue
raise
if _active_vt() != vt:
raise ScreenControlError("SCREEN_ACTIVE_VT_CHANGED")
if not matches:
raise ScreenControlError("SCREEN_VISIBLE_TMUX_NOT_FOUND")
if len(matches) != 1:
raise ScreenControlError("SCREEN_VISIBLE_CLIENT_AMBIGUOUS")
return matches[0]
def validate_binding(expected):
if not isinstance(expected, dict) or expected.get("backend") != "visible-tmux":
raise ScreenControlError("SCREEN_BINDING_REQUIRED")
current = snapshot_binding()
if current != expected:
raise ScreenControlError("SCREEN_BINDING_CHANGED")
return current
def bind_screen(manifest):
if not isinstance(manifest, dict) or not isinstance(manifest.get("capture_id"), str) or not manifest["capture_id"]:
raise ScreenControlError("SCREEN_CAPTURE_ID_REQUIRED")
if manifest.get("source") != "linux-framebuffer" or manifest.get("screen_type") != "tty":
raise ScreenControlError("SCREEN_CONTROL_MODE_UNSUPPORTED")
binding = snapshot_binding()
if manifest.get("active_vt") != binding["active_vt"]:
raise ScreenControlError("SCREEN_CAPTURE_VT_MISMATCH")
if manifest.get("node") != binding["node"] or manifest.get("uid") != binding["uid"]:
raise ScreenControlError("SCREEN_CAPTURE_NODE_MISMATCH")
return {**manifest, "binding": binding}
def _verify_capture(manifest, max_age_s):
if not isinstance(manifest, dict) or not manifest.get("capture_id"):
raise ScreenControlError("SCREEN_CAPTURE_ID_REQUIRED")
if manifest.get("source") != "linux-framebuffer" or manifest.get("screen_type") != "tty":
raise ScreenControlError("SCREEN_CONTROL_MODE_UNSUPPORTED")
if manifest.get("synthetic") is not False or manifest.get("pixel_source_verified") is not True:
raise ScreenControlError("SCREEN_REAL_CAPTURE_REQUIRED")
if max_age_s is not None and (not isinstance(max_age_s, (int, float)) or not 0 < max_age_s <= 300):
raise ScreenControlError("SCREEN_CAPTURE_AGE_LIMIT_INVALID")
try:
when = datetime.fromisoformat(manifest["completed_at"])
if when.tzinfo is None:
raise ValueError()
age = (datetime.now(timezone.utc) - when).total_seconds()
except (KeyError, TypeError, ValueError):
raise ScreenControlError("SCREEN_CAPTURE_TIME_INVALID") from None
if age < -5 or (max_age_s is not None and age > max_age_s):
raise ScreenControlError("SCREEN_CAPTURE_EXPIRED")
path = Path(manifest.get("path", ""))
expected = manifest.get("sha256", "")
if not path.is_absolute() or re.fullmatch(r"[a-f0-9]{64}", expected) is None:
raise ScreenControlError("SCREEN_CAPTURE_FILE_INVALID")
try:
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or st.st_size > 64 * 1024 * 1024:
raise ScreenControlError("SCREEN_CAPTURE_FILE_UNTRUSTED")
with os.fdopen(fd, "rb", closefd=False) as f:
digest = hashlib.file_digest(f, "sha256").hexdigest()
if digest != expected:
raise ScreenControlError("SCREEN_CAPTURE_SHA256_MISMATCH")
finally:
os.close(fd)
except OSError as exc:
raise ScreenControlError("SCREEN_CAPTURE_FILE_UNAVAILABLE") from exc
def act(capture_manifest, kind, *, text=None, key=None, max_age_s=None):
"""Send literal text (no controls/newline) or one explicit allowlisted key.
The RPC caller must load this manifest by capture_id from its private store;
it must not accept a replacement manifest supplied by a remote caller.
Foreground changes are checked before and after dispatch. An independent
local user's VT switch concurrent with the actual write cannot be made
atomic with tmux; postflight failure therefore reports performed=True.
"""
if kind in ("text", "text.type", "type"):
if key is not None or not isinstance(text, str) or not text or len(text.encode()) > 16384:
raise ScreenControlError("SCREEN_TEXT_INVALID")
if any(ord(c) < 32 or ord(c) == 127 for c in text):
raise ScreenControlError("SCREEN_TEXT_CONTROL_CHARACTER")
command = ["send-keys", "-l", "--", text]
action = "text.type"
elif kind in ("key", "key.send"):
if text is not None or not isinstance(key, str) or key not in KEYS:
raise ScreenControlError("SCREEN_KEY_INVALID")
command = ["send-keys", KEYS[key]]
action = "key.send"
else:
raise ScreenControlError("SCREEN_ACTION_UNSUPPORTED")
_verify_capture(capture_manifest, max_age_s)
expected = capture_manifest.get("binding")
binding = validate_binding(expected)
if binding.get("pane_in_mode"):
raise ScreenControlError("SCREEN_TMUX_MODE_INPUT_UNSUPPORTED")
if capture_manifest.get("active_vt") != binding["active_vt"]:
raise ScreenControlError("SCREEN_CAPTURE_VT_MISMATCH")
if capture_manifest.get("node") != binding["node"] or capture_manifest.get("uid") != binding["uid"]:
raise ScreenControlError("SCREEN_CAPTURE_NODE_MISMATCH")
# A second check immediately before dispatch prevents using a stale probe.
binding = validate_binding(expected)
path, pane = binding["socket"]["path"], binding["pane_id"]
_tmux(path, command[0], "-t", pane, *command[1:])
try:
validate_binding(expected)
except (ScreenControlError, OSError) as exc:
raise ScreenControlError("SCREEN_CHANGED_AFTER_INPUT", performed=True,
action=action, capture_id=capture_manifest["capture_id"]) from exc
return {"ok": True, "performed": True, "action": action,
"capture_id": capture_manifest["capture_id"], "binding": binding,
"input_bytes": len(text.encode()) if action == "text.type" else None,
"key": key if action == "key.send" else None,
"input_target": "observed-visible-pane"}
@@ -0,0 +1,267 @@
"""Original FrameBridge real-screen MCP adapter.
The node captures pixels. This adapter validates and transports those bytes;
it never renders terminal text into an image.
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import json
import math
import re
import shlex
import struct
import uuid
from typing import Callable
CHUNK_BYTES = 2100
MAX_IMAGE_BYTES = 32 * 1024 * 1024
MAX_STDOUT_BYTES = 4096
RPC_COMMAND = '/usr/bin/python3 -B -m framebridge_expanded.real_screen.screen_rpc '
HEX_ID = re.compile(r"[0-9a-f]{32}\Z")
SHA = re.compile(r"[0-9a-f]{64}\Z")
NODE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}\Z")
SID = re.compile(r"[A-Za-z0-9_-]{1,100}\Z")
ERROR = re.compile(r"[A-Z0-9_]{1,100}\Z")
READ = {"readOnlyHint": True, "destructiveHint": False,
"idempotentHint": False, "openWorldHint": False}
CHANGE = {**READ, "readOnlyHint": False}
class ScreenError(RuntimeError):
"""A bounded public error suitable for the MCP response."""
def __init__(self, code, details=None):
super().__init__(code)
self.details = details or {}
def require(ok, code):
if not ok:
raise ScreenError(code)
def encode(value):
return json.dumps(value, ensure_ascii=True, sort_keys=True,
separators=(",", ":"), allow_nan=False).encode("ascii")
def valid_reference(value, regex, optional=False):
return type(value) is str and ((optional and value == "") or regex.fullmatch(value) is not None)
class _AuthenticatedReply(dict):
"""A payload tied to the EXEC recipient verified by this client instance.
The Linux hostname in the payload is observed screen identity. The route
alias and UUID come from the existing authenticated executor envelope.
A JSON object cannot supply this local proof or change its capture identity.
"""
_IDENTITY_FIELDS = ("capture_id", "node", "sha256", "session_id", "bytes",
"width", "height", "source", "screen_type",
"full_screen", "authentic_pixels")
def __init__(self, payload, owner, node, node_id):
super().__init__(payload)
self.__owner, self.__node, self.__node_id = owner, node, node_id
self.__identity = tuple(self.get(key) for key in self._IDENTITY_FIELDS)
def bound_to(self, owner, node, node_id=None):
return (self.__owner is owner and self.__node == node
and (node_id is None or self.__node_id == node_id)
and self.__identity == tuple(self.get(key) for key in self._IDENTITY_FIELDS))
@property
def node_id(self):
return self.__node_id
class ScreenClient:
"""Transport only; execution policy and node resolution stay in the host."""
def __init__(self, exec_request: Callable, image_factory: Callable,
resolve_node: Callable | None = None):
self.exec_request = exec_request
self.image_factory = image_factory
self.resolve_node = resolve_node or (lambda node: node.upper())
self._reply_owner = object()
def remote(self, node, request):
payload = base64.b64encode(encode(request)).decode("ascii")
script = RPC_COMMAND + shlex.quote(payload) + "\n"
digest = hashlib.sha256(script.encode("utf-8")).hexdigest()
job_id = "cua-screen-" + uuid.uuid4().hex
outer = self.exec_request(script=script, authorized=True, timeout_s=30,
job_id=job_id, sha256=digest,
include_stdout=True, target=node)
require(type(outer) is dict, "SCREEN_EXEC_FAILED")
require(type(outer.get("exit_code")) in (int, str) and
str(outer["exit_code"]) in ("0", "1"), "SCREEN_EXEC_FAILED")
require(outer.get("target") == node and outer.get("mcp_job_id") == job_id and
outer.get("mcp_script_sha256") == digest, "SCREEN_EXEC_BINDING_INVALID")
try:
node_id = outer.get("node_id")
require(type(node_id) is str, "SCREEN_EXEC_NODE_ID_INVALID")
node_id = str(uuid.UUID(node_id))
except (ValueError, AttributeError):
raise ScreenError("SCREEN_EXEC_NODE_ID_INVALID") from None
stdout = outer.get("stdout")
require(type(stdout) is str and len(stdout.encode("utf-8")) <= MAX_STDOUT_BYTES and
outer.get("stdout_truncated") is not True, "SCREEN_STDOUT_INVALID")
try:
envelope = json.loads(stdout)
except (ValueError, UnicodeError):
raise ScreenError("SCREEN_RESPONSE_INVALID") from None
require(type(envelope) is dict and type(envelope.get("ok")) is bool,
"SCREEN_RESPONSE_INVALID")
if not envelope["ok"]:
code = envelope.get("error")
details = {}
if type(code) is dict:
for name in ("performed", "capture_consumed", "observe_again"):
if type(code.get(name)) is bool:
details[name] = code[name]
if valid_reference(code.get("capture_id"), HEX_ID):
details["capture_id"] = code["capture_id"]
if valid_reference(code.get("cause"), ERROR):
details["cause"] = code["cause"]
code = code.get("code")
raise ScreenError(code if valid_reference(code, ERROR) else "SCREEN_BACKEND_FAILED", details)
require(outer.get("ok") is True and str(outer["exit_code"]) == "0", "SCREEN_EXEC_FAILED")
require(type(envelope.get("result")) is dict, "SCREEN_RESPONSE_INVALID")
return _AuthenticatedReply(envelope["result"], self._reply_owner, node, node_id)
def image(self, node, manifest):
capture_id = manifest.get("capture_id")
total, digest = manifest.get("bytes"), manifest.get("sha256")
require(valid_reference(capture_id, HEX_ID) and valid_reference(digest, SHA),
"SCREEN_MANIFEST_INVALID")
require(type(total) is int and 0 < total <= MAX_IMAGE_BYTES, "SCREEN_IMAGE_SIZE_INVALID")
require(isinstance(manifest, _AuthenticatedReply) and
manifest.bound_to(self._reply_owner, node), "SCREEN_IMAGE_NODE_MISMATCH")
require(valid_reference(manifest.get("node"), NODE), "SCREEN_IMAGE_HOSTNAME_INVALID")
width, height = manifest.get("width"), manifest.get("height")
require(type(width) is int and type(height) is int and
0 < width <= 16384 and 0 < height <= 16384,
"SCREEN_DIMENSIONS_INVALID")
require(manifest.get("source") in ("linux-framebuffer", "x11-root-XGetImage"),
"SCREEN_PIXEL_SOURCE_INVALID")
require(manifest.get("full_screen") is True and manifest.get("authentic_pixels") is True,
"SCREEN_PIXEL_PROVENANCE_INVALID")
parts = []
count = math.ceil(total / CHUNK_BYTES)
for index in range(count):
part = self.remote(node, {"op": "read_chunk", "capture_id": capture_id,
"index": index, "expected_sha256": digest})
require(isinstance(part, _AuthenticatedReply) and
part.bound_to(self._reply_owner, node, manifest.node_id),
"SCREEN_CHUNK_NODE_MISMATCH")
require(part.get("capture_id") == capture_id and type(part.get("index")) is int and
part["index"] == index and type(part.get("total_chunks")) is int and
part["total_chunks"] == count, "SCREEN_CHUNK_BINDING_INVALID")
require(type(part.get("bytes")) is int and part["bytes"] == total and
part.get("sha256") == digest and part.get("encoding") == "base64" and
type(part.get("more")) is bool and part["more"] == (index + 1 < count),
"SCREEN_CHUNK_MANIFEST_INVALID")
text = part.get("data")
require(type(text) is str and len(text) <= 2800, "SCREEN_CHUNK_INVALID")
try:
data = base64.b64decode(text, validate=True)
except (ValueError, UnicodeError, binascii.Error):
raise ScreenError("SCREEN_CHUNK_INVALID") from None
require(len(data) == min(CHUNK_BYTES, total - index * CHUNK_BYTES),
"SCREEN_CHUNK_LENGTH_INVALID")
parts.append(data)
data = b"".join(parts)
require(len(data) == total and hashlib.sha256(data).hexdigest() == digest,
"SCREEN_IMAGE_HASH_MISMATCH")
require(len(data) >= 33 and data[:8] == b"\x89PNG\r\n\x1a\n" and
data[12:16] == b"IHDR" and struct.unpack(">II", data[16:24]) == (width, height),
"SCREEN_PNG_INVALID")
return self.image_factory(data=data, format="png")
def call(self, op, node, authorized, **kwargs):
requested_node = node
try:
require(authorized is True, "AUTHORIZED_TRUE_REQUIRED")
require(valid_reference(node, NODE), "SCREEN_NODE_INVALID")
node = self.resolve_node(node)
require(valid_reference(node, NODE), "SCREEN_NODE_INVALID")
if kwargs.get("session_id"):
require(valid_reference(kwargs["session_id"], SID), "SCREEN_SESSION_INVALID")
else:
kwargs.pop("session_id", None)
if op in ("key", "text", "read_capture"):
require(valid_reference(kwargs.get("capture_id"), HEX_ID), "SCREEN_CAPTURE_ID_REQUIRED")
if op == "key":
require(type(kwargs.get("key")) is str and 0 < len(kwargs["key"]) <= 32,
"SCREEN_KEY_INVALID")
if op == "text":
require(type(kwargs.get("text")) is str and 0 < len(kwargs["text"].encode("utf-8")) <= 4096,
"SCREEN_TEXT_INVALID")
manifest = self.remote(node, {"op": op, **kwargs})
if op == "read_capture":
require(manifest.get("capture_id") == kwargs["capture_id"],
"SCREEN_CAPTURE_REFERENCE_MISMATCH")
image = self.image(node, manifest)
metadata = {"ok": True, "node": node, "requested_node": requested_node,
"node_id": manifest.node_id, "hostname": manifest["node"],
"result": dict(manifest, transport_node=node, node_id=manifest.node_id)}
metadata["result"].pop("path", None)
metadata["result"].pop("image_base64", None)
metadata["result"]["image_mime"] = "image/png"
metadata["result"]["full_screen"] = True
metadata["result"]["authentic_pixels"] = True
return [metadata, image]
except ScreenError as exc:
return {"ok": False, "node": requested_node, "error": str(exc), **exc.details}
except Exception:
return {"ok": False, "node": requested_node, "error": "SCREEN_BACKEND_UNAVAILABLE"}
def resolve_profile_node(alias):
"""Canonicalize only through the enrolled profile registry, never guessed hostnames."""
from ..fleet import resolve_node, FleetError
try:
descriptor = resolve_node(alias)
except FleetError as exc:
raise ScreenError(str(exc)) from None
return "LOCAL" if descriptor["transport"] == "local" else descriptor["alias"]
def register_framebridge_screen_tools(mcp, exec_request, *, image_factory=None,
resolve_node=None):
"""Register real image tools. The host supplies its existing executor."""
if image_factory is None:
from mcp.server.fastmcp import Image
image_factory = Image
client = ScreenClient(exec_request, image_factory, resolve_node)
@mcp.tool(annotations=dict(READ))
def framebridge_screen_observe(node: str, authorized: bool,
session_id: str = ""):
"""Capture the node's entire active screen as real pixels, without rendering text.
To display or redisplay this original in ChatGPT, call framebridge_screen_show
with the returned node and capture_id. Do not take another capture to show it.
"""
return client.call("observe", node, authorized, session_id=session_id)
@mcp.tool(annotations=dict(CHANGE))
def framebridge_screen_key(node: str, capture_id: str, key: str,
authorized: bool, session_id: str = ""):
"""Send a key only to the visible target bound to a previous real capture."""
return client.call("key", node, authorized, capture_id=capture_id,
key=key, session_id=session_id)
@mcp.tool(annotations=dict(CHANGE))
def framebridge_screen_text(node: str, capture_id: str, text: str,
authorized: bool, session_id: str = ""):
"""Type into the visible target bound to a previous real capture, then capture again."""
return client.call("text", node, authorized, capture_id=capture_id,
text=text, session_id=session_id)
return client
@@ -0,0 +1,173 @@
"""Display an already captured, authenticated PNG in MCP Apps-capable clients.
No HTTP image endpoint, upload, credential, external asset, or new capture is used.
The normal ImageContent remains available to clients that do not render MCP Apps.
"""
from __future__ import annotations
import json
from typing import Annotated, Any
from mcp.types import CallToolResult, TextContent
from pydantic import BaseModel
from .screen_frontdoor import READ
TEMPLATE_URI = "ui://framebridge/screen-v1.html"
TOOL_META = {
"ui": {"resourceUri": TEMPLATE_URI},
"openai/outputTemplate": TEMPLATE_URI,
"openai/toolInvocation/invoking": "Loading original screen capture",
"openai/toolInvocation/invoked": "Original screen capture ready",
}
RESOURCE_META = {
"ui": {"prefersBorder": True, "csp": {"connectDomains": [], "resourceDomains": []}},
"openai/widgetDescription": (
"FrameBridge original screen capture viewer. Shows the full saved PNG, "
"its node, capture identifier, dimensions and verified SHA-256. "
"This is a historical capture, not a live screen."
),
"openai/ui": {"availableDisplayModes": ["inline", "fullscreen"]},
}
class ScreenViewResult(BaseModel):
ok: bool
node: str
requested_node: str | None = None
node_id: str | None = None
hostname: str | None = None
result: dict[str, Any] | None = None
error: str | None = None
VIEWER_HTML = r"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>FrameBridge original screen capture</title>
<style>
:root{color-scheme:light dark;font-family:system-ui,sans-serif}
body{margin:0;padding:12px;background:Canvas;color:CanvasText}
header{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap}
h1{font-size:16px;margin:0}p{font-size:13px;margin:6px 0}
#status{font-weight:600}#details{overflow-wrap:anywhere;font:12px ui-monospace,monospace;white-space:pre-wrap}
.controls{display:flex;gap:8px;margin:10px 0;flex-wrap:wrap}
button,a{font:inherit;padding:6px 10px;border:1px solid GrayText;border-radius:6px;background:ButtonFace;color:ButtonText;text-decoration:none}
button:disabled,a[aria-disabled=true]{opacity:.5;pointer-events:none}
#viewport{overflow:auto;border:1px solid GrayText;border-radius:6px;max-height:75vh}
#screen{display:block;max-width:100%;height:auto;margin:auto}
#viewport.native #screen{max-width:none;margin:0}
#screen[hidden],#viewport[hidden]{display:none}
</style></head><body>
<header><h1>FrameBridge original screen capture</h1><span>Saved capture · read only</span></header>
<p id="status" role="status">Waiting for the original PNG.</p>
<pre id="details"></pre>
<div class="controls">
<button id="fit" disabled>Fit full screen</button><button id="native" disabled>Original size</button>
<button id="fullscreen" disabled>Fullscreen</button>
<a id="download" aria-disabled="true">Download original PNG</a>
</div>
<div id="viewport" hidden><img id="screen" hidden alt="Full original FrameBridge screen capture"></div>
<script>
"use strict";
const statusEl=document.getElementById("status"),details=document.getElementById("details");
const img=document.getElementById("screen"),viewport=document.getElementById("viewport");
const fit=document.getElementById("fit"),native=document.getElementById("native");
const full=document.getElementById("fullscreen"),download=document.getElementById("download");
let serial=0,active=null,nextId=1;
const pending=new Map();
function post(method,params,id){const m={jsonrpc:"2.0",method,params};if(id!==undefined)m.id=id;window.parent.postMessage(m,"*");}
function request(method,params){const id=nextId++;post(method,params,id);return new Promise((resolve,reject)=>{pending.set(id,{resolve,reject});setTimeout(()=>{if(pending.has(id)){pending.delete(id);reject(new Error("Host bridge unavailable"));}},5000);});}
function error(message){active=null;img.removeAttribute("src");details.textContent="";img.hidden=true;viewport.hidden=true;statusEl.textContent=message;[fit,native,full].forEach(x=>x.disabled=true);download.removeAttribute("href");download.setAttribute("aria-disabled","true");}
function envelope(value){
if(!value||typeof value!=="object")return null;
if(value.structuredContent||value._meta?.framebridgeImage)return value;
for(const name of ["mcp_tool_result","call_tool_result","toolResult","result"]){const item=value[name];if(item&&typeof item==="object"&&(item.structuredContent||item._meta?.framebridgeImage))return item;}
return null;
}
async function render(raw){
const payload=envelope(raw);if(!payload)return;
const n=++serial,meta=payload.structuredContent;
if(!meta?.ok){error("Original capture unavailable: "+String(meta?.error||"unknown error"));return;}
const manifest=meta.result||{},image=payload._meta?.framebridgeImage;
if(!image||image.mimeType!=="image/png"||typeof image.data!=="string"){error("The host did not provide the original PNG to this viewer.");return;}
if(!/^[a-f0-9]{32}$/.test(manifest.capture_id||"")||!/^[a-f0-9]{64}$/.test(manifest.sha256||"")){error("Invalid capture reference.");return;}
const identity=String(meta.node)+" / "+manifest.capture_id+" / "+manifest.sha256;
if(active===identity)return;
try{
statusEl.textContent="Checking original PNG integrity…";
const binary=atob(image.data),bytes=Uint8Array.from(binary,c=>c.charCodeAt(0));
if(bytes.length!==manifest.bytes||bytes.length<33||bytes[0]!==137||bytes[1]!==80||bytes[2]!==78||bytes[3]!==71)throw new Error("PNG length or signature mismatch");
const view=new DataView(bytes.buffer);
if(view.getUint32(16)!==manifest.width||view.getUint32(20)!==manifest.height)throw new Error("PNG dimensions do not match the capture");
if(!globalThis.crypto?.subtle)throw new Error("This host cannot verify SHA-256");
const digest=Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256",bytes)),x=>x.toString(16).padStart(2,"0")).join("");
if(digest!==manifest.sha256)throw new Error("PNG SHA-256 mismatch");
if(n!==serial)return;
img.src="data:image/png;base64,"+image.data;
await img.decode();
if(n!==serial)return;
if(img.naturalWidth!==manifest.width||img.naturalHeight!==manifest.height)throw new Error("Decoded PNG dimensions mismatch");
active=identity;
img.alt="Original full screen of "+String(meta.hostname||meta.node)+", capture "+manifest.capture_id;
img.hidden=false;viewport.hidden=false;viewport.classList.remove("native");
const time=manifest.captured_at||manifest.timestamp||manifest.created_at||"not supplied";
details.textContent="Node: "+String(meta.node)+" ("+String(meta.hostname||"")+")\nCapture: "+manifest.capture_id+"\nCaptured: "+String(time)+"\nOriginal: "+manifest.width+" × "+manifest.height+" · "+manifest.bytes+" bytes\nSHA-256: "+digest;
statusEl.textContent="Original PNG verified. This saved capture does not show subsequent screen changes.";
[fit,native,full].forEach(x=>x.disabled=false);
download.href=img.src;download.download="framebridge-"+manifest.capture_id+".png";download.removeAttribute("aria-disabled");
window.openai?.notifyIntrinsicHeight?.(Math.min(document.body.scrollHeight,900));
}catch(e){if(n===serial)error("Cannot display the original capture: "+String(e?.message||e));}
}
function fromOpenAI(){const api=window.openai;if(!api)return;const out=envelope(api.toolResponseMetadata);if(out)render(out);else if(api.toolOutput)render({structuredContent:api.toolOutput,_meta:api.toolResponseMetadata});}
window.addEventListener("message",event=>{
if(event.source!==window.parent)return;
const m=event.data;if(!m||m.jsonrpc!=="2.0")return;
if(m.id!==undefined&&pending.has(m.id)){const p=pending.get(m.id);pending.delete(m.id);if(m.error)p.reject(m.error);else p.resolve(m.result);return;}
if(m.method==="ui/notifications/tool-result")render(m.params);
if(m.method==="ui/notifications/teardown"){++serial;error("Screen viewer closed.");}
});
window.addEventListener("openai:set_globals",fromOpenAI,{passive:true});
fit.onclick=()=>viewport.classList.remove("native");
native.onclick=()=>viewport.classList.add("native");
full.onclick=()=>{if(window.openai?.requestDisplayMode)window.openai.requestDisplayMode({mode:"fullscreen"});else request("ui/request-display-mode",{mode:"fullscreen"}).catch(()=>{statusEl.textContent="Fullscreen is unavailable. Use Original size to inspect all pixels.";});};
request("ui/initialize",{appInfo:{name:"FrameBridge screen viewer",version:"1.0.0"},appCapabilities:{},protocolVersion:"2026-01-26"}).then(()=>post("ui/notifications/initialized",{})).catch(()=>fromOpenAI());
fromOpenAI();
</script></body></html>"""
def presentation_result(response):
"""Preserve the exact image for the model and pass it to the optional UI."""
if not isinstance(response, list) or len(response) != 2:
metadata = response if isinstance(response, dict) else {"ok": False, "node": "", "error": "SCREEN_PRESENTATION_FAILED"}
return CallToolResult(
content=[TextContent(type="text", text=json.dumps(metadata, sort_keys=True))],
structuredContent=metadata, isError=True,
)
metadata, image = response
content = image.to_image_content()
return CallToolResult(
content=[TextContent(type="text", text=json.dumps(metadata, sort_keys=True)), content],
structuredContent=metadata,
_meta={"framebridgeImage": {"mimeType": content.mimeType, "data": content.data}},
)
def register_screen_presentation(mcp, client):
"""Add an explicit render/replay tool without changing observation semantics."""
@mcp.resource(TEMPLATE_URI, name="FrameBridge original screen",
description="Offline viewer for an authenticated saved screen PNG.",
mime_type="text/html;profile=mcp-app", meta=dict(RESOURCE_META))
def framebridge_screen_viewer():
return VIEWER_HTML
@mcp.tool(annotations=dict(READ), meta=dict(TOOL_META))
def framebridge_screen_show(node: str, capture_id: str, authorized: bool) -> Annotated[CallToolResult, ScreenViewResult]:
"""Display or redisplay a saved original screen PNG, without taking a new capture.
First use framebridge_screen_observe, then pass its node and capture_id here.
This returns the same full PNG bytes and SHA-256, and an optional ChatGPT viewer.
It is a historical read only view; use a fresh observation before controlling a screen.
"""
return presentation_result(client.call("read_capture", node, authorized, capture_id=capture_id))
return framebridge_screen_show
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Original FrameBridge real-screen RPC: actual pixels and bound visible input."""
from __future__ import annotations
import base64
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import stat
import sys
import time
from .screen_capture import CaptureError, capture_screen, read_chunk
from .screen_control import ControlError, act, bind_screen, snapshot_binding
MAX_REQUEST = 16384
CAPTURE_ID = re.compile(r"[0-9a-f]{32}\Z")
HEX_SHA = re.compile(r"[0-9a-f]{64}\Z")
class ScreenRpcError(RuntimeError):
def __init__(self, code: str, **details):
self.code = code
self.details = details
super().__init__(code)
def require(condition, code):
if not condition:
raise ScreenRpcError(code)
def encode(value):
return json.dumps(value, sort_keys=True, ensure_ascii=True, separators=(",", ":"), allow_nan=False).encode("ascii")
def state_directory() -> Path:
parent = Path('/run/user') / str(os.getuid())
st = parent.lstat()
require(stat.S_ISDIR(st.st_mode) and st.st_uid == os.getuid() and st.st_mode & 0o077 == 0,
'RUNTIME_DIRECTORY_UNTRUSTED')
profile = Path(os.environ.get('FRAMEBRIDGE_EXPANDED_STATE', str(Path.home() / '.local/share/framebridge-expanded'))).expanduser().absolute()
path = parent / ('framebridge-screen-' + hashlib.sha256(str(profile).encode()).hexdigest()[:16])
try:
path.mkdir(mode=0o700)
except FileExistsError:
pass
st = path.lstat()
require(stat.S_ISDIR(st.st_mode) and st.st_uid == os.getuid() and st.st_mode & 0o077 == 0,
'SCREEN_DIRECTORY_UNTRUSTED')
return path
def checked_read(path: Path, max_bytes=32768):
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
try:
st = os.fstat(fd)
require(stat.S_ISREG(st.st_mode) and st.st_uid == os.getuid() and st.st_mode & 0o077 == 0,
'SCREEN_RECORD_UNTRUSTED')
require(st.st_size <= max_bytes, 'SCREEN_RECORD_TOO_LARGE')
data = os.read(fd, max_bytes + 1)
require(len(data) == st.st_size, 'SCREEN_RECORD_CHANGED')
return json.loads(data)
finally:
os.close(fd)
def write_record(path: Path, value, *, replace=False):
data = encode(value)
temporary = path.with_name(path.name + '.' + str(os.getpid()) + '.tmp')
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | os.O_NOFOLLOW, 0o600)
try:
with os.fdopen(fd, 'wb') as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
if not replace:
require(not path.exists(), 'SCREEN_RECORD_EXISTS')
os.replace(temporary, path)
finally:
try:
temporary.unlink()
except FileNotFoundError:
pass
def public_manifest(manifest):
# Internal path is unnecessary to use or understand a captured screen.
return {k: v for k, v in manifest.items() if k not in {'path', '_action_consumed'}}
def observe(directory: Path, requested_session_id=None):
before = None
before_error = None
try:
before = snapshot_binding()
except ControlError as exc:
before_error = exc.code
manifest = capture_screen(directory)
manifest['full_screen'] = True
manifest['authentic_pixels'] = True
manifest['mime'] = 'image/png'
if manifest.get('screen_type') == 'tty' and before is not None:
manifest = bind_screen(manifest)
require(manifest.get('binding') == before, 'VISIBLE_TARGET_CHANGED_DURING_CAPTURE')
manifest['control_available'] = True
else:
manifest['control_available'] = False
manifest['control_error'] = before_error or 'USE_GRAPHICAL_CUA_FOR_INPUT'
identity = manifest.get('binding') or {
'node': manifest['node'], 'uid': manifest['uid'],
'active_vt': manifest.get('active_vt'), 'session': manifest.get('session'),
'screen_type': manifest.get('screen_type'),
}
manifest['session_id'] = 'screen_' + hashlib.sha256(encode(identity)).hexdigest()[:32]
if requested_session_id:
manifest['requested_session_id'] = requested_session_id
manifest['requested_session_visible'] = requested_session_id == manifest['session_id']
manifest['_action_consumed'] = False
write_record(directory / (manifest['capture_id'] + '.bound.json'), manifest)
return manifest
def load_capture(directory, capture_id):
require(type(capture_id) is str and CAPTURE_ID.fullmatch(capture_id), 'CAPTURE_ID_INVALID')
result = checked_read(directory / (capture_id + '.bound.json'))
require(type(result) is dict, 'CAPTURE_RECORD_INVALID')
require(result.get('capture_id') == capture_id, 'CAPTURE_RECORD_MISMATCH')
return result
def validate_request(request):
require(type(request) is dict, 'REQUEST_INVALID')
op = request.get('op')
allowed = {
'observe': {'op', 'session_id'},
'status': {'op'},
'read_chunk': {'op', 'capture_id', 'index', 'expected_sha256'},
'read_capture': {'op', 'capture_id'},
'key': {'op', 'capture_id', 'session_id', 'key'},
'text': {'op', 'capture_id', 'session_id', 'text'},
}
require(type(op) is str and op in allowed, 'OPERATION_INVALID')
require(not set(request).difference(allowed[op]), 'UNKNOWN_REQUEST_FIELDS')
if 'session_id' in request:
sid = request['session_id']
require(type(sid) is str and len(sid) <= 96 and re.fullmatch(r'[A-Za-z0-9_-]+', sid), 'SESSION_ID_INVALID')
if op == 'read_chunk':
require(type(request.get('index')) is int and request['index'] >= 0, 'CHUNK_INDEX_INVALID')
require(type(request.get('expected_sha256')) is str and HEX_SHA.fullmatch(request['expected_sha256']), 'EXPECTED_SHA256_REQUIRED')
if op == 'key':
require(type(request.get('key')) is str and 0 < len(request['key']) <= 32, 'KEY_INVALID')
if op == 'text':
require(type(request.get('text')) is str and 0 < len(request['text'].encode('utf-8')) <= 4096, 'TEXT_INVALID')
require('\x00' not in request['text'], 'TEXT_INVALID')
return op
def dispatch(request, directory=None):
op = validate_request(request)
directory = directory or state_directory()
if op == 'status':
return {'ok': True, 'result': {'node': os.uname().nodename, 'uid': os.getuid(),
'full_screen': True, 'authentic_pixels': True,
'input_binding': 'foreground-visible-tmux-pane'}}
if op == 'observe':
return {'ok': True, 'result': public_manifest(observe(directory, request.get('session_id')))}
manifest = load_capture(directory, request.get('capture_id'))
if op == 'read_capture':
# Verify the existing artifact without observing or consuming input control.
read_chunk(directory, manifest['capture_id'], 0, expected_sha256=manifest['sha256'])
historical = public_manifest(manifest)
historical.update(historical_capture=True, control_available=False,
control_error='HISTORICAL_CAPTURE_OBSERVE_AGAIN_FOR_INPUT')
return {'ok': True, 'result': historical}
if op == 'read_chunk':
require(manifest['sha256'] == request['expected_sha256'], 'CAPTURE_SHA256_MISMATCH')
chunk = read_chunk(directory, manifest['capture_id'], request['index'],
chunk_bytes=2100, expected_sha256=request['expected_sha256'])
return {'ok': True, 'result': chunk}
if manifest.get('screen_type') == 'x11':
raise ScreenRpcError('USE_GRAPHICAL_CUA_FOR_X11_INPUT')
require(manifest.get('control_available') is True, 'VISIBLE_INPUT_UNAVAILABLE')
require(not manifest.get('_action_consumed'), 'CAPTURE_ALREADY_USED_OBSERVE_AGAIN')
if request.get('session_id'):
require(request['session_id'] == manifest['session_id'], 'SESSION_NOT_VISIBLE')
# Read and verify the immutable PNG before relying on its binding.
read_chunk(directory, manifest['capture_id'], 0, expected_sha256=manifest['sha256'])
# Mark the observation used before input: never replay after an uncertain send.
manifest['_action_consumed'] = True
write_record(directory / (manifest['capture_id'] + '.bound.json'), manifest, replace=True)
try:
result = act(manifest, op, text=request.get('text'), key=request.get('key'))
except ControlError as exc:
raise ScreenRpcError(exc.code, capture_id=manifest['capture_id'],
capture_consumed=True, observe_again=True,
performed=exc.details.get('performed')) from exc
time.sleep(0.08)
try:
after = observe(directory)
except (CaptureError, ControlError, ScreenRpcError, OSError) as exc:
raise ScreenRpcError('INPUT_SENT_OBSERVE_FAILED', performed=True,
capture_id=manifest['capture_id'], observe_again=True,
cause=getattr(exc, 'code', 'SCREEN_IO_FAILED')) from exc
summary = {k: v for k, v in result.items() if k in {'ok', 'performed', 'action', 'input_bytes', 'key', 'input_target'}}
after['action'] = {'kind': op, 'previous_capture_id': manifest['capture_id'], 'result': summary}
return {'ok': True, 'result': public_manifest(after)}
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
try:
require(len(argv) == 1 and len(argv[0]) <= MAX_REQUEST * 2, 'ENCODED_REQUEST_INVALID')
raw = base64.b64decode(argv[0], validate=True)
require(len(raw) <= MAX_REQUEST, 'REQUEST_TOO_LARGE')
request = json.loads(raw)
directory = state_directory()
lock_fd = os.open(directory / '.lock', os.O_WRONLY | os.O_CREAT | os.O_CLOEXEC | os.O_NOFOLLOW, 0o600)
try:
st = os.fstat(lock_fd)
require(stat.S_ISREG(st.st_mode) and st.st_uid == os.getuid() and st.st_mode & 0o077 == 0, 'SCREEN_LOCK_UNTRUSTED')
fcntl.flock(lock_fd, fcntl.LOCK_EX)
reply = dispatch(request, directory)
finally:
os.close(lock_fd)
print(encode(reply).decode('ascii'))
return 0
except (ScreenRpcError, CaptureError, ControlError) as exc:
details = {k: v for k, v in getattr(exc, 'details', {}).items()
if k in {'performed', 'capture_id', 'capture_consumed', 'observe_again', 'cause'}}
print(encode({'ok': False, 'error': {'code': exc.code, **details}}).decode('ascii'))
return 1
except (ValueError, OSError, KeyError, TypeError):
print(encode({'ok': False, 'error': {'code': 'SCREEN_REQUEST_FAILED'}}).decode('ascii'))
return 1
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,197 @@
"""Typed real-screen transport over the existing authenticated admin.exec channel.
Only this explicit request type bypasses the visible administrative FBTERM engine.
The script field is canonical protocol data and a hash binding, never shell code.
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import json
import os
import re
import selectors
import shlex
import signal
import subprocess
import time
from .screen_frontdoor import RPC_COMMAND, MAX_STDOUT_BYTES
from .screen_rpc import MAX_REQUEST, encode, validate_request, ScreenRpcError
REQUEST_TYPE = "screen_rpc"
MAX_TIMEOUT = 30
MAX_STDERR_BYTES = 4096
JOB = re.compile(r"[A-Za-z0-9._-]{1,80}\Z")
FIELDS = {"version", "mode", "authorized", "script", "timeout_s", "job_id",
"sha256", "include_stdout", "request_type"}
class ScreenTransportError(ValueError):
pass
class ScreenProcessError(RuntimeError):
def __init__(self, code, *, started=False):
super().__init__(code)
self.started = started
def need(condition, code):
if not condition:
raise ScreenTransportError(code)
def parse_script(script):
need(type(script) is str and script.startswith(RPC_COMMAND)
and script.endswith("\n"), "SCREEN_RPC_SCRIPT_INVALID")
encoded = script[len(RPC_COMMAND):-1]
need(len(encoded) <= MAX_REQUEST * 2, "SCREEN_RPC_REQUEST_TOO_LARGE")
try:
words = shlex.split(encoded, posix=True)
need(len(words) == 1, "SCREEN_RPC_SCRIPT_INVALID")
raw = base64.b64decode(words[0], validate=True)
need(len(raw) <= MAX_REQUEST, "SCREEN_RPC_REQUEST_TOO_LARGE")
request = json.loads(raw)
validate_request(request)
canonical = encode(request)
need(raw == canonical, "SCREEN_RPC_REQUEST_NONCANONICAL")
argument = base64.b64encode(canonical).decode("ascii")
need(script == RPC_COMMAND + shlex.quote(argument) + "\n",
"SCREEN_RPC_SCRIPT_NONCANONICAL")
return request, argument
except (ValueError, UnicodeError, binascii.Error, ScreenRpcError) as exc:
if isinstance(exc, ScreenTransportError):
raise
raise ScreenTransportError("SCREEN_RPC_REQUEST_INVALID") from None
def validate_payload(payload):
need(type(payload) is dict and set(payload) == FIELDS, "SCREEN_RPC_FIELDS_INVALID")
need(type(payload["version"]) is int and payload["version"] == 1,
"SCREEN_RPC_VERSION_INVALID")
need(payload["mode"] == "EXEC" and payload["request_type"] == REQUEST_TYPE,
"SCREEN_RPC_TYPE_INVALID")
need(payload["authorized"] is True, "AUTHORIZED_TRUE_REQUIRED")
need(type(payload["job_id"]) is str and JOB.fullmatch(payload["job_id"]),
"INVALID_JOB_ID")
need(type(payload["timeout_s"]) is int and 1 <= payload["timeout_s"] <= MAX_TIMEOUT,
"INVALID_SCREEN_TIMEOUT")
need(payload["include_stdout"] is True, "SCREEN_RPC_STDOUT_REQUIRED")
request, argument = parse_script(payload["script"])
digest = hashlib.sha256(payload["script"].encode("utf-8")).hexdigest()
need(type(payload["sha256"]) is str and payload["sha256"] == digest,
"SCRIPT_SHA256_MISMATCH")
return request, argument
def _run_rpc(argument, timeout_s):
"""Bound memory while draining both pipes; bound wall time and child lifetime."""
argv = ["/usr/bin/python3", "-B", "-m",
"framebridge_expanded.real_screen.screen_rpc", argument]
process = None
completed = False
selector = selectors.DefaultSelector()
buffers = {"stdout": bytearray(), "stderr": bytearray()}
deadline = time.monotonic() + timeout_s
try:
process = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, close_fds=True,
start_new_session=True)
selector.register(process.stdout, selectors.EVENT_READ, "stdout")
selector.register(process.stderr, selectors.EVENT_READ, "stderr")
while selector.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ScreenProcessError("SCREEN_RPC_TIMEOUT", started=True)
for item, _ in selector.select(min(remaining, 0.2)):
chunk = os.read(item.fileobj.fileno(), 4096)
if not chunk:
selector.unregister(item.fileobj)
continue
name = item.data
limit = MAX_STDOUT_BYTES if name == "stdout" else MAX_STDERR_BYTES
if len(buffers[name]) + len(chunk) > limit:
raise ScreenProcessError("SCREEN_RPC_OUTPUT_LIMIT", started=True)
buffers[name].extend(chunk)
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ScreenProcessError("SCREEN_RPC_TIMEOUT", started=True)
try:
code = process.wait(timeout=remaining)
except subprocess.TimeoutExpired:
raise ScreenProcessError("SCREEN_RPC_TIMEOUT", started=True) from None
completed = True
return code, bytes(buffers["stdout"]), bytes(buffers["stderr"])
except OSError:
raise ScreenProcessError("SCREEN_RPC_PROCESS_FAILED", started=process is not None) from None
finally:
selector.close()
if process is not None:
if not completed or process.poll() is None:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
pass
if process.stdout is not None:
process.stdout.close()
if process.stderr is not None:
process.stderr.close()
def _response(payload, envelope, code):
return {"version": 1, "ok": envelope["ok"], "mode": "EXEC",
"request_type": REQUEST_TYPE, "job_id": payload["job_id"],
"command_sha256": payload["sha256"], "exit_code": str(code),
"stdout": encode(envelope).decode("ascii") + "\n",
"stdout_truncated": False, "stdout_sanitized": False}
def _failure(payload, request, code, *, started=False):
uncertain = started and request["op"] in {"key", "text"}
error = {"code": code}
if uncertain:
error.update(observe_again=True)
capture = request.get("capture_id")
if type(capture) is str and re.fullmatch(r"[0-9a-f]{32}", capture):
error["capture_id"] = capture
return _response(payload, {"ok": False, "error": error}, 1)
def handle_request(payload, *, runner=None):
request, argument = validate_payload(payload)
runner = _run_rpc if runner is None else runner
try:
code, stdout, stderr = runner(argument, payload["timeout_s"])
except ScreenProcessError as exc:
return _failure(payload, request, str(exc), started=exc.started)
if (type(stdout) is not bytes or type(stderr) is not bytes
or len(stdout) > MAX_STDOUT_BYTES or len(stderr) > MAX_STDERR_BYTES):
return _failure(payload, request, "SCREEN_RPC_OUTPUT_LIMIT", started=True)
try:
envelope = json.loads(stdout)
valid = (type(code) is int and type(envelope) is dict and type(envelope.get("ok")) is bool
and ((code == 0 and envelope["ok"] is True and type(envelope.get("result")) is dict)
or (code == 1 and envelope["ok"] is False and type(envelope.get("error")) is dict))
and not stderr and stdout == encode(envelope) + b"\n")
if not valid:
raise ValueError
except (ValueError, UnicodeError, TypeError):
return _failure(payload, request, "SCREEN_RPC_RESPONSE_INVALID", started=True)
return _response(payload, envelope, code)
def install_adapter_route(adapter):
original = adapter.handle
def handle(payload):
if isinstance(payload, dict) and str(payload.get("request_type", "")).strip().lower() == REQUEST_TYPE:
return handle_request(payload)
return original(payload)
adapter.handle = handle
@@ -0,0 +1,478 @@
"""Manual process supervision for FrameBridge Expanded; no system services."""
from __future__ import annotations
import argparse
import fcntl
import hashlib
import importlib.machinery
import importlib.util
import json
import os
import platform
import re
import secrets
import shutil
import signal
import socket
import stat
import subprocess
import sys
import time
from pathlib import Path
from types import SimpleNamespace
from .config import load_config, state_path, private_dir
PREFIX = Path(os.environ.get("FRAMEBRIDGE_EXPANDED_PREFIX", Path(__file__).resolve().parents[2]))
_STOP = False
_LEGACY = None
def _legacy():
global _LEGACY
if _LEGACY is None:
path = PREFIX / "bin/framebridge-original"
loader = importlib.machinery.SourceFileLoader("framebridge_original_launcher", str(path))
spec = importlib.util.spec_from_loader(loader.name, loader)
_LEGACY = importlib.util.module_from_spec(spec)
loader.exec_module(_LEGACY)
return _LEGACY
def _args(state, config):
return SimpleNamespace(state_dir=str(state), port=config["ports"]["mcp"],
hub_port=config["ports"]["hub"], broker_port=config["ports"]["broker"])
def environment(state, config=None, prepare=False):
state = state_path(state)
config = config or load_config(state)
original = _legacy()
_, _, env = (original.prepare if prepare else original.settings)(_args(state, config))
for key in list(env):
upper = key.upper()
if upper.startswith(("CONTROL_PLANE_", "OPENAI_", "MCP_TUNNEL_", "TUNNEL_")) or upper in ("HARPOON_API_KEY", "RUNTIMES_API_KEY"):
env.pop(key, None)
broker_source = PREFIX / "original/geranas/FrameBridge-Broker-RC1/src"
fleet_source = PREFIX / "original/geranas/FrameBridge-Fleet-Hub/app"
env.update({
"FRAMEBRIDGE_EXPANDED_STATE": str(state), "FRAMEBRIDGE_STATE_DIR": str(state),
"FRAMEBRIDGE_EXPANDED_PREFIX": str(PREFIX),
"FRAMEBRIDGE_NATIVE_HOST": str(PREFIX / "bin/framebridge-native-expanded"),
"FRAMEBRIDGE_BROKER_SOURCE": str(broker_source),
"FRAMEBRIDGE_FLEET_SOURCE": str(fleet_source),
"FRAMEBRIDGE_FRONT_DOOR_PORT": str(config["ports"]["mcp"]),
"FRAMEBRIDGE_FLEET_HUB_PORT": str(config["ports"]["hub"]),
"FRAMEBRIDGE_HUB_MCP_URL": "http://127.0.0.1:" + str(config["ports"]["hub"]) + "/mcp",
"FRAMEBRIDGE_BROKER_CONTROL": config.get("broker_url") or "http://127.0.0.1:" + str(config["ports"]["broker"]),
"PYTHONPATH": os.pathsep.join(map(str, [PREFIX / "expanded", PREFIX / "portable",
PREFIX / "original/geranas/FrameBridge-Fleet-Hub/vendor", broker_source, fleet_source])),
"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", "PYTHONDONTWRITEBYTECODE": "1",
"FBTERM_TITLE": "FrameBridge Expanded", "FRAMEBRIDGE_FBTERM_TITLE": "FrameBridge Expanded",
})
from .display_context import configure
configure(env, config.get("headless", "auto"), state)
return env
def _atomic(path, value):
temporary = path.with_name("." + path.name + "-" + secrets.token_hex(4))
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(fd, "w") as stream:
json.dump(value, stream, indent=2)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
def _birth(pid):
try:
fields = Path("/proc", str(pid), "stat").read_text().rsplit(") ", 1)[1].split()
return fields[19] if fields[0] not in ("Z", "X") else None
except (OSError, IndexError):
return None
def _alive(record):
return bool(record.get("birth") and _birth(record.get("pid", 0)) == record["birth"])
def _record(proc, role):
return {"pid": proc.pid, "birth": _birth(proc.pid), "role": role, "uid": os.getuid()}
def _read(path):
try:
return json.loads(path.read_text())
except (OSError, ValueError):
return {}
def _port(port):
with socket.socket() as probe:
probe.settimeout(0.2)
return probe.connect_ex(("127.0.0.1", port)) == 0
def status(state):
state = state_path(state)
config = load_config(state)
manager = _read(state / "manager.json")
worker = _read(state / "worker.json")
running = _alive(manager)
children = [dict(r, alive=_alive(r)) for r in worker.get("processes", [])]
ready = bool(running and children and all(r["alive"] for r in children)
and _read(state / "ready.json").get("ready"))
context = _read(state / "ready.json").get("display_context", {"kind": "not_started"})
if running and config.get("headless", "auto") == "auto":
from .display_context import describe, bind_text
try:
current_env = environment(state, config)
if not _display_ok(current_env):
bind_text(current_env, state, reason="PHYSICAL_X11_UNAVAILABLE_TEXT_TOOLS_ACTIVE")
context = describe(current_env)
except (OSError, ValueError, RuntimeError):
context = {"kind": "text", "physical": False, "reason": "DISPLAY_DETECTION_UNAVAILABLE"}
return {"ok": True, "role": config["role"], "name": config["name"],
"node_id": config["node_id"], "running": running, "ready": ready,
"state_dir": str(state), "mcp_url": None if config["role"] == "node" else
"http://127.0.0.1:" + str(config["ports"]["mcp"]) + "/mcp",
"processes": children, "manual_chatgpt_connection": True,
"display_context": context,
"capabilities": {"fbexec": ready, "fbterm": ready, "cua_tty_module": ready,
"graphical_cua": ready and context.get("kind") in ("physical-x11", "virtual-x11")},
"error": _read(state / "last-error.json").get("error", "")}
def _display_ok(env):
if not env.get("DISPLAY"):
return False
try:
result = subprocess.run(["xwininfo", "-root"], env=env, capture_output=True, timeout=3)
return result.returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
def doctor(state):
state = state_path(state)
config = load_config(state)
env = environment(state, config)
commands = ["bash", "tmux", "tee", "flock", "awk", "sha256sum", "timeout",
"dbus-run-session", "ip", "lsblk", "systemctl", "systemd-run", "sudo", "uptime"]
missing = [x for x in commands if not shutil.which(x, path=env.get("PATH"))]
from .display_context import missing_graphical_commands, VIRTUAL_COMMANDS
graphical_missing = missing_graphical_commands(env)
virtual_missing = [x for x in VIRTUAL_COMMANDS if not shutil.which(x, path=env.get("PATH"))]
console_missing = [x for x in ("gnome-terminal", "xterm") if not shutil.which(x, path=env.get("PATH"))]
if config.get("headless") in ("no", False):
missing.extend(graphical_missing)
elif config.get("headless") in ("yes", True):
missing.extend(graphical_missing + virtual_missing)
os_release = platform.freedesktop_os_release()
compatible = (sys.version_info[:2] == (3, 13) and platform.machine() == "x86_64"
and os_release.get("ID") == "debian" and os_release.get("VERSION_ID") == "13")
issues = []
if config["role"] == "central" and config.get("broker_listen_host", "127.0.0.1") not in ("127.0.0.1", "::1", "localhost"):
if not config.get("tls_cert") or not config.get("tls_key"):
issues.append("A central listener outside loopback requires a TLS certificate and private-key reference.")
if config.get("headless") in ("no", False) and not _display_ok(env):
issues.append("No usable X11 display. Select headless auto or provide DISPLAY and XAUTHORITY.")
from .display_context import describe
from .network import inspect as inspect_network
display_context = describe(env)
if config.get("headless") in ("no", False) and display_context.get("kind") != "physical-x11":
issues.append("No verified active physical X11 session. Log in graphically or explicitly choose a virtual headless display.")
return {"ok": compatible and not missing and not issues, "compatible_debian_13_amd64": compatible,
"python": platform.python_version(), "missing_commands": missing,
"display": {"physical-x11": "existing physical X11", "virtual-x11": "explicit private virtual display", "text": "text tools"}.get(display_context.get("kind"), "unavailable"),
"display_context": display_context,
"capabilities": {"fbexec": not missing, "fbterm": not missing, "cua_tty_module": True,
"graphical_cua": display_context.get("kind") in ("physical-x11", "virtual-x11") and not graphical_missing,
"mode": "graphical" if display_context.get("kind") in ("physical-x11", "virtual-x11") else "text"},
"optional_missing_commands": {"graphical_cua": graphical_missing, "private_desktop": virtual_missing, "graphical_terminal": console_missing},
"network": inspect_network(),
"role": config["role"], "issues": issues}
def adapter_request(payload):
state = state_path()
env = environment(state)
is_cua = payload.get("request_type") == "cua" or payload.get("mode") == "CUA"
if is_cua:
from .display_context import check, describe, prepare, DisplayContextError
try:
prepare(env)
check(env)
except DisplayContextError as exc:
return {"ok": False, "error": str(exc), "performed": False, "input_may_have_been_sent": False, "do_not_replay": False}
path = Path(env["FRAMEBRIDGE_TOOL_ROOT"]) / "adapter.sock"
st = path.stat()
if not stat.S_ISSOCK(st.st_mode) or st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) != 0o600:
raise RuntimeError("The local adapter socket must be owned by this user with mode 0600.")
request = dict(payload)
timeout = request.get("timeout_s", 180 if request.get("mode") == "CUA" else 120)
if type(timeout) is not int or not 1 <= timeout <= 600:
return {"ok": False, "error": "INVALID_TIMEOUT"}
raw = (json.dumps(request, ensure_ascii=True) + "\n").encode()
send_attempted = False
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(timeout + 15)
client.connect(str(path))
send_attempted = True
client.sendall(raw)
client.shutdown(socket.SHUT_WR)
data = bytearray()
while True:
chunk = client.recv(65536)
if not chunk:
break
data.extend(chunk)
if len(data) > 20 * 1024 * 1024:
raise RuntimeError("The adapter response exceeded its size limit.")
result = json.loads(data)
except (OSError, TimeoutError, ValueError, RuntimeError):
if not is_cua:
raise
from .adapter import _blocked
is_input = str(payload.get("cua", {}).get("action", "")).upper() != "OBSERVE"
return _blocked("CUA_ADAPTER_RESPONSE_UNCERTAIN", send_attempted and is_input)
if not isinstance(result, dict):
if is_cua:
from .adapter import _blocked
is_input = str(payload.get("cua", {}).get("action", "")).upper() != "OBSERVE"
return _blocked("CUA_ADAPTER_RESPONSE_INVALID", is_input)
return {"ok": False, "error": "ADAPTER_RESPONSE_INVALID"}
if request.get("include_stdout") and result.get("stdout_path"):
stdout_path = Path(result["stdout_path"]).resolve()
allowed = Path(env["FBTERM_ROOT"]).resolve() / "jobs"
if stdout_path.is_relative_to(allowed) and stdout_path.is_file():
data = stdout_path.read_bytes()
text = data[:65536].decode("utf-8", "replace")
text = re.sub(r"(?i)sk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{16,}", "[REDACTED]", text)
result["stdout"] = text
result["stdout_truncated"] = len(data) > 65536
if is_cua:
try:
check(env)
except DisplayContextError as exc:
is_input = str(payload.get("cua", {}).get("action", "")).upper() != "OBSERVE"
may_have_sent = bool(result.get("input_may_have_been_sent", is_input))
failure = {"ok": False, "error": str(exc), "result_discarded": True, "input_may_have_been_sent": may_have_sent, "do_not_replay": may_have_sent}
if not may_have_sent:
failure["performed"] = False
return failure
result = dict(result, display_context=describe(env))
return result
def _spawn(command, env, state, role):
log = (state / "logs" / (role + ".log")).open("ab")
try:
proc = subprocess.Popen(command, env=env, cwd=state, stdin=subprocess.DEVNULL,
stdout=log, stderr=subprocess.STDOUT, start_new_session=True, umask=0o077)
finally:
log.close()
return proc, _record(proc, role)
def _terminate(proc, record):
if not _alive(record):
return
if record.get("uid") != os.getuid():
raise RuntimeError("Refusing to stop a process owned by a different user.")
try:
os.killpg(record["pid"], signal.SIGTERM)
except ProcessLookupError:
return
deadline = time.monotonic() + 8
while _alive(record) and time.monotonic() < deadline:
time.sleep(0.1)
if _alive(record):
os.killpg(record["pid"], signal.SIGKILL)
if proc:
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
pass
def _signal(*_):
global _STOP
_STOP = True
def _worker(state):
state = state_path(state)
config = load_config(state)
env = environment(state, config, prepare=True)
os.environ.update(env)
signal.signal(signal.SIGTERM, _signal)
signal.signal(signal.SIGINT, _signal)
commands = [
("adapter", [sys.executable, "-m", "framebridge_expanded.adapter", "serve"]),
("tty", [sys.executable, "-m", "framebridge_expanded.tty", "serve", "--state", str(state)])]
if config["role"] == "central":
code = "from framebridge_expanded.broker import serve; serve(host=" + repr(config.get("broker_listen_host", "127.0.0.1")) + ",port=" + str(config["ports"]["broker"]) + ",state_dir=" + repr(str(state)) + ")"
commands.append(("broker", [sys.executable, "-c", code]))
if config["role"] == "node":
code = "from framebridge_expanded.node import run; run(state_dir=" + repr(str(state)) + ")"
commands.append(("node", [sys.executable, "-c", code]))
else:
commands.extend([
("fleet", [sys.executable, "-m", "framebridge_expanded.fleet"]),
("frontdoor", [sys.executable, str(PREFIX / "portable/front_door_mcp.py")])])
processes = []
try:
for role, command in commands:
proc, rec = _spawn(command, env, state, role)
processes.append((proc, rec))
_atomic(state / "worker.json", {"processes": [r for _, r in processes]})
deadline = time.monotonic() + 35
ready = False
while not _STOP and time.monotonic() < deadline:
if any(p.poll() is not None for p, _ in processes):
raise RuntimeError("A component exited during startup. Check this profile's component logs.")
adapter = Path(env["FRAMEBRIDGE_TOOL_ROOT"], "adapter.sock").is_socket()
ports = True if config["role"] == "node" else all(_port(config["ports"][k]) for k in ("mcp", "hub"))
if config["role"] == "central":
ports = ports and _port(config["ports"]["broker"])
try:
from . import tty
tty_ready = bool(tty.status(state).get("ok"))
except Exception:
tty_ready = False
if adapter and ports and tty_ready:
ready = True
break
time.sleep(0.2)
if not ready:
raise RuntimeError("Components did not become ready before the startup deadline.")
from .display_context import describe
_atomic(state / "ready.json", {"ready": True, "display": env.get("DISPLAY", ""), "display_context": describe(env)})
while not _STOP:
if any(p.poll() is not None for p, _ in processes):
raise RuntimeError("A component stopped unexpectedly. Check this profile's component logs.")
time.sleep(0.25)
finally:
_atomic(state / "ready.json", {"ready": False})
try:
from . import tty
tty.stop(state)
except Exception:
pass
for proc, rec in reversed(processes):
_terminate(proc, rec)
subprocess.run(["tmux", "kill-server"], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def _manager(state):
state = state_path(state)
config = load_config(state)
env = environment(state, config, prepare=True)
signal.signal(signal.SIGTERM, _signal)
signal.signal(signal.SIGINT, _signal)
lock = (state / "manager.lock").open("a")
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
processes = []
try:
from .display_context import describe, bind_virtual
from .display_context import private_display_required
display_available = _display_ok(env)
private_display = private_display_required(env, config.get("headless", "auto"), display_available)
if config.get("headless", "auto") == "auto" and not display_available:
from .display_context import bind_text
bind_text(env, state, reason="PHYSICAL_X11_UNAVAILABLE_TEXT_TOOLS_ACTIVE")
if private_display:
display = next((n for n in range(140, 240) if not Path("/tmp/.X11-unix/X" + str(n)).exists() and not Path("/tmp/.X" + str(n) + "-lock").exists()), None)
if display is None:
raise RuntimeError("No private X11 display number is available.")
env["DISPLAY"] = ":" + str(display)
env["XDG_SESSION_TYPE"] = "x11"
env["XDG_CURRENT_DESKTOP"] = "Openbox"
env["GDK_BACKEND"] = "x11"
authority = Path(env["XDG_RUNTIME_DIR"]) / "Xauthority"
env["XAUTHORITY"] = str(authority)
result = subprocess.run(["xauth", "-f", str(authority), "add", env["DISPLAY"], "MIT-MAGIC-COOKIE-1", secrets.token_hex(16)], env=env, capture_output=True, timeout=5)
if result.returncode:
raise RuntimeError("Could not create private X11 display authorization.")
proc, rec = _spawn(["Xvfb", env["DISPLAY"], "-screen", "0", "1280x800x24", "-nolisten", "tcp", "-noreset", "-auth", str(authority)], env, state, "display")
processes.append((proc, rec))
deadline = time.monotonic() + 10
while not _display_ok(env) and time.monotonic() < deadline:
if proc.poll() is not None:
raise RuntimeError("The private display failed to start.")
time.sleep(0.15)
if not _display_ok(env):
raise RuntimeError("The private display did not become ready.")
proc, rec = _spawn(["openbox", "--sm-disable"], env, state, "window-manager")
processes.append((proc, rec))
if private_display:
bind_virtual(env, state, explicit=config.get("headless") in ("yes", True))
env.pop("DBUS_SESSION_BUS_ADDRESS", None)
proc, rec = _spawn(["dbus-run-session", "--", sys.executable, "-m", "framebridge_expanded.runtime", "_worker", "--state", str(state)], env, state, "private-session")
processes.append((proc, rec))
_atomic(state / "manager-children.json", {"processes": [r for _, r in processes]})
while not _STOP:
if any(p.poll() is not None for p, _ in processes):
raise RuntimeError("The private runtime stopped. Check this profile's logs.")
time.sleep(0.25)
except Exception as exc:
_atomic(state / "last-error.json", {"error": str(exc)})
raise
finally:
try:
for proc, rec in reversed(processes):
_terminate(proc, rec)
_atomic(state / "ready.json", {"ready": False})
finally:
lock.close()
def start(state):
state = state_path(state)
config = load_config(state)
current = status(state)
if current["running"]:
return current
checks = doctor(state)
if not checks["ok"]:
return {"ok": False, "error": "Preflight failed. Run framebridge doctor.", "doctor": checks}
for name in ("logs", "run"):
private_dir(state / name)
for name in ("ready.json", "last-error.json", "worker.json"):
(state / name).unlink(missing_ok=True)
if config["role"] != "node":
for key in ("mcp", "hub") + (("broker",) if config["role"] == "central" else ()):
with socket.socket() as probe:
# Match server restart semantics while still rejecting live listeners.
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
probe.bind(("127.0.0.1", config["ports"][key]))
env = environment(state, config, prepare=True)
proc, rec = _spawn([sys.executable, "-m", "framebridge_expanded.runtime", "_manager", "--state", str(state)], env, state, "manager")
_atomic(state / "manager.json", rec)
deadline = time.monotonic() + 50
while time.monotonic() < deadline and _alive(rec):
result = status(state)
if result["ready"]:
return result
if (state / "last-error.json").exists():
break
time.sleep(0.2)
_terminate(proc, rec)
result = status(state)
result.update(ok=False, error=result["error"] or "Startup failed. Check this profile's logs.")
return result
def stop(state):
state = state_path(state)
load_config(state)
from .connection_background import disconnect
connection = disconnect(state)
if not connection.get("ok"):
raise RuntimeError("The background ChatGPT connection could not be stopped safely; local services were left unchanged.")
from .autostart import mark_manual_stop
mark_manual_stop(state)
rec = _read(state / "manager.json")
_terminate(None, rec)
children = _read(state / "manager-children.json").get("processes", [])
for child in reversed(children):
_terminate(None, child)
for child in reversed(_read(state / "worker.json").get("processes", [])):
_terminate(None, child)
_atomic(state / "ready.json", {"ready": False})
return status(state)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=["_manager", "_worker"])
parser.add_argument("--state", required=True)
args = parser.parse_args()
(_manager if args.command == "_manager" else _worker)(args.state)
if __name__ == "__main__":
main()
@@ -0,0 +1,446 @@
"""Explicit, persistent, read-only fb0 access for one local account.
No permission changes occur on import, status, or package installation.
The administrator explicitly opts in with sudo fb screen-access enable --user NAME.
The selected UID can read framebuffer pixels even after logout or a VT switch.
Disable restores only this operation's exact unchanged ACL and retains backups.
"""
from __future__ import annotations
import argparse
import base64
from contextlib import contextmanager
import errno
import fcntl
import json
import os
from pathlib import Path
import pwd
import stat
import struct
import subprocess
import sys
import time
import uuid
DEVICE = Path("/dev/fb0")
POLICY = Path("/etc/framebridge-expanded/screen-access.json")
STATE = Path("/var/lib/framebridge-expanded/screen-access")
RULE = Path("/usr/lib/udev/rules.d/70-framebridge-screen.rules")
XATTR = "system.posix_acl_access"
ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LC_ALL": "C"}
RISK = ("The selected account can read real /dev/fb0 pixels through its processes "
"after logout or a VT switch. No framebuffer write permission is granted.")
UNDEFINED = 0xffffffff
def require(value, code):
if not value:
raise RuntimeError(code)
def protected_stat(info, *, directory=False, private=False):
require(stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode),
"PROTECTED_PATH_TYPE_INVALID")
require(info.st_uid == 0 and not stat.S_IMODE(info.st_mode) & 0o022,
"PROTECTED_PATH_OWNERSHIP_INVALID")
if private:
require(not stat.S_IMODE(info.st_mode) & 0o077, "PRIVATE_PATH_MODE_INVALID")
def sync_directory(path):
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC)
try:
protected_stat(os.fstat(fd), directory=True)
os.fsync(fd)
finally:
os.close(fd)
def secure_directory(path, *, create=False, private=False):
path = Path(path)
require(path.is_absolute() and ".." not in path.parts, "PROTECTED_PATH_INVALID")
current = Path("/")
for part in path.parts[1:]:
current /= part
try:
info = current.lstat()
except FileNotFoundError:
require(create, "PROTECTED_DIRECTORY_MISSING")
os.mkdir(current, 0o700 if current == path and private else 0o755)
sync_directory(current.parent)
info = current.lstat()
protected_stat(info, directory=True, private=private and current == path)
return path
def read_json(path, *, private=False):
secure_directory(path.parent)
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
try:
protected_stat(os.fstat(fd), private=private)
data = os.read(fd, 65537)
require(len(data) <= 65536, "PROTECTED_RECORD_TOO_LARGE")
value = json.loads(data)
require(isinstance(value, dict), "PROTECTED_RECORD_INVALID")
return value
finally:
os.close(fd)
def write_json(path, value, *, private=True, exclusive=False):
secure_directory(path.parent, create=True, private=path.parent == STATE)
if path.exists():
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
try:
protected_stat(os.fstat(fd), private=private)
finally:
os.close(fd)
require(not exclusive, "PROTECTED_RECORD_EXISTS")
temporary = path.parent / ("." + path.name + "." + uuid.uuid4().hex)
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600 if private else 0o644)
try:
payload = (json.dumps(value, sort_keys=True) + "\n").encode()
os.fchmod(fd, 0o600 if private else 0o644)
with os.fdopen(fd, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
sync_directory(path.parent)
finally:
try:
temporary.unlink()
except FileNotFoundError:
pass
@contextmanager
def locked():
require(os.geteuid() == 0, "ROOT_REQUIRED_USE_SUDO")
secure_directory(STATE, create=True, private=True)
fd = os.open(STATE / "lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
try:
protected_stat(os.fstat(fd), private=True)
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
os.close(fd)
def account(name, expected_uid=None):
require(isinstance(name, str) and 0 < len(name) <= 256
and not any(c.isspace() or ord(c) < 32 for c in name), "ACCOUNT_INVALID")
try:
user = pwd.getpwnam(name)
except KeyError:
raise RuntimeError("ACCOUNT_NOT_FOUND") from None
require(user.pw_uid > 0, "ROOT_ACCOUNT_NOT_ALLOWED")
require(expected_uid is None or user.pw_uid == expected_uid, "ACCOUNT_IDENTITY_CHANGED")
return {"user": user.pw_name, "uid": user.pw_uid}
def validate_policy(value):
require(value.get("schema") == 1 and type(value.get("enabled")) is bool
and value.get("device") == str(DEVICE) and type(value.get("uid")) is int
and value["uid"] > 0 and isinstance(value.get("user"), str)
and 0 < len(value["user"]) <= 256
and not any(c.isspace() or ord(c) < 32 for c in value["user"]),
"SCREEN_ACCESS_POLICY_INVALID")
# Status/revocation must still work after an account is removed or its name
# is reused. The live name/UID identity is checked before every grant.
return value
def load_policy():
try:
return validate_policy(read_json(POLICY))
except FileNotFoundError:
return None
except RuntimeError as exc:
if str(exc) == "PROTECTED_DIRECTORY_MISSING" and not POLICY.parent.exists():
return None
raise
def acl_entries(raw, mode):
if raw is None:
return [(1, (mode >> 6) & 7, UNDEFINED),
(4, (mode >> 3) & 7, UNDEFINED), (32, mode & 7, UNDEFINED)]
require(len(raw) >= 28 and (len(raw) - 4) % 8 == 0
and struct.unpack("<I", raw[:4])[0] == 2, "ACL_FORMAT_INVALID")
entries = [struct.unpack("<HHI", raw[i:i + 8]) for i in range(4, len(raw), 8)]
require(all(tag in (1, 2, 4, 8, 16, 32) and perm <= 7 for tag, perm, ident in entries),
"ACL_FORMAT_INVALID")
for tag in (1, 4, 32):
require(sum(e[0] == tag for e in entries) == 1, "ACL_FORMAT_INVALID")
require(sum(e[0] == 16 for e in entries) <= 1, "ACL_FORMAT_INVALID")
require(all(ident == UNDEFINED for tag, perm, ident in entries if tag in (1, 4, 16, 32)),
"ACL_FORMAT_INVALID")
for tag in (2, 8):
ids = [ident for kind, perm, ident in entries if kind == tag]
require(len(ids) == len(set(ids)) and UNDEFINED not in ids, "ACL_FORMAT_INVALID")
require(not any(e[0] in (2, 8) for e in entries) or any(e[0] == 16 for e in entries),
"ACL_MASK_MISSING")
return entries
def read_only_acl(raw, mode, uid):
entries = acl_entries(raw, mode)
mask = next((perm for tag, perm, ident in entries if tag == 16),
next(perm for tag, perm, ident in entries if tag == 4))
require(mask & 4, "ACL_MASK_HAS_NO_READ_REVIEW_REQUIRED")
existing = [perm for tag, perm, ident in entries if tag == 2 and ident == uid]
require(not existing or existing == [4], "EXISTING_USER_ACL_REVIEW_REQUIRED")
if existing:
return raw
entries.append((2, 4, uid))
if not any(tag == 16 for tag, perm, ident in entries):
entries.append((16, mask, UNDEFINED))
order = {1: 0, 2: 1, 4: 2, 8: 3, 16: 4, 32: 5}
entries.sort(key=lambda e: (order[e[0]], e[2]))
return struct.pack("<I", 2) + b"".join(struct.pack("<HHI", *e) for e in entries)
def device_snapshot(fd):
info = os.fstat(fd)
require(stat.S_ISCHR(info.st_mode) and os.major(info.st_rdev) == 29
and os.minor(info.st_rdev) == 0 and info.st_uid == 0,
"FRAMEBUFFER_DEVICE_INVALID")
try:
raw = os.getxattr(fd, XATTR)
except OSError as exc:
if exc.errno != errno.ENODATA:
raise
raw = None
meta = {"dev": info.st_dev, "inode": info.st_ino, "rdev": info.st_rdev,
"uid": info.st_uid, "gid": info.st_gid, "mode": stat.S_IMODE(info.st_mode),
"boot": Path("/proc/sys/kernel/random/boot_id").read_text().strip()}
return meta, raw
def encode(raw):
return None if raw is None else base64.b64encode(raw).decode("ascii")
def decode(value):
return None if value is None else base64.b64decode(value, validate=True)
def probe_user(policy):
code = ("import os,json;fd=os.open('/dev/fb0',os.O_RDONLY|os.O_NOFOLLOW);"
"os.close(fd);print(json.dumps({'read_open':True,"
"'write_access':os.access('/dev/fb0',os.W_OK)}))")
user = pwd.getpwnam(policy["user"])
require(user.pw_uid == policy["uid"], "ACCOUNT_IDENTITY_CHANGED")
result = subprocess.run(["/usr/bin/python3", "-I", "-S", "-c", code],
user=user.pw_uid, group=user.pw_gid,
extra_groups=os.getgrouplist(user.pw_name, user.pw_gid),
env=ENV, capture_output=True, timeout=8)
require(result.returncode == 0, "FRAMEBUFFER_READ_PROBE_FAILED")
require(json.loads(result.stdout) == {"read_open": True, "write_access": False},
"FRAMEBUFFER_ACCESS_NOT_READ_ONLY")
def load_record():
try:
value = read_json(STATE / "active.json", private=True)
except FileNotFoundError:
return None
require(value.get("schema") == 1 and isinstance(value.get("meta"), dict)
and type(value.get("uid")) is int and value["uid"] > 0, "ACCESS_BACKUP_INVALID")
meta = value["meta"]
require(set(meta) == {"dev", "inode", "rdev", "uid", "gid", "mode", "boot"}
and all(type(meta[k]) is int and meta[k] >= 0
for k in ("dev", "inode", "rdev", "uid", "gid", "mode"))
and meta["rdev"] == os.makedev(29, 0) and meta["uid"] == 0
and meta["mode"] <= 0o777 and isinstance(meta["boot"], str)
and 0 < len(meta["boot"]) <= 64, "ACCESS_BACKUP_IDENTITY_INVALID")
require("before_acl" in value and "expected_acl" in value, "ACCESS_BACKUP_INVALID")
decode(value["before_acl"])
decode(value["expected_acl"])
return value
def same_device(left, right):
return all(left.get(key) == right.get(key) for key in ("dev", "inode", "rdev", "boot"))
def restore_owned(fd, record):
meta, raw = device_snapshot(fd)
if not same_device(meta, record["meta"]):
return "DEVICE_RECREATED_NO_OLD_GRANT"
require(meta == record["meta"], "FRAMEBUFFER_METADATA_CHANGED_REVIEW_REQUIRED")
before, expected = decode(record["before_acl"]), decode(record["expected_acl"])
if raw == before:
return "ORIGINAL_ACL_ALREADY_RESTORED"
require(raw == expected, "FRAMEBUFFER_ACL_CHANGED_REVIEW_REQUIRED")
if raw != before:
if before is None:
os.removexattr(fd, XATTR)
else:
os.setxattr(fd, XATTR, before, os.XATTR_REPLACE)
require(device_snapshot(fd) == (meta, before), "FRAMEBUFFER_RESTORE_VERIFY_FAILED")
return "ORIGINAL_ACL_RESTORED"
def apply_locked(policy):
account(policy["user"], policy["uid"])
try:
fd = os.open(DEVICE, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
except FileNotFoundError:
return {"status": "ENABLED_WAITING_FOR_FB0", "changed": False}
try:
meta, before = device_snapshot(fd)
previous = load_record()
if previous and same_device(previous["meta"], meta):
require(previous["meta"] == meta, "FRAMEBUFFER_METADATA_CHANGED_REVIEW_REQUIRED")
require(previous["uid"] == policy["uid"], "ACCESS_BACKUP_IDENTITY_CHANGED")
require(before == decode(previous["expected_acl"]), "FRAMEBUFFER_ACL_CHANGED_REVIEW_REQUIRED")
probe_user(policy)
return {"status": "ENABLED_READ_ONLY", "changed": False}
expected = read_only_acl(before, meta["mode"], policy["uid"])
record = {"schema": 1, "uid": policy["uid"], "user": policy["user"],
"meta": meta, "before_acl": encode(before), "expected_acl": encode(expected)}
backup = STATE / ("backup-" + str(time.time_ns()) + "-" + uuid.uuid4().hex + ".json")
write_json(backup, record, exclusive=True)
write_json(STATE / "active.json", record)
require(device_snapshot(fd) == (meta, before), "FRAMEBUFFER_CHANGED_BEFORE_APPLY")
if before != expected:
os.setxattr(fd, XATTR, expected,
os.XATTR_CREATE if before is None else os.XATTR_REPLACE)
try:
require(device_snapshot(fd) == (meta, expected), "FRAMEBUFFER_APPLY_VERIFY_FAILED")
probe_user(policy)
except Exception:
restore_owned(fd, record)
raise
return {"status": "ENABLED_READ_ONLY", "changed": before != expected}
finally:
os.close(fd)
def reload_rules():
result = subprocess.run(["/usr/bin/udevadm", "control", "--reload-rules"],
env=ENV, capture_output=True, timeout=8)
require(result.returncode == 0, "UDEV_RULE_RELOAD_FAILED")
def status():
policy = load_policy()
info = {"enabled": bool(policy and policy["enabled"]), "device": str(DEVICE),
"persistent": bool(policy and policy["enabled"]), "risk": RISK,
"status": "DISABLED" if not policy else
("ENABLED" if policy["enabled"] else "DISABLED_REVOKE_PENDING")}
if policy:
info.update({"user": policy["user"], "uid": policy["uid"]})
try:
info["current_account_can_read"] = os.access(DEVICE, os.R_OK)
info["current_account_can_write"] = os.access(DEVICE, os.W_OK)
info["device_present"] = DEVICE.exists()
info["udev_rule_present"] = RULE.is_file() and not RULE.is_symlink()
except OSError:
info["device_present"] = False
return info
def enable(user):
chosen = account(user)
with locked():
secure_directory(RULE.parent)
fd = os.open(RULE, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
try:
protected_stat(os.fstat(fd))
finally:
os.close(fd)
old = load_policy()
if old:
require(old["enabled"] and all(old[k] == chosen[k] for k in ("user", "uid")),
"DISABLE_EXISTING_ACCESS_POLICY_FIRST")
policy = old
else:
policy = {"schema": 1, "enabled": True, "device": str(DEVICE), **chosen}
write_json(POLICY, policy, private=False, exclusive=True)
try:
reload_rules()
answer = apply_locked(policy)
except Exception:
# Stop later udev grants. Keep the tombstone and protected backup
# for an explicit disable/recovery instead of concealing a failure.
write_json(POLICY, dict(policy, enabled=False), private=False)
raise
return dict(answer, persistent=True, user=chosen["user"], uid=chosen["uid"], risk=RISK)
def disable():
with locked():
policy = load_policy()
if policy is None:
return {"status": "DISABLED", "changed": False, "persistent": False}
write_json(POLICY, dict(policy, enabled=False), private=False)
reload_rules()
record = load_record()
result = "NO_CURRENT_OWNED_GRANT"
if record:
require(record["uid"] == policy["uid"], "ACCESS_BACKUP_IDENTITY_CHANGED")
try:
fd = os.open(DEVICE, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
except FileNotFoundError:
result = "DEVICE_ABSENT_NO_OLD_GRANT"
else:
try:
result = restore_owned(fd, record)
finally:
os.close(fd)
# Successful revocation must not leave an active record belonging
# to an earlier enable, even when the device has been recreated.
(STATE / "active.json").unlink()
sync_directory(STATE)
POLICY.unlink()
sync_directory(POLICY.parent)
return {"status": "DISABLED", "changed": True, "persistent": False, "restore": result}
def apply_device():
with locked():
policy = load_policy()
if policy is None or not policy["enabled"]:
return {"status": "DISABLED", "changed": False}
return apply_locked(policy)
def command(args):
if args.screen_access_command == "status":
return status()
if args.screen_access_command == "enable":
return enable(args.user)
if args.screen_access_command == "disable":
return disable()
raise ValueError("SCREEN_ACCESS_COMMAND_INVALID")
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="action", required=True)
item = sub.add_parser("enable")
item.add_argument("--user", required=True)
sub.add_parser("disable")
sub.add_parser("status")
sub.add_parser("apply-device")
args = parser.parse_args(argv)
try:
result = (enable(args.user) if args.action == "enable" else
disable() if args.action == "disable" else
status() if args.action == "status" else apply_device())
print(json.dumps(result, sort_keys=True))
return 0
except (OSError, ValueError, RuntimeError, KeyError) as exc:
# No passwords, environment, process argv, or screen pixels are read.
print("Error: " + (str(exc) if isinstance(exc, RuntimeError)
else "SCREEN_ACCESS_OPERATION_FAILED"), file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+162
View File
@@ -0,0 +1,162 @@
"""Profile-scoped LAN TLS bootstrap; never changes OS trust or OpenAI credentials."""
from __future__ import annotations
import datetime
import ipaddress
import json
import os
from pathlib import Path
import re
import shutil
import sys
from urllib.parse import urlsplit
from .config import load_config, save_config, state_path, private_dir
def _crypto():
# The original network_security module has network policy helpers, but no
# certificate generator. Reuse the captured SDK's cryptography dependency.
from .runtime import PREFIX
for vendor in (Path(PREFIX) / "original/geranas/FrameBridge-Fleet-Hub/vendor",
Path(PREFIX) / "sources/original-geranas/FrameBridge-Fleet-Hub/vendor"):
if vendor.is_dir():
value = str(vendor)
if value not in sys.path:
sys.path.insert(0, value)
break
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
return x509, hashes, serialization, ec, NameOID, ExtendedKeyUsageOID
def _dns_name(value):
if not isinstance(value, str):
raise ValueError("Enter a certificate DNS name.")
try:
value = value.strip().rstrip(".").encode("idna").decode("ascii").lower()
except UnicodeError:
raise ValueError("The certificate DNS name is invalid.") from None
if not value or len(value) > 253 or any(not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) for label in value.split(".")):
raise ValueError("Use a DNS name containing letters, numbers, hyphens, and dots.")
return value
def _address(value):
try:
address = ipaddress.ip_address(value)
except (ValueError, TypeError):
raise ValueError("Enter an IPv4 or IPv6 address for the broker certificate.") from None
if address.is_unspecified or address.is_multicast or getattr(address, "scope_id", None):
raise ValueError("The advertised address must identify a computer, not a wildcard or multicast address.")
return address
def _url(host, port):
return "https://" + ("[" + host + "]" if ":" in host else host) + ":" + str(port)
def _write(path, data, mode):
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode)
with os.fdopen(fd, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
path.chmod(mode)
def create_certificates(state, host, address, *, listen_host=None, advertise_host=None):
"""Create a new private CA and server certificate in a stopped central profile."""
state = state_path(state)
config = load_config(state)
if config["role"] != "central":
raise ValueError("Create LAN certificates in a central profile. Run framebridge setup first.")
from .runtime import status
current = status(state)
if current.get("running") or any(p.get("alive") for p in current.get("processes", [])):
raise RuntimeError("Stop this profile before creating certificates.")
directory = state / "tls"
if directory.exists() or directory.is_symlink() or any(config.get(k) for k in ("tls_cert", "tls_key", "tls_ca")):
raise FileExistsError("This profile already has TLS material or references. Use existing certificates or an explicit new profile.")
host = _dns_name(host)
address = _address(address)
listen = listen_host if listen_host is not None else config.get("broker_listen_host", "127.0.0.1")
if listen != "localhost":
try:
ipaddress.ip_address(listen)
except (ValueError, TypeError):
raise ValueError("The listen host must be an IP address or localhost.") from None
advertised = str(address) if advertise_host is None else str(advertise_host)
if advertised not in {host, str(address), "localhost", "127.0.0.1", "::1"}:
raise ValueError("The advertised host must match the certificate DNS name or one of its IP addresses.")
x509, hashes, serialization, ec, NameOID, ExtendedKeyUsageOID = _crypto()
now = datetime.datetime.now(datetime.timezone.utc)
ca_key = ec.generate_private_key(ec.SECP256R1())
server_key = ec.generate_private_key(ec.SECP256R1())
ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "FrameBridge " + config["name"] + " local CA")])
ca = (x509.CertificateBuilder().subject_name(ca_name).issuer_name(ca_name)
.public_key(ca_key.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(now + datetime.timedelta(days=3650))
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
.add_extension(x509.KeyUsage(digital_signature=False, content_commitment=False, key_encipherment=False,
data_encipherment=False, key_agreement=False, key_cert_sign=True,
crl_sign=True, encipher_only=False, decipher_only=False), critical=True)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False)
.sign(ca_key, hashes.SHA256()))
dns_names = list(dict.fromkeys([host, "localhost"]))
ips = list(dict.fromkeys([address, ipaddress.ip_address("127.0.0.1"), ipaddress.ip_address("::1")]))
san = x509.SubjectAlternativeName([x509.DNSName(v) for v in dns_names] + [x509.IPAddress(v) for v in ips])
server_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, host)])
server = (x509.CertificateBuilder().subject_name(server_name).issuer_name(ca.subject)
.public_key(server_key.public_key()).serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(now + datetime.timedelta(days=825))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(san, critical=False)
.add_extension(x509.KeyUsage(digital_signature=True, content_commitment=False, key_encipherment=False,
data_encipherment=False, key_agreement=False, key_cert_sign=False,
crl_sign=False, encipher_only=False, decipher_only=False), critical=True)
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False)
.sign(ca_key, hashes.SHA256()))
state = private_dir(state)
directory.mkdir(mode=0o700) # Exclusive: never overwrite an existing CA or key.
ca_file = directory / "ca-public.pem"
ca_key_file = directory / "ca-private.key"
server_file = directory / "server.pem"
server_key_file = directory / "server-private.key"
encoding = serialization.Encoding.PEM
fmt = serialization.PrivateFormat.PKCS8
no_password = serialization.NoEncryption()
updated = dict(config)
updated.update(tls_ca=str(ca_file), tls_cert=str(server_file), tls_key=str(server_key_file),
broker_url=_url(advertised, config["ports"]["broker"]),
broker_control_url=_url("127.0.0.1", config["ports"]["broker"]),
broker_listen_host=listen)
public = {
"ca_public_file": str(ca_file),
"ca_fingerprint_sha256": ca.fingerprint(hashes.SHA256()).hex(),
"server_certificate_file": str(server_file),
"server_fingerprint_sha256": server.fingerprint(hashes.SHA256()).hex(),
"dns_subject_alternative_names": dns_names,
"ip_subject_alternative_names": [str(v) for v in ips],
"broker_url": updated["broker_url"],
"broker_control_url": updated["broker_control_url"],
"broker_listen_host": listen,
"private_key_values_exported": False,
"openai_credentials_created_or_saved": False,
}
try:
_write(ca_key_file, ca_key.private_bytes(encoding, fmt, no_password), 0o600)
_write(server_key_file, server_key.private_bytes(encoding, fmt, no_password), 0o600)
_write(server_file, server.public_bytes(encoding) + ca.public_bytes(encoding), 0o600)
_write(ca_file, ca.public_bytes(encoding), 0o644)
_write(directory / "certificates.json", (json.dumps(public, indent=2) + "\n").encode(), 0o600)
save_config(state, updated)
except Exception:
shutil.rmtree(directory)
raise
return public
+558
View File
@@ -0,0 +1,558 @@
"""Portable access to the captured real FrameBridge tmux CUA-TTY engine.
The captured Manager/LocalServer are loaded from original/gerameg, never from
an installed ALPHA. _tty_backend.py preserves RealTerminalMixin with a recorded
portability diff. The public RPC uses same-UID sockets plus authorized=True.
"""
from __future__ import annotations
import argparse
import base64
from dataclasses import asdict, dataclass, fields
import fcntl
import hashlib
import importlib
import importlib.util
import json
import math
import os
from pathlib import Path
import re
import shutil
import signal
import socket
import socketserver
import stat
import struct
import subprocess
import sys
import threading
import time
import uuid
import zlib
_SESSION = re.compile(r"[a-f0-9]{32}\Z")
_ERROR = re.compile(r"[A-Z][A-Z0-9_]{0,79}\Z")
_WIRE_CHARS = 1800
_STARTED_CHILDREN = {}
def _source_root():
here = Path(__file__).resolve().parents[2]
prefix = Path(os.environ.get("FRAMEBRIDGE_EXPANDED_PREFIX", str(here)))
for p in (prefix / "original/gerameg", prefix / "sources/original-gerameg",
here / "original/gerameg", here / "sources/original-gerameg"):
if (p / "framebridge_cua_tty/core.py").is_file():
return p
raise RuntimeError("TTY_ORIGINAL_SOURCE_NOT_FOUND")
def _load_captured():
name = "framebridge_expanded._captured_cua_tty"
if name not in sys.modules:
path = _source_root() / "framebridge_cua_tty"
spec = importlib.util.spec_from_file_location(
name, path / "__init__.py", submodule_search_locations=[str(path)])
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return (importlib.import_module(name + ".core"),
importlib.import_module(name + ".server"))
_core, _server = _load_captured()
TerminalDenied = _core.TerminalDenied
from ._tty_backend import RealTerminalMixin
from .tty_sudo_control import SudoControlError
@dataclass(frozen=True)
class Limits:
max_sessions: int = 16
min_rows: int = 2
max_rows: int = 500
min_cols: int = 10
max_cols: int = 1000
max_text_bytes: int = 65536
max_screen_bytes: int = 262144
history_lines: int = 10000
idle_timeout_s: int = 0
command_timeout_s: int = 5
rpc_max_bytes: int = 1048576
def _private(path):
path = Path(path)
if path.is_symlink():
raise TerminalDenied("TTY_PRIVATE_PATH_IS_SYMLINK")
path.mkdir(mode=0o700, parents=True, exist_ok=True)
s = path.stat()
if s.st_uid != os.geteuid() or stat.S_IMODE(s.st_mode) != 0o700:
raise TerminalDenied("TTY_PRIVATE_DIRECTORY_REQUIRED")
return path
def state_path(value=None):
return Path(value or os.environ.get("FRAMEBRIDGE_EXPANDED_STATE",
str(Path.home() / ".local/state/framebridge-expanded"))).expanduser().absolute()
def runtime_path(state):
state = state_path(state)
tag = hashlib.sha256(str(state).encode()).hexdigest()[:16]
base = Path("/run/user") / str(os.geteuid())
try:
safe = base.is_dir() and base.stat().st_uid == os.geteuid() and not base.is_symlink()
except OSError:
safe = False
if not safe:
base = Path("/tmp")
return base / ("fbe-tty-" + str(os.geteuid()) + "-" + tag)
def _config(state):
path = state / "tty.json"
data = {}
if path.exists():
if path.is_symlink() or path.stat().st_uid != os.geteuid():
raise TerminalDenied("TTY_CONFIG_OWNER_INVALID")
try:
data = json.loads(path.read_text())
except (ValueError, OSError):
raise TerminalDenied("TTY_CONFIG_INVALID") from None
if type(data) is not dict or set(data) - {"limits", "cwd"}:
raise TerminalDenied("TTY_CONFIG_INVALID")
values = data.get("limits", {})
allowed = {f.name for f in fields(Limits)}
if type(values) is not dict or set(values) - allowed:
raise TerminalDenied("TTY_LIMITS_INVALID")
limits = Limits(**values)
if any(type(getattr(limits, f.name)) is not int for f in fields(Limits)):
raise TerminalDenied("TTY_LIMITS_INVALID")
if not (1 <= limits.max_sessions <= 4096 and
1 <= limits.min_rows <= limits.max_rows <= 65535 and
1 <= limits.min_cols <= limits.max_cols <= 65535 and
1 <= limits.max_text_bytes <= limits.rpc_max_bytes // 2 and
1024 <= limits.max_screen_bytes <= limits.rpc_max_bytes - 4096 and
0 <= limits.history_lines <= 1000000 and
0 <= limits.idle_timeout_s and 1 <= limits.command_timeout_s <= 300 and
65536 <= limits.rpc_max_bytes <= 16777216):
raise TerminalDenied("TTY_LIMITS_INVALID")
cwd = Path(data.get("cwd", str(state / "home"))).expanduser().absolute()
if not cwd.exists():
cwd.mkdir(mode=0o700, parents=True)
if not cwd.is_dir():
raise TerminalDenied("TTY_CWD_INVALID")
return limits, cwd
def _identity(pid):
try:
p = Path("/proc") / str(pid)
parts = (p / "stat").read_text().rsplit(")", 1)[1].split()
return {"pid": int(pid), "birth": parts[19], "uid": p.stat().st_uid,
"state": parts[0]}
except (OSError, ValueError, IndexError):
return None
def _alive(record):
now = _identity(record.get("pid", 0))
return bool(now and now["birth"] == record.get("birth")
and now["uid"] == os.geteuid() and now["state"] != "Z")
def _write_json(path, value):
tmp = path.with_name(path.name + ".new")
tmp.write_text(json.dumps(value, ensure_ascii=True, indent=2) + "\n")
tmp.chmod(0o600)
tmp.replace(path)
def _safe_error(exc):
name = str(exc.args[0]) if getattr(exc, "args", ()) else ""
if name.startswith("OWNER_"):
return "TTY_INPUT_CONTEXT_INVALID"
return name if _ERROR.fullmatch(name) else "TTY_INTERNAL_ERROR"
class PortableManager(RealTerminalMixin, _core.Manager):
"""The operative real-terminal mixin with portable runtime configuration."""
def __init__(self, state, limits, home):
self.profile = state
self.limits = limits
self.home = home
self.tmux_socket = runtime_path(state) / "tmux.sock"
self.tmux_bin = shutil.which("tmux")
if not self.tmux_bin:
raise TerminalDenied("TMUX_NOT_AVAILABLE")
# The original constructor initializes its lock and bookkeeping.
# Session policy is supplied by this profile, not its old staging cap.
super().__init__(owner_verifier=self._local_context, max_sessions=2,
idle_timeout_s=600)
self.max_sessions = limits.max_sessions
self._last_used = {}
@staticmethod
def _local_context(context):
return (type(context) is dict and context.get("program") == "bash"
and context.get("operation") in {"text.type", "key.send"}
and type(context.get("session_id")) is str
and _SESSION.fullmatch(context["session_id"]) is not None
and type(context.get("payload_sha256")) is str
and re.fullmatch(r"[a-f0-9]{64}", context["payload_sha256"]) is not None
and context.get("owner_assertion") == {})
def _size(self, rows, cols):
if (type(rows) is not int or type(cols) is not int or
not self.limits.min_rows <= rows <= self.limits.max_rows or
not self.limits.min_cols <= cols <= self.limits.max_cols):
raise TerminalDenied("SIZE_INVALID")
def open(self, program="bash", rows=24, cols=80):
if program != "bash":
raise TerminalDenied("PROGRAM_NOT_SUPPORTED")
self._size(rows, cols)
result = super().open("bash", rows, cols)
self._last_used[result["session_id"]] = time.monotonic()
return result
def reap(self):
super().reap()
if self.limits.idle_timeout_s:
now = time.monotonic()
for sid, last in list(self._last_used.items()):
if now - last > self.limits.idle_timeout_s and sid in self._real:
try:
super().abort(sid)
finally:
self._last_used.pop(sid, None)
def touch(self, sid):
if sid not in self._real:
raise TerminalDenied("SESSION_NOT_FOUND")
self._last_used[sid] = time.monotonic()
def abort(self, sid):
if sid not in self._real:
raise TerminalDenied("SESSION_NOT_FOUND")
result = super().abort(sid)
self._last_used.pop(sid, None)
return result
def status(self):
result = super().status()
for key in ("owner_device_connected", "owner_signature_required"):
result.pop(key, None)
result.update({"input_policy": "same-uid-authorized",
"input_enabled": True,
"limits": asdict(self.limits),
"real_idle_timeout_s": self.limits.idle_timeout_s or None})
return result
class PortableHandler(socketserver.StreamRequestHandler):
def send(self, value):
raw = (json.dumps(value, ensure_ascii=True, separators=(",", ":")) + "\n").encode()
if len(raw) > self.server.limits.rpc_max_bytes:
raw = b'{"ok":false,"error":"TTY_RESPONSE_TOO_LARGE"}\n'
self.wfile.write(raw)
def handle(self):
try:
self.request.settimeout(self.server.limits.command_timeout_s + 5)
_, uid, _ = struct.unpack("3i", self.request.getsockopt(
socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
if uid != self.server.owner_uid:
self.send({"ok": False, "error": "SAME_UID_REQUIRED"})
return
raw = self.rfile.readline(self.server.limits.rpc_max_bytes + 1)
if len(raw) > self.server.limits.rpc_max_bytes or not raw.endswith(b"\n"):
raise TerminalDenied("TTY_REQUEST_TOO_LARGE_OR_TRUNCATED")
payload = json.loads(raw)
result = dispatch(self.server.manager, payload)
self.send(result)
except (TerminalDenied, SudoControlError, KeyError) as exc:
self.send({"ok": False, "error": _safe_error(exc)})
except (ValueError, TypeError, UnicodeError):
self.send({"ok": False, "error": "TTY_REQUEST_INVALID"})
except Exception:
self.send({"ok": False, "error": "TTY_INTERNAL_ERROR"})
class PortableServer(_server.LocalServer):
def __init__(self, path, manager):
p = Path(path).absolute()
_private(p.parent)
if p.exists() or p.is_symlink():
raise TerminalDenied("TTY_SOCKET_ALREADY_EXISTS")
self.owner_uid = os.geteuid()
self.manager = manager
self.limits = manager.limits
self._socket_path = p
old = os.umask(0o077)
try:
socketserver.UnixStreamServer.__init__(self, str(p), PortableHandler)
finally:
os.umask(old)
p.chmod(0o600)
_SHAPES = {
"status": (set(), set()),
"open": (set(), {"program", "rows", "cols"}),
"observe": ({"session_id"}, set()),
"key.send": ({"session_id", "key"}, set()),
"text.type": ({"session_id", "text"}, set()),
"resize": ({"session_id", "rows", "cols"}, set()),
"close": ({"session_id"}, set()),
"sudo": ({"session_id", "mode"}, set()),
}
def dispatch(manager, payload):
if type(payload) is not dict:
raise TerminalDenied("TTY_REQUEST_INVALID")
if payload.get("authorized") is not True:
raise TerminalDenied("AUTHORIZED_TRUE_REQUIRED")
op = payload.get("op")
if op == "abort":
op = "close"
if type(op) is not str or op not in _SHAPES:
raise TerminalDenied("TTY_OPERATION_INVALID")
required, optional = _SHAPES[op]
keys = set(payload) - {"op", "authorized"}
if not required <= keys or keys - required - optional:
raise TerminalDenied("TTY_REQUEST_INVALID")
sid = payload.get("session_id")
if sid is not None and (type(sid) is not str or not _SESSION.fullmatch(sid)):
raise TerminalDenied("SESSION_ID_INVALID")
with manager._lock:
manager.reap()
if op not in {"status", "open"}:
manager.touch(sid)
if op == "status":
data = manager.status()
elif op == "open":
data = manager.open(payload.get("program", "bash"),
payload.get("rows", 24), payload.get("cols", 80))
elif op == "sudo":
data = manager.sudo(sid, payload["mode"])
if payload["mode"] != "status" and data.get("cleanup_pending"):
return {"ok": False, "error": "TTY_SUDO_TRANSITION_CLEANUP_PENDING",
"result": data, "transport": "same-uid-unix"}
elif op == "observe":
data = manager.observe(sid)
data = dict(data, observation_kind="terminal_transcript", authentic_pixels=False, full_screen=False)
elif op == "key.send":
data = manager.key(sid, payload["key"], approved_context={})
elif op == "text.type":
data = manager.text(sid, payload["text"], approved_context={})
elif op == "resize":
manager._size(payload["rows"], payload["cols"])
data = manager.resize(sid, payload["rows"], payload["cols"])
else:
data = manager.abort(sid)
return {"ok": True, "result": data, "transport": "same-uid-unix"}
def request(state, payload):
"""Send one authorized operation to this profile's real TTY daemon."""
try:
if type(payload) is not dict or payload.get("authorized") is not True:
return {"ok": False, "error": "AUTHORIZED_TRUE_REQUIRED"}
state = state_path(state)
path = runtime_path(state) / "agent.sock"
if not path.exists():
return {"ok": False, "error": "TTY_NOT_RUNNING"}
s = path.lstat()
if (not stat.S_ISSOCK(s.st_mode) or s.st_uid != os.geteuid()
or stat.S_IMODE(s.st_mode) != 0o600):
return {"ok": False, "error": "TTY_SOCKET_UNTRUSTED"}
limits, _ = _config(state)
raw = (json.dumps(payload, ensure_ascii=True, separators=(",", ":")) + "\n").encode()
if len(raw) > limits.rpc_max_bytes:
return {"ok": False, "error": "TTY_REQUEST_TOO_LARGE_OR_TRUNCATED"}
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(limits.command_timeout_s + 10)
client.connect(str(path))
client.sendall(raw)
client.shutdown(socket.SHUT_WR)
received = bytearray()
while True:
part = client.recv(65536)
if not part:
break
received.extend(part)
if len(received) > limits.rpc_max_bytes:
return {"ok": False, "error": "TTY_RESPONSE_TOO_LARGE"}
value = json.loads(received)
return value if type(value) is dict else {"ok": False, "error": "TTY_RESPONSE_INVALID"}
except TerminalDenied as exc:
return {"ok": False, "error": _safe_error(exc)}
except (OSError, TimeoutError):
return {"ok": False, "error": "TTY_UNAVAILABLE"}
except (ValueError, TypeError, UnicodeError):
return {"ok": False, "error": "TTY_RESPONSE_INVALID"}
def status(state=None):
return request(state, {"op": "status", "authorized": True})
def serve(state=None):
state = _private(state_path(state))
limits, home = _config(state)
rt = _private(runtime_path(state))
lock = (state / "tty.lock").open("a")
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
path = rt / "agent.sock"
if path.exists() or path.is_symlink():
old = status(state)
if old.get("ok"):
raise TerminalDenied("TTY_ALREADY_RUNNING")
st = path.lstat()
if not stat.S_ISSOCK(st.st_mode) or st.st_uid != os.geteuid():
raise TerminalDenied("TTY_SOCKET_UNTRUSTED")
path.unlink()
manager = PortableManager(state, limits, home)
def stopped(_signum, _frame):
raise SystemExit(0)
signal.signal(signal.SIGTERM, stopped)
signal.signal(signal.SIGINT, stopped)
pidfile = state / "tty-process.json"
_write_json(pidfile, {**_identity(os.getpid()), "socket": str(path), "state_dir": str(state)})
try:
with PortableServer(path, manager) as server:
print(json.dumps({"ok": True, "ready": True, "socket": str(path),
"policy": "same-uid-authorized", "x11_required": False}), flush=True)
server.serve_forever(poll_interval=0.2)
finally:
pidfile.unlink(missing_ok=True)
def start(state=None):
state = _private(state_path(state))
current = status(state)
if current.get("ok"):
return current
env = os.environ.copy()
root = str(Path(__file__).resolve().parents[1])
env["PYTHONPATH"] = root + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
log = (state / "tty.log").open("ab")
proc = subprocess.Popen([sys.executable, "-m", "framebridge_expanded.tty",
"serve", "--state", str(state)], env=env,
stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT,
start_new_session=True, umask=0o077)
log.close()
_STARTED_CHILDREN[str(state)] = proc
deadline = time.monotonic() + 5
while proc.poll() is None and time.monotonic() < deadline:
current = status(state)
if current.get("ok"):
return current
time.sleep(0.1)
return {"ok": False, "error": "TTY_START_FAILED"}
def stop(state=None):
state = state_path(state)
p = state / "tty-process.json"
if not p.is_file() or p.is_symlink():
return {"ok": True, "state": "STOPPED", "already_stopped": True}
try:
record = json.loads(p.read_text())
if not _alive(record):
return {"ok": True, "state": "STOPPED", "already_stopped": True}
os.kill(record["pid"], signal.SIGTERM)
deadline = time.monotonic() + 10
while _alive(record) and time.monotonic() < deadline:
time.sleep(0.1)
return {"ok": not _alive(record), "state": "STOPPED" if not _alive(record) else "STOP_TIMEOUT"}
except (ValueError, OSError, KeyError):
return {"ok": False, "error": "TTY_STOP_FAILED"}
finally:
child = _STARTED_CHILDREN.get(str(state))
if child is not None:
try:
child.wait(timeout=2)
_STARTED_CHILDREN.pop(str(state), None)
except subprocess.TimeoutExpired:
pass
def _wire_reply(state, result):
cache = _private(state_path(state) / "tty-replies")
for old in cache.glob("*.json"):
if time.time() - old.stat().st_mtime > 3600:
old.unlink(missing_ok=True)
raw = json.dumps(result, ensure_ascii=True, separators=(",", ":")).encode()
encoded = base64.b64encode(zlib.compress(raw, 6)).decode("ascii")
chunks = [encoded[i:i + _WIRE_CHARS] for i in range(0, len(encoded), _WIRE_CHARS)]
ticket = uuid.uuid4().hex
item = {"ticket": ticket, "sha256": hashlib.sha256(raw).hexdigest(),
"bytes": len(raw), "chunks": chunks}
_write_json(cache / (ticket + ".json"), item)
return {"ok": True, "wire": "zlib-base64-v1", "ticket": ticket,
"sha256": item["sha256"], "bytes": len(raw), "chunks": len(chunks),
"index": 0, "data": chunks[0]}
def read_reply(state, ticket, index):
if type(ticket) is not str or not _SESSION.fullmatch(ticket) or type(index) is not int or index < 0:
return {"ok": False, "error": "TTY_REPLY_INVALID"}
p = state_path(state) / "tty-replies" / (ticket + ".json")
try:
st = p.lstat()
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.geteuid() or st.st_mode & 0o077:
raise ValueError()
item = json.loads(p.read_text())
return {"ok": True, "ticket": ticket, "index": index, "data": item["chunks"][index],
"chunks": len(item["chunks"]), "sha256": item["sha256"]}
except (OSError, ValueError, KeyError, IndexError):
return {"ok": False, "error": "TTY_REPLY_NOT_FOUND"}
def main(argv=None):
parser = argparse.ArgumentParser(description="Real persistent Bash CUA-TTY over a same-UID Unix socket.")
parser.add_argument("command", choices=("serve", "start", "stop", "status", "request", "read"))
parser.add_argument("--state", default=None)
parser.add_argument("--payload-b64", default=None)
parser.add_argument("--wire", action="store_true", help="Return a compressed, chunked response for bounded transports.")
parser.add_argument("--ticket")
parser.add_argument("--index", type=int, default=0)
args = parser.parse_args(argv)
try:
if args.command == "serve":
serve(args.state)
return 0
if args.command == "start":
result = start(args.state)
elif args.command == "stop":
result = stop(args.state)
elif args.command == "status":
result = status(args.state)
elif args.command == "read":
result = read_reply(args.state, args.ticket, args.index)
else:
if args.payload_b64:
raw = base64.b64decode(args.payload_b64, validate=True)
else:
raw = sys.stdin.buffer.read(16777217)
if len(raw) > 16777216:
raise TerminalDenied("TTY_REQUEST_TOO_LARGE_OR_TRUNCATED")
result = request(args.state, json.loads(raw))
if args.wire:
result = _wire_reply(args.state, result)
print(json.dumps(result, ensure_ascii=True, separators=(",", ":")))
return 0 if result.get("ok") else 1
except TerminalDenied as exc:
print(json.dumps({"ok": False, "error": _safe_error(exc)}))
return 1
except (ValueError, TypeError, UnicodeError):
print(json.dumps({"ok": False, "error": "TTY_REQUEST_INVALID"}))
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,147 @@
"""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
@@ -0,0 +1,519 @@
"""Per-console ordinary-sudo capability; no privilege grant or custom authentication.
Only transient user services created by this controller are stopped. Each new
console has its own tmux server and cgroup. NoNewPrivileges is checked on both
the actual server and Bash process. Callers must hold their terminal-manager
lock while changing mode so keyboard input cannot race the transition.
"""
from __future__ import annotations
import hashlib
import os
from pathlib import Path
import pwd
import re
import shutil
import shlex
import stat
import subprocess
import threading
import time
import uuid
class SudoControlError(RuntimeError):
def __init__(self, code):
self.code = code
super().__init__(code)
def _fail(code):
raise SudoControlError(code)
def _proc(pid):
try:
root = Path("/proc") / str(int(pid))
raw = (root / "stat").read_text().rsplit(") ", 1)[1].split()
fields = {}
for line in (root / "status").read_text().splitlines():
key, _, value = line.partition(":")
if key in {"Uid", "NoNewPrivs"}:
fields[key] = value.split()
try:
executable = os.readlink(root / "exe")
except OSError:
executable = ""
return {"pid": int(pid), "state": raw[0], "ppid": int(raw[1]),
"pgrp": int(raw[2]), "sid": int(raw[3]), "tty": int(raw[4]),
"tpgid": int(raw[5]), "birth": raw[19], "uids": tuple(map(int, fields["Uid"])),
"nnp": int(fields["NoNewPrivs"][0]), "exe": executable}
except (OSError, ValueError, KeyError, IndexError):
return None
def _processes():
result = {}
for path in Path("/proc").iterdir():
if path.name.isdigit():
item = _proc(int(path.name))
if item and item["state"] not in {"Z", "X"}:
result[item["pid"]] = item
return result
def _descendants(processes, pid):
found = set()
while True:
added = {p for p, info in processes.items()
if info["ppid"] == pid or info["ppid"] in found} - found
if not added:
return found
found.update(added)
SESSION_ENVIRONMENT_KEYS = (
"DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR",
"LD_LIBRARY_PATH", "LIBGL_DRIVERS_PATH", "GBM_BACKENDS_PATH",
"__EGL_VENDOR_LIBRARY_FILENAMES", "__GLX_VENDOR_LIBRARY_NAME",
"GALLIUM_DRIVER", "MESA_LOADER_DRIVER_OVERRIDE", "VK_DRIVER_FILES",
"VK_ICD_FILENAMES", "DRI_PRIME",
)
class SudoConsoleController:
def __init__(self, runtime_dir, home=None, timeout=6.0, *, min_rows=2, max_rows=500, min_cols=10, max_cols=1000):
if os.geteuid() == 0:
_fail("TTY_SUDO_USER_SESSION_REQUIRED")
self.uid = os.getuid()
dimensions = (min_rows, max_rows, min_cols, max_cols)
if (not all(type(value) is int for value in dimensions)
or not 1 <= min_rows <= max_rows or not 1 <= min_cols <= max_cols):
_fail("TTY_SUDO_SIZE_LIMITS_INVALID")
self.min_rows, self.max_rows = min_rows, max_rows
self.min_cols, self.max_cols = min_cols, max_cols
# The package may run inside a profile-scoped private desktop bus.
# sudo-mode units belong to the actual logged-in user's systemd manager.
self.user_runtime = Path("/run/user") / str(self.uid)
try:
runtime_info = self.user_runtime.lstat()
bus_info = (self.user_runtime / "bus").lstat()
except OSError:
_fail("TTY_SUDO_USER_MANAGER_UNAVAILABLE")
if (not stat.S_ISDIR(runtime_info.st_mode) or runtime_info.st_uid != self.uid
or stat.S_IMODE(runtime_info.st_mode) != 0o700
or not stat.S_ISSOCK(bus_info.st_mode) or bus_info.st_uid != self.uid):
_fail("TTY_SUDO_USER_MANAGER_UNAVAILABLE")
self.user_bus_address = "unix:path=" + str(self.user_runtime / "bus")
# Preserve only this profile's explicit graphical context in the shell
# unit. These are paths and driver selections, never authority cookies.
self.session_environment = {
key: os.environ[key] for key in SESSION_ENVIRONMENT_KEYS if key in os.environ
}
self.home = Path(home or pwd.getpwuid(self.uid).pw_dir).resolve()
self.base = Path(runtime_dir).absolute() / "sudo"
self.base.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
self.base.mkdir(mode=0o700, exist_ok=True)
for path in (self.base.parent, self.base):
info = path.lstat()
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != self.uid
or stat.S_IMODE(info.st_mode) != 0o700):
_fail("TTY_SUDO_RUNTIME_NOT_PRIVATE")
if len(os.fsencode(self.base / ("s-" + "a" * 26 + ".sock"))) >= 104:
_fail("TTY_SUDO_SOCKET_PATH_TOO_LONG")
self.tmux = shutil.which("tmux")
self.systemd_run = shutil.which("systemd-run")
self.systemctl = shutil.which("systemctl")
if not all((self.tmux, self.systemd_run, self.systemctl)):
_fail("TTY_SUDO_BACKEND_NOT_AVAILABLE")
self.timeout = float(timeout)
self.prefix = "framebridge-tty-" + hashlib.sha256(str(self.base).encode()).hexdigest()[:12] + "-"
self._records = {}
self._lock = threading.RLock()
def _run(self, command, *, data=None, optional=False):
env = {k: v for k, v in os.environ.items() if k not in {"TMUX", "TMUX_PANE"}}
env["XDG_RUNTIME_DIR"] = str(self.user_runtime)
env["DBUS_SESSION_BUS_ADDRESS"] = self.user_bus_address
try:
result = subprocess.run(command, input=data, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=env, timeout=self.timeout)
except subprocess.TimeoutExpired:
_fail("TTY_SUDO_BACKEND_TIMEOUT")
except OSError:
_fail("TTY_SUDO_BACKEND_UNAVAILABLE")
if result.returncode and not optional:
_fail("TTY_SUDO_BACKEND_FAILED")
return result
def _unit(self, name):
if not re.fullmatch(re.escape(self.prefix) + r"[a-f0-9]{32}-[a-f0-9]{8}\.service", name):
_fail("TTY_SUDO_UNIT_NOT_OWNED")
result = self._run([self.systemctl, "--user", "show", name, "--no-pager",
"--property=MainPID,ActiveState,SubState,NoNewPrivileges,ControlGroup"], optional=True)
return dict(line.split("=", 1) for line in result.stdout.decode("utf-8", "replace").splitlines()
if "=" in line)
def _tmux(self, record, *args, optional=False, data=None):
socket = record.get("tmux_socket") or record.get("legacy_socket")
if not isinstance(socket, str) or not socket.startswith("/") or "\0" in socket:
_fail("TTY_SUDO_SOCKET_INVALID")
return self._run([self.tmux, "-S", socket, "-f", "/dev/null", *map(str, args)],
optional=optional, data=data)
def _resolve(self, session):
if isinstance(session, str):
session = {"session_id": session}
elif not isinstance(session, dict):
value = dict(vars(session))
value.update(value.pop("_sudo_record", value.pop("sudo_record", {})) or {})
session = value
sid = session.get("session_id")
if not isinstance(sid, str) or not re.fullmatch(r"[a-f0-9]{32}", sid):
_fail("TTY_SUDO_SESSION_INVALID")
record = dict(self._records.get(sid) or session)
if not record.get("pane") or not (record.get("tmux_socket") or record.get("legacy_socket")):
_fail("TTY_SUDO_SESSION_NOT_FOUND")
return record
def _pane(self, record):
values = {}
for field in ("pane_pid", "pane_width", "pane_height", "pane_dead"):
value = self._tmux(record, "display-message", "-p", "-t", record["pane"],
"#{" + field + "}").stdout.decode().strip()
if not value.isdigit():
_fail("TTY_SUDO_PANE_METADATA_INVALID")
values[field] = int(value)
if values["pane_dead"]:
_fail("TTY_SUDO_CONSOLE_EXITED")
proc = _proc(values["pane_pid"])
if proc is None:
_fail("TTY_SUDO_PROCESS_NOT_FOUND")
if record.get("pid") and (record["pid"] != proc["pid"] or record.get("birth") != proc["birth"]):
_fail("TTY_SUDO_PROCESS_IDENTITY_CHANGED")
try:
cwd = os.readlink(Path("/proc") / str(proc["pid"]) / "cwd")
except OSError:
_fail("TTY_SUDO_CWD_UNAVAILABLE")
return proc, cwd, values["pane_height"], values["pane_width"]
def _scope(self, group):
# cgroup v2 cgroup.procs plus nested cgroups includes reparented descendants.
if not group or not group.startswith("/") or ".." in Path(group).parts:
_fail("TTY_SUDO_CGROUP_UNAVAILABLE")
root = Path("/sys/fs/cgroup") / group.lstrip("/")
try:
files = [root / "cgroup.procs", *root.glob("**/cgroup.procs")]
return {int(line) for file in files for line in file.read_text().splitlines() if line.isdigit()}
except OSError:
_fail("TTY_SUDO_CGROUP_UNAVAILABLE")
def _pane_group(self, pid):
try:
entries = Path("/proc", str(pid), "cgroup").read_text().splitlines()
return next(line[3:] for line in entries if line.startswith("0::"))
except (OSError, StopIteration):
_fail("TTY_SUDO_CGROUP_UNAVAILABLE")
def _pane_scope(self, group, server_group):
if group == server_group:
return None
name = Path(group).name
if not re.fullmatch(r"tmux-spawn-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.scope", name):
_fail("TTY_SUDO_CGROUP_UNEXPECTED")
if Path(group).parent != Path(server_group).parent:
_fail("TTY_SUDO_CGROUP_UNEXPECTED")
return name
def _inspect(self, record):
proc, cwd, rows, cols = self._pane(record)
all_processes = _processes()
shell_pid = proc["pid"]
candidates = _descendants(all_processes, shell_pid)
candidates.update(p for p, info in all_processes.items() if p != shell_pid and (
info["sid"] == proc["sid"] or (proc["tty"] and info["tty"] == proc["tty"])))
unit_name = record.get("unit_name")
server = None
group = None
pane_group = None
pane_scope = None
if unit_name:
unit = self._unit(unit_name)
try:
main_pid = int(unit["MainPID"])
except (KeyError, ValueError):
_fail("TTY_SUDO_UNIT_NOT_RUNNING")
server = _proc(main_pid)
if (unit.get("ActiveState") != "active" or server is None
or Path(server["exe"]).name != Path(self.tmux).name
or (record.get("server_pid") and record["server_pid"] != main_pid)
or (record.get("server_birth") and record["server_birth"] != server["birth"])):
_fail("TTY_SUDO_UNIT_IDENTITY_CHANGED")
group = unit.get("ControlGroup")
pane_group = self._pane_group(shell_pid)
pane_scope = self._pane_scope(pane_group, group)
if record.get("pane_control_group") and record["pane_control_group"] != pane_group:
_fail("TTY_SUDO_CGROUP_IDENTITY_CHANGED")
scoped = self._scope(group) | self._scope(pane_group)
candidates.update(scoped - {main_pid, shell_pid})
expected = "yes" if proc["nnp"] else "no"
if unit.get("NoNewPrivileges") != expected or server["nnp"] != proc["nnp"]:
_fail("TTY_SUDO_PRIVILEGE_STATE_MISMATCH")
elevated = (any(uid != self.uid for uid in proc["uids"])
or any(p in all_processes and any(u != self.uid for u in all_processes[p]["uids"])
for p in candidates))
unknown = candidates - all_processes.keys()
idle_shell = (Path(proc["exe"]).name == "bash" and proc["state"] == "S"
and proc["tpgid"] == proc["pgrp"] == proc["pid"])
marker = record.get("prompt_marker")
try:
prompt_idle = bool(marker and Path(marker).read_text().strip() == record.get("generation", "") + ":idle")
except OSError:
prompt_idle = False
reason = "TTY_SUDO_ELEVATED_PROCESS_PRESENT" if elevated else (
"TTY_SUDO_PROCESS_STATE_UNVERIFIED" if unknown else (
"TTY_SUDO_CONSOLE_BUSY" if candidates or not idle_shell else (
"TTY_SUDO_PROMPT_UNVERIFIED" if not prompt_idle else None)))
return {**record, "ok": True, "verified": True, "pid": proc["pid"], "birth": proc["birth"],
"mode": "off" if proc["nnp"] else "on", "no_new_privs": proc["nnp"],
"server_pid": server["pid"] if server else None,
"server_no_new_privs": server["nnp"] if server else None,
"server_birth": server["birth"] if server else None, "control_group": group,
"pane_control_group": pane_group, "pane_scope_unit": pane_scope,
"cwd": cwd, "rows": rows, "cols": cols, "busy": bool(reason),
"busy_reason": reason, "busy_pids": sorted(candidates),
"managed": bool(unit_name), "sudo_authentication": "system_policy",
"sudo_authorization_granted": False,
"legacy_detached_scope_complete": bool(unit_name), "prompt_verified": prompt_idle}
def _old_cleanup_complete(self, record):
try:
old = _proc(record["pid"])
if old and old["birth"] == record["birth"] and old["state"] not in {"Z", "X"}:
return False
unit = self._unit(record["unit_name"])
if int(unit.get("MainPID", "0")) or unit.get("ActiveState") in {"active", "activating", "deactivating"}:
return False
for group in {record.get("control_group"), record.get("pane_control_group")}:
if not group:
return False
if (Path("/sys/fs/cgroup") / group.lstrip("/")).exists() and self._scope(group):
return False
return True
except (SudoControlError, KeyError, ValueError):
return False
def _refresh_pending(self, record):
old = record.get("pending_old_record")
if old and self._old_cleanup_complete(old):
record = dict(record)
record.pop("pending_old_record", None)
record.update(cleanup_pending=False, transition_complete=True, cleanup_error=None)
self._records[record["session_id"]] = record
return record
def status(self, session):
with self._lock:
record = self._refresh_pending(self._resolve(session))
return self._inspect(record)
def _idle(self, record):
current = self._inspect(record)
if current["busy"]:
_fail(current["busy_reason"])
return current
def _launch(self, session_id, cwd, rows, cols, mode):
if mode not in {"on", "off"}:
_fail("TTY_SUDO_MODE_INVALID")
if not re.fullmatch(r"[a-f0-9]{32}", str(session_id)):
_fail("TTY_SUDO_SESSION_INVALID")
if not (type(rows) is int and self.min_rows <= rows <= self.max_rows and type(cols) is int and self.min_cols <= cols <= self.max_cols):
_fail("TTY_SUDO_SIZE_INVALID")
cwd = str(Path(cwd).resolve())
if not Path(cwd).is_dir():
_fail("TTY_SUDO_CWD_INVALID")
nonce = uuid.uuid4().hex[:8]
socket = self.base / ("s-" + hashlib.sha256((session_id + nonce).encode()).hexdigest()[:26] + ".sock")
unit_name = self.prefix + session_id + "-" + nonce + ".service"
marker = self.base / ("p-" + nonce + ".state")
rcfile = self.base / ("p-" + nonce + ".bashrc")
record = {"session_id": session_id, "name": "fb-real-" + session_id, "pane": "",
"tmux_socket": str(socket), "unit_name": unit_name, "mode": mode,
"prompt_marker": str(marker), "rcfile": str(rcfile), "generation": nonce}
def write_private(path, text):
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, "w") as stream:
stream.write(text)
quoted = shlex.quote(str(marker))
rc = ("__fb_tty_busy() { builtin printf '%s\\n' '" + nonce + ":busy' > " + quoted + "; }\n"
"__fb_tty_prompt() { if [[ $(builtin trap -p DEBUG) == \"trap -- '__fb_tty_busy' DEBUG\" ]]; then "
"builtin printf '%s\\n' '" + nonce + ":idle' > " + quoted + "; else "
"builtin printf '%s\\n' '" + nonce + ":busy' > " + quoted + "; fi; }\n"
"set -o functrace\ntrap '__fb_tty_busy' DEBUG\nPROMPT_COMMAND=__fb_tty_prompt\nPS1='\\s-\\v\\$ '\n")
write_private(marker, nonce + ":busy\n")
write_private(rcfile, rc)
identity = pwd.getpwuid(self.uid).pw_name
command = [self.systemd_run, "--user", "--quiet", "--collect", "--unit=" + unit_name,
"--property=Type=exec", "--property=NoNewPrivileges=" + ("yes" if mode == "off" else "no"),
"--property=KillMode=control-group", "--property=TimeoutStopSec=3",
"--working-directory=" + str(self.home)]
for key, value in {"HOME": str(self.home), "USER": identity, "LOGNAME": identity,
"HISTFILE": "/dev/null", "SHELL": "/usr/bin/bash",
"LANG": "C.UTF-8", "TERM": "xterm-256color"}.items():
command.append("--setenv=" + key + "=" + value)
for key, value in self.session_environment.items():
command.append("--setenv=" + key + "=" + value)
command += [self.tmux, "-D", "-S", str(socket), "-f", "/dev/null"]
launched = False
try:
launched = True
self._run(command)
deadline = time.monotonic() + self.timeout
while time.monotonic() < deadline:
if socket.exists():
break
time.sleep(0.05)
else:
_fail("TTY_SUDO_TMUX_NOT_READY")
pane = self._tmux(record, "new-session", "-d", "-s", record["name"],
"-x", cols, "-y", rows, "-c", cwd, "-P", "-F", "#{pane_id}",
shlex.join(["/usr/bin/bash", "--noprofile", "--rcfile", str(rcfile), "-i"])).stdout.decode().strip()
if not re.fullmatch(r"%[0-9]+", pane):
_fail("TTY_SUDO_PANE_METADATA_INVALID")
record["pane"] = pane
self._tmux(record, "set-option", "-p", "-t", pane, "remain-on-exit", "on")
self._tmux(record, "set-option", "-t", record["name"], "status", "off")
# Verify the real process, not merely the requested unit property.
expected = 1 if mode == "off" else 0
deadline = time.monotonic() + self.timeout
while time.monotonic() < deadline:
current = self._inspect(record)
if current["no_new_privs"] != expected:
_fail("TTY_SUDO_PRIVILEGE_STATE_MISMATCH")
if not current["busy"]:
return current
time.sleep(0.05)
_fail("TTY_SUDO_CONSOLE_NOT_IDLE")
except BaseException:
if launched:
try:
unit = self._unit(unit_name)
proc, _, _, _ = self._pane(record)
scope = self._pane_scope(self._pane_group(proc["pid"]), unit.get("ControlGroup"))
if scope:
self._run([self.systemctl, "--user", "stop", scope], optional=True)
except SudoControlError:
pass
self._run([self.systemctl, "--user", "stop", unit_name], optional=True)
socket.unlink(missing_ok=True)
marker.unlink(missing_ok=True)
rcfile.unlink(missing_ok=True)
raise
def launch(self, session_id, cwd=None, rows=24, cols=80, mode="off"):
with self._lock:
if session_id in self._records:
_fail("TTY_SUDO_SESSION_EXISTS")
record = self._launch(session_id, cwd or self.home, rows, cols, mode)
self._records[session_id] = record
return dict(record)
def _terminated(self, record):
# A dead pane still owns its tmux service and may retain detached jobs.
unit = self._unit(record["unit_name"])
server = _proc(int(unit.get("MainPID", "0")))
if server and (server["pid"] != record.get("server_pid")
or server["birth"] != record.get("server_birth")):
_fail("TTY_SUDO_UNIT_IDENTITY_CHANGED")
groups = {record.get("control_group"), record.get("pane_control_group")}
if None in groups:
_fail("TTY_SUDO_CGROUP_UNAVAILABLE")
processes = _processes()
members = set()
for group in groups:
if (Path("/sys/fs/cgroup") / group.lstrip("/")).exists():
members.update(self._scope(group))
if members - processes.keys():
_fail("TTY_SUDO_PROCESS_STATE_UNVERIFIED")
if any(any(uid != self.uid for uid in processes[pid]["uids"]) for pid in members):
_fail("TTY_SUDO_ELEVATED_PROCESS_PRESENT")
return {**record, "busy_reason": None}
def _close(self, record, require_idle=False):
try:
current = self._idle(record) if require_idle else self._inspect(record)
except SudoControlError as error:
if (require_idle or not record.get("unit_name")
or error.code not in {"TTY_SUDO_CONSOLE_EXITED", "TTY_SUDO_PROCESS_NOT_FOUND"}):
raise
current = self._terminated(record)
if current["busy_reason"] in {"TTY_SUDO_ELEVATED_PROCESS_PRESENT", "TTY_SUDO_PROCESS_STATE_UNVERIFIED"}:
_fail(current["busy_reason"])
if record.get("unit_name"):
if current.get("pane_scope_unit"):
self._run([self.systemctl, "--user", "stop", current["pane_scope_unit"]], optional=True)
self._run([self.systemctl, "--user", "stop", record["unit_name"]], optional=True)
deadline = time.monotonic() + self.timeout
while time.monotonic() < deadline:
remaining = set()
for group in {current["control_group"], current["pane_control_group"]}:
if (Path("/sys/fs/cgroup") / group.lstrip("/")).exists():
remaining.update(self._scope(group))
if not remaining and _proc(current["pid"]) is None:
break
time.sleep(0.05)
else:
_fail("TTY_SUDO_SCOPE_STILL_RUNNING")
Path(record["tmux_socket"]).unlink(missing_ok=True)
else:
self._tmux(record, "kill-session", "-t", record["name"])
deadline = time.monotonic() + self.timeout
while time.monotonic() < deadline:
proc = _proc(current["pid"])
if proc is None or proc["birth"] != current["birth"]:
break
time.sleep(0.05)
else:
_fail("TTY_SUDO_OLD_CONSOLE_STILL_RUNNING")
for key in ("prompt_marker", "rcfile"):
if record.get(key):
path = Path(record[key])
if path.parent == self.base:
path.unlink(missing_ok=True)
return {"ok": True, "session_id": record["session_id"], "closed": True,
"root_process_cleanup_claimed": False}
def close(self, session):
with self._lock:
record = self._refresh_pending(self._resolve(session))
if record.get("cleanup_pending"):
_fail("TTY_SUDO_TRANSITION_CLEANUP_PENDING")
result = self._close(record)
self._records.pop(record["session_id"], None)
return result
def set_mode(self, session, mode):
if mode not in {"on", "off"}:
_fail("TTY_SUDO_MODE_INVALID")
with self._lock:
old = self._refresh_pending(self._resolve(session))
if old.get("cleanup_pending"):
_fail("TTY_SUDO_TRANSITION_CLEANUP_PENDING")
current = self._idle(old)
if old.get("unit_name") and current["mode"] == mode:
return {**current, "recreated": False}
candidate = self._launch(current["session_id"], current["cwd"],
current["rows"], current["cols"], mode)
try:
# The old console remains alive until its replacement is verified.
self._idle(old)
self._close(old, require_idle=True)
except BaseException as error:
previous = _proc(current["pid"])
if previous and previous["birth"] == current["birth"] and previous["state"] not in {"Z", "X"}:
self._close(candidate)
raise
# The former Bash is gone. Keep the verified replacement and
# retain old ownership metadata until all old scopes are empty.
candidate = {**candidate, "cleanup_pending": True, "transition_complete": False,
"cleanup_error": getattr(error, "code", "TTY_SUDO_OLD_CLEANUP_FAILED"),
"pending_old_record": dict(current)}
self._records[current["session_id"]] = candidate
return {**candidate, "recreated": True, "previous_pid": current["pid"]}
candidate.update(cleanup_pending=False, transition_complete=True)
self._records[current["session_id"]] = candidate
return {**candidate, "recreated": True, "previous_pid": current["pid"]}
@@ -0,0 +1,497 @@
"""Manual ChatGPT connection using the packaged, unmodified tunnel client."""
from __future__ import annotations
from dataclasses import dataclass
import getpass
import os
from pathlib import Path
import re
import shlex
import signal
import subprocess
import sys
import threading
import time
from typing import Callable
from urllib.parse import urlsplit
import warnings
KEY_ENV = "FRAMEBRIDGE_EXPANDED_RUNTIME_KEY"
_ID = re.compile(r"tunnel_[A-Za-z0-9_-]{1,128}\Z")
_ENV_REF = re.compile(r"env:[A-Za-z_][A-Za-z0-9_]*\Z")
_RUNTIME_PREFIXES = ("TUNNEL_CLIENT_", "CONTROL_PLANE_", "OPENAI_", "MCP_", "HEALTH_", "LOG_", "CLOUDFLARED_", "HARPOON_")
_RUNTIME_NAMES = {"TUNNEL_ID", "TUNNEL_BIN", "MCP_URL", "CRED_FILE", "OPEN_WEB_UI", "PID_FILE", "ALLOW_REMOTE_UI", "ADMIN_UI_LOG_BUFFER_EVENTS", "PROXY_CHECK_INTERVAL", KEY_ENV}
class TunnelInputError(ValueError):
"""A safe error that never includes the pasted command or credential."""
@dataclass(frozen=True)
class TunnelSpec:
tunnel_id: str
organization_id: str = ""
url_path: str = ""
def __post_init__(self):
if not isinstance(self.tunnel_id, str) or not _ID.fullmatch(self.tunnel_id):
raise TunnelInputError("A valid tunnel ID is required.")
if not isinstance(self.organization_id, str) or (self.organization_id and not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", self.organization_id)):
raise TunnelInputError("The organization identifier is invalid.")
if not isinstance(self.url_path, str) or (self.url_path and (not self.url_path.startswith("/") or self.url_path.startswith("//") or any(c in self.url_path for c in ("?", "#", "\\", "..")))):
raise TunnelInputError("The control-plane path is invalid.")
def public_settings(self):
return {"tunnel_id": self.tunnel_id,
"control_plane_organization_id": self.organization_id,
"control_plane_url_path": self.url_path}
def parse_tunnel_input(text: str) -> TunnelSpec:
"""Extract public setup data; never execute or retain the pasted command."""
if not isinstance(text, str) or len(text) > 16384:
raise TunnelInputError("Paste a tunnel ID or a supported tunnel-client command.")
raw = text.strip()
if any(c in raw for c in ("\n", "\r", "\x00", ";", "|", "&", "<", ">", "`", "$(", "${", "#")):
raise TunnelInputError("Shell expressions are not accepted. Paste only the tunnel ID or one tunnel-client command.")
try:
words = shlex.split(raw, posix=True)
except ValueError:
raise TunnelInputError("The pasted command has incomplete quoting.") from None
if len(words) == 1 and _ID.fullmatch(words[0]):
return TunnelSpec(words[0])
if not words:
raise TunnelInputError("A tunnel ID is required.")
program = Path(words.pop(0)).name
if program in {"framebridge-original", "framebridge", "fb"}:
if not words or words.pop(0) != "tunnel":
raise TunnelInputError("Paste a tunnel-client command or only its tunnel ID.")
if words[:1] == ["--"]:
words.pop(0)
elif program != "tunnel-client":
raise TunnelInputError("Only a tunnel-client command or a tunnel ID is accepted.")
if words[:2] == ["runtimes", "connect"]:
mode, words = "connect", words[2:]
elif words[:1] in (["run"], ["init"]):
mode, words = words[0], words[1:]
else:
raise TunnelInputError("Use a run, init, or runtimes connect command containing an explicit tunnel ID.")
options = {
"--control-plane.tunnel-id": "id", "--tunnel-id": "id",
"--control-plane.organization-id": "org", "--organization-id": "org",
"--control-plane.base-url": "base", "--control-plane-base-url": "base",
"--control-plane.url-path": "path", "--control-plane-url-path": "path",
"--control-plane.api-key": "credential", "--runtime-api-key": "credential",
"--control-plane-api-key-ref": "credential",
"--mcp.server-url": "mcp", "--mcp-server-url": "mcp",
"--health.listen-addr": "health", "--health-listen-addr": "health",
"--health.url-file": "health_file",
"--alias": "alias", "--name": "name", "--description": "description",
"--profile": "profile", "--profile-dir": "profile_dir", "--sample": "sample",
}
found = {}
while words:
flag = words.pop(0)
name, equals, value = flag.partition("=")
if name == "--json" and mode == "connect" and not equals:
continue
kind = options.get(name)
if kind is None:
raise TunnelInputError("That command contains an unsupported option. Paste only its tunnel ID instead.")
if not equals:
if not words or words[0].startswith("--"):
raise TunnelInputError("The pasted command has an option without a value.")
value = words.pop(0)
if not value or kind in found:
raise TunnelInputError("The pasted command contains missing or repeated setup values.")
found[kind] = value
identifier = found.get("id", "")
if not _ID.fullmatch(identifier):
raise TunnelInputError("The command must contain one explicit tunnel ID.")
if "credential" in found and not _ENV_REF.fullmatch(found["credential"]):
raise TunnelInputError("Do not include a literal key or a key file. Enter the runtime key at the separate hidden prompt.")
if "base" in found:
u = urlsplit(found["base"])
if u.scheme != "https" or u.netloc != "api.openai.com" or u.path not in ("", "/") or u.query or u.fragment:
raise TunnelInputError("Use the OpenAI control plane. Paste only the tunnel ID if the command uses another endpoint.")
if "mcp" in found:
hint = found["mcp"]
if hint.startswith("url="):
hint = hint[4:].split(",", 1)[0]
u = urlsplit(hint)
if u.scheme not in {"http", "https"} or not u.hostname or u.username or u.password or u.query or u.fragment:
raise TunnelInputError("The copied MCP URL cannot contain credentials, a query, or a fragment.")
org = found.get("org", "")
if org and not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", org):
raise TunnelInputError("The organization identifier is invalid.")
path = found.get("path", "")
if path and (not path.startswith("/") or path.startswith("//") or any(c in path for c in ("?", "#", "\\", ".."))):
raise TunnelInputError("The control-plane path must be a plain absolute URL path.")
return TunnelSpec(identifier, org, path)
def child_environment(secret: str, base=None, *, state=None):
"""Return a new child environment; never change os.environ."""
env = dict(os.environ if base is None else base)
for key in list(env):
if key.startswith(_RUNTIME_PREFIXES) or key in _RUNTIME_NAMES:
env.pop(key, None)
if state is not None:
from .config import private_dir
home = private_dir(Path(state) / "chatgpt-home")
for name in (".config", ".cache", ".local/state", ".run", "tmp"):
private_dir(home / name)
env.update({"HOME": str(home), "XDG_CONFIG_HOME": str(home / ".config"),
"XDG_CACHE_HOME": str(home / ".cache"), "XDG_STATE_HOME": str(home / ".local/state"),
"XDG_RUNTIME_DIR": str(home / ".run"), "TMPDIR": str(home / "tmp")})
env[KEY_ENV] = secret
return env
def build_command(binary, state, mcp_url, spec: TunnelSpec):
u = urlsplit(mcp_url)
if u.scheme != "http" or u.hostname not in {"localhost", "127.0.0.1", "::1"} or u.username or u.password or u.query or u.fragment:
raise TunnelInputError("The application MCP endpoint must be local HTTP without credentials.")
args = [str(binary), "run",
"--control-plane.base-url", "https://api.openai.com",
"--control-plane.tunnel-id", spec.tunnel_id,
"--control-plane.api-key", "env:" + KEY_ENV,
"--mcp.server-url", "url=" + mcp_url,
"--health.listen-addr", "127.0.0.1:0",
"--log.level", "info"]
if spec.organization_id:
args.extend(["--control-plane.organization-id", spec.organization_id])
if spec.url_path:
args.extend(["--control-plane.url-path", spec.url_path])
return args
def _stop_owned(process):
if process.poll() is not None:
return
for sig, seconds in ((signal.SIGINT, 5), (signal.SIGTERM, 2), (signal.SIGKILL, 2)):
try:
os.killpg(process.pid, sig)
except ProcessLookupError:
return
try:
process.wait(timeout=seconds)
return
except subprocess.TimeoutExpired:
pass
def run_foreground(command, secret, *, state=None, output=None, popen=None, observe=None):
"""Keep the owned client in the foreground, with private logs and status."""
from . import connection_monitor as monitor
output = output or sys.stdout
popen = popen or subprocess.Popen
progress = monitor.Progress(output)
process = reader = log = attempt = None
reader_errors = []
stop_reader = threading.Event()
env = {}
previous = {}
ready_once = False
last_detail = None
def interrupt(signum, frame):
raise KeyboardInterrupt
try:
if state is None:
raise monitor.ConnectionMonitorError("A private profile is required for connection logs.")
log = monitor.PrivateLog(state, secret)
attempt = monitor.Attempt(state)
args = list(command)
# Never reuse an endpoint file from an earlier connection attempt.
while "--health.url-file" in args:
index = args.index("--health.url-file")
del args[index:index + 2]
args.extend(["--health.url-file", str(attempt.health_file)])
expected_tunnel = args[args.index("--control-plane.tunnel-id") + 1]
expected_mcp = args[args.index("--mcp.server-url") + 1]
if expected_mcp.startswith("url="):
expected_mcp = expected_mcp[4:]
env = child_environment(secret, state=state)
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
previous[sig] = signal.signal(sig, interrupt)
progress.change("starting", "[1/3] Starting the secure tunnel client.")
output.write("Private connection log: " + str(log.path) + "\n")
output.flush()
started = time.time()
process = popen(args, env=env, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
start_new_session=True, bufsize=0, umask=0o077)
env.pop(KEY_ENV, None)
log.write("Connection attempt started; tunnel=" + expected_tunnel + "; child_pid=" + str(process.pid))
reader = threading.Thread(target=monitor.capture_output,
args=(process.stdout, log, reader_errors, stop_reader), daemon=True)
reader.start()
next_probe = 0.0
waiting_since = time.monotonic()
instructions_shown = False
while True:
rc = process.poll()
if rc is not None:
log.write("Tunnel client exited; code=" + str(rc))
if not ready_once:
progress.change("error", "[ERROR] The tunnel stopped before it was ready. Check the private connection log.")
return rc if rc > 0 else 1
progress.change("closed" if rc == 0 else "error",
"[CLOSED] The tunnel client stopped." if rc == 0 else
"[ERROR] The tunnel connection stopped unexpectedly. Check the private connection log.")
return rc if rc >= 0 else 1
if reader_errors:
progress.change("error", "[ERROR] Private connection logging failed. The tunnel will be stopped.")
return 1
if time.monotonic() >= next_probe:
status, detail = monitor.probe(attempt.health_file, expected_tunnel, expected_mcp, started)
if observe is not None:
observe(status)
next_probe = time.monotonic() + 1.0
if detail != last_detail:
log.write("Connection check: " + detail)
last_detail = detail
if process.poll() is not None:
continue
messages = {
"starting": "[1/3] Waiting for the tunnel client to start.",
"checking": "[2/3] Verifying the local MCP service.",
"access": "[3/3] Verifying tunnel access and authenticated polling.",
"ready": "[OK] Secure tunnel connected; local MCP service verified.",
"rejected": "[ERROR] Tunnel access was rejected. Press Ctrl+C and check the runtime key and tunnel permissions.",
}
if status == "ready":
ready_once = True
progress.change(status, messages[status])
if not instructions_shown:
output.write("Complete or select this tunnel in ChatGPT. A successful tool call verifies that connection.\n")
output.write("Keep this terminal open. Press Ctrl+C to disconnect.\n")
output.flush()
instructions_shown = True
elif ready_once:
progress.change("retrying", "[RETRYING] Tunnel readiness was lost. Waiting for verified recovery; see the private log.")
elif status == "rejected":
progress.change(status, messages[status])
elif time.monotonic() - waiting_since >= 90:
progress.change("unverified", "[NOT READY] The connection is not verified yet. The client is retrying; check the private log or press Ctrl+C.")
else:
progress.change(status, messages[status])
progress.tick()
time.sleep(0.2)
except KeyboardInterrupt:
progress.change("closed", "[STOPPED] Disconnecting this tunnel client.")
return 130
except Exception as error:
if log is not None:
try:
log.write("Connection wrapper failed; type=" + type(error).__name__ + "; detail=" + str(error))
except Exception:
pass
progress.change("error", "[ERROR] The connection could not be started or monitored. Check the private connection log and profile permissions.")
return 1
finally:
cleanup_failed = False
for sig in previous:
try:
signal.signal(sig, signal.SIG_IGN)
except Exception:
cleanup_failed = True
try:
if process is not None:
try:
_stop_owned(process)
if process.poll() is None:
cleanup_failed = True
except Exception:
cleanup_failed = True
finally:
stop_reader.set()
if reader is not None:
reader.join(timeout=3)
reader_finished = reader is None or not reader.is_alive()
if not reader_finished or reader_errors:
cleanup_failed = True
try:
if process is not None and process.stdout is not None and reader_finished:
process.stdout.close()
except Exception:
cleanup_failed = True
try:
progress.clear()
if attempt is not None:
attempt.close()
except Exception:
cleanup_failed = True
finally:
try:
# Never close descriptors out from under an active writer.
if log is not None and reader_finished:
log.close()
except Exception:
cleanup_failed = True
finally:
env.pop(KEY_ENV, None)
secret = None
for sig, handler in previous.items():
try:
signal.signal(sig, handler)
except Exception:
cleanup_failed = True
if cleanup_failed:
progress.change("error", "[ERROR] Connection cleanup could not be fully verified. Check this client process before reconnecting.")
return 1
def _hidden(prompt):
if not sys.stdin.isatty():
raise TunnelInputError("Use an interactive terminal for the hidden setup prompts.")
with warnings.catch_warnings():
warnings.simplefilter("error", getpass.GetPassWarning)
try:
return getpass.getpass(prompt)
except getpass.GetPassWarning:
raise TunnelInputError("A terminal with hidden input is required.") from None
def _public_answer(label, saved=""):
suffix = " [" + saved + "]" if saved else ""
return input(label + suffix + ": ").strip() or saved
def _wait_managed_connection(state, current, output, *, wait_timeout=95):
from . import autostart
from .connection_monitor import Progress
progress = Progress(output)
deadline = time.monotonic() + max(0, wait_timeout)
try:
while True:
if current.get("interrupted_after_commit"):
progress.change("closed", "Connection continues in the background.")
return 130
connection = current.get("connection", {})
local = current.get("runtime", {})
if (current.get("configured") and current.get("enabled")
and current.get("service_active") and current.get("service_state") == "active"
and current.get("worker", {}).get("state") != "failed"
and local.get("running") and local.get("ready")
and connection.get("alive") and connection.get("verified")):
progress.change("ready", "[OK] Connected.")
return 0
if (current.get("service_state") == "failed"
or current.get("worker", {}).get("state") == "failed"
or connection.get("state") in {"rejected", "error"}):
progress.change("error", "[ERROR] Connection failed. Run fb connection status for details.")
return 1
if not current.get("configured") or not current.get("enabled"):
progress.change("error", "[ERROR] Connection setup could not be completed.")
return 1
if time.monotonic() >= deadline:
progress.change("unverified", "[PENDING] Connection is still being established in the background.")
return 1
progress.tick()
time.sleep(0.2)
current = autostart.status(state)
finally:
progress.clear()
def resume_managed_connection(state, resume_connection, *, output=None, wait_timeout=95):
output = output or sys.stdout
resumed = False
try:
output.write("Connecting...\n")
output.flush()
current = resume_connection(state)
resumed = bool(current.get("configured") and current.get("enabled"))
return _wait_managed_connection(state, current, output, wait_timeout=wait_timeout)
except (EOFError, KeyboardInterrupt):
output.write("\nConnection continues in the background.\n" if resumed else "\nConnection cancelled.\n")
return 130
def _connect_managed(state, public_settings, configure_connection, output, *, wait_timeout=95):
if not sys.stdin.isatty():
raise TunnelInputError("Use an interactive terminal for connection setup.")
runtime_key = None
committed = False
try:
saved_label = public_settings.get("account_label", "")
if saved_label and not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", saved_label):
saved_label = ""
label = _public_answer("ChatGPT account (email label)", saved_label)
if label and not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", label):
raise TunnelInputError("Enter an email label or leave it empty.")
saved_id = public_settings.get("tunnel_id", "")
if not _ID.fullmatch(saved_id):
saved_id = ""
raw = _hidden("Tunnel ID (hidden; Enter reuses saved ID): ")
tunnel_id = raw.strip() or saved_id
if not _ID.fullmatch(tunnel_id):
raise TunnelInputError("Enter only a valid tunnel ID beginning with tunnel_.")
spec = TunnelSpec(tunnel_id)
reuse_saved = tunnel_id == saved_id
raw = None
if reuse_saved:
spec = TunnelSpec(spec.tunnel_id, public_settings.get("control_plane_organization_id", ""), public_settings.get("control_plane_url_path", ""))
runtime_key = _hidden("MCP server access key (hidden): ")
if (not runtime_key or not runtime_key.isascii() or len(runtime_key.encode()) > 16384
or any(ord(ch) < 33 or ord(ch) == 127 or ch.isspace() for ch in runtime_key)):
raise TunnelInputError("Enter a single nonempty MCP server access key.")
settings = dict(spec.public_settings(), account_label=label)
output.write("Connecting...\n")
output.flush()
current = configure_connection(state, settings, runtime_key)
runtime_key = None
committed = bool(current.get("configured") and current.get("enabled"))
return _wait_managed_connection(state, current, output, wait_timeout=wait_timeout)
except (EOFError, KeyboardInterrupt):
output.write("\nConnection continues in the background.\n" if committed else "\nConnection cancelled.\n")
return 130
finally:
runtime_key = None
def connect_chatgpt(binary, state, mcp_url, public_settings=None,
save_public: Callable | None = None, *, output=None, foreground=False,
configure_connection: Callable | None = None, wait_timeout=95):
"""Connect with one hidden key; normal setup uses protected Linux services."""
output = output or sys.stdout
public_settings = dict(public_settings or {})
if configure_connection is not None and not foreground:
return _connect_managed(state, public_settings, configure_connection, output, wait_timeout=wait_timeout)
from . import connection_background
current = connection_background.status(state)
if current["alive"]:
output.write("This profile already has a background ChatGPT connection. Use framebridge connection status.\n")
return 0 if current["verified"] else 1
label = public_settings.get("account_label", "")
output.write("ChatGPT login happens in your browser. The saved email is only an account label.\n")
if label:
output.write("An account label is saved for this profile.\n")
output.write("Create or select your tunnel manually in OpenAI Platform.\n")
output.write("The runtime key needs Tunnels Read + Use. No Admin API key is needed here.\n")
output.write("The command is parsed as data; this profile's local MCP server will be used.\n")
secret = None
try:
raw = _hidden("Tunnel ID or full tunnel-client command (hidden; Enter reuses the saved ID): ")
reuse_saved = not raw.strip()
if reuse_saved:
raw = public_settings.get("tunnel_id", "")
spec = parse_tunnel_input(raw)
if reuse_saved:
spec = TunnelSpec(spec.tunnel_id, public_settings.get("control_plane_organization_id", ""), public_settings.get("control_plane_url_path", ""))
raw = None
if save_public:
save_public(spec.public_settings())
secret = _hidden("Runtime API key (hidden; kept in memory for this connection): ").strip()
if not secret or any(c.isspace() for c in secret) or "\x00" in secret:
raise TunnelInputError("Enter a single nonempty runtime key.")
command = build_command(binary, state, mcp_url, spec)
if foreground:
return connection_background.run_foreground(command, secret, state, output=output)
return connection_background.start(command, secret, state, output=output)
except (EOFError, KeyboardInterrupt):
output.write("\nConnection cancelled.\n")
return 130
finally:
secret = None
@@ -0,0 +1,146 @@
"""Stop only this package's managed user services before Debian removal."""
from pathlib import Path
import hashlib
import os
import pwd
import re
import shlex
import stat
import subprocess
import sys
MODULE_ROOT = "/usr/lib/framebridge-expanded/expanded"
UNIT_NAME = re.compile(r"framebridge-autostart-[0-9a-f]{16}\.service\Z")
def managed_unit(path, uid):
"""Inspect a bounded owned regular file; never execute user configuration."""
if not UNIT_NAME.fullmatch(path.name):
return False
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != uid or info.st_size > 16384:
return False
raw = os.read(fd, 16385)
finally:
os.close(fd)
lines = raw.decode("utf-8").splitlines()
if not lines or lines[0] != "# FrameBridge managed autostart v1":
return False
if lines.count('Environment="PYTHONPATH=' + MODULE_ROOT + '"') != 1:
return False
starts = [line[10:] for line in lines if line.startswith("ExecStart=")]
if len(starts) != 1:
return False
args = shlex.split(starts[0])
if len(args) != 7 or args[0] not in {"/usr/bin/python3", "/usr/bin/python3.13"}:
return False
if args[1:6] != ["-B", "-m", "framebridge_expanded.autostart", "_run", "--state"]:
return False
state = args[6].replace("%%", "%").replace("$$", "$")
if not state.startswith("/") or any(ord(c) < 32 for c in state):
return False
expected = "framebridge-autostart-" + hashlib.sha256(state.encode()).hexdigest()[:16] + ".service"
return path.name == expected
except (OSError, UnicodeError, ValueError):
return False
def cleanup_user(home=None, *, run=None, bus_available=None):
run = run or subprocess.run
home = Path(home) if home is not None else Path.home()
uid = os.getuid()
directory = home / ".config/systemd/user"
if not directory.is_dir():
return 0
units = sorted(p.name for p in directory.glob("framebridge-autostart-*.service") if managed_unit(p, uid))
if not units:
return 0
runtime = Path("/run/user") / str(uid)
if bus_available is None:
try:
bus_available = stat.S_ISSOCK((runtime / "bus").stat().st_mode)
except OSError:
bus_available = False
env = {"HOME": str(home), "PATH": "/usr/bin:/bin", "LANG": "C.UTF-8", "XDG_CONFIG_HOME": str(home / ".config"), "XDG_RUNTIME_DIR": str(runtime), "DBUS_SESSION_BUS_ADDRESS": "unix:path=" + str(runtime / "bus")}
failure = False
if bus_available:
try:
result = run(["/usr/bin/systemctl", "--user", "stop", *units], env=env, capture_output=True, timeout=35)
if result.returncode:
current = run(["/usr/bin/systemctl", "--user", "show", *units, "--property=ActiveState", "--value"], env=env, capture_output=True, timeout=5)
values = current.stdout.decode().split() if isinstance(current.stdout, bytes) else current.stdout.split()
failure = current.returncode != 0 or len(values) != len(units) or any(value not in {"inactive", "failed"} for value in values)
except (OSError, subprocess.TimeoutExpired):
failure = True
try:
result = run(["/usr/bin/systemctl", "--user", "--no-reload", "disable", *units], env=env, capture_output=True, timeout=10)
failure = result.returncode != 0 or failure
except (OSError, subprocess.TimeoutExpired):
failure = True
# Remove only enablement symlinks targeting these exact managed files.
# This also works offline: systemctl can delegate file operations to a live
# user manager with a different environment, and no bus exists at all for
# an offline owner. Never delete profile data, ciphertext, or unit files.
for group in directory.iterdir():
if not group.name.endswith((".wants", ".requires")) or group.is_symlink() or not group.is_dir():
continue
for name in units:
link = group / name
if link.is_symlink():
try:
if link.resolve() == (directory / name).resolve():
link.unlink()
except OSError:
failure = True
if bus_available:
try:
run(["/usr/bin/systemctl", "--user", "daemon-reload"], env=env, capture_output=True, timeout=3)
except (OSError, subprocess.TimeoutExpired):
pass # Units are stopped/disabled; an owner may have just logged out.
if failure:
raise RuntimeError("A managed FrameBridge service could not be stopped or disabled.")
return len(units)
def cleanup_all_users(*, run=None, accounts=None):
if os.getuid() != 0:
raise RuntimeError("Package service cleanup requires the normal package manager's root context.")
run = run or subprocess.run
accounts = pwd.getpwall() if accounts is None else accounts
seen = set()
failed = False
for account in accounts:
identity = (account.pw_uid, account.pw_dir)
if identity in seen or account.pw_uid == 65534 or not account.pw_dir.startswith("/") or account.pw_dir in {"/", "/nonexistent", "/dev/null"}:
continue
seen.add(identity)
command = ["/usr/sbin/runuser", "-u", account.pw_name, "--", "/usr/bin/env", "-i", "HOME=" + account.pw_dir, "USER=" + account.pw_name, "LOGNAME=" + account.pw_name, "PATH=/usr/bin:/bin", "LANG=C.UTF-8", "/usr/bin/python3", "-I", "-S", str(Path(__file__).resolve()), "--user"]
try:
result = run(command, capture_output=True, timeout=55)
failed = result.returncode != 0 or failed
except (OSError, subprocess.TimeoutExpired):
failed = True
if failed:
raise RuntimeError("Some FrameBridge services could not be stopped; package removal was paused.")
def main(argv=None):
args = list(sys.argv[1:] if argv is None else argv)
try:
if args == ["--all-users"]:
cleanup_all_users()
elif args == ["--user"]:
cleanup_user()
else:
return 2
return 0
except (OSError, RuntimeError, subprocess.TimeoutExpired):
print("FrameBridge service cleanup did not complete. Package data was retained.", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())