FrameBridge 0.3.1 community source release
This commit is contained in:
commit
b23819fff1
1354 files changed
+343716
No files matched your search
@@ -0,0 +1 @@
|
||||
__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()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user