u-chan-secrets 4.0.1 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ import { createServer, request as httpRequest } from 'node:http';
2
+ import { connect } from 'node:net';
3
+ /** Per-context HTTP/CONNECT proxy: every redirect and child target must obtain a separately checked destination. */
4
+ export async function createPrivateNetwork(hostCheck, signal, loopbackHttpOriginForTesting) {
5
+ let blocked = false, closing;
6
+ const testOrigin = loopbackHttpOriginForTesting ? new URL(loopbackHttpOriginForTesting) : undefined;
7
+ if (testOrigin && (testOrigin.protocol !== 'http:' || testOrigin.hostname !== '127.0.0.1' || testOrigin.username || testOrigin.password))
8
+ throw new Error('Invalid loopback fixture origin');
9
+ const sockets = new Set();
10
+ const allowed = (url) => {
11
+ try {
12
+ return !signal.aborted && !url.username && !url.password && hostCheck(url.href);
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ };
18
+ const track = (socket) => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)); socket.on('error', () => { }); socket.setTimeout(60000, () => socket.destroy()); };
19
+ const server = createServer((incoming, response) => {
20
+ let url;
21
+ try {
22
+ url = new URL(incoming.url ?? '');
23
+ }
24
+ catch {
25
+ blocked = true;
26
+ response.writeHead(403);
27
+ response.end();
28
+ return;
29
+ }
30
+ if (!testOrigin || url.protocol !== 'http:' || url.origin !== testOrigin.origin || !allowed(url)) {
31
+ blocked = true;
32
+ response.writeHead(403);
33
+ response.end();
34
+ return;
35
+ }
36
+ const headers = { ...incoming.headers, host: testOrigin.host };
37
+ delete headers['proxy-authorization'];
38
+ delete headers['proxy-connection'];
39
+ // Plain HTTP exists only for explicitly injected loopback fixtures; the production path uses approved HTTPS CONNECT.
40
+ const outgoing = httpRequest({ hostname: '127.0.0.1', port: Number(testOrigin.port || 80), path: url.pathname + url.search, method: incoming.method, headers, agent: false }, upstream => {
41
+ response.writeHead(upstream.statusCode ?? 502, upstream.headers);
42
+ upstream.pipe(response);
43
+ });
44
+ outgoing.on('socket', track);
45
+ outgoing.on('error', () => { if (!response.headersSent)
46
+ response.writeHead(502); response.end(); });
47
+ incoming.on('error', () => outgoing.destroy());
48
+ incoming.pipe(outgoing); // No redirect following or credential buffering in the proxy.
49
+ });
50
+ server.on('connection', socket => { track(socket); if (sockets.size > 128)
51
+ socket.destroy(); });
52
+ server.on('clientError', (_error, socket) => socket.destroy());
53
+ server.on('upgrade', (_request, socket) => { blocked = true; socket.destroy(); }); // Production permits WSS via checked CONNECT only.
54
+ server.on('connect', (incoming, connection, head) => {
55
+ const client = connection;
56
+ let url;
57
+ try {
58
+ url = new URL('https://' + incoming.url);
59
+ }
60
+ catch {
61
+ blocked = true;
62
+ client.destroy();
63
+ return;
64
+ }
65
+ if (url.pathname !== '/' || url.search || url.hash || !allowed(url)) {
66
+ blocked = true;
67
+ client.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
68
+ return;
69
+ }
70
+ const upstream = connect({ host: url.hostname.replace(/^\[|\]$/g, ''), port: Number(url.port || 443) });
71
+ track(upstream);
72
+ upstream.once('connect', () => {
73
+ if (signal.aborted || client.destroyed) {
74
+ upstream.destroy();
75
+ return;
76
+ }
77
+ client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
78
+ if (head.length)
79
+ upstream.write(head);
80
+ upstream.pipe(client);
81
+ client.pipe(upstream);
82
+ });
83
+ upstream.once('error', () => client.destroy());
84
+ client.once('close', () => upstream.destroy());
85
+ });
86
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve(); }); });
87
+ const close = () => closing ??= new Promise(resolve => { signal.removeEventListener('abort', abort); for (const socket of sockets)
88
+ socket.destroy(); server.close(() => resolve()); });
89
+ const abort = () => { void close(); };
90
+ signal.addEventListener('abort', abort, { once: true });
91
+ if (signal.aborted)
92
+ await close();
93
+ const address = server.address();
94
+ if (!address || typeof address === 'string')
95
+ throw new Error('Private network unavailable');
96
+ return { proxy: { server: `http://127.0.0.1:${address.port}`, bypass: '<-loopback>' }, wasBlocked: () => blocked, close };
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-chan-secrets",
3
- "version": "4.0.1",
3
+ "version": "5.0.0",
4
4
  "description": "U-Chan Secrets: local MCP server for Bitwarden-backed logins",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,69 +1,69 @@
1
- @echo off
2
- setlocal
3
- set ROOT=%~dp0..
4
-
5
- fltmc.exe >nul 2>&1
6
- if %errorlevel% neq 0 (
7
- echo [*] Requesting Administrator elevation...
8
- powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
9
- exit /b
10
- )
11
-
12
- cd /d "%ROOT%"
13
- echo ========================================================
14
- echo U-Chan Secrets V2 Installation & Setup
15
- echo ========================================================
16
- echo.
17
- echo [*] Step 1: Stopping previous service (if any)...
18
- taskkill /F /IM prompt-helper.exe >nul 2>&1
19
- taskkill /F /IM auth-helper.exe >nul 2>&1
20
- call node dist\cli.js uninstall
21
- echo.
22
- echo [*] Step 2: Installing U-Chan Windows Service...
23
- call node dist\cli.js install
24
- if %errorlevel% neq 0 (
25
- echo.
26
- echo [X] Installation failed with exit code %errorlevel%.
27
- pause
28
- exit /b %errorlevel%
29
- )
30
- icacls C:\ProgramData\u-chan-secrets\bin /grant "Users:(OI)(CI)RX" >nul 2>&1
31
- echo.
32
- echo [*] Step 3: Checking service status...
33
- sc.exe query u-chan-secrets
34
- echo.
35
-
36
- echo [*] Step 4: Bitwarden Vault Login...
37
- set BITWARDENCLI_APPDATA_DIR=C:\ProgramData\u-chan-secrets\bw-data
38
- set BW_BIN=C:\ProgramData\u-chan-secrets\bin\bw.exe
39
- if not exist "%BW_BIN%" (
40
- for /f "delims=" %%i in ('where.exe bw.exe 2^>nul') do set BW_BIN=%%i
41
- )
42
-
43
- if exist "%BW_BIN%" (
44
- "%BW_BIN%" status > "%TEMP%\bw_stat.json" 2>&1
45
- findstr /C:"\"unauthenticated\"" "%TEMP%\bw_stat.json" >nul 2>&1
46
- if %errorlevel% equ 0 (
47
- echo [!] Bitwarden is not logged in for the privileged service.
48
- echo [*] Please log in to your Bitwarden vault below:
49
- "%BW_BIN%" login
50
- ) else (
51
- echo [+] Bitwarden vault is already logged in.
52
- )
53
- del "%TEMP%\bw_stat.json" >nul 2>&1
54
- ) else (
55
- echo [!] Warning: bw.exe not found.
56
- )
57
-
58
- echo.
59
- copy /y C:\ProgramData\u-chan-secrets\service.log "%ROOT%\service-copy.log" >nul 2>&1
60
- copy /y C:\ProgramData\u-chan-secrets\settings.json "%ROOT%\settings-copy.json" >nul 2>&1
61
- echo ========================================================
62
- echo [+] Service and Bitwarden configuration complete!
63
- echo.
64
- echo Next: Close this window, open your normal terminal, and run:
65
- echo u-chan import-v1
66
- echo u-chan unlock
67
- echo ========================================================
68
- echo.
69
- pause
1
+ @echo off
2
+ setlocal
3
+ set ROOT=%~dp0..
4
+
5
+ fltmc.exe >nul 2>&1
6
+ if %errorlevel% neq 0 (
7
+ echo [*] Requesting Administrator elevation...
8
+ powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
9
+ exit /b
10
+ )
11
+
12
+ cd /d "%ROOT%"
13
+ echo ========================================================
14
+ echo U-Chan Secrets V2 Installation & Setup
15
+ echo ========================================================
16
+ echo.
17
+ echo [*] Step 1: Stopping previous service (if any)...
18
+ taskkill /F /IM prompt-helper.exe >nul 2>&1
19
+ taskkill /F /IM auth-helper.exe >nul 2>&1
20
+ call node dist\cli.js uninstall
21
+ echo.
22
+ echo [*] Step 2: Installing U-Chan Windows Service...
23
+ call node dist\cli.js install
24
+ if %errorlevel% neq 0 (
25
+ echo.
26
+ echo [X] Installation failed with exit code %errorlevel%.
27
+ pause
28
+ exit /b %errorlevel%
29
+ )
30
+ icacls C:\ProgramData\u-chan-secrets\bin /grant "Users:(OI)(CI)RX" >nul 2>&1
31
+ echo.
32
+ echo [*] Step 3: Checking service status...
33
+ sc.exe query u-chan-secrets
34
+ echo.
35
+
36
+ echo [*] Step 4: Bitwarden Vault Login...
37
+ set BITWARDENCLI_APPDATA_DIR=C:\ProgramData\u-chan-secrets\bw-data
38
+ set BW_BIN=C:\ProgramData\u-chan-secrets\bin\bw.exe
39
+ if not exist "%BW_BIN%" (
40
+ for /f "delims=" %%i in ('where.exe bw.exe 2^>nul') do set BW_BIN=%%i
41
+ )
42
+
43
+ if exist "%BW_BIN%" (
44
+ "%BW_BIN%" status > "%TEMP%\bw_stat.json" 2>&1
45
+ findstr /C:"\"unauthenticated\"" "%TEMP%\bw_stat.json" >nul 2>&1
46
+ if %errorlevel% equ 0 (
47
+ echo [!] Bitwarden is not logged in for the privileged service.
48
+ echo [*] Please log in to your Bitwarden vault below:
49
+ "%BW_BIN%" login
50
+ ) else (
51
+ echo [+] Bitwarden vault is already logged in.
52
+ )
53
+ del "%TEMP%\bw_stat.json" >nul 2>&1
54
+ ) else (
55
+ echo [!] Warning: bw.exe not found.
56
+ )
57
+
58
+ echo.
59
+ copy /y C:\ProgramData\u-chan-secrets\service.log "%ROOT%\service-copy.log" >nul 2>&1
60
+ copy /y C:\ProgramData\u-chan-secrets\settings.json "%ROOT%\settings-copy.json" >nul 2>&1
61
+ echo ========================================================
62
+ echo [+] Service and Bitwarden configuration complete!
63
+ echo.
64
+ echo Next: Close this window, open your normal terminal, and run:
65
+ echo u-chan import-v1
66
+ echo u-chan unlock
67
+ echo ========================================================
68
+ echo.
69
+ pause
@@ -0,0 +1,103 @@
1
+ # Shared user-side Chrome/Edge launcher and read-only process policy. No elevation or vault access.
2
+ function Split-UChanWindowsCommandLine([string]$CommandLine) {
3
+ if (-not ('UChanWindowsCommandLine' -as [type])) {
4
+ Add-Type -TypeDefinition @'
5
+ using System;
6
+ using System.Runtime.InteropServices;
7
+ public static class UChanWindowsCommandLine {
8
+ [DllImport("shell32.dll", SetLastError=true, CharSet=CharSet.Unicode)] static extern IntPtr CommandLineToArgvW(string value, out int count);
9
+ [DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr value);
10
+ public static string[] Split(string value) {
11
+ int count; IntPtr memory=CommandLineToArgvW(value,out count);
12
+ if(memory==IntPtr.Zero) throw new InvalidOperationException("Command line is unavailable");
13
+ try { string[] result=new string[count]; for(int i=0;i<count;i++)result[i]=Marshal.PtrToStringUni(Marshal.ReadIntPtr(memory,i*IntPtr.Size)); return result; }
14
+ finally { LocalFree(memory); }
15
+ }
16
+ }
17
+ '@
18
+ }
19
+ return [UChanWindowsCommandLine]::Split($CommandLine)
20
+ }
21
+ function Test-UChanBrowserProcess($Observation, [string]$Executable, [string]$Profile, [string]$OwnerSid, [int]$Port) {
22
+ if (-not $Observation.ExecutablePath -or -not $Observation.OwnerSid -or -not $Observation.CommandLine -or -not $Observation.Addresses) { return 'unknown' }
23
+ if ($Observation.ExecutablePath -ine $Executable -or $Observation.OwnerSid -ne $OwnerSid) { return 'fail' }
24
+ foreach ($address in $Observation.Addresses) { if ($address -notin @('127.0.0.1','::1')) { return 'fail' } }
25
+ try { $tokens = @(Split-UChanWindowsCommandLine $Observation.CommandLine) } catch { return 'unknown' }
26
+ if ($tokens.Count -lt 1 -or $tokens[0] -ine $Executable) { return 'fail' }
27
+ $profiles = @($tokens | Where-Object { $_ -imatch '^--user-data-dir(?:=|$)' })
28
+ if ($profiles.Count -ne 1 -or $profiles[0] -inotmatch '^--user-data-dir=.+$') { return 'fail' }
29
+ $actual = $profiles[0].Substring('--user-data-dir='.Length)
30
+ try { if ([IO.Path]::GetFullPath($actual).TrimEnd('\') -ine [IO.Path]::GetFullPath($Profile).TrimEnd('\')) { return 'fail' } } catch { return 'fail' }
31
+ $ports = @($tokens | Where-Object { $_ -imatch '^--remote-debugging-port(?:=|$)' })
32
+ if ($ports.Count -ne 1 -or $ports[0] -inotmatch '^--remote-debugging-port=[0-9]+$' -or $ports[0].Substring('--remote-debugging-port='.Length) -ne [string]$Port) { return 'fail' }
33
+ return 'pass'
34
+ }
35
+
36
+ function Get-UChanBrowserConfiguration([string]$Browser, [string]$HomeOverride, [string]$OwnerOverride, [string]$LocalOverride) {
37
+ $userHome = if ($HomeOverride) { $HomeOverride } elseif ($env:U_CHAN_HOME) { $env:U_CHAN_HOME } else { Join-Path $env:APPDATA 'u-chan-secrets' }
38
+ if (-not $Browser) { $Browser = $env:U_CHAN_BROWSER }
39
+ if (-not $Browser) {
40
+ $choice = Join-Path $userHome 'browser.json'
41
+ if (Test-Path -LiteralPath $choice) {
42
+ $item = Get-Item -LiteralPath $choice -Force
43
+ if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Browser preference path is redirected.' }
44
+ $saved = Get-Content -LiteralPath $choice -Raw | ConvertFrom-Json
45
+ if (@($saved.PSObject.Properties).Count -ne 1) { throw 'Invalid browser preference.' }
46
+ $Browser = $saved.browser
47
+ if (-not $Browser) { throw 'Invalid browser preference.' }
48
+ } else { $Browser = 'chrome' }
49
+ }
50
+ if ($Browser -cnotin @('chrome','edge')) { throw 'Choose chrome or edge.' }
51
+ $profile = Join-Path $userHome ($Browser + '-profile')
52
+ $check = [IO.Path]::GetFullPath($profile)
53
+ while ($check) {
54
+ if ((Test-Path -LiteralPath $check) -and ((Get-Item -LiteralPath $check -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Dedicated browser profile is redirected.' }
55
+ $check = [IO.Path]::GetDirectoryName($check)
56
+ }
57
+ $cdp = [Uri]$(if ($env:U_CHAN_CDP) { $env:U_CHAN_CDP } else { 'http://127.0.0.1:9222' })
58
+ if ($cdp.Scheme -ne 'http' -or $cdp.Host -notin @('127.0.0.1','localhost','[::1]','::1') -or $cdp.UserInfo -or $cdp.AbsolutePath -ne '/' -or $cdp.Query -or $cdp.Fragment) { throw 'Use an HTTP loopback CDP origin without credentials, path, query or fragment.' }
59
+ $suffix = if ($Browser -eq 'edge') { 'Microsoft\Edge\Application\msedge.exe' } else { 'Google\Chrome\Application\chrome.exe' }
60
+ $local = if ($LocalOverride) { $LocalOverride } else { $env:LOCALAPPDATA }
61
+ $candidates = @(foreach ($base in @($env:ProgramFiles, ${env:ProgramFiles(x86)}, $local)) { if ($base) { Join-Path $base $suffix } })
62
+ $executables = @($candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf })
63
+ $owner = if ($OwnerOverride) { $OwnerOverride } else { [Security.Principal.WindowsIdentity]::GetCurrent().User.Value }
64
+ return @{ Browser=$Browser; Profile=$profile; Endpoint=$cdp; Executables=$executables; OwnerSid=$owner }
65
+ }
66
+
67
+ # Privileged checks derive the desktop home from the installed owner's SID, never SYSTEM's APPDATA or user preference files.
68
+ function Get-UChanOwnerBrowserConfiguration([string]$OwnerSid, [string]$Browser) {
69
+ if ($OwnerSid -notmatch '^S-1-5-21-(?:[0-9]+-){3}[0-9]+$') { throw 'Installed desktop owner is invalid.' }
70
+ $profile = (Get-ItemProperty -LiteralPath ('HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\'+$OwnerSid) -ErrorAction Stop).ProfileImagePath
71
+ if (-not $profile) { throw 'Installed desktop owner profile is unavailable.' }
72
+ $profile = [Environment]::ExpandEnvironmentVariables($profile)
73
+ $roaming = Join-Path $profile 'AppData\Roaming'
74
+ $local = Join-Path $profile 'AppData\Local'
75
+ $shell = Get-ItemProperty -LiteralPath ('Registry::HKEY_USERS\'+$OwnerSid+'\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders') -ErrorAction SilentlyContinue
76
+ if ($shell.AppData) { $roaming = [Environment]::ExpandEnvironmentVariables(($shell.AppData -ireplace '%USERPROFILE%', $profile)) }
77
+ if ($shell.'Local AppData') { $local = [Environment]::ExpandEnvironmentVariables(($shell.'Local AppData' -ireplace '%USERPROFILE%', $profile)) }
78
+ $desktopHome = if ($env:U_CHAN_DESKTOP_HOME) { $env:U_CHAN_DESKTOP_HOME } else { Join-Path $roaming 'u-chan-secrets' }
79
+ return Get-UChanBrowserConfiguration $Browser $desktopHome $OwnerSid $local
80
+ }
81
+
82
+ function Get-UChanBrowserState($Configuration) {
83
+ try {
84
+ $listeners = @(Get-NetTCPConnection -State Listen -ErrorAction Stop | Where-Object LocalPort -eq $Configuration.Endpoint.Port)
85
+ if ($listeners.Count -eq 0) { return 'missing' }
86
+ $owners = @($listeners.OwningProcess | Select-Object -Unique)
87
+ if ($owners.Count -ne 1) { return 'fail' }
88
+ $process = Get-CimInstance Win32_Process -Filter ("ProcessId=" + $owners[0]) -ErrorAction Stop
89
+ if (-not $process) { return 'unknown' }
90
+ $owner = Invoke-CimMethod -InputObject $process -MethodName GetOwnerSid -ErrorAction Stop
91
+ $observation = @{ ExecutablePath=$process.ExecutablePath; OwnerSid=$owner.Sid; CommandLine=$process.CommandLine; Addresses=@($listeners.LocalAddress) }
92
+ if (-not $process.ExecutablePath) { return 'unknown' }
93
+ if ($process.ExecutablePath -notin $Configuration.Executables) { return 'fail' }
94
+ $policy = Test-UChanBrowserProcess $observation $process.ExecutablePath $Configuration.Profile $Configuration.OwnerSid $Configuration.Endpoint.Port
95
+ if ($policy -ne 'pass') { return $policy }
96
+ $version = Invoke-RestMethod -Uri ([Uri]::new($Configuration.Endpoint,'/json/version')) -TimeoutSec 2 -ErrorAction Stop
97
+ $socket = [Uri]$version.webSocketDebuggerUrl
98
+ if ($socket.Scheme -ne 'ws' -or $socket.Host -notin @('127.0.0.1','localhost','[::1]','::1') -or $socket.Port -ne $Configuration.Endpoint.Port -or $socket.UserInfo) { return 'fail' }
99
+ $edge = $version.'User-Agent' -match '(?:Edg|Edge)/'
100
+ if (($Configuration.Browser -eq 'edge') -ne $edge) { return 'fail' }
101
+ return 'pass'
102
+ } catch { return 'unknown' }
103
+ }
@@ -1,21 +1,21 @@
1
- @echo off
2
- setlocal
3
- set ROOT=%~dp0..
4
-
5
- fltmc.exe >nul 2>&1
6
- if %errorlevel% neq 0 (
7
- echo [*] Requesting Administrator elevation...
8
- powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
9
- exit /b
10
- )
11
-
12
- set BITWARDENCLI_APPDATA_DIR=C:\ProgramData\u-chan-secrets\bw-data
13
- set BW_BIN=C:\ProgramData\u-chan-secrets\bin\bw.exe
14
- if not exist "%BW_BIN%" (
15
- for /f "delims=" %%i in ('where.exe bw.exe 2^>nul') do set BW_BIN=%%i
16
- )
17
-
18
- echo [*] Logging in to Bitwarden for U-Chan Service Store...
19
- "%BW_BIN%" login
20
- echo.
21
- pause
1
+ @echo off
2
+ setlocal
3
+ set ROOT=%~dp0..
4
+
5
+ fltmc.exe >nul 2>&1
6
+ if %errorlevel% neq 0 (
7
+ echo [*] Requesting Administrator elevation...
8
+ powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
9
+ exit /b
10
+ )
11
+
12
+ set BITWARDENCLI_APPDATA_DIR=C:\ProgramData\u-chan-secrets\bw-data
13
+ set BW_BIN=C:\ProgramData\u-chan-secrets\bin\bw.exe
14
+ if not exist "%BW_BIN%" (
15
+ for /f "delims=" %%i in ('where.exe bw.exe 2^>nul') do set BW_BIN=%%i
16
+ )
17
+
18
+ echo [*] Logging in to Bitwarden for U-Chan Service Store...
19
+ "%BW_BIN%" login
20
+ echo.
21
+ pause
@@ -1,4 +1,4 @@
1
- @echo off
2
- setlocal
3
- call "%~dp0debug-install.bat" %*
4
-
1
+ @echo off
2
+ setlocal
3
+ call "%~dp0debug-install.bat" %*
4
+
@@ -0,0 +1,33 @@
1
+ param([ValidateSet('chrome','edge')][string]$Browser, [switch]$Background, [switch]$CheckOnly, [string]$DesktopOwnerSid)
2
+ $ErrorActionPreference='Stop'
3
+ . (Join-Path $PSScriptRoot 'desktop-browser.ps1')
4
+ if ($DesktopOwnerSid) {
5
+ if (-not $CheckOnly) { throw 'An owner-specific inspection cannot launch a desktop browser.' }
6
+ $observed = 'unknown'
7
+ $kinds = if ($Browser) { @($Browser) } else { @('chrome','edge') }
8
+ foreach ($kind in $kinds) {
9
+ $configuration = Get-UChanOwnerBrowserConfiguration $DesktopOwnerSid $kind
10
+ $candidate = Get-UChanBrowserState $configuration
11
+ if ($candidate -eq 'pass') { 'pass'; return }
12
+ if ($candidate -eq 'fail' -or $candidate -eq 'missing') { $observed = 'fail' }
13
+ }
14
+ $observed; return
15
+ }
16
+ $configuration=Get-UChanBrowserConfiguration $Browser
17
+ $state=Get-UChanBrowserState $configuration
18
+ if ($CheckOnly) { if ($state -eq 'missing') { 'fail' } else { $state }; return }
19
+ if ($state -eq 'pass') { Write-Host ($configuration.Browser + ' dedicated browser is ready.'); return }
20
+ if ($state -ne 'missing') { throw 'The browser endpoint is occupied, mismatched, or unverifiable. Close only the previous dedicated U-Chan browser before switching; keep personal windows open.' }
21
+ if ($configuration.Executables.Count -eq 0) { throw ($configuration.Browser + ' is not installed. Install it or explicitly select the other supported browser.') }
22
+ New-Item -ItemType Directory -Force -Path $configuration.Profile | Out-Null
23
+ $arguments=@(('--remote-debugging-port='+$configuration.Endpoint.Port), '--remote-debugging-address=127.0.0.1', ('--user-data-dir="'+$configuration.Profile+'"'), '--no-first-run')
24
+ if ($Background) { $arguments += '--no-startup-window' }
25
+ Start-Process -FilePath $configuration.Executables[0] -ArgumentList $arguments -WindowStyle Hidden
26
+ $deadline=[DateTime]::UtcNow.AddSeconds(15)
27
+ do {
28
+ Start-Sleep -Milliseconds 300
29
+ $state=Get-UChanBrowserState $configuration
30
+ if ($state -eq 'pass') { Write-Host ($configuration.Browser + ' dedicated browser started.'); return }
31
+ if ($state -eq 'fail') { break }
32
+ } while ([DateTime]::UtcNow -lt $deadline)
33
+ throw 'Selected dedicated browser did not become verifiably ready. Check browser policy and run setup again.'
@@ -1,39 +1,5 @@
1
- param([switch]$Background)
1
+ param([switch]$Background, [switch]$CheckOnly)
2
2
  $ErrorActionPreference = 'Stop'
3
- $home_ = if ($env:U_CHAN_HOME) { $env:U_CHAN_HOME } else { Join-Path $env:APPDATA 'u-chan-secrets' }
4
- $profileDir = Join-Path $home_ 'chrome-profile' # dedicated profile, never the default Chrome profile
5
- $cdp = [Uri]$(if ($env:U_CHAN_CDP) { $env:U_CHAN_CDP } else { 'http://127.0.0.1:9222' })
6
- if ($cdp.Scheme -ne 'http' -or $cdp.Host -notin @('127.0.0.1', 'localhost', '[::1]', '::1') -or $cdp.UserInfo -or $cdp.AbsolutePath -ne '/' -or $cdp.Query -or $cdp.Fragment) {
7
- throw 'Use an HTTP loopback CDP origin without user information, path, query or fragment.'
8
- }
9
- $port = $cdp.Port
10
- $bindHost = $cdp.Host
11
-
12
- # Already listening? Then do nothing (short TCP probe; Test-NetConnection is slow).
13
- $client = New-Object System.Net.Sockets.TcpClient
14
- try {
15
- $iar = $client.BeginConnect($bindHost, $port, $null, $null)
16
- if ($iar.AsyncWaitHandle.WaitOne(500) -and $client.Connected) {
17
- Write-Host "Port $port already open; Chrome is already running with remote debugging."
18
- return
19
- }
20
- } catch { } finally { $client.Close() }
21
-
22
- $candidates = @(
23
- (Join-Path $env:ProgramFiles 'Google\Chrome\Application\chrome.exe'),
24
- (Join-Path ${env:ProgramFiles(x86)} 'Google\Chrome\Application\chrome.exe'),
25
- (Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe')
26
- )
27
- $chrome = $candidates | Where-Object { $_ -and (Test-Path $_) } | Select-Object -First 1
28
- if (-not $chrome) { throw 'chrome.exe not found' }
29
-
30
- New-Item -ItemType Directory -Force -Path $profileDir | Out-Null
31
- $chromeArgs = @(
32
- "--remote-debugging-port=$port",
33
- "--remote-debugging-address=$bindHost",
34
- "--user-data-dir=`"$profileDir`"",
35
- '--no-first-run'
36
- )
37
- if ($Background) { $chromeArgs += '--no-startup-window' }
38
- Start-Process -FilePath $chrome -ArgumentList $chromeArgs -WindowStyle Hidden
39
- Write-Host "Chrome started (profile: $profileDir, CDP: http://${bindHost}:$port)"
3
+ # Compatibility entry point: dedicated profile, never the default Chrome profile.
4
+ # Already listening? Validate browser identity, owner, profile and CDP before reusing it.
5
+ & (Join-Path $PSScriptRoot 'start-browser.ps1') -Browser chrome -Background:$Background -CheckOnly:$CheckOnly
@@ -121,16 +121,26 @@ if (command === '--prepare') {
121
121
  const publicDirectory = await createChannel(runIdArgument ?? randomUUID())
122
122
  const home = path.join(process.env.ProgramData ?? 'C:\\ProgramData', 'u-chan-secrets')
123
123
  const root = path.join(home, '#0 backup', 'browser-probe-' + randomUUID()), task = 'UChanBrowserProbe-' + randomUUID()
124
+ const program = process.argv[4] ? path.resolve(process.argv[4]) : path.join(home, 'program'), endpoint = process.argv[5] ?? 'http://127.0.0.1:9222', matrix = process.argv[6] === 'matrix'
125
+ if (program !== path.join(home, 'program') && !program.startsWith(path.resolve(home, '#0 backup') + path.sep)) throw new Error('Candidate must be in protected backup storage')
126
+ const { safeCdpOrigin } = await import('../dist/core/doctor.js')
127
+ if (!safeCdpOrigin(endpoint)) throw new Error('Unsafe fixture endpoint')
128
+ const desktopHome = matrix ? path.resolve(process.argv[7] ?? '') : undefined
129
+ if (matrix && (!desktopHome.startsWith(path.resolve(os.tmpdir(), 'u-chan-v5-matrix-' + runIdArgument) + path.sep) || !['chrome','edge'].includes(path.basename(desktopHome)))) throw new Error('Unsafe disposable desktop home')
124
130
  let registered = false
125
131
  try {
126
132
  await fs.mkdir(root); await setAdminOnlyAcl(root)
133
+ if (matrix) {
134
+ const settings = JSON.parse(await fs.readFile(path.join(home, 'settings.json'), 'utf8'))
135
+ await fs.writeFile(path.join(root, 'matrix-context.json'), JSON.stringify({ desktopHome, ownerSid: settings.authorizedSid }))
136
+ }
127
137
  const runner = path.join(root, 'runner.mjs')
128
138
  await fs.copyFile(new URL('./windows-security/browser-worker.mjs', import.meta.url), runner)
129
139
  await fs.copyFile(new URL('./windows-security/module-audit.ps1', import.meta.url), path.join(root, 'module-audit.ps1'))
130
140
  await fs.copyFile(new URL('./windows-security/result-policy.mjs', import.meta.url), path.join(root, 'result-policy.mjs'))
131
141
  await setAdminOnlyAcl(root)
132
142
  const transcripts = path.join(process.env.USERPROFILE, '.codex', 'sessions')
133
- await ps(`$ErrorActionPreference='Stop';$a=New-ScheduledTaskAction -Execute ${quote(path.join(home, 'bin', 'node.exe'))} -Argument ${quote('"' + runner + '" "' + root + '" "' + transcripts + '"')} -WorkingDirectory ${quote(root)};$p=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest;$s=New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 3) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries;Register-ScheduledTask -TaskName ${quote(task)} -Action $a -Principal $p -Settings $s | Out-Null`)
143
+ await ps(`$ErrorActionPreference='Stop';$a=New-ScheduledTaskAction -Execute ${quote(path.join(home, 'bin', 'node.exe'))} -Argument ${quote('"' + runner + '" "' + root + '" "' + transcripts + '" "' + program + '" "' + endpoint + '"' + (matrix ? ' matrix' : ''))} -WorkingDirectory ${quote(root)};$p=New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest;$s=New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 3) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries;Register-ScheduledTask -TaskName ${quote(task)} -Action $a -Principal $p -Settings $s | Out-Null`)
134
144
  registered = true; await ps(`Start-ScheduledTask -TaskName ${quote(task)}`)
135
145
  const output = path.join(root, 'browser-result.json'), deadline = Date.now() + 90000
136
146
  while (Date.now() < deadline && !await fs.stat(output).catch(() => null)) await sleep(200)
@@ -10,13 +10,18 @@ import { moduleAuditSucceeded } from './result-policy.mjs'
10
10
  const exec = promisify(execFile), root = path.resolve(process.argv[2])
11
11
  const home = path.join(process.env.ProgramData ?? 'C:\\ProgramData', 'u-chan-secrets')
12
12
  if (!root.startsWith(path.resolve(home, '#0 backup') + path.sep)) throw new Error('Unsafe fixture path')
13
+ const program = process.argv[4] ? path.resolve(process.argv[4]) : path.join(home, 'program')
14
+ if (program !== path.join(home, 'program') && !program.startsWith(path.resolve(home, '#0 backup') + path.sep)) throw new Error('Unsafe candidate path')
15
+ const endpoint = process.argv[5] ?? 'http://127.0.0.1:9222', matrix = process.argv[6] === 'matrix'
13
16
  const result = { ok: false }, started = Date.now()
14
- const load = relative => import(pathToFileURL(path.join(home, 'program', relative)).href)
17
+ const load = relative => import(pathToFileURL(path.join(program, relative)).href)
15
18
  const { stdout: identity } = await exec('whoami.exe', ['/user'], { windowsHide: true })
16
19
  if (!identity.includes('S-1-5-18')) throw new Error('SYSTEM runner required')
17
20
  const username = randomUUID() + '@example.invalid', password = randomUUID() + randomUUID(), cookie = 'u_chan_probe_' + randomUUID().replaceAll('-', '')
18
21
  let server, shared, userPage, browser, provider, bitwarden, bwTimer
19
22
  let receivedCredentials = false
23
+ let sharedInputCount = 0
24
+ let matrixMetadata
20
25
  try {
21
26
  const { chromium } = await load('node_modules/playwright/index.mjs')
22
27
  const { IsolatedLogin } = await load('dist/service/isolated-login.js')
@@ -36,20 +41,41 @@ try {
36
41
  })
37
42
  await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
38
43
  const origin = 'http://127.0.0.1:' + server.address().port
39
- shared = await chromium.connectOverCDP('http://127.0.0.1:9222')
44
+ shared = await chromium.connectOverCDP(endpoint)
45
+ if (matrix) {
46
+ const binding = '__uChanMatrixInput' + randomUUID().replaceAll('-', '')
47
+ await shared.contexts()[0].exposeBinding(binding, () => { sharedInputCount++ })
48
+ await shared.contexts()[0].addInitScript(name => document.addEventListener('input', () => { void window[name]().catch(() => {}) }, true), binding)
49
+ const { inspectServiceDesktopBrowser } = await load('dist/platform/windows/desktop-browser.js')
50
+ matrixMetadata = JSON.parse(await fs.readFile(path.join(root, 'matrix-context.json'), 'utf8'))
51
+ const { observeCdpBrowser } = await load('dist/core/doctor.js')
52
+ result.fixtureBrowser = await observeCdpBrowser(endpoint)
53
+ result.desktopIdentity = await inspectServiceDesktopBrowser(endpoint, matrixMetadata.ownerSid, { ...process.env, U_CHAN_DESKTOP_HOME: matrixMetadata.desktopHome }, result.fixtureBrowser)
54
+ result.wrongBrowserRejected = await inspectServiceDesktopBrowser(endpoint, matrixMetadata.ownerSid, { ...process.env, U_CHAN_DESKTOP_HOME: matrixMetadata.desktopHome }, result.fixtureBrowser === 'edge' ? 'chrome' : 'edge') !== 'pass'
55
+ }
40
56
  userPage = await shared.contexts()[0].newPage()
41
57
  await userPage.addInitScript(() => { window.__uChanProbeInputCount = 0; document.addEventListener('input', () => { window.__uChanProbeInputCount++ }, true) })
42
58
  await userPage.goto(origin)
43
59
  await userPage.locator('input[type=email]').fill('positive-control@example.invalid')
44
60
  result.initScriptPositiveControl = await userPage.evaluate(() => window.__uChanProbeInputCount > 0)
61
+ if (matrix) { await new Promise(resolve => setTimeout(resolve, 150)); result.allPageInputPositiveControl = sharedInputCount > 0; sharedInputCount = 0 }
45
62
  await userPage.goto(origin)
46
- browser = await chromium.launch({ headless: true, executablePath: await privateBrowserPath(home) })
47
- provider = new CdpBrowserProvider('http://127.0.0.1:9222')
48
- const target = new CdpSessionTarget(provider)
63
+ browser = await chromium.launch({ headless: true, executablePath: await privateBrowserPath(home), args: ['--disable-http2', '--disable-quic'] })
64
+ provider = new CdpBrowserProvider(endpoint, matrixMetadata ? { validate: async requested => {
65
+ const { inspectServiceDesktopBrowser } = await load('dist/platform/windows/desktop-browser.js')
66
+ return await inspectServiceDesktopBrowser(endpoint, matrixMetadata.ownerSid, { ...process.env, U_CHAN_DESKTOP_HOME: matrixMetadata.desktopHome }, requested) === 'pass'
67
+ } } : {})
68
+ const target = new CdpSessionTarget(provider, { hostCheck: (_entry, url) => new URL(url).hostname === '127.0.0.1' })
69
+ if (matrix) {
70
+ await provider.withPage(async page => { await page.goto(origin); await page.locator('input[type=email]').fill('cross-connection-control@example.invalid') }, undefined, result.fixtureBrowser)
71
+ await new Promise(resolve => setTimeout(resolve, 150))
72
+ result.crossConnectionInputPositiveControl = sharedInputCount > 0
73
+ sharedInputCount = 0
74
+ }
49
75
  const login = new IsolatedLogin({ launch: async () => browser, target, timeoutMs: 15000, hostCheck: url => new URL(url).hostname === '127.0.0.1' })
50
- const outcome = await login.run({ domain: '127.0.0.1', hosts: ['127.0.0.1'], item: 'disposable', loginUrl: origin + '/login', success: { selector: '#signed-in' } }, async () => ({ username, password }))
76
+ const outcome = await login.run({ domain: '127.0.0.1', hosts: ['127.0.0.1'], item: 'disposable', loginUrl: origin + '/login', success: { selector: '#signed-in' } }, async () => ({ username, password }), { browser: result.fixtureBrowser ?? 'chrome' })
51
77
  result.confirmedFixtureLogin = outcome.status === 'logged_in' && receivedCredentials
52
- result.sharedInputEvents = await userPage.evaluate(() => window.__uChanProbeInputCount)
78
+ result.sharedInputEvents = matrix ? sharedInputCount : await userPage.evaluate(() => window.__uChanProbeInputCount)
53
79
  await userPage.goto(origin + '/account')
54
80
  result.transferredFixtureSession = await userPage.locator('#signed-in').isVisible()
55
81
  const { CoreService } = await load('dist/core/service.js')
@@ -60,9 +86,9 @@ try {
60
86
  await store.upsert(once)
61
87
  let credentialReads = 0
62
88
  const deps = { vault: { status: async () => 'unlocked', getLogin: async () => { credentialReads++; return { username, password } } }, allowlist: store, approver: { ask: async () => false }, audit: { record: async () => {} }, login, mode: async () => 'easy' }
63
- const core = new CoreService(deps), attempts = await Promise.all([core.login(once.domain), core.login(once.domain)])
89
+ const core = new CoreService(deps), attempts = await Promise.all([core.login(once.domain, result.fixtureBrowser ?? 'chrome'), core.login(once.domain, result.fixtureBrowser ?? 'chrome')])
64
90
  const restarted = new CoreService({ ...deps, allowlist: new AllowlistStore(grantPath) })
65
- const replay = await restarted.login(once.domain)
91
+ const replay = await restarted.login(once.domain, result.fixtureBrowser ?? 'chrome')
66
92
  result.onceGrantConcurrency = attempts.filter(r => r.ok).length === 1 && attempts.filter(r => r.code === 'grant_exhausted').length === 1 && credentialReads === 1 && replay.code === 'grant_exhausted'
67
93
  let wall = Date.now(), mono = 0
68
94
  const timed = { ...once, grantId: randomUUID(), remainingUses: undefined, expiresAt: new Date(wall + 60000).toISOString() }
@@ -71,6 +97,19 @@ try {
71
97
  wall -= 5000; mono += 1000
72
98
  await clock.run(async () => {})
73
99
  result.clockRollbackPersisted = (await new AllowlistStore(grantPath).get(timed.domain)).expiresAt === new Date(0).toISOString()
100
+ if (matrix) {
101
+ const { createPrivateNetwork } = await load('dist/service/private-network.js')
102
+ const network = await createPrivateNetwork(url => new URL(url).protocol === 'https:' && new URL(url).hostname === 'example.com', AbortSignal.timeout(20000))
103
+ const context = await browser.newContext({ serviceWorkers: 'block', proxy: network.proxy })
104
+ try {
105
+ const page = await context.newPage()
106
+ const response = await page.goto('https://example.com/', { waitUntil: 'domcontentloaded', timeout: 15000 })
107
+ const security = await response?.securityDetails()
108
+ result.privateHttpsHttpStatus = response?.status()
109
+ result.privateHttpsProtocol = security?.protocol?.startsWith('TLS') ? security.protocol : 'unknown'
110
+ result.privateHttpsCertificateValidated = !!response && new URL(response.url()).origin === 'https://example.com' && new URL(page.url()).origin === 'https://example.com' && result.privateHttpsProtocol !== 'unknown'
111
+ } finally { await context.close(); await network.close() }
112
+ }
74
113
  await fs.mkdir(path.join(root, 'bw-fixture'))
75
114
  bitwarden = spawn(path.join(home, 'bin', 'bw.exe'), ['status'], { windowsHide: true, stdio: 'ignore', env: { ...process.env, BITWARDENCLI_APPDATA_DIR: path.join(root, 'bw-fixture'), BW_NOINTERACTION: 'true' } })
76
115
  const bwFinished = new Promise(resolve => bitwarden.once('exit', resolve))
@@ -103,7 +142,7 @@ try {
103
142
  if (verified.ok) {
104
143
  const updateHome = path.join(root, 'update-fixture'), programDir = path.join(updateHome, 'program')
105
144
  await fs.mkdir(path.join(programDir, 'node_modules'), { recursive: true })
106
- const pkg = JSON.parse(await fs.readFile(path.join(home, 'program', 'package.json'), 'utf8'))
145
+ const pkg = JSON.parse(await fs.readFile(path.join(program, 'package.json'), 'utf8'))
107
146
  await fs.writeFile(path.join(programDir, 'package.json'), JSON.stringify({ ...pkg, version: '3.0.1' }))
108
147
  await fs.writeFile(path.join(programDir, 'node_modules', 'private-marker'), 'preserve-private-dependencies')
109
148
  const state = JSON.stringify({ protectedFixture: randomUUID() })
@@ -115,6 +154,7 @@ try {
115
154
  result.signedUpdateRehearsal = updated.ok && JSON.parse(await fs.readFile(path.join(programDir, 'package.json'), 'utf8')).version === '3.0.2' && await fs.readFile(path.join(programDir, 'node_modules', 'private-marker'), 'utf8') === 'preserve-private-dependencies' && await fs.readFile(path.join(updateHome, 'settings.json'), 'utf8') === state && restarts === 1
116
155
  }
117
156
  result.ok = result.initScriptPositiveControl && result.confirmedFixtureLogin && result.sharedInputEvents === 0 && result.transferredFixtureSession && result.onceGrantConcurrency && result.clockRollbackPersisted && moduleAuditSucceeded(result.nativeModules) && result.realRegistryVerification && result.signedUpdateRehearsal && matches === 0
157
+ if (matrix) result.ok &&= result.allPageInputPositiveControl && result.crossConnectionInputPositiveControl && result.desktopIdentity === 'pass' && result.wrongBrowserRejected && result.privateHttpsCertificateValidated && ['chrome','edge'].includes(result.fixtureBrowser)
118
158
  } catch (e) { result.errorType = e.name }
119
159
  finally {
120
160
  clearTimeout(bwTimer); if (bitwarden && bitwarden.exitCode === null) bitwarden.kill()