railiance-apps/tools/vergabe_operator_browser_probe.py
tegwick f6eec13f7c
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s
Record native Windows browser workaround for pilot recovery login
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1abf1-7e95-7141-87c9-ee78332bf790
2026-11-06 19:57:59 +01:00

236 lines
12 KiB
Python

#!/usr/bin/env python3
"""Credential-free manual UI probe using the real operator Chromium launcher.
Default reviews only. --run opens a fresh window and private profile against a
disposable loopback page. --software-x11 selects X11/software rendering for
comparison. No issuer URL, account, credentials, callbacks or real profile.
"""
import argparse
import base64
import importlib.machinery
import importlib.util
import json
import os
import subprocess
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
LAUNCHER = Path(__file__).resolve().parents[2] / "railiance-platform/scripts/operator-browser/xdg-open"
POWERSHELL = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"
WINDOWS_PROBE = r'''
$ErrorActionPreference='Stop'
$ProgressPreference='SilentlyContinue'
$profile=$null;$process=$null;$startTicks=$null;$closed=$false;$removed=$false;$userClosed=$false;$ok=$false
$stage='browser_detection';$failureStage=$null;$errorKind=$null
try {
$browser=Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe'
if(-not (Test-Path -LiteralPath $browser)){throw 'browser unavailable'}
$stage='profile_creation'
$profile=Join-Path ([IO.Path]::GetTempPath()) ('railiance-browser-probe-'+[guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $profile | Out-Null
$stage='profile_acl'
$stage='profile_acl_identity'
$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User
$stage='profile_acl_apply'
$grant='*'+$sid.Value+':(OI)(CI)F'
& "$env:SystemRoot\System32\icacls.exe" $profile '/grant:r' $grant '/inheritance:r' | Out-Null
if($LASTEXITCODE -ne 0){throw 'private profile ACL command failed'}
$stage='profile_acl_verify'
$verified=Get-Acl -LiteralPath $profile
$rules=$verified.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])
if(-not $verified.AreAccessRulesProtected -or $rules.Count -ne 1 -or $rules[0].IdentityReference.Value -ne $sid.Value){throw 'private profile ACL verification failed'}
$arguments=@(('--user-data-dir="'+$profile+'"'),'--no-first-run','--no-default-browser-check','--disable-sync','--new-window','__URL__')
$stage='process_start'
$process=Start-Process -FilePath $browser -ArgumentList $arguments -PassThru
$startTicks=$process.StartTime.ToUniversalTime().Ticks
$stage='process_wait'
$deadline=[datetime]::UtcNow.AddSeconds(__MAXIMUM__)
while(-not $process.HasExited -and [datetime]::UtcNow -lt $deadline){Start-Sleep -Milliseconds 250;$process.Refresh()}
$userClosed=$process.HasExited;$ok=$true
} catch {
$ok=$false;$failureStage=$stage;$errorKind=$_.Exception.GetType().Name
} finally {
if($process -and $startTicks){
$current=Get-Process -Id $process.Id -ErrorAction SilentlyContinue
if($current -and $current.StartTime.ToUniversalTime().Ticks -eq $startTicks -and $current.Path -eq $browser){
$null=$current.CloseMainWindow()
if(-not $current.WaitForExit(5000)){Stop-Process -Id $current.Id -Force -ErrorAction SilentlyContinue;$null=$current.WaitForExit(5000)}
}
$process.Refresh();$closed=$process.HasExited
}
if($profile -and ($closed -or -not $process)){
try {Remove-Item -LiteralPath $profile -Recurse -Force -ErrorAction Stop;$removed=$true} catch {$removed=$false}
}
@{status=$(if($ok){'native_probe_finished'}else{'native_probe_failed'});failure_stage=$failureStage;error_kind=$errorKind;browser='Windows Edge';user_closed=$userClosed;owned_process_closed=$closed;private_profile_removed=$removed;native_login=$false} | ConvertTo-Json -Compress
}
'''
HTML = b'''<!doctype html><meta charset="utf-8"><title>Browser response check - no login</title>
<style>body{font:20px system-ui;max-width:40rem;margin:2rem auto}button{display:block;margin:1rem;padding:1rem;background:#def;color:#123;width:32rem;text-align:left}output{display:block;padding:1rem;background:#eee}</style>
<h1>Browser response check</h1><p>This is a local test. No sign-in or credentials.</p>
<button id="one">1. First test button</button>
<button id="two">2. Second test button</button>
<button id="three">3. Third test button</button>
<output id="result">No test button activated yet.</output>
<p>Click each numbered button and check that the reported number matches.
Then press Tab to focus a button and Enter to activate it. Close this window with its titlebar X when done.</p>
<script>
for(const button of document.querySelectorAll('button'))button.addEventListener('click',event=>{
document.getElementById('result').textContent='Activated: '+button.textContent;
const r=button.getBoundingClientRect();
const observation={target:button.id,keyboard:event.detail===0,client_x:event.clientX,client_y:event.clientY,
device_scale:window.devicePixelRatio,button_top:r.top,button_bottom:r.bottom};
fetch('/event',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(observation)});
});
</script>
'''
def launcher_module():
loader = importlib.machinery.SourceFileLoader("operator_browser_probe_launcher", str(LAUNCHER))
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
def probe_argv(module, browser, profile, url, software_x11):
command = module.launch_argv(browser, str(profile), url)
if software_x11:
command[-1:-1] = ["--ozone-platform=x11", "--disable-gpu"]
return command
def windows_argv(url, maximum):
parsed = urlsplit(url)
if not (parsed.scheme == "http" and parsed.hostname == "127.0.0.1" and parsed.port
and parsed.path == "/" and not parsed.query and not parsed.fragment
and not parsed.username and not parsed.password and 10 <= maximum <= 180):
raise ValueError("synthetic_loopback_only")
script = WINDOWS_PROBE.replace("__URL__", url).replace("__MAXIMUM__", str(maximum - 10))
encoded = base64.b64encode(script.encode("utf-16le")).decode()
return [POWERSHELL, "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]
def run(software_x11, maximum, windows=False):
if not windows:
module = launcher_module()
browser = module.browser_path()
if not browser:
raise RuntimeError("pinned_browser_unavailable")
events = []
started = time.monotonic()
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
if self.path not in ("/", "/favicon.ico"):
self.send_error(404)
return
if self.path != "/favicon.ico":
event = {"event": self.path, "elapsed_seconds": round(time.monotonic() - started, 2)}
events.append(event)
print(json.dumps(event), flush=True)
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'")
self.end_headers()
self.wfile.write(HTML)
def do_POST(self):
if self.path != "/event" or not 0 < int(self.headers.get("Content-Length", 0)) <= 1024:
self.send_error(400)
return
try:
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
if set(body) != {"target", "keyboard", "client_x", "client_y", "device_scale", "button_top", "button_bottom"}:
raise ValueError
if body["target"] not in ("one", "two", "three") or type(body["keyboard"]) is not bool:
raise ValueError
if any(type(body[k]) not in (int, float) for k in body if k not in ("target", "keyboard")):
raise ValueError
except (ValueError, KeyError, TypeError):
self.send_error(400)
return
event = {"event": "button_activated", "elapsed_seconds": round(time.monotonic() - started, 2), **body}
events.append(event)
print(json.dumps(event), flush=True)
self.send_response(204)
self.end_headers()
with tempfile.TemporaryDirectory(prefix="vergabe-browser-probe-") as temporary:
root = Path(temporary)
home, profile = root / "empty-home", root / "empty-profile"
home.mkdir(mode=0o700)
profile.mkdir(mode=0o700)
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
url = "http://127.0.0.1:" + str(server.server_port) + "/"
command = windows_argv(url, maximum) if windows else probe_argv(module, browser, profile, url, software_x11)
env = {k: v for k, v in os.environ.items() if k not in ("BAO_TOKEN", "VAULT_TOKEN", "OPENBAO_TOKEN")}
env["HOME"] = str(home)
process = subprocess.Popen(command, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE if windows else subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
print(json.dumps({"status": "ui_probe_started", "software_x11": software_x11,
"native_windows": windows, "native_login": False, "maximum_seconds": maximum}), flush=True)
try:
while process.poll() is None and time.monotonic() - started < maximum:
time.sleep(0.25)
finally:
user_closed = process.poll() is not None
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=8)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
server.shutdown()
server.server_close()
worker.join(timeout=2)
if windows:
raw = process.stdout.read(16385)
process.stdout.close()
try:
metadata = json.loads(raw) if len(raw) <= 16384 else {}
print(json.dumps({k: metadata.get(k) for k in ("status", "failure_stage", "error_kind", "browser", "user_closed", "owned_process_closed", "private_profile_removed", "native_login")}), flush=True)
except (ValueError, UnicodeError):
print(json.dumps({"status": "native_probe_metadata_unavailable", "details_suppressed": True}), flush=True)
print(json.dumps({"status": "ui_probe_complete", "software_x11": software_x11,
"window_process_exited_before_deadline": user_closed, "events": events,
"human_wait_included_in_times": True, "native_login": False}), flush=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--run", action="store_true")
parser.add_argument("--software-x11", action="store_true")
parser.add_argument("--windows", action="store_true", help="use native Windows Edge with its own disposable profile")
parser.add_argument("--maximum-seconds", type=int, default=120)
args = parser.parse_args()
if not 10 <= args.maximum_seconds <= 180:
parser.error("maximum must be between 10 and 180 seconds")
if args.windows and args.software_x11:
parser.error("Windows and Linux rendering modes are mutually exclusive")
if not args.run:
print(json.dumps({"status": "prepared", "native_login": False, "fresh_profile": True,
"software_x11": args.software_x11, "native_windows": args.windows, "next": "--run"}))
return 0
run(args.software_x11, args.maximum_seconds, args.windows)
return 0
if __name__ == "__main__":
os.umask(0o077)
try:
raise SystemExit(main())
except Exception: # noqa: BLE001 — do not return browser or environment diagnostics
print(json.dumps({"status": "ui_probe_failed", "details_suppressed": True}))
raise SystemExit(1)