Improve base guidance and qualify bounded aircraft route and hidden test lifecycle

This commit is contained in:
2026-09-19 16:41:32 +02:00
parent 6f0e4c5978
commit 5a24eea2f5
192 changed files with 27066 additions and 46 deletions
+301 -9
View File
@@ -12,6 +12,7 @@ import re
import shutil
import signal
import subprocess
import sys
import time
import uuid
from native_provenance import snapshot, persist, bindings, seal_run
@@ -309,7 +310,7 @@ def prefix_process_candidate(proc):
def assert_prefix_idle(compat, proc_root=Path('/proc')):
target = ('WINEPREFIX='+str(compat/'pfx')).encode()
target = str(compat/'pfx').encode()
failures = []
for proc in proc_root.iterdir():
if not proc.name.isdigit() or int(proc.name) == os.getpid():
@@ -325,7 +326,9 @@ def assert_prefix_idle(compat, proc_root=Path('/proc')):
except (OSError, ValueError) as error:
failures.append(f'Cannot verify disposable prefix is idle (PID {proc.name}): {error}')
continue
if target in environment:
# Proton assigns pfx/; only trailing separators are equivalent. Do not
# resolve untrusted process paths or accept sibling/symlink spellings.
if any(value.startswith(b'WINEPREFIX=') and value[len(b'WINEPREFIX='):].rstrip(b'/') == target for value in environment):
failures.append(f'Disposable prefix already has a process (PID {proc.name}); leave it untouched')
if failures:
refuse('\n'.join(failures))
@@ -447,6 +450,42 @@ def cleanup(child, proton, env):
child.wait(timeout=10)
def cleanup_owned_run(child, proton, env, compat):
"""After a launched owned session, while the caller still holds the prefix lock.
Noncanonical private prefixes retain normal shutdown plus strict idle proof;
adopted-helper signalling is restricted to its one explicitly owned prefix.
"""
report = {'cleanup_confirmed':False, 'idle_verified':False, 'errors':[],
'orphan_cleanup':{'attempted':False,'reason':'noncanonical-private-prefix'}}
try:
cleanup(child,proton,env)
except (OSError,ValueError,subprocess.SubprocessError) as error:
report['errors'].append('normal shutdown: '+str(error))
canonical = Path('/tmp/erri-headless-compat')
if Path(compat) == canonical and Path(compat).resolve() == canonical:
report['orphan_cleanup'] = {'attempted':True}
try:
from private_prefix_cleanup import cleanup_private_prefix
details = cleanup_private_prefix(compat,proton)
report['orphan_cleanup']['report'] = details
if not isinstance(details,dict) or details.get('cleared') is not True:
raise ValueError('Private helper cleanup did not prove completion')
except (OSError,ValueError,ImportError,subprocess.SubprocessError) as error:
report['orphan_cleanup']['error'] = str(error)
details = getattr(error,'report',None)
if details is not None:
report['orphan_cleanup']['report'] = details
report['errors'].append('private helper cleanup: '+str(error))
try:
assert_prefix_idle(compat)
report['idle_verified'] = True
except (OSError,ValueError,subprocess.SubprocessError) as error:
report['errors'].append('idle verification: '+str(error))
report['cleanup_confirmed'] = report['idle_verified'] and not report['errors']
return report
class StartupMonitor:
"""Latch failures while allowing a bounded diagnostic drain; no native I/O."""
def __init__(self, started, timeout, settle, failure_grace=0):
@@ -494,10 +533,240 @@ class StartupMonitor:
return final
def display_probe_command(command, root, compat, profile, executable, run_id, host_display):
"""Wrap only the existing child of headless Gamescope; preserve Proton argv."""
separator = command.index('--')
if command[:separator].count('--backend') != 1 or command[command.index('--backend')+1] != 'headless':
refuse('Display inspection requires the explicit headless Gamescope boundary')
helper = root/'tools/headless_display_probe.py'
report = profile/('display-ownership-'+run_id+'.json')
if helper.is_symlink() or not helper.is_file() or report.exists() or report.is_symlink():
refuse('Display inspection requires a regular helper and a new report')
child = command[separator+1:]
context = {'report': str(report), 'helper_sha256': hashlib.sha256(helper.read_bytes()).hexdigest(),
'prefix': str(compat/'pfx'), 'profile': str(profile), 'game_executable': str(executable),
'command': child, 'host_display': host_display}
wrapper = [sys.executable, str(helper), '--host-display', host_display,
'--prefix', context['prefix'], '--profile', str(profile), '--game-executable', str(executable),
'--report', str(report), '--observe-seconds', '30', '--']
return command[:separator+1] + wrapper + child, context
def display_probe_result(context, session):
"""Retain exact observation bytes after cleanup; an absent/unverified report fails closed."""
result = {'requested': True, 'verified': False, 'read_only': True, 'input_sent': False}
try:
source = Path(context['report'])
if source.is_symlink() or not source.is_file() or not 0 < source.stat().st_size <= 1024*1024:
refuse('Missing or invalid bounded display ownership report')
raw = source.read_bytes()
report = json.loads(raw)
result['sha256'] = hashlib.sha256(raw).hexdigest()
if session:
with (session/'display-ownership.json').open('xb') as output:
output.write(raw)
result['archive_file'] = 'display-ownership.json'
if not isinstance(report, dict):
refuse('Display ownership report is not an object')
for key in ('helper_sha256', 'prefix', 'profile', 'game_executable', 'report', 'command'):
if report.get(key) != context[key]:
refuse('Display report binding mismatch: '+key)
if (report.get('schema') != 1 or report.get('kind') != 'headless-display-read-only'
or report.get('read_only') is not True or report.get('input_sent') is not False
or report.get('observation_finished') is not True or report.get('owned_game_window_verified') is not True):
refuse('Private display game-window ownership was not verified')
from headless_display_probe import validate_display, shared_relationship_valid
bound = report.get('binding', {})
number = validate_display(bound.get('display'), context['host_display'])
sockets = bound.get('sockets')
allowed = {f'/tmp/.X11-unix/X{number}', f'@/tmp/.X11-unix/X{number}'}
if not isinstance(sockets, list) or not sockets or len(sockets) > 2:
refuse('Missing private X11 socket ownership')
paths = set()
for sock in sockets:
if not isinstance(sock, dict) or sock.get('path') not in allowed or sock['path'] in paths or type(sock.get('inode')) is not int or sock['inode'] <= 0:
refuse('Invalid private X11 socket ownership')
paths.add(sock['path'])
for identity in [report.get('wrapper'), bound.get('gamescope'), bound.get('xwayland')]:
if not isinstance(identity, dict) or identity.get('uid') != os.getuid() or any(type(identity.get(k)) is not int or identity[k] <= 0 for k in ('pid', 'start')):
refuse('Missing owned process binding')
relationship = bound.get('relationship')
if not isinstance(relationship, dict):
refuse('Missing private display process relationship')
if relationship.get('mode') == 'descendant':
if relationship.get('descendant') is not True:
refuse('Native descendant relationship was not verified')
elif relationship.get('mode') == 'shared-listeners':
checks = relationship.get('checks')
if (not shared_relationship_valid(checks) or
checks['listener_inodes'] != [sock['inode'] for sock in sockets] or
bound['xwayland']['start'] < bound['gamescope']['start']):
refuse('Shared-listener process relationship was not verified')
else:
refuse('Unknown private display process relationship')
windows = report.get('owned_windows')
if not isinstance(windows, list) or not windows:
refuse('No verified game window records')
for window in windows:
identity = window.get('process', {})
if (type(window.get('window')) is not int or window['window'] <= 0
or type(window.get('server_pid')) is not int or type(identity.get('pid')) is not int
or identity.get('uid') != os.getuid() or type(identity.get('start')) is not int or identity['start'] <= 0
or type(window.get('pid')) is not int or window['pid'] <= 0
or window['pid'] != window.get('server_pid') or window['pid'] != identity.get('pid')):
refuse('Window lacks consistent native and server process ownership')
result['verified'] = True
except (OSError, ValueError, TypeError, AttributeError) as error:
result['error'] = str(error)
return result
def validate_input_observer(a):
if not getattr(a, 'observe_player_input', False):
return
if a.project == CONTROL or getattr(a, 'packed_core', None) or getattr(a, 'packed_addons', None):
refuse('Input observation requires loose Core or Chat, not packed input or vanilla control')
if a.settle < 50:
refuse('Input observation requires --settle >=50 for the bounded 45-second sampler')
def input_observer_command(command, project, packed, nonce):
if project not in {'core', 'chat'} or packed is not None:
refuse('Input observation requires loose Core or Chat')
if not re.fullmatch(r'[A-Za-z0-9]{1,32}', nonce):
refuse('Input observer nonce must contain 1..32 ASCII letters/digits')
if any(value.startswith('-errOrdinaryInput') for value in command):
refuse('Input observer command already has observer options')
return command + ['-errOrdinaryInputObserve', nonce, '-errOrdinaryInputScreenshots', '1']
def input_observer_evidence(content, nonce):
"""Strictly summarize native sampler records; never infer input delivery."""
import math
errors, records, samples = [], [], []
prefix = 'ERR INPUT OBSERVE: '
number = r'-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?'
header = re.compile(r'run=([A-Za-z0-9]{1,32}) sample=(\d+) real=(\d+) world=('+number+r') (.+)')
sample_pattern = re.compile(r'SAMPLE origin=(.+?) forward=('+number+r') input=(.+?) velocity=(.+?) inventory=(0|1|true|false)')
def vector_value(value):
if value.startswith('<') and value.endswith('>'):
value = value[1:-1]
if not re.fullmatch(number+r'(?:\s*,\s*|\s+)'+number+r'(?:\s*,\s*|\s+)'+number, value.strip()):
raise ValueError('invalid vector')
result = [float(x) for x in re.findall(number, value)]
if not all(math.isfinite(x) for x in result):
raise ValueError('nonfinite vector')
return result
lines = [line.split(prefix, 1)[1].strip() for line in content.splitlines() if prefix in line]
if not re.fullmatch(r'[A-Za-z0-9]{1,32}', nonce):
errors.append('invalid expected nonce')
if len(lines) > 460:
errors.append('observer record bound exceeded')
for line in lines[:460]:
match = header.fullmatch(line)
if not match:
errors.append('malformed observer record')
continue
actual, index, real, world, event = match.groups()
index, real, world = int(index), int(real), float(world)
if actual != nonce:
errors.append('observer nonce mismatch')
continue
if not math.isfinite(world) or world < 0 or index > 450 or real > 2147483647:
errors.append('observer sample/clock bound invalid')
continue
record = {'sample': index, 'real_ms': real, 'world_ms': world, 'event': event}
if records and (real < records[-1]['real_ms'] or world < records[-1]['world_ms'] or index < records[-1]['sample']):
errors.append('observer records reversed')
if event.startswith('SAMPLE '):
sample = sample_pattern.fullmatch(event)
if not sample:
errors.append('malformed observer sample')
continue
origin, forward, movement, velocity, opened = sample.groups()
try:
value = {**record, 'origin': vector_value(origin), 'forward': float(forward),
'input': vector_value(movement), 'velocity': vector_value(velocity),
'inventory': opened in {'1', 'true'}}
if not math.isfinite(value['forward']):
raise ValueError('nonfinite forward')
except ValueError:
errors.append('invalid observer sample values')
continue
if index != len(samples)+1:
errors.append('observer sample sequence is not contiguous')
samples.append(value)
elif event not in {'QUEUED', 'READY', 'INVENTORY_OPEN', 'INVENTORY_CLOSED',
'CAPTURE_REQUEST baseline', 'CAPTURE_REQUEST open', 'CAPTURE_REQUEST closed',
'FINISHED observation-bound', 'FINISHED sample-bound'} and not re.fullmatch(r'CANCELLED [a-z-]+', event):
errors.append('unknown observer event')
elif index != len(samples):
errors.append('observer transition sample does not match last sample')
records.append(record)
def matching(name):
return [r for r in records if r['event'] == name or r['event'].startswith(name+' ')]
queued, ready, finished, cancelled = (matching(name) for name in ('QUEUED', 'READY', 'FINISHED', 'CANCELLED'))
if len(queued) != 1 or not records or records[0] not in queued or records[0]['sample'] != 0:
errors.append('missing/duplicate/misordered QUEUED')
if len(ready) != 1:
errors.append('missing/duplicate READY')
if len(finished) != 1 or not records or records[-1] not in finished:
errors.append('missing/duplicate/misordered FINISHED')
if cancelled:
errors.append('observer cancelled')
if not samples:
errors.append('no native samples')
if queued:
start = queued[0]['real_ms']
if any(s['real_ms'] < start or s['real_ms']-start >= 45000 for s in samples):
errors.append('native sample outside real-time observation window')
if ready and (not samples or ready[0]['real_ms'] < samples[0]['real_ms']+2000):
errors.append('READY before stationary baseline interval')
if finished:
end = finished[0]
if end['event'] == 'FINISHED observation-bound' and end['real_ms']-start < 45000:
errors.append('FINISHED before real-time bound')
if end['event'] == 'FINISHED sample-bound' and len(samples) != 450:
errors.append('FINISHED before sample bound')
opens, closes = matching('INVENTORY_OPEN'), matching('INVENTORY_CLOSED')
if len(opens) > 1 or len(closes) > 1:
errors.append('duplicate inventory transition')
by_index = {s['sample']: s for s in samples}
for transition, expected in [(r, True) for r in opens] + [(r, False) for r in closes]:
sample = by_index.get(transition['sample'])
if not ready or records.index(transition) < records.index(ready[0]) or not sample or sample['inventory'] is not expected:
errors.append('inventory transition lacks matching post-READY sample')
if closes and (not opens or records.index(closes[0]) <= records.index(opens[0])):
errors.append('inventory close lacks prior observed open')
captures = matching('CAPTURE_REQUEST')
for suffix, transition in [('baseline', ready), ('open', opens), ('closed', closes)]:
requested = [r for r in captures if r['event'] == 'CAPTURE_REQUEST '+suffix]
if len(requested) > 1 or (requested and (not transition or records.index(requested[0]) <= records.index(transition[0]) or requested[0]['sample'] != transition[0]['sample'])):
errors.append('capture request lacks its unique transition')
return {'nonce': nonce, 'read_only': True, 'input_sent': False, 'sampler_complete': not errors,
'queued': len(queued) == 1, 'ready': len(ready) == 1, 'finished': len(finished) == 1,
'cancelled': bool(cancelled), 'sample_count': len(samples), 'record_count': len(lines),
'inventory_open_observed': len(opens) == 1, 'inventory_close_observed': len(closes) == 1,
'capture_requests': [r['event'].split()[-1] for r in captures],
'transitions': [r for r in records if not r['event'].startswith('SAMPLE ')],
'errors': list(dict.fromkeys(errors)),
'scope': 'native read-only sampler lifecycle only; no generated input, movement success, inventory usability, screenshot file verification, manual playtest or multiplayer claim'}
def input_observer_result(result, content, nonce):
evidence = input_observer_evidence(content, nonce)
if result == 'game-started' and not evidence['sampler_complete']:
result = 'observation-incomplete'
return result, evidence
def run(a):
validate_input_observer(a)
packed = packed_request(a)
if getattr(a, 'capture_prison_view', False) and (a.project == CONTROL or packed is not None):
refuse('Prison view capture requires loose successor source with its opt-in observer')
if getattr(a, 'inspect_headless_display', False) and (a.project == CONTROL or packed is not None):
refuse('Display ownership inspection currently requires loose Core or Chat')
steam, compat = a.steam.resolve(), a.compat.resolve()
profile = packed['session']/'profile' if packed else a.profile.resolve()
if not 60 <= a.timeout <= 1800 or not 5 <= a.settle <= 120:
@@ -549,6 +818,12 @@ def run_locked(a, steam, compat, profile, packed=None):
if link.resolve() != vanilla.resolve() or any(x.name not in {'ArmaReforger', 'saves'} for x in addons.iterdir()) or any((addons/'saves').rglob('*.gproj')):
refuse('Private profile addons must contain only the installed vanilla link')
run_id = uuid.uuid4().hex
observation_nonce = run_id if getattr(a, 'observe_player_input', False) else None
if observation_nonce:
command = input_observer_command(command, a.project, packed, observation_nonce)
display_context = None
if getattr(a, 'inspect_headless_display', False):
command, display_context = display_probe_command(command, ROOT, compat, profile, executable, run_id, os.environ.get('DISPLAY', ''))
silent_sink = 'erri_game_'+run_id
application_id = 'org.escapistsrereintroduced.game.'+run_id
env = os.environ.copy()
@@ -565,6 +840,7 @@ def run_locked(a, steam, compat, profile, packed=None):
result = 'timeout'
runner_error = None
cleanup_ok = True
cleanup_report = None
log_root = profile/'logs'
previous = set(log_root.glob('logs_*'))
launch_log = profile/('game-launch-'+run_id+'.log')
@@ -618,13 +894,12 @@ def run_locked(a, steam, compat, profile, packed=None):
runner_error = str(error)
finally:
if child is not None:
try:
cleanup(child, proton, env)
assert_prefix_idle(compat)
except (OSError, ValueError, subprocess.SubprocessError) as error:
cleanup_ok = False
cleanup_report = cleanup_owned_run(child,proton,env,compat)
cleanup_ok = cleanup_report['cleanup_confirmed']
if not cleanup_ok:
result = 'cleanup-failed'
runner_error = str(error)
cleanup_error = '; '.join(cleanup_report['errors'])
runner_error = (runner_error + '; ' if runner_error else '') + cleanup_error
if sink_module is not None:
try:
subprocess.run(['pactl', 'unload-module', sink_module], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5)
@@ -650,10 +925,20 @@ def run_locked(a, steam, compat, profile, packed=None):
result = 'vanilla-control-started'
if packed and result == 'game-started':
result = 'packed-'+packed.get('project', 'core')+'-started' if final.get('ready') is True else 'packed-evidence-incomplete'
display_result = None
startup_result = result
if display_context:
display_result = display_probe_result(display_context, session)
if not display_result['verified'] and result == 'game-started':
result = 'display-probe-incomplete'
observation_result = None
if observation_nonce:
result, observation_result = input_observer_result(result, content, observation_nonce)
final['input_observation'] = observation_result
source_bindings = {} if packed else bindings(captured)
summary = {**sealed, **source_bindings, 'started_unix': started, 'result': result,
'session': str(session) if session else None, 'project': a.project,
'cleanup_confirmed': cleanup_ok, 'runner_error': runner_error,
'cleanup_confirmed': cleanup_ok, 'cleanup_report':cleanup_report, 'runner_error': runner_error,
'failure_grace_seconds': a.failure_grace,
'failure_observed_during_polling': monitor.first_failure_at is not None,
'first_failure_elapsed_seconds': (monitor.first_failure_at - monitor.started) if monitor.first_failure_at is not None else None,
@@ -662,6 +947,11 @@ def run_locked(a, steam, compat, profile, packed=None):
'control_only': a.project == CONTROL,
'source_manifest_scope': 'none: packed input provenance is separate; loose source manifests cannot qualify this run' if packed else 'successor addon snapshot retained for provenance bookkeeping only; none loaded by vanilla control' if a.project == CONTROL else 'successor addon sources',
'scope': final.get('scope', 'ordinary game compile/startup; no scripted fixture, full escape or multiplayer acceptance')}
if observation_result is not None:
summary['input_observation'] = observation_result
if display_result is not None:
summary['display_ownership'] = display_result
summary['startup_result_before_display_check'] = startup_result
if packed:
summary['packed_consumption_verified'] = result == 'packed-'+packed.get('project', 'core')+'-started' and cleanup_ok and final.get('ready') is True and sealed['packed_inputs_unchanged_after_run']
final['packed_consumption_verified'] = summary['packed_consumption_verified']
@@ -686,7 +976,9 @@ def main():
packed_options.add_argument('--packed-addons', type=Path, help='Unqualified complete core/ai/intel/map/chat packed directories; requires --project chat')
parser.add_argument('--packed-session', type=Path, help='New /tmp/erri-packed-core-<name> or erri-packed-chat-<name> session; owns a fresh input copy and profile')
parser.add_argument('--inspect-processes', action='store_true', help='Read-only process preflight report; never launch, lock, write a profile or configure audio')
parser.add_argument('--inspect-headless-display', action='store_true', help='Read-only ownership inspection within the hidden display; no input sent, loose Core/Chat only')
parser.add_argument('--capture-prison-view', action='store_true', help='Opt-in single genuine local prison screenshot; loose successor source only, no camera or input changes')
parser.add_argument('--observe-player-input', action='store_true', help='Read-only native local-input sampler; loose Core/Chat only, requires --settle >=50; sends no input')
parser.add_argument('--timeout', type=int, default=180)
parser.add_argument('--settle', type=int, default=15)
parser.add_argument('--failure-grace', type=int, default=0, help='Keep a failed run alive for diagnostic logs only, at most 0..30 seconds within the original timeout')