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,2 @@
|
||||
"""FrameBridge CUA-TTY prototype. No network or shell execution."""
|
||||
__version__ = "0.1.0-staging"
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Local staging inspection client; input operations cannot bypass OwnerDevice."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
|
||||
|
||||
def rpc(op, *, path, **kwargs):
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(4)
|
||||
sock.connect(str(path))
|
||||
sock.sendall((json.dumps({'op':op, **kwargs})+'\n').encode())
|
||||
reply = bytearray()
|
||||
while len(reply) < 65536:
|
||||
chunk = sock.recv(min(8192, 65536-len(reply)))
|
||||
if not chunk: break
|
||||
reply.extend(chunk)
|
||||
if b'\n' in chunk: break
|
||||
if len(reply) >= 65536 or b'\n' not in reply: raise RuntimeError('RESPONSE_TOO_LARGE')
|
||||
return json.loads(reply.split(b'\n',1)[0])
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser()
|
||||
p.add_argument('op', choices=('status','open','observe','resize','abort','key.send','text.type'))
|
||||
p.add_argument('--session-id',default='')
|
||||
p.add_argument('--socket',default=str(Path.home()/'.local/share/framebridge-cua-tty-r1/agent.sock'))
|
||||
p.add_argument('--key',default='')
|
||||
p.add_argument('--text',default='')
|
||||
args=p.parse_args(argv)
|
||||
req={}
|
||||
if args.session_id: req['session_id']=args.session_id
|
||||
if args.key: req['key']=args.key
|
||||
if args.text: req['text']=args.text
|
||||
print(json.dumps(rpc(args.op,path=args.socket,**req),ensure_ascii=False,indent=2))
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Unprivileged local PTY session engine with mandatory owner gate for inputs.
|
||||
|
||||
Staging only: only fixed, no-side-effects demo program may be opened. Never
|
||||
use an LLM-provided shell/argv or `authorized=True` as authority.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import fcntl
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import secrets
|
||||
import select
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import termios
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
from .screen import Screen
|
||||
|
||||
|
||||
class TerminalDenied(PermissionError):
|
||||
pass
|
||||
|
||||
|
||||
KEYS = {'UP': b'\x1b[A', 'DOWN': b'\x1b[B', 'LEFT': b'\x1b[D',
|
||||
'RIGHT': b'\x1b[C', 'ENTER': b'\r', 'TAB': b'\t', 'ESC': b'\x1b',
|
||||
'BACKSPACE': b'\x7f', 'CTRL_C': b'\x03'}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
session_id: str
|
||||
proc: subprocess.Popen
|
||||
fd: int
|
||||
screen: Screen
|
||||
born: float
|
||||
last_access: float
|
||||
program: str
|
||||
sequence: int = 0
|
||||
|
||||
|
||||
class Manager:
|
||||
"""A trusted OwnerDevice verifier must implement callable(context)->bool.
|
||||
|
||||
IMPORTANT: no grants can be passed in via RPC, environment, or `authorized`.
|
||||
An absent verifier rejects ALL input. A production verifier must validate
|
||||
signed challenge, owner identity, exact payload hash, nonce, TTL and one-use.
|
||||
"""
|
||||
def __init__(self, owner_verifier: Callable | None = None, max_sessions: int = 2,
|
||||
idle_timeout_s: int = 600):
|
||||
if not 1 <= max_sessions <= 4: raise ValueError('MAX_SESSIONS_INVALID')
|
||||
self._gate = owner_verifier
|
||||
self._sessions = {}
|
||||
self._lock = threading.RLock()
|
||||
self.max_sessions, self.idle_timeout_s = max_sessions, idle_timeout_s
|
||||
|
||||
def _get(self, sid):
|
||||
s = self._sessions.get(sid)
|
||||
if not s: raise KeyError('SESSION_NOT_FOUND')
|
||||
if time.monotonic() - s.last_access > self.idle_timeout_s:
|
||||
self._terminate(s)
|
||||
del self._sessions[sid]
|
||||
raise TerminalDenied('SESSION_EXPIRED')
|
||||
s.last_access = time.monotonic()
|
||||
return s
|
||||
|
||||
@staticmethod
|
||||
def _terminate(s):
|
||||
if s.proc.poll() is None:
|
||||
try: os.killpg(s.proc.pid, signal.SIGTERM)
|
||||
except ProcessLookupError: pass
|
||||
try: s.proc.wait(timeout=0.7)
|
||||
except subprocess.TimeoutExpired:
|
||||
try: os.killpg(s.proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError: pass
|
||||
try: s.proc.wait(timeout=1)
|
||||
except subprocess.TimeoutExpired: pass
|
||||
try: os.close(s.fd)
|
||||
except OSError: pass
|
||||
|
||||
@staticmethod
|
||||
def _child_ctty():
|
||||
# CUA does not use setuid. Child has same UID as daemon.
|
||||
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
|
||||
|
||||
def open(self, program='demo-readonly', rows=24, cols=80):
|
||||
if program != 'demo-readonly':
|
||||
raise TerminalDenied('PROGRAM_NOT_ALLOWLISTED')
|
||||
if os.geteuid() == 0:
|
||||
raise TerminalDenied('ROOT_DAEMON_FORBIDDEN')
|
||||
with self._lock:
|
||||
self.reap()
|
||||
if len(self._sessions) >= self.max_sessions:
|
||||
raise TerminalDenied('SESSION_LIMIT')
|
||||
screen = Screen(rows, cols)
|
||||
master, slave = pty.openpty()
|
||||
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0))
|
||||
env = {'PATH': '/usr/bin:/bin', 'TERM': 'xterm', 'LANG': 'C.UTF-8',
|
||||
'HOME': str(Path.home())}
|
||||
try:
|
||||
proc = subprocess.Popen([sys.executable, '-B', str(Path(__file__).with_name('demo.py'))],
|
||||
stdin=slave, stdout=slave, stderr=slave, env=env, close_fds=True,
|
||||
start_new_session=True, preexec_fn=self._child_ctty)
|
||||
except BaseException:
|
||||
os.close(master)
|
||||
raise
|
||||
finally:
|
||||
os.close(slave)
|
||||
os.set_blocking(master, False)
|
||||
sid = secrets.token_hex(16)
|
||||
now = time.monotonic()
|
||||
self._sessions[sid] = Session(sid, proc, master, screen, now, now, program)
|
||||
return {'session_id': sid, 'program': program, 'state': 'OPEN'}
|
||||
|
||||
def _drain(self, s):
|
||||
total = 0
|
||||
while total < 65536:
|
||||
ready, _, _ = select.select([s.fd], [], [], 0)
|
||||
if not ready: break
|
||||
try: data = os.read(s.fd, min(4096, 65536-total))
|
||||
except (BlockingIOError, OSError): break
|
||||
if not data: break
|
||||
total += len(data)
|
||||
s.screen.feed(data)
|
||||
return total
|
||||
|
||||
def observe(self, sid):
|
||||
with self._lock:
|
||||
s = self._get(sid)
|
||||
n = self._drain(s)
|
||||
s.sequence += 1
|
||||
return {'session_id': sid, 'program': s.program, 'running': s.proc.poll() is None,
|
||||
'sequence': s.sequence, 'drained_bytes': n, 'screen': s.screen.snapshot()}
|
||||
|
||||
def status(self):
|
||||
with self._lock:
|
||||
self.reap()
|
||||
return {'active_sessions': len(self._sessions), 'max_sessions': self.max_sessions,
|
||||
'owner_device_connected': self._gate is not None,
|
||||
'input_enabled': False if self._gate is None else 'per-call-only'}
|
||||
|
||||
def _authorize(self, s, operation, payload: bytes, approved_context=None):
|
||||
if self._gate is None:
|
||||
raise TerminalDenied('OWNER_DEVICE_NOT_CONNECTED')
|
||||
if approved_context is None:
|
||||
raise TerminalDenied('OWNER_APPROVAL_REQUIRED')
|
||||
context = {'session_id': s.session_id, 'program': s.program, 'operation': operation,
|
||||
'payload_sha256': hashlib.sha256(payload).hexdigest(),
|
||||
'owner_assertion': approved_context}
|
||||
if self._gate(context) is not True:
|
||||
raise TerminalDenied('OWNER_APPROVAL_INVALID_OR_REPLAYED')
|
||||
|
||||
def key(self, sid, key, approved_context=None):
|
||||
if key not in KEYS:
|
||||
raise TerminalDenied('KEY_NOT_ALLOWLISTED')
|
||||
with self._lock:
|
||||
s = self._get(sid)
|
||||
data = KEYS[key]
|
||||
self._authorize(s, 'key.send', data, approved_context)
|
||||
if s.proc.poll() is not None: raise TerminalDenied('SESSION_CLOSED')
|
||||
os.write(s.fd, data)
|
||||
return {'session_id': sid, 'submitted': key, 'state': 'SENT'}
|
||||
|
||||
def text(self, sid, text, approved_context=None):
|
||||
if not isinstance(text, str) or not 1 <= len(text) <= 64 or any(ord(c)<32 or ord(c)==127 for c in text):
|
||||
raise TerminalDenied('TEXT_NOT_ALLOWED')
|
||||
with self._lock:
|
||||
s = self._get(sid)
|
||||
data = text.encode('utf-8')
|
||||
self._authorize(s, 'text.type', data, approved_context)
|
||||
if s.proc.poll() is not None: raise TerminalDenied('SESSION_CLOSED')
|
||||
os.write(s.fd, data)
|
||||
return {'session_id': sid, 'submitted_chars': len(text), 'state': 'SENT'}
|
||||
|
||||
def resize(self, sid, rows, cols):
|
||||
with self._lock:
|
||||
s = self._get(sid)
|
||||
s.screen.resize(rows, cols)
|
||||
fcntl.ioctl(s.fd, termios.TIOCSWINSZ, struct.pack('HHHH', rows, cols, 0, 0))
|
||||
return {'session_id': sid, 'rows': rows, 'cols': cols}
|
||||
|
||||
def abort(self, sid):
|
||||
# Emergency stop always works, but only against its own demo session.
|
||||
with self._lock:
|
||||
s = self._get(sid)
|
||||
self._terminate(s)
|
||||
del self._sessions[sid]
|
||||
return {'session_id': sid, 'state': 'ABORTED'}
|
||||
|
||||
def reap(self):
|
||||
for sid, s in list(self._sessions.items()):
|
||||
if time.monotonic() - s.last_access > self.idle_timeout_s:
|
||||
self._terminate(s)
|
||||
self._sessions.pop(sid, None)
|
||||
|
||||
def shutdown(self):
|
||||
with self._lock:
|
||||
for s in self._sessions.values(): self._terminate(s)
|
||||
self._sessions.clear()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""No-side-effects simulated interactive menu. Not a shell."""
|
||||
import os
|
||||
import sys
|
||||
import tty
|
||||
|
||||
OPTIONS = ('Estado del sistema (demo)', 'Almacenamiento (demo)', 'Red (demo)', 'Salir')
|
||||
|
||||
|
||||
def show(i):
|
||||
sys.stdout.write('\x1b[2J\x1b[H')
|
||||
sys.stdout.write('FrameBridge CUA-TTY - DEMOSTRACION AISLADA\r\n')
|
||||
sys.stdout.write('NO ejecuta comandos; NO modifica el servidor.\r\n\r\n')
|
||||
for n, label in enumerate(OPTIONS):
|
||||
sys.stdout.write(('> ' if n == i else ' ') + label + '\r\n')
|
||||
sys.stdout.write('\r\nUse flechas arriba/abajo, Enter y q.\r\n')
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main():
|
||||
tty.setcbreak(sys.stdin.fileno())
|
||||
i = 0
|
||||
show(i)
|
||||
while True:
|
||||
b = os.read(0, 1)
|
||||
if not b: break
|
||||
if b in (b'q', b'Q', b'\x03'): break
|
||||
if b == b'\x1b':
|
||||
seq = os.read(0, 2)
|
||||
if seq == b'[A': i = (i - 1) % len(OPTIONS)
|
||||
elif seq == b'[B': i = (i + 1) % len(OPTIONS)
|
||||
elif b in (b'j', b'J'): i = (i + 1) % len(OPTIONS)
|
||||
elif b in (b'k', b'K'): i = (i - 1) % len(OPTIONS)
|
||||
elif b in (b'\r', b'\n'):
|
||||
if i == len(OPTIONS) - 1: break
|
||||
sys.stdout.write('\r\nSeleccion simulada: ' + OPTIONS[i] + '\r\n')
|
||||
sys.stdout.flush()
|
||||
show(i)
|
||||
sys.stdout.write('\r\nDEMOSTRACION_FINALIZADA\r\n')
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Small bounded VT-like ASCII/UTF-8 text screen for staging.
|
||||
|
||||
This is deliberately NOT advertised as a full xterm emulator. It handles common
|
||||
CSI cursor/erase controls and suppresses OSC/DCS/other escape payloads.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import codecs
|
||||
import unicodedata
|
||||
|
||||
class Screen:
|
||||
def __init__(self, rows: int = 24, cols: int = 80):
|
||||
if not 8 <= rows <= 80 or not 20 <= cols <= 200:
|
||||
raise ValueError('SCREEN_DIMENSIONS_INVALID')
|
||||
self.rows, self.cols = rows, cols
|
||||
self.buf = [[' '] * cols for _ in range(rows)]
|
||||
self.y = self.x = 0
|
||||
self.mode = 'normal'
|
||||
self.control = ''
|
||||
self.decoder = codecs.getincrementaldecoder('utf-8')('replace')
|
||||
self.max_control = 96
|
||||
|
||||
def reset(self):
|
||||
self.buf = [[' '] * self.cols for _ in range(self.rows)]
|
||||
self.y = self.x = 0
|
||||
|
||||
def resize(self, rows: int, cols: int):
|
||||
if not 8 <= rows <= 80 or not 20 <= cols <= 200:
|
||||
raise ValueError('SCREEN_DIMENSIONS_INVALID')
|
||||
new = [[' '] * cols for _ in range(rows)]
|
||||
for y in range(min(rows, self.rows)):
|
||||
for x in range(min(cols, self.cols)):
|
||||
new[y][x] = self.buf[y][x]
|
||||
self.rows, self.cols, self.buf = rows, cols, new
|
||||
self.y = min(self.y, rows - 1)
|
||||
self.x = min(self.x, cols - 1)
|
||||
|
||||
def scroll(self):
|
||||
self.buf.pop(0)
|
||||
self.buf.append([' '] * self.cols)
|
||||
self.y = self.rows - 1
|
||||
|
||||
def newline(self):
|
||||
self.y += 1
|
||||
if self.y >= self.rows:
|
||||
self.scroll()
|
||||
|
||||
def put(self, c: str):
|
||||
if unicodedata.combining(c):
|
||||
return
|
||||
# Avoid unmodeled full-width terminal-cell ambiguity in this R1 pilot.
|
||||
if unicodedata.east_asian_width(c) in ('W', 'F'):
|
||||
c = '?'
|
||||
if self.x >= self.cols:
|
||||
self.x = 0
|
||||
self.newline()
|
||||
self.buf[self.y][self.x] = c
|
||||
self.x += 1
|
||||
|
||||
def csi(self, final: str, args: str):
|
||||
if len(args) > self.max_control:
|
||||
return
|
||||
if args.startswith('?'):
|
||||
if final in ('h', 'l') and args in ('?1049', '?1047', '?47') and final == 'h':
|
||||
self.reset()
|
||||
return
|
||||
nums = [int(a) if a.isdecimal() else 0 for a in args.split(';')[:8]]
|
||||
n = nums[0] if nums else 0
|
||||
count = max(1, min(n, 200))
|
||||
if final == 'A': self.y = max(0, self.y - count)
|
||||
elif final == 'B': self.y = min(self.rows - 1, self.y + count)
|
||||
elif final == 'C': self.x = min(self.cols - 1, self.x + count)
|
||||
elif final == 'D': self.x = max(0, self.x - count)
|
||||
elif final in ('H', 'f'):
|
||||
self.y = max(0, min(self.rows - 1, (nums[0] if nums and nums[0] else 1) - 1))
|
||||
self.x = max(0, min(self.cols - 1, (nums[1] if len(nums) > 1 and nums[1] else 1) - 1))
|
||||
elif final == 'G': self.x = max(0, min(self.cols - 1, count - 1))
|
||||
elif final == 'd': self.y = max(0, min(self.rows - 1, count - 1))
|
||||
elif final == 'J':
|
||||
if n == 2: self.reset()
|
||||
elif n == 0:
|
||||
for y in range(self.y, self.rows):
|
||||
start = self.x if y == self.y else 0
|
||||
for x in range(start, self.cols): self.buf[y][x] = ' '
|
||||
elif final == 'K':
|
||||
start, end = (0, self.cols) if n == 2 else ((0, self.x + 1) if n == 1 else (self.x, self.cols))
|
||||
for x in range(start, min(end, self.cols)): self.buf[self.y][x] = ' '
|
||||
# SGR and unrecognized commands are intentionally ignored.
|
||||
|
||||
def feed(self, data: bytes):
|
||||
for c in self.decoder.decode(data):
|
||||
if self.mode == 'normal':
|
||||
if c == '\x1b': self.mode = 'esc'
|
||||
elif c == '\r': self.x = 0
|
||||
elif c == '\n': self.newline()
|
||||
elif c == '\b': self.x = max(0, self.x - 1)
|
||||
elif c == '\t': self.x = min(self.cols - 1, (self.x // 8 + 1) * 8)
|
||||
elif c.isprintable() and c not in '\x7f': self.put(c)
|
||||
elif self.mode == 'esc':
|
||||
if c == '[': self.mode, self.control = 'csi', ''
|
||||
elif c == ']': self.mode, self.control = 'osc', ''
|
||||
elif c in ('P', '^', '_'): self.mode, self.control = 'str', ''
|
||||
elif c == 'c': self.reset(); self.mode = 'normal'
|
||||
else: self.mode = 'normal'
|
||||
elif self.mode == 'csi':
|
||||
if '@' <= c <= '~':
|
||||
self.csi(c, self.control); self.mode = 'normal'; self.control = ''
|
||||
elif len(self.control) >= self.max_control: self.mode, self.control = 'discard_csi', ''
|
||||
else: self.control += c
|
||||
elif self.mode == 'discard_csi':
|
||||
if '@' <= c <= '~': self.mode = 'normal'
|
||||
elif self.mode in ('osc', 'str'):
|
||||
if c == '\x07' and self.mode == 'osc': self.mode = 'normal'
|
||||
elif c == '\x1b': self.mode = 'string_esc'
|
||||
elif self.mode == 'string_esc':
|
||||
if c == '\\': self.mode = 'normal'
|
||||
elif c == '\x1b': self.mode = 'string_esc'
|
||||
else: self.mode = 'str'
|
||||
|
||||
def snapshot(self):
|
||||
return {'rows': self.rows, 'cols': self.cols, 'cursor': {'row': self.y, 'col': self.x},
|
||||
'lines': [''.join(row).rstrip() for row in self.buf]}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Local-only JSON-lines Unix socket prototype. No TCP listener, no shell."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import socketserver
|
||||
import stat
|
||||
import struct
|
||||
from .core import Manager, TerminalDenied
|
||||
|
||||
MAX_REQUEST = 65536
|
||||
|
||||
|
||||
class Handler(socketserver.StreamRequestHandler):
|
||||
def handle(self):
|
||||
# On Linux: SO_PEERCRED is the connecting process's kernel UID.
|
||||
try:
|
||||
_, uid, _ = struct.unpack('3i', self.request.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
|
||||
if uid != self.server.owner_uid:
|
||||
return
|
||||
raw = self.rfile.readline(MAX_REQUEST + 1)
|
||||
if len(raw) > MAX_REQUEST or not raw.endswith(b'\n'):
|
||||
self.send({'ok': False, 'error': 'REQUEST_TOO_LARGE_OR_TRUNCATED'})
|
||||
return
|
||||
req = json.loads(raw)
|
||||
if type(req) is not dict or type(req.get('op')) is not str:
|
||||
self.send({'ok': False, 'error': 'INVALID_REQUEST'})
|
||||
return
|
||||
# Only specific RPC shapes. The caller cannot supply binary argv,
|
||||
# host path, a shell, an approval bool, or an OwnerDevice verifier.
|
||||
op = req['op']
|
||||
# Enforce exact wire shape for every operation. Untrusted clients
|
||||
# may not attach arbitrary authority or additional arguments.
|
||||
permitted = {
|
||||
'status': ({'op'},),
|
||||
'open': ({'op'}, {'op','program'}),
|
||||
'observe': ({'op','session_id'},),
|
||||
'resize': ({'op','session_id','rows','cols'},),
|
||||
'abort': ({'op','session_id'},),
|
||||
'key.send': ({'op','session_id','key','owner_assertion'},),
|
||||
'text.type': ({'op','session_id','text','owner_assertion'},),
|
||||
}
|
||||
accepted = permitted.get(op)
|
||||
if accepted is None:
|
||||
raise TerminalDenied('UNKNOWN_OPERATION')
|
||||
if set(req) not in accepted:
|
||||
raise TerminalDenied('REQUEST_INVALID')
|
||||
m = self.server.manager
|
||||
if op == 'status': data = m.status()
|
||||
elif op == 'open': data = m.open(req.get('program', 'demo-readonly'))
|
||||
elif op == 'observe': data = m.observe(req['session_id'])
|
||||
elif op == 'resize': data = m.resize(req['session_id'], req['rows'], req['cols'])
|
||||
elif op == 'abort': data = m.abort(req['session_id'])
|
||||
elif op in ('key.send', 'text.type'):
|
||||
# Server default remains Manager(owner_verifier=None) -> DENY.
|
||||
# A separately deployed trusted bootstrap must bind a real
|
||||
# WebAuthn/Owner verifier. Neither AUTHORIZED nor an RPC bool
|
||||
# can alter the verifier. Never accept arbitrary shell/argv.
|
||||
if m._gate is None:
|
||||
raise TerminalDenied('OWNER_DEVICE_NOT_CONNECTED')
|
||||
if type(req.get('owner_assertion')) is not dict:
|
||||
raise TerminalDenied('OWNER_APPROVAL_REQUIRED')
|
||||
required = ({'op', 'session_id', 'key', 'owner_assertion'}
|
||||
if op == 'key.send' else
|
||||
{'op', 'session_id', 'text', 'owner_assertion'})
|
||||
if set(req) != required:
|
||||
raise TerminalDenied('REQUEST_INVALID')
|
||||
if op == 'key.send':
|
||||
data = m.key(req['session_id'], req['key'],
|
||||
approved_context=req['owner_assertion'])
|
||||
else:
|
||||
data = m.text(req['session_id'], req['text'],
|
||||
approved_context=req['owner_assertion'])
|
||||
else: raise TerminalDenied('UNKNOWN_OPERATION')
|
||||
self.send({'ok': True, 'result': data})
|
||||
except (ValueError, KeyError, TypeError, json.JSONDecodeError):
|
||||
self.send({'ok': False, 'error': 'REQUEST_INVALID'})
|
||||
except TerminalDenied as exc:
|
||||
self.send({'ok': False, 'error': str(exc)})
|
||||
except Exception:
|
||||
# Never echo arbitrary error content, terminal output or secrets.
|
||||
self.send({'ok': False, 'error': 'INTERNAL_ERROR'})
|
||||
|
||||
def send(self, data):
|
||||
self.wfile.write((json.dumps(data, ensure_ascii=True, separators=(',', ':')) + '\n').encode())
|
||||
|
||||
|
||||
class LocalServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = False
|
||||
def __init__(self, path, manager=None):
|
||||
if os.geteuid() == 0:
|
||||
raise TerminalDenied('ROOT_SERVER_FORBIDDEN')
|
||||
p = Path(path).absolute()
|
||||
p.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
# Do not operate in a shared or symlinked parent directory.
|
||||
if p.parent.is_symlink() or stat.S_IMODE(p.parent.stat().st_mode) != 0o700:
|
||||
raise TerminalDenied('SOCKET_PARENT_NOT_PRIVATE')
|
||||
if p.exists() or p.is_symlink():
|
||||
raise TerminalDenied('SOCKET_ALREADY_EXISTS')
|
||||
self.owner_uid = os.geteuid()
|
||||
self.manager = manager or Manager()
|
||||
self._socket_path = p
|
||||
old = os.umask(0o077)
|
||||
try: super().__init__(str(p), Handler)
|
||||
finally: os.umask(old)
|
||||
os.chmod(p, 0o600)
|
||||
|
||||
def server_close(self):
|
||||
self.manager.shutdown()
|
||||
super().server_close()
|
||||
try: self._socket_path.unlink(missing_ok=True)
|
||||
except OSError: pass
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description='FrameBridge CUA-TTY staging demo, local socket only')
|
||||
parser.add_argument('--socket', default=str(Path.home()/'.local/share/framebridge-cua-tty-r1/agent.sock'))
|
||||
args = parser.parse_args(argv)
|
||||
with LocalServer(args.socket) as server:
|
||||
print('CUA_TTY_R1_LOCAL_ONLY_SOCKET_READY', flush=True)
|
||||
try: server.serve_forever(poll_interval=0.2)
|
||||
except KeyboardInterrupt: pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user