From 8519459e6fedd1bfb25dc2cdee6372d7252f5d0a Mon Sep 17 00:00:00 2001 From: codex Date: Fri, 6 Nov 2026 19:53:07 +0100 Subject: [PATCH] Use isolated native Windows browser for attended WSL login Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1abf1-7e95-7141-87c9-ee78332bf790 --- ...11-06-operator-browser-native-windows.json | 43 +++++++ scripts/operator-browser/windows_browser.py | 109 +++++++++++++++++ scripts/operator-browser/xdg-open | 22 +++- tests/test_operator_windows_browser.py | 114 ++++++++++++++++++ 4 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 docs/evidence/2026-11-06-operator-browser-native-windows.json create mode 100644 scripts/operator-browser/windows_browser.py create mode 100644 tests/test_operator_windows_browser.py diff --git a/docs/evidence/2026-11-06-operator-browser-native-windows.json b/docs/evidence/2026-11-06-operator-browser-native-windows.json new file mode 100644 index 0000000..909c3c3 --- /dev/null +++ b/docs/evidence/2026-11-06-operator-browser-native-windows.json @@ -0,0 +1,43 @@ +{ + "schema": "railiance-platform.operator-browser-native-windows.v1", + "prepared_at": "2026-11-06T18:49:12.290563+00:00", + "trigger_workplan": "railiance-apps/RAPPS-WP-0014-T03", + "problem": "Founder reports WSL Chrome password/OTP delay, tab/close failures and random wrong controls. Same real-launcher X11/software probe remains unreliable; native Windows Edge disposable-profile probe is founder-confirmed responsive.", + "user_native_windows_verdict": "Everything works normally", + "windows_acl_setup": "Use numeric current-user SID with icacls and verify protected owner-only DACL before browser creation. Set-Acl attempted domain-name translation and failed with a domain trust error; no domain/account setting changed.", + "native_profile": "unique private Windows temporary folder; normal browser profile never selected", + "url_transport": "Authorization URL to PowerShell worker uses stdin, not worker argv/encoded script or a handoff file. Edge receives initial authorization URL as its browser startup argument, as Linux Chromium did. Credential-bearing query keys are refused.", + "native_windows_process_environment": "Clear BAO_TOKEN, VAULT_TOKEN and OPENBAO_TOKEN in child process only", + "callback_allowlist": [ + "http://localhost:8250/oidc/callback", + "http://127.0.0.1:8250/oidc/callback" + ], + "issuer": "https://kc.coulomb.social", + "wsl_selection": "microsoft in kernel release; fail closed without fallback to shared/default browser", + "non_wsl": "existing pinned Linux Chromium path retained", + "lifecycle": "Owned PID/start-time/executable guards, close after browser exit or 600s, bounded private-profile removal retry", + "tests": { + "operator_unit_cases": 15, + "operator_unit_status": "passed", + "native_fixture": { + "production_guard_refuses_local_fixture": true, + "private_acl_verified_before_launch": true, + "ipv4_and_localhost_redirect_reached": true, + "owned_process_closed": true, + "private_profile_removed": true, + "native_identity_request": false + }, + "final_native_windows_credential_query_guard": "passed without browser creation or network request" + }, + "sources_sha256": { + "scripts/operator-browser/xdg-open": "a6794d50dbe4131ff2b3fd259365d5f30d467702daf8d8d4f63265bab47e2e51", + "scripts/operator-browser/windows_browser.py": "fd952b6960cac3159e50708e492163fcab8540f7b9d6227e88ce75c5626b2824", + "tests/test_operator_windows_browser.py": "b0e87ff253a978649015ddc80c27ff0478834144db3d0938d229b7edd5cdbcdf" + }, + "actual_attended_owner_login_with_new_launcher": "pending", + "scope_limits": [ + "No issuer/role/callback registrations, output allowlist, token-helper control, factor policy or credential grants changed", + "No real browser page/profile contents, authorization URL, cookies, token helper, password or OTP read by the agent", + "Underlying WSLg issue is bypassed, not claimed repaired" + ] +} diff --git a/scripts/operator-browser/windows_browser.py b/scripts/operator-browser/windows_browser.py new file mode 100644 index 0000000..b0b3971 --- /dev/null +++ b/scripts/operator-browser/windows_browser.py @@ -0,0 +1,109 @@ +"""WSL operator opener: native Edge, private disposable profile, no shared session. + +Authorization URL reaches the PowerShell worker through stdin, not its command +argument or a file. Edge receives the initial URL as a browser startup argument, +as the Linux launcher did. Passwords, OTPs and token values are never accepted. +The child prints only a fixed startup marker, closes only its owned process and +removes its private profile after close or the bounded authentication window. +""" + +import base64 +import json +import select +import subprocess + +POWERSHELL = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" +MAXIMUM_SECONDS = 600 +SCRIPT = r''' +$ErrorActionPreference='Stop' +$ProgressPreference='SilentlyContinue' +$profile=$null;$process=$null;$startTicks=$null;$opened=$false;$removed=$false +function Remove-PrivateProfile([string]$path) { + if([IO.Path]::GetDirectoryName($path).TrimEnd('\') -ne [IO.Path]::GetTempPath().TrimEnd('\') -or -not [IO.Path]::GetFileName($path).StartsWith('railiance-operator-browser-')){return $false} + for($attempt=0;$attempt -lt 20;$attempt++){ + try {Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction Stop;return $true} catch { + if(-not (Test-Path -LiteralPath $path)){return $true} + Start-Sleep -Milliseconds 250 + } + } + return $false +} +try { + foreach($name in @('BAO_TOKEN','VAULT_TOKEN','OPENBAO_TOKEN')){[Environment]::SetEnvironmentVariable($name,$null,'Process')} + [Console]::InputEncoding=[Text.UTF8Encoding]::new($false) + $inputBody=[Console]::In.ReadToEnd() + if($inputBody.Length -gt 32768){throw 'invalid payload'} + $payload=$inputBody | ConvertFrom-Json + if(@($payload.PSObject.Properties).Count -ne 1 -or -not ($payload.url -is [string])){throw 'invalid payload'} + $url=$payload.url + if($url.Length -gt 16384 -or $url.IndexOfAny([char[]]@([char]0,[char]10,[char]13,[char]34)) -ge 0){throw 'invalid url'} + $uri=[Uri]$url + if($uri.Scheme -ne 'https' -or $uri.Host -ne 'kc.coulomb.social' -or $uri.Port -ne 443 -or $uri.UserInfo -or $uri.Fragment){throw 'invalid origin'} + Add-Type -AssemblyName System.Web + $query=[Web.HttpUtility]::ParseQueryString($uri.Query) + foreach($key in @('code','access_token','id_token','refresh_token','mfa_token','password','client_secret')){ + if($query.AllKeys -contains $key){throw 'credential-bearing url refused'} + } + $callbacks=$query.GetValues('redirect_uri') + if($callbacks.Count -ne 1 -or $callbacks[0] -notin @('http://localhost:8250/oidc/callback','http://127.0.0.1:8250/oidc/callback')){throw 'invalid callback'} + $browser=Join-Path ${env:ProgramFiles(x86)} 'Microsoft\Edge\Application\msedge.exe' + if(-not (Test-Path -LiteralPath $browser)){throw 'browser unavailable'} + $profile=Join-Path ([IO.Path]::GetTempPath()) ('railiance-operator-browser-'+[guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $profile | Out-Null + $sid=[Security.Principal.WindowsIdentity]::GetCurrent().User + $grant='*'+$sid.Value+':(OI)(CI)F' + & "$env:SystemRoot\System32\icacls.exe" $profile '/grant:r' $grant '/inheritance:r' | Out-Null + if($LASTEXITCODE -ne 0){throw 'profile protection failed'} + $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 -or ($rules[0].FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -ne [Security.AccessControl.FileSystemRights]::FullControl){throw 'profile protection unverified'} + $arguments=@(('--user-data-dir="'+$profile+'"'),'--no-first-run','--no-default-browser-check','--disable-sync','--new-window',('"'+$url+'"')) + $process=Start-Process -FilePath $browser -ArgumentList $arguments -PassThru + $startTicks=$process.StartTime.ToUniversalTime().Ticks + $opened=$true + [Console]::Out.WriteLine('opened');[Console]::Out.Flush() + $deadline=[datetime]::UtcNow.AddSeconds(__MAXIMUM__) + while(-not $process.HasExited -and [datetime]::UtcNow -lt $deadline){Start-Sleep -Milliseconds 250;$process.Refresh()} +} catch { + if(-not $opened){[Console]::Out.WriteLine('failed');[Console]::Out.Flush()} +} finally { + if($process -and $startTicks){ + try { + $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)){$current.Kill();$null=$current.WaitForExit(5000)} + } + $process.Refresh() + if($process.HasExited){$removed=Remove-PrivateProfile $profile} + } catch { } + } elseif($profile -and -not $process) { + $removed=Remove-PrivateProfile $profile + } +} +''' + + +def command(maximum_seconds=MAXIMUM_SECONDS): + if type(maximum_seconds) is not int or not 10 <= maximum_seconds <= MAXIMUM_SECONDS: + raise ValueError("invalid_browser_lifetime") + source = SCRIPT.replace("__MAXIMUM__", str(maximum_seconds)) + return [POWERSHELL, "-NoProfile", "-NonInteractive", "-EncodedCommand", + base64.b64encode(source.encode("utf-16le")).decode()] + + +def launch(url): + """Caller must apply its issuer/callback allowlist before passing the URL.""" + if not isinstance(url, str) or len(url) > 16384: + return False + try: + process = subprocess.Popen(command(), stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, start_new_session=True) + process.stdin.write(json.dumps({"url": url}).encode("utf-8")) + process.stdin.close() + readable, _, _ = select.select([process.stdout], [], [], 20) + marker = process.stdout.readline(32) if readable else b"" + process.stdout.close() + return marker in (b"opened\n", b"opened\r\n") + except (OSError, ValueError, subprocess.SubprocessError): + return False diff --git a/scripts/operator-browser/xdg-open b/scripts/operator-browser/xdg-open index 0e2e6c5..271e969 100755 --- a/scripts/operator-browser/xdg-open +++ b/scripts/operator-browser/xdg-open @@ -1,12 +1,15 @@ #!/usr/bin/env python3 -"""Open the approved OpenBao login in a new empty Playwright Chromium profile. +"""Open approved OpenBao login in a new isolated operator browser profile. The Windows default browser is not used. A previous login opened Firefox, which was already signed in as another account. This launcher uses the -headed Chromium binary pinned by coulomb-social's Playwright install. +native Windows Edge in a private disposable profile on WSL, where the Linux +window/input path proved unreliable. Other Linux hosts retain pinned Chromium. """ +import importlib.util import json import os +import platform import pwd import subprocess import sys @@ -14,7 +17,6 @@ import tempfile from pathlib import Path from urllib.parse import parse_qs, urlsplit - BROWSERS_JSON = Path( "/home/worsch/coulomb-social/scripts/node_modules/playwright-core/browsers.json" ) @@ -118,6 +120,20 @@ def stamp(result): def open_url(url): + if not allowed(url): + stamp("rejected") + return 1 + if "microsoft" in platform.release().lower(): + path = Path(__file__).resolve().with_name("windows_browser.py") + try: + spec = importlib.util.spec_from_file_location("operator_windows_browser", path) + native = importlib.util.module_from_spec(spec) + spec.loader.exec_module(native) + opened = native.launch(url) + except Exception: # noqa: BLE001 — keep native browser setup details contained + opened = False + stamp("opened" if opened else "windows-open-failed") + return 0 if opened else 1 if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): stamp("no-display") return 1 diff --git a/tests/test_operator_windows_browser.py b/tests/test_operator_windows_browser.py new file mode 100644 index 0000000..3ba90f5 --- /dev/null +++ b/tests/test_operator_windows_browser.py @@ -0,0 +1,114 @@ +"""Native WSL opener boundaries; no GUI, identity request or real profile.""" + +import base64 +import importlib.machinery +import importlib.util +import io +import json +import runpy +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] / "scripts/operator-browser" +loader = importlib.machinery.SourceFileLoader("native_browser_fixture", str(ROOT / "windows_browser.py")) +spec = importlib.util.spec_from_loader(loader.name, loader) +native = importlib.util.module_from_spec(spec) +loader.exec_module(native) +launcher = runpy.run_path(str(ROOT / "xdg-open")) +URL = "https://kc.coulomb.social/authorize?redirect_uri=http%3A%2F%2Flocalhost%3A8250%2Foidc%2Fcallback&state=synthetic" + + +class SavedPipe(io.BytesIO): + def close(self): + self.saved = self.getvalue() + super().close() + + +class WindowsBrowserBoundary(unittest.TestCase): + def process(self, marker=b"opened\r\n"): + return SimpleNamespace(stdin=SavedPipe(), stdout=SavedPipe(marker)) + + def test_worker_receives_url_only_via_pipe(self): + process = self.process() + with patch.object(native.subprocess, "Popen", return_value=process) as popen, \ + patch.object(native.select, "select", return_value=([process.stdout], [], [])): + self.assertTrue(native.launch(URL)) + argv = popen.call_args.args[0] + self.assertEqual(json.loads(process.stdin.saved), {"url": URL}) + self.assertNotIn(URL, " ".join(argv)) + source = base64.b64decode(argv[-1]).decode("utf-16le") + self.assertNotIn("state=synthetic", source) + self.assertIn("[Console]::In.ReadToEnd()", source) + + def test_uses_numeric_owner_sid_and_verifies_protection_before_launch(self): + source = base64.b64decode(native.command()[-1]).decode("utf-16le") + self.assertIn("$grant='*'+$sid.Value+':(OI)(CI)F'", source) + self.assertIn("'/inheritance:r'", source) + self.assertIn("$rules.Count -ne 1", source) + self.assertIn("$rules[0].IdentityReference.Value -ne $sid.Value", source) + self.assertLess(source.index("$verified=Get-Acl"), source.index("Start-Process")) + self.assertNotIn("Set-Acl", source) + + def test_worker_rechecks_same_issuer_and_both_approved_callbacks(self): + source = native.SCRIPT + self.assertIn("$uri.Host -ne 'kc.coulomb.social'", source) + self.assertIn("$callbacks.Count -ne 1", source) + self.assertIn("http://localhost:8250/oidc/callback", source) + self.assertIn("http://127.0.0.1:8250/oidc/callback", source) + self.assertIn("'code','access_token','id_token','refresh_token','mfa_token','password','client_secret'", source) + for banned in ("--no-sandbox", "--disable-web-security", "--ignore-certificate-errors", "--remote-debugging"): + self.assertNotIn(banned, source) + + def test_cleanup_checks_exact_pid_start_and_executable(self): + self.assertIn("$current.StartTime.ToUniversalTime().Ticks -eq $startTicks", native.SCRIPT) + self.assertIn("$current.Path -eq $browser", native.SCRIPT) + self.assertIn("$current.Kill()", native.SCRIPT) + self.assertNotIn("Stop-Process -Name", native.SCRIPT) + for value in (True, 0, 601, "600"): + with self.subTest(value=value), self.assertRaises(ValueError): + native.command(value) + + def test_unknown_output_failure_and_timeout_do_not_become_opened(self): + for marker in (b"failed\r\n", b"unexpected synthetic output\n", b""): + process = self.process(marker) + with self.subTest(marker=marker), patch.object(native.subprocess, "Popen", return_value=process), \ + patch.object(native.select, "select", return_value=([process.stdout], [], [])): + self.assertFalse(native.launch(URL)) + process = self.process() + with patch.object(native.subprocess, "Popen", return_value=process), \ + patch.object(native.select, "select", return_value=([], [], [])): + self.assertFalse(native.launch(URL)) + + def test_wsl_uses_native_before_display_check_without_changing_url(self): + process = self.process() + markers = [] + with patch.dict(launcher["open_url"].__globals__, {"stamp": markers.append}), \ + patch.object(launcher["platform"], "release", return_value="6.18-microsoft-standard-WSL2"), \ + patch.dict(launcher["os"].environ, {}, clear=True), \ + patch.object(native.subprocess, "Popen", return_value=process), \ + patch.object(native.select, "select", return_value=([process.stdout], [], [])): + self.assertEqual(launcher["open_url"](URL), 0) + self.assertEqual(markers, ["opened"]) + self.assertEqual(json.loads(process.stdin.saved), {"url": URL}) + + def test_foreign_url_never_reaches_native_and_failure_never_falls_back(self): + markers = [] + with patch.dict(launcher["open_url"].__globals__, {"stamp": markers.append}), \ + patch.object(launcher["platform"], "release", return_value="microsoft"), \ + patch.object(native.subprocess, "Popen") as popen: + self.assertEqual(launcher["open_url"]("https://foreign.invalid/"), 1) + popen.assert_not_called() + process = self.process(b"failed\n") + with patch.dict(launcher["open_url"].__globals__, {"stamp": markers.append}), \ + patch.object(launcher["platform"], "release", return_value="microsoft"), \ + patch.object(native.subprocess, "Popen", return_value=process) as popen, \ + patch.object(native.select, "select", return_value=([process.stdout], [], [])): + self.assertEqual(launcher["open_url"](URL), 1) + self.assertEqual(popen.call_count, 1) + self.assertEqual(markers, ["rejected", "windows-open-failed"]) + + +if __name__ == "__main__": + unittest.main()