Use isolated native Windows browser for attended WSL login
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1abf1-7e95-7141-87c9-ee78332bf790
This commit is contained in:
parent
de06f49037
commit
8519459e6f
4 changed files with 285 additions and 3 deletions
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
109
scripts/operator-browser/windows_browser.py
Normal file
109
scripts/operator-browser/windows_browser.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
114
tests/test_operator_windows_browser.py
Normal file
114
tests/test_operator_windows_browser.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue