#!/usr/bin/env python3 """Opt-in bounded private XTest keyboard and optional relative-look experiment. Not a launcher or gameplay proof.""" import argparse import ctypes as C import hashlib import json import multiprocessing import math import os from pathlib import Path import re import signal import stat import subprocess import sys import time import headless_display_probe as display NOTIFY = None def notify(value): if NOTIFY is not None: NOTIFY.send(value) class Refused(ValueError): pass class KeyStateBusy(Refused): pass def option(args, name): if args.count(name) != 1: raise Refused('Missing or repeated native option: ' + name) index = args.index(name) if index + 1 >= len(args): raise Refused('Missing native option value') return args[index + 1] def game_arguments(owner, args): native = display.process(owner['pid']) if display.identity(native) != owner['process']: raise Refused('Game process changed') if option(native['args'], '-errOrdinaryInputObserve') != args.nonce: raise Refused('Native observer nonce mismatch') if getattr(args, 'enable_private_look_proof', False) and option(native['args'], '-errOrdinaryLookObserve') != args.nonce: raise Refused('Native look observer nonce mismatch') if option(native['args'], '-errOrdinaryScenario') not in ('core', 'chat'): raise Refused('Not an ordinary successor scenario') if option(native['args'], '-profile') != 'Z:' + str(args.profile): raise Refused('Native profile mismatch') NUMBER = r'[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?' VECTOR = r'<\s*(' + NUMBER + r')\s*,\s*(' + NUMBER + r')\s*,\s*(' + NUMBER + r')\s*>' LOOK_HEADER = re.compile(r'ERR LOOK OBSERVE: run=([A-Za-z0-9]{1,32}) sample=(\d+) real=(\d+) (.+)$') LOOK_SAMPLE = re.compile('SAMPLE direction=' + VECTOR + ' camera=' + VECTOR + ' body=' + VECTOR + ' bodyForward=' + VECTOR + ' turn=' + VECTOR) INPUT_SAMPLE = re.compile('SAMPLE origin=' + VECTOR + ' forward=(' + NUMBER + ') input=' + VECTOR + ' velocity=' + VECTOR + r' inventory=(0|1|true|false)') def parse_look_record(line, nonce): """Strict live handshake parser; final qualification independently reparses logs.""" if 'ERR LOOK OBSERVE:' not in line: return None match = LOOK_HEADER.search(line) if not match or match[1] != nonce or len(line) > 2048: raise Refused('Malformed or foreign look observation') sample, real, detail = int(match[2]), int(match[3]), match[4] if not 0 <= sample <= 450 or not 0 <= real <= 2147483647: raise Refused('Look observation bounds invalid') record = {'sample': sample, 'real_ms': real, 'raw': line} if detail == 'BASIS native-player-camera-world-transform': return {**record, 'kind': 'basis'} if re.fullmatch(r'CANCELLED [a-z-]+', detail): return {**record, 'kind': 'cancelled', 'reason': detail.split(' ', 1)[1]} values = LOOK_SAMPLE.fullmatch(detail) if not values: raise Refused('Malformed native look sample') numbers = [float(value) for value in values.groups()] if not all(math.isfinite(value) for value in numbers): raise Refused('Nonfinite native look sample') result = {**record, 'kind': 'sample'} for index, name in enumerate(('direction', 'camera', 'body', 'bodyForward', 'turn')): result[name] = numbers[index*3:index*3+3] for name in ('direction', 'bodyForward'): length = math.sqrt(sum(value*value for value in result[name])) if not .95 <= length <= 1.05 or math.hypot(result[name][0], result[name][2]) <= .1: raise Refused('Native look direction is degenerate or not normalized') return result def parse_stationary_sample(sample, real, detail): match = INPUT_SAMPLE.fullmatch(detail) if not match: raise Refused('Malformed input sample during look observation') values = [float(value) for value in match.groups()[:-1]] if not all(math.isfinite(value) for value in values): raise Refused('Nonfinite input sample during look observation') return {'sample': sample, 'real_ms': real, 'origin': values[:3], 'forward': values[3], 'input': values[4:7], 'velocity': values[7:10], 'inventory': match.groups()[-1] in ('1', 'true')} def yaw(direction): return math.degrees(math.atan2(direction[0], direction[2])) def yaw_delta(first, second): return (yaw(second) - yaw(first) + 180) % 360 - 180 class RelativeMouse: def __init__(self, query, keyboard, owner): self.query, self.keyboard, self.owner = query, keyboard, owner self.xt = keyboard.xt self.xt.XTestFakeRelativeMotionEvent.restype = C.c_int self.xt.XTestFakeRelativeMotionEvent.argtypes = [C.c_void_p, C.c_int, C.c_int, C.c_ulong] query.x.XQueryPointer.restype = C.c_int query.x.XQueryPointer.argtypes = [C.c_void_p, C.c_ulong, C.POINTER(C.c_ulong), C.POINTER(C.c_ulong), C.POINTER(C.c_int), C.POINTER(C.c_int), C.POINTER(C.c_int), C.POINTER(C.c_int), C.POINTER(C.c_uint)] def pointer(self): root, child = C.c_ulong(), C.c_ulong() rx, ry, wx, wy = C.c_int(), C.c_int(), C.c_int(), C.c_int() mask = C.c_uint() if not self.query.x.XQueryPointer(self.query.d, self.owner['window'], C.byref(root), C.byref(child), C.byref(rx), C.byref(ry), C.byref(wx), C.byref(wy), C.byref(mask)): raise Refused('Private pointer is unavailable or on another screen') return {'root': root.value, 'child': child.value, 'root_x': rx.value, 'root_y': ry.value, 'mask': mask.value} def validate_pointer(self, target, dx, dy): position = self.pointer() geometry = target['geometry']; left, top, right, bottom = target['focused_clipped_bounds'] if position['root'] != geometry['root'] or position['mask'] != 0: raise Refused('Private pointer root or button/modifier state invalid') for x, y in ((position['root_x'], position['root_y']), (position['root_x']+dx, position['root_y']+dy)): if not left <= x < right or not top <= y < bottom: raise Refused('Relative pointer step leaves owned clipped bounds') child = position['child'] if child and self.query.server_pid(child) != self.owner['pid']: raise Refused('Pointer child belongs to a foreign native client') def move(self, dx, dy, guard): if type(dx) is not int or dx not in (-24, 24) or dy != 0 or type(dy) is not int: raise Refused('Unsupported relative look step') target = guard.check() self.keyboard.require_idle() self.validate_pointer(target, dx, dy) # Recheck process/focus after the pointer roundtrip, with no focus changes. target = guard.check() self.validate_pointer(target, dx, dy) if not self.xt.XTestFakeRelativeMotionEvent(self.query.d, dx, dy, 0): raise Refused('Native relative motion request failed') self.query.x.XFlush(self.query.d) def perform_look(guard, keyboard, observer, mouse, report, clock=time.monotonic, sleep=time.sleep): proof = {'requested': True, 'completed': False, 'started_monotonic': clock(), 'finished_monotonic': None, 'baseline_samples': [], 'response_sample': None, 'return_sample': None, 'motions': []} report['look_proof'] = proof deadline = proof['started_monotonic'] + 4 notify({'kind': 'look-pending'}) ready_sample = observer.ready_sample baseline = None def current(): if clock() >= deadline: raise Refused('Native look proof exceeded four seconds') observer.fresh(); guard.check(); keyboard.require_idle() if observer.events != ['READY'] or any(r['kind'] == 'cancelled' for r in observer.look_records): raise Refused('Native look context ended or inventory changed') if sum(r['kind'] == 'basis' for r in observer.look_records) != 1: return [] poses = [r for r in observer.look_records if r['kind'] == 'sample' and r['sample'] >= ready_sample] inputs = {r['sample']: r for r in observer.input_samples} joined = [] for pose in poses: sample = inputs.get(pose['sample']) if sample is None: continue if sample['real_ms'] != pose['real_ms'] or math.dist(sample['origin'], pose['body']) > .01: raise Refused('Native look/input body or clock mismatch') ready_input = inputs.get(ready_sample) if ready_input is None: raise Refused('Missing native READY body sample') origin = ready_input['origin'] if (sample['inventory'] or abs(sample['forward']) > .01 or math.sqrt(sum(x*x for x in sample['input'])) > .01 or math.sqrt(sum(x*x for x in sample['velocity'])) > .05 or math.dist(pose['body'], origin) > .05): raise Refused('Player moved before look proof completed') joined.append(pose) for previous, following in zip(joined, joined[1:]): if following['sample'] != previous['sample'] + 1 or not 0 < following['real_ms'] - previous['real_ms'] <= 500: raise Refused('Native look sampling gap or repeated clock') if joined and observer.input_samples[-1]['real_ms'] - joined[-1]['real_ms'] > 500: raise Refused('Native look pose is stale relative to input') return joined def wait(predicate): while clock() < deadline: poses = current() if poses and predicate(poses): return poses[-1] sleep(.025) raise Refused('Native view rotation acknowledgement missing') def motion(dx, after): poses = current() if not poses: raise Refused('Native look sample unavailable before motion') watermark = poses[-1]['sample'] report['input_attempted'] = True mouse.move(dx, 0, guard) proof['motions'].append({'dx': dx, 'dy': 0, 'monotonic': clock(), 'after_sample': after['sample'], 'after_real_ms': after['real_ms']}) report['input_sent'] = True return watermark try: def stable(poses): return len(poses) >= 2 and poses[-1]['sample'] == poses[-2]['sample']+1 and abs(yaw_delta(poses[-2]['direction'], poses[-1]['direction'])) <= .1 wait(stable) poses = current() if not stable(poses): raise Refused('Initial native view no longer stable') baseline = poses[-1] proof['baseline_samples'] = [poses[-2]['sample'], baseline['sample']] outbound_after = motion(24, baseline) response = wait(lambda p: p[-1]['sample'] > outbound_after and .25 <= abs(yaw_delta(baseline['direction'], p[-1]['direction'])) <= 30) proof['response_sample'] = response['sample'] displacement = abs(yaw_delta(baseline['direction'], response['direction'])) return_after = motion(-24, response) returned = wait(lambda p: p[-1]['sample'] > return_after and abs(yaw_delta(baseline['direction'], p[-1]['direction'])) <= .35 and abs(yaw_delta(baseline['direction'], p[-1]['direction'])) < displacement/2) proof['return_sample'] = returned['sample'] current() if clock() >= deadline: raise Refused('Native look proof exceeded four seconds') proof['completed'] = True finally: proof['finished_monotonic'] = clock() notify({'kind': 'look-finished'}) class Observer: """Only records appended after this exact owned console was opened count.""" pattern = re.compile(r'ERR INPUT OBSERVE: run=([A-Za-z0-9]{1,32}) sample=(\d+) real=(\d+) world=(\d+(?:\.\d+)?) (.+)$') def __init__(self, path, nonce, clock=time.monotonic, enable_look=False): self.path, self.nonce, self.clock = path, nonce, clock self.fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) info = os.fstat(self.fd) if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid(): os.close(self.fd) raise Refused('Console is not a regular same-user file') self.identity = info.st_dev, info.st_ino self.start_offset = os.lseek(self.fd, 0, os.SEEK_END) self.offset = self.start_offset self.pending = b'' self.sample = self.real = self.world = -1 self.last_sample_at = None self.events = [] self.raw = [] self.bytes = 0 self.terminal = False self.enable_look = enable_look self.look_records = []; self.input_samples = []; self.ready_sample = None def close(self): os.close(self.fd) def poll(self): info = self.path.lstat() if (info.st_dev, info.st_ino) != self.identity or not stat.S_ISREG(info.st_mode) or info.st_size < self.offset: raise Refused('Console replaced or truncated') data = os.read(self.fd, 65536) self.offset += len(data); self.bytes += len(data) if self.bytes > 8 * 1024 * 1024: raise Refused('Console observation byte budget exceeded') self.pending += data if len(self.pending) > 131072: raise Refused('Console line budget exceeded') lines = self.pending.split(b'\n'); self.pending = lines.pop() for raw in lines: line = raw.decode('utf-8', 'strict').rstrip('\r') if self.enable_look and 'ERR LOOK OBSERVE:' in line: record = parse_look_record(line, self.nonce) if len(self.look_records) >= 453: raise Refused('Native look record bound exceeded') if self.look_records and (record['sample'] < self.look_records[-1]['sample'] or record['real_ms'] < self.look_records[-1]['real_ms']): raise Refused('Native look records reversed') if any(r['kind'] == 'cancelled' for r in self.look_records): raise Refused('Native look records after cancellation') if record['kind'] == 'basis' and (self.look_records or record['sample'] != 1): raise Refused('Repeated or misplaced native look basis') if record['kind'] == 'sample' and not self.look_records: raise Refused('Missing native look basis') if record['kind'] == 'sample' and any(r['kind'] == 'sample' and r['sample'] == record['sample'] for r in self.look_records): raise Refused('Duplicate native look sample') self.look_records.append(record) continue if 'ERR INPUT OBSERVE: run=' + self.nonce + ' ' not in line: continue match = self.pattern.search(line) if not match or match[1] != self.nonce: raise Refused('Malformed matching observer record') sample, real, world = int(match[2]), int(match[3]), float(match[4]) if not math.isfinite(world) or real > 2147483647: raise Refused('Invalid native observer clock') if sample < self.sample or real < self.real or world < self.world: raise Refused('Native observer clock/sample reversed') if sample > 450 or len(self.raw) >= 512: raise Refused('Native observer record budget exceeded') detail = match[5] if detail.startswith('SAMPLE '): if sample <= self.sample: raise Refused('Repeated native sample') self.last_sample_at = self.clock() if self.enable_look: self.input_samples.append(parse_stationary_sample(sample, real, detail)) self.sample, self.real, self.world = sample, real, world self.raw.append(line) if detail == 'READY': if self.events or self.last_sample_at is None: raise Refused('Repeated or unbound READY') self.events.append('READY') self.ready_sample = sample elif detail in ('INVENTORY_OPEN', 'INVENTORY_CLOSED'): expected = ['READY'] if detail == 'INVENTORY_OPEN' else ['READY', 'INVENTORY_OPEN'] if self.events != expected: raise Refused('Unexpected native inventory transition') self.events.append(detail) elif detail.startswith(('CANCELLED ', 'FINISHED ')): self.terminal = True if self.terminal: raise Refused('Native observer ended') def fresh(self): self.poll() if self.last_sample_at is None or self.clock() - self.last_sample_at > 1: raise Refused('Native observer samples are stale') class Keyboard: """Only W/Tab key events on the pre-existing explicit owned connection.""" def __init__(self, query): self.query = query self.xt = C.CDLL('libXtst.so.6') self.xt.XTestQueryExtension.restype = C.c_int self.xt.XTestQueryExtension.argtypes = [C.c_void_p] + [C.POINTER(C.c_int)] * 4 self.xt.XTestFakeKeyEvent.restype = C.c_int self.xt.XTestFakeKeyEvent.argtypes = [C.c_void_p, C.c_uint, C.c_int, C.c_ulong] for name, restype, argtypes in ( ('XKeysymToKeycode', C.c_ubyte, [C.c_void_p, C.c_ulong]), ('XKeycodeToKeysym', C.c_ulong, [C.c_void_p, C.c_ubyte, C.c_int]), ('XQueryKeymap', C.c_int, [C.c_void_p, C.POINTER(C.c_char)]), ('XFlush', C.c_int, [C.c_void_p])): fn = getattr(query.x, name); fn.restype = restype; fn.argtypes = argtypes values = [C.c_int() for _ in range(4)] if not self.xt.XTestQueryExtension(query.d, *(C.byref(v) for v in values)): raise Refused('Private XTest extension unavailable') self.codes = {} for key, keysym in (('W', 0x77), ('Tab', 0xff09)): code = int(query.x.XKeysymToKeycode(query.d, keysym)) if not 8 <= code <= 255 or query.x.XKeycodeToKeysym(query.d, code, 0) != keysym: raise Refused('Default unmodified key mapping unavailable') self.codes[key] = code if len(set(self.codes.values())) != 2: raise Refused('Ambiguous key mapping') def require_idle(self): state = C.create_string_buffer(32) if not self.query.x.XQueryKeymap(self.query.d, state): raise Refused('Native private keyboard state query failed') if any(state.raw): raise KeyStateBusy('A private keyboard key is still held') def send(self, key, down): if key not in self.codes or type(down) is not bool: raise Refused('Unsupported input event') if not self.xt.XTestFakeKeyEvent(self.query.d, self.codes[key], int(down), 0): raise Refused('Native XTest key request failed') self.query.x.XFlush(self.query.d) class Guard: def __init__(self, args, bound, query, owner): self.args, self.bound, self.query, self.owner = args, bound, query, owner def check(self): if display.binding(self.args.host_display, self.args.prefix) != self.bound: raise Refused('Original display binding changed') # A new per-check read budget, still bounded by the experiment deadline. self.query.query_budget = 128 current = {'window': self.owner['window'], 'pid': self.query.prop(self.owner['window'], '_NET_WM_PID'), 'server_pid': self.query.server_pid(self.owner['window'])} verified = display.owned_window(current, self.bound, self.args.prefix, self.args.game_executable) if verified is None or verified['process'] != self.owner['process'] or verified['pid'] != self.owner['pid']: raise Refused('Original game window or process changed') game_arguments(verified, self.args) target = display.input_target(self.query, verified) if target.get('verified') is not True: raise Refused('Owned existing focus/mapping unavailable: ' + target.get('reason', 'unknown')) if display.binding(self.args.host_display, self.args.prefix) != self.bound: raise Refused('Display binding changed during target check') return target def confirm_release(guard, keyboard, report, key, clock=time.monotonic, sleep=time.sleep): """Bounded read-only state polling; never sends/retries an input event.""" start = clock(); deadline = start + .35 observation = {'key': key, 'start': start, 'attempts': 0, 'confirmed': False} report.setdefault('release_observations', []).append(observation) try: while True: guard.check() if clock() >= deadline: raise Refused('Private key release state did not settle within 350ms') observation['attempts'] += 1 try: keyboard.require_idle() if clock() >= deadline: raise Refused('Private key release state query exceeded 350ms bound') observation['confirmed'] = True return except KeyStateBusy as error: observation['last_busy_error'] = str(error) remaining = deadline - clock() if remaining <= 0: raise Refused('Private key release state did not settle within 350ms') from error sleep(min(.025, remaining)) finally: observation['end'] = clock() def sequence(guard, keyboard, observer, report, clock=time.monotonic, sleep=time.sleep, mouse=None): deadline = clock() + 70 held = None report.update(events=[], paired_release_errors=[], sequence_completed=False) def check(native=True): if clock() >= deadline: raise Refused('Input sequence deadline exceeded') if native: observer.fresh() return guard.check() def wait_for(event, seconds): end = min(deadline, clock() + seconds) while clock() < end: observer.poll() guard.check() if event in observer.events: observer.fresh() return sleep(0.05) raise Refused('Native transition timeout: ' + event) def key(key, down): nonlocal held check() if down: keyboard.require_idle() # Mark uncertain commit before the FFI call, so an exception also releases. held = key notify({'kind': 'press-pending', 'key': key}) report['input_attempted'] = True keyboard.send(key, down) report['events'].append({'key': key, 'down': down, 'monotonic': clock()}) report['input_sent'] = True if not down: confirm_release(guard, keyboard, report, key, clock, sleep) held = None notify({'kind': 'release-confirmed', 'key': key}) def pulse(key_name, duration): key(key_name, True) end = clock() + duration while clock() < end: sleep(min(0.05, end - clock())) # The key-up path performs a full fresh guard immediately before # sending. Avoid duplicating that guard once the hold has elapsed. if clock() < end: check() key(key_name, False) try: wait_for('READY', 45) deadline = clock() + 12 if observer.events != ['READY']: raise Refused('Inventory was already changed before input') if mouse is not None: perform_look(guard, keyboard, observer, mouse, report, clock, sleep) deadline = clock() + 12 pulse('W', 0.30) check() pulse('Tab', 0.05) wait_for('INVENTORY_OPEN', 4) open_until = clock() + 3.0 while clock() < open_until: sleep(min(.05, open_until - clock())) check() if observer.events != ['READY', 'INVENTORY_OPEN']: raise Refused('Inventory closed before the observed open interval') pulse('Tab', 0.05) wait_for('INVENTORY_CLOSED', 4) check() keyboard.require_idle() report['sequence_completed'] = True finally: if held is not None: try: # Release needs live display/window/focus authority, but not a live # observer; cancellation must not suppress an otherwise safe key-up. guard.check() keyboard.send(held, False) confirm_release(guard, keyboard, report, held, clock, sleep) notify({'kind': 'release-confirmed', 'key': held}) report['events'].append({'key': held, 'down': False, 'finally': True, 'monotonic': clock()}) except (OSError, ValueError) as error: report['paired_release_errors'].append(str(error)) report['unconfirmed_held_key'] = held if report['paired_release_errors'] else None report['all_attempted_presses_released'] = not report.get('unconfirmed_held_key') def arguments(argv=None): p = argparse.ArgumentParser(description=__doc__) p.add_argument('--enable-private-keyboard-proof', action='store_true', required=True) p.add_argument('--enable-private-look-proof', action='store_true') for name in ('prefix', 'game-executable', 'profile', 'report'): p.add_argument('--' + name, type=Path, required=True) p.add_argument('--console', type=Path, help='Existing exact session; omit only in wrapper mode') p.add_argument('--host-display', required=True) p.add_argument('--nonce', required=True) p.add_argument('command', nargs=argparse.REMAINDER) return p.parse_args(argv) def wrapper_command(args): command = args.command[1:] if args.command[:1] == ['--'] else args.command if not command: if args.console is None: raise Refused('Sidecar requires an exact existing console') return [] if args.console is not None: raise Refused('Wrapper discovers a new session; do not supply console') if (len(command) < 3 or Path(command[0]).resolve() != Path(sys.executable).resolve() or Path(command[1]) != Path(display.__file__).resolve()): raise Refused('Only the unchanged sibling read-only display wrapper is allowed') wrapped = display.parse_args(command[2:]) if (wrapped.query_worker or wrapped.prefix != args.prefix or wrapped.profile != args.profile or wrapped.game_executable != args.game_executable or wrapped.host_display != args.host_display or wrapped.report == args.report): raise Refused('Read-only wrapper context differs') display.validate_paths(wrapped) native = wrapped.command[1:] if wrapped.command[:1] == ['--'] else wrapped.command proton = Path.home() / '.local/share/Steam/steamapps/common/Proton - Experimental/proton' if len(native) < 3 or native[:3] != [str(proton), 'run', str(args.game_executable)]: raise Refused('Expected exact installed Proton/game command') if (option(native, '-errOrdinaryInputObserve') != args.nonce or option(native, '-profile') != 'Z:' + str(args.profile) or option(native, '-errOrdinaryScenario') not in ('core', 'chat')): raise Refused('Wrapped native observer context differs') if args.enable_private_look_proof and option(native, '-errOrdinaryLookObserve') != args.nonce: raise Refused('Wrapped native look observer context differs') return command def sessions(profile): folder = profile / 'logs' if not folder.exists(): return set() if folder.is_symlink() or not folder.is_dir(): raise Refused('Invalid private native logs directory') return {path for path in folder.iterdir() if re.fullmatch(r'logs_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}', path.name)} def console_path(args): path = args.console if (path != path.resolve() or path.name != 'console.log' or path.parent.parent != args.profile / 'logs' or not re.fullmatch(r'logs_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}', path.parent.name)): raise Refused('Expected exact canonical native session console') def main(argv=None): args = arguments(argv) args.observe_seconds = 25; args.query_worker = False display.validate_paths(args) if not re.fullmatch(r'[A-Za-z0-9]{1,32}', args.nonce): raise Refused('Invalid observer nonce') command = wrapper_command(args) if args.console is not None: console_path(args) bound = display.binding(args.host_display, args.prefix) report = {'schema': 1, 'kind': 'headless-private-keyboard-experiment', 'input_sent': False, 'look_requested': args.enable_private_look_proof, 'sequence_completed': False, 'release_qualified': False, 'gameplay_qualified': False, 'hard_supervision': NOTIFY is not None, 'nonce': args.nonce, 'binding': bound, 'command': command, 'report': str(args.report), 'input_attempted': False, 'helper_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), 'ownership_helper_sha256': hashlib.sha256(Path(display.__file__).read_bytes()).hexdigest(), 'prefix': str(args.prefix), 'profile': str(args.profile), 'game_executable': str(args.game_executable)} fd = os.open(args.report, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) info = os.fstat(fd) notify({'kind': 'report', 'path': str(args.report), 'device': info.st_dev, 'inode': info.st_ino, 'metadata': report}) query = observer = child = None before_sessions = sessions(args.profile) if command else set() deadline = time.monotonic() + 180 previous = {} def interrupted(signum, frame): if report.get('sequence_completed') and fd is None and child is not None: if child.poll() is None: child.send_signal(signum) return raise Refused('Input experiment interrupted by signal ' + str(signum)) try: for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): previous[signum] = signal.signal(signum, interrupted) if command: child = subprocess.Popen(command) # inherit the existing private process group notify({'kind': 'child', 'identity': display.identity(display.process(child.pid)), 'command': command}) while time.monotonic() < deadline: if child.poll() is not None: raise Refused('Read-only wrapper exited before native session') if display.binding(args.host_display, args.prefix) != bound: raise Refused('Display binding changed during startup') new = sessions(args.profile) - before_sessions if len(new) > 1: raise Refused('Ambiguous new native sessions') if new: candidate = next(iter(new)) / 'console.log' if candidate.exists(): args.console = candidate; console_path(args); break time.sleep(.1) if args.console is None: raise Refused('New native console deadline exceeded') report['console'] = str(args.console) observer = Observer(args.console, args.nonce, enable_look=args.enable_private_look_proof) query = display.X11(bound['display']) while time.monotonic() < deadline: if child is not None and child.poll() is not None: raise Refused('Read-only wrapper exited during startup') observer.poll() if display.binding(args.host_display, args.prefix) != bound: raise Refused('Display binding changed during window startup') query.query_budget = display.MAX_WINDOWS owners = [owner for window in query.windows() if (owner := display.owned_window(window, bound, args.prefix, args.game_executable))] eligible = [owner for owner in owners[:16] if display.input_target(query, owner).get('verified') is True] exact = [owner for owner in eligible if query.focus()[0] == owner['window']] selected = exact or eligible if len(selected) == 1: break if len(selected) > 1: raise Refused('Ambiguous mapped focused owned game windows') time.sleep(.1) else: raise Refused('Mapped owned game window deadline exceeded') owner = selected[0] report['owner'] = {key: owner[key] for key in ('window', 'pid', 'server_pid', 'process')} guard = Guard(args, bound, query, owner) guard.check() keyboard = Keyboard(query) keyboard.require_idle() mouse = RelativeMouse(query, keyboard, owner) if args.enable_private_look_proof else None sequence(guard, keyboard, observer, report, mouse=mouse) except (OSError, ValueError, UnicodeError, subprocess.SubprocessError) as error: report['error'] = str(error) finally: report['whole_process_group_cleanup_verified'] = False # outer runner owns it if observer: report['observer'] = {'inode': observer.identity, 'start_offset': observer.start_offset, 'end_offset': observer.offset, 'records': observer.raw, 'events': observer.events} if args.enable_private_look_proof: report['look_observation'] = observer.look_records observer.close() if query: query.close() with os.fdopen(fd, 'w') as stream: fd = None json.dump(report, stream, indent=2); stream.write('\n'); stream.flush(); os.fsync(stream.fileno()) notify({'kind': 'proof-finished', 'successful': report['sequence_completed'] and not report.get('error')}) child_code = 0 try: if child is not None: if report.get('error'): child.terminate() child_code = child.wait(timeout=max(.1, deadline - time.monotonic()) if not report.get('error') else 5) except subprocess.TimeoutExpired: child.terminate() try: child.wait(timeout=5) except subprocess.TimeoutExpired: child.kill(); child.wait(timeout=2) child_code = 1 finally: for signum, handler in previous.items(): signal.signal(signum, handler) return 0 if report['sequence_completed'] and not report.get('error') and child_code == 0 else 1 def worker_entry(argv, pipe): global NOTIFY NOTIFY = pipe try: code = main(argv) finally: pipe.close() raise SystemExit(code) def stop_exact_child(record, read=display.process, kill=os.kill): if record is None: return False try: current = read(record['identity']['pid']) if (display.identity(current) != record['identity'] or current['args'] != record['command'] or current['uid'] != os.getuid()): return False # Only the exact display wrapper created by our worker; no group signalling. kill(current['pid'], signal.SIGTERM) return True except (OSError, ValueError, KeyError): return False def supervision_failure(record, key, reason): """Amend only the exact exclusive report created by our now-stopped worker.""" if record is None: return fd = os.open(record['path'], os.O_RDWR | os.O_NOFOLLOW) try: info = os.fstat(fd) if (info.st_dev, info.st_ino) != (record['device'], record['inode']) or info.st_uid != os.getuid() or not stat.S_ISREG(info.st_mode): raise Refused('Worker report changed before timeout recording') raw = os.read(fd, 2 * 1024 * 1024) try: report = json.loads(raw) if raw else dict(record['metadata']) except (ValueError, UnicodeError): report = dict(record['metadata']) # Keep the worker's actual refusal; supervisor lifecycle is separate evidence. key = key or report.get('unconfirmed_held_key') report.setdefault('error', reason) report.update(supervision_failed=True, supervision_error=reason, sequence_completed=False, all_attempted_presses_released=key is None and report.get('all_attempted_presses_released', True), unconfirmed_held_key=key) if key is not None: report['input_attempted'] = True report.setdefault('paired_release_errors', []).append('Supervisor could not confirm paired key release') payload = (json.dumps(report, indent=2) + '\n').encode() os.lseek(fd, 0, os.SEEK_SET); os.write(fd, payload); os.ftruncate(fd, len(payload)); os.fsync(fd) finally: os.close(fd) def supervise(argv=None): # Fork before loading Xlib or opening any display. The worker owns the one # connection. Parent never opens a second connection or retries an event. context = multiprocessing.get_context('fork') parent, child_pipe = context.Pipe(duplex=False) child = context.Process(target=worker_entry, args=(argv, child_pipe)) previous = {}; received = [] def forward(signum, frame): received.append(signum) if child.pid and child.is_alive(): os.kill(child.pid, signum) for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): previous[signum] = signal.signal(signum, forward) look_at = None; held_at = None; held_key = None; owned_child = None; report_record = None; proof_finished = False; expired = False deadline = time.monotonic() + 190 try: child.start(); child_pipe.close() while child.is_alive(): if parent.poll(.05): try: event = parent.recv() except EOFError: event = None if event and event['kind'] == 'look-pending': look_at = time.monotonic() elif event and event['kind'] == 'look-finished': look_at = None elif event and event['kind'] == 'press-pending': held_at = time.monotonic(); held_key = event['key'] elif event and event['kind'] == 'release-confirmed': held_at = None; held_key = None elif event and event['kind'] == 'child': owned_child = event elif event and event['kind'] == 'report': report_record = event elif event and event['kind'] == 'proof-finished': proof_finished = event['successful'] now = time.monotonic() if received or now >= deadline or (held_at is not None and now - held_at > 1.5) or (look_at is not None and now - look_at > 4): expired = True child.terminate() # asks worker finally to release if still provable child.join(1) stop_exact_child(owned_child) if child.is_alive(): child.kill(); child.join(2) # Drain release acknowledgements emitted during cooperative finally. while parent.poll(): try: last = parent.recv() except EOFError: break if last['kind'] == 'release-confirmed': held_key = None elif last['kind'] == 'report': report_record = last elif last['kind'] == 'proof-finished': proof_finished = last['successful'] if not (received and proof_finished and held_key is None): supervision_failure(report_record, held_key, 'Private input worker timed out or was interrupted') break child.join(2) if child.exitcode != 0 and not expired: stop_exact_child(owned_child) if not (received and proof_finished and held_key is None): supervision_failure(report_record, held_key, 'Private input worker exited unsuccessfully') return 1 if expired or child.exitcode != 0 else 0 finally: if child.pid and child.is_alive(): child.terminate(); child.join(1) stop_exact_child(owned_child) if child.is_alive(): child.kill(); child.join(2) parent.close() for signum, handler in previous.items(): signal.signal(signum, handler) if __name__ == '__main__': raise SystemExit(supervise())