u-chan-secrets 4.0.1 → 5.0.1

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.
@@ -3,7 +3,9 @@ import { isHostAllowed } from '../core/allowlist.js';
3
3
  import { abortable } from '../core/abort.js';
4
4
  import { filterState } from './session-transfer.js';
5
5
  import { UserFacingError } from '../core/types.js';
6
- /** Signs in inside a service-owned browser, then hands only the entry's session to the user's Chrome. */
6
+ import { waitForLoginSuccess } from '../core/login-success.js';
7
+ import { createPrivateNetwork } from './private-network.js';
8
+ /** Signs in inside a service-owned browser, then verifies only the approved session in the user's selected browser. */
7
9
  export class IsolatedLogin {
8
10
  o;
9
11
  browser;
@@ -17,19 +19,23 @@ export class IsolatedLogin {
17
19
  else
18
20
  await this.o.target.cancel?.();
19
21
  }
20
- async preflight(signal) {
21
- const check = signal ? AbortSignal.any([signal, AbortSignal.timeout(5000)]) : AbortSignal.timeout(5000);
22
+ async preflight(signal, browser = 'chrome') {
23
+ const check = signal ? AbortSignal.any([signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000);
22
24
  try {
25
+ if (this.o.target.bindBrowser && !await abortable(this.o.target.bindBrowser(browser, check), check, async () => { await this.o.target.cancel?.(); }))
26
+ throw new Error('browser mismatch');
23
27
  await abortable(this.o.target.userAgent(check), check, async () => { await this.o.target.cancel?.(); });
24
28
  }
25
29
  catch {
26
- throw new UserFacingError(signal?.aborted ? 'login timed out; retry if needed' : 'dedicated Chrome is unavailable; start its launcher', signal?.aborted ? 'login_timeout' : 'chrome_unavailable');
30
+ throw new UserFacingError(signal?.aborted ? 'login timed out; retry if needed' : 'selected dedicated browser is unavailable; run u-chan setup --start-browser', signal?.aborted ? 'login_timeout' : 'browser_unavailable');
27
31
  }
28
32
  }
29
33
  async run(entry, getCreds, options = {}) {
30
34
  const signal = options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(60000)]) : AbortSignal.timeout(60000);
31
35
  const hostCheck = this.o.hostCheck ?? ((url) => isHostAllowed(entry, url));
32
36
  let browser = this.browser, ctx;
37
+ let transferStarted = false;
38
+ let network;
33
39
  let closing, stopping;
34
40
  const closeBrowser = async () => {
35
41
  const current = browser;
@@ -41,16 +47,21 @@ export class IsolatedLogin {
41
47
  const stop = () => stopping ??= Promise.allSettled([
42
48
  ctx ? closeContext() : closeBrowser(),
43
49
  this.o.target.cancel?.(),
50
+ network?.close(),
44
51
  ]).then(() => { });
45
52
  this.cancellation = stop;
46
53
  try {
47
54
  signal.throwIfAborted();
55
+ if (this.o.target.bindBrowser && !await abortable(this.o.target.bindBrowser(options.browser ?? 'chrome', signal), signal, stop))
56
+ throw new UserFacingError('selected dedicated browser is unavailable or mismatched', 'browser_unavailable');
48
57
  const userAgent = await abortable(this.o.target.userAgent(signal), signal, stop);
49
58
  if (!browser?.isConnected()) {
50
59
  browser = await abortable(this.o.launch(), signal, stop, b => b.close());
51
60
  this.browser = browser;
52
61
  }
53
- ctx = await abortable(browser.newContext({ userAgent }), signal, stop, c => c.close());
62
+ const fixtureOrigin = this.o.hostCheck && new URL(entry.loginUrl).protocol === 'http:' ? new URL(entry.loginUrl).origin : undefined;
63
+ network = await abortable(createPrivateNetwork(hostCheck, signal, fixtureOrigin), signal, stop, n => n.close());
64
+ ctx = await abortable(browser.newContext({ userAgent, serviceWorkers: 'block', proxy: network.proxy }), signal, stop, c => c.close());
54
65
  const page = await abortable(ctx.newPage(), signal, stop, p => p.close());
55
66
  const checkHost = () => {
56
67
  if (!hostCheck(page.url()))
@@ -58,12 +69,14 @@ export class IsolatedLogin {
58
69
  };
59
70
  const outcome = await abortable(fillLogin(page, entry, getCreds, { timeoutMs: this.o.timeoutMs, hostCheck }), signal, stop);
60
71
  if (outcome.status === 'failed')
61
- return { ...outcome, code: outcome.reason?.includes('needs you') ? 'needs_human' : outcome.reason?.includes('form still present') || outcome.reason?.includes('rejected') ? 'authentication_unconfirmed' : 'unsupported_form' };
72
+ return network.wasBlocked()
73
+ ? { status: 'failed', code: 'unsupported_form', reason: 'an unapproved private-browser request was blocked; session was not transferred' }
74
+ : { ...outcome, code: outcome.code ?? 'unsupported_form' };
62
75
  checkHost();
63
76
  if (entry.success) {
64
77
  let matched = false;
65
78
  try {
66
- matched = 'url' in entry.success ? page.url() === entry.success.url : await abortable(page.locator(entry.success.selector).first().isVisible(), signal, stop);
79
+ matched = await abortable(waitForLoginSuccess(page, entry.success, hostCheck, signal, this.o.timeoutMs ?? 15000), signal, stop);
67
80
  }
68
81
  catch (e) {
69
82
  if (signal.aborted)
@@ -80,19 +93,28 @@ export class IsolatedLogin {
80
93
  signal.throwIfAborted();
81
94
  await options.beforeTransfer?.();
82
95
  checkHost();
96
+ transferStarted = true;
83
97
  try {
84
98
  await abortable(this.o.target.inject(state, signal), signal, stop);
85
99
  }
86
100
  catch (e) {
87
101
  if (signal.aborted)
88
102
  throw e;
89
- return { status: 'failed', reason: 'could not hand the session to Chrome', code: 'session_transfer_failed' };
103
+ return { status: 'failed', reason: 'session transfer did not complete; some browser session state may already exist', code: 'session_transfer_failed' };
90
104
  }
91
- return entry.success ? { status: 'logged_in' } : { status: 'unconfirmed', code: 'authentication_unconfirmed', reason: 'session state transferred to Chrome; sign-in was not confirmed; inspect the site before continuing' };
105
+ await options.beforeTransfer?.();
106
+ signal.throwIfAborted();
107
+ if (entry.success) {
108
+ const verified = this.o.target.verify ? await abortable(this.o.target.verify(entry, page.url(), signal), signal, stop) : false;
109
+ return verified ? { status: 'logged_in' } : { status: 'unconfirmed', code: 'destination_unconfirmed', reason: 'session state was transferred, but login was not confirmed in the selected browser; inspect the site before continuing' };
110
+ }
111
+ return { status: 'unconfirmed', code: 'authentication_unconfirmed', reason: 'session state transferred to the selected browser; sign-in was not confirmed; inspect the site before continuing' };
92
112
  }
93
113
  catch (e) {
94
114
  if (signal.aborted)
95
- return { status: 'failed', code: 'login_timeout', reason: 'login cancelled or timed out; retry with fresh approval if needed' };
115
+ return { status: 'failed', code: 'login_timeout', reason: transferStarted ? 'login cancelled or timed out after transfer started; browser session state may already exist and login is unconfirmed' : 'login cancelled or timed out; retry with fresh approval if needed' };
116
+ if (transferStarted)
117
+ return { status: 'unconfirmed', code: 'destination_unconfirmed', reason: 'destination verification did not complete; browser session state may already exist and login is unconfirmed' };
96
118
  if (e instanceof UserFacingError && e.code === 'unsupported_form')
97
119
  return { status: 'failed', code: e.code, reason: e.message };
98
120
  throw e;
@@ -102,6 +124,7 @@ export class IsolatedLogin {
102
124
  await stop();
103
125
  else
104
126
  await closeContext();
127
+ await network?.close();
105
128
  this.cancellation = undefined;
106
129
  }
107
130
  }
@@ -25,6 +25,7 @@ import { ensureV3State } from './state.js';
25
25
  import { ManagementService } from './manage.js';
26
26
  import { privateBrowserPath } from '../platform/runtime-assets.js';
27
27
  import { safeVersion } from '../core/doctor.js';
28
+ import { inspectServiceDesktopBrowser } from '../platform/windows/desktop-browser.js';
28
29
  export function prepareSocketEndpoint(endpoint, platform = process.platform) {
29
30
  if (platform !== 'win32') {
30
31
  const dir = dirname(endpoint);
@@ -60,9 +61,14 @@ export async function runService(platform = process.platform, env = process.env)
60
61
  const allowlist = new AllowlistStore(cfg.allowlistPath, () => stateReady ??= ensureV3State(cfg.home).catch(e => { stateReady = undefined; throw e; }));
61
62
  const grants = new GrantCoordinator(allowlist);
62
63
  const audit = new FileAuditLog(cfg.auditPath);
64
+ const endpoint = loadConfig(env, platform).cdpEndpoint;
65
+ const validateTarget = platform === 'win32' ? async (browser = 'chrome') => {
66
+ const owner = (await settings.get()).authorizedSid;
67
+ return typeof owner === 'string' && await inspectServiceDesktopBrowser(endpoint, owner, env, browser) === 'pass';
68
+ } : undefined;
63
69
  const login = new IsolatedLogin({
64
- launch: async () => chromium.launch({ headless: true, executablePath: await privateBrowserPath(cfg.home), timeout: 15000 }),
65
- target: new CdpSessionTarget(new CdpBrowserProvider(loadConfig(env, platform).cdpEndpoint)),
70
+ launch: async () => chromium.launch({ headless: true, executablePath: await privateBrowserPath(cfg.home), timeout: 15000, args: ['--disable-http2', '--disable-quic'] }),
71
+ target: new CdpSessionTarget(new CdpBrowserProvider(endpoint, { validate: validateTarget })),
66
72
  });
67
73
  const core = new CoreService({ vault, allowlist, grants, approver, audit, login, mode: () => approver.effectiveMode() });
68
74
  let currentVersion = 'unknown';
@@ -94,7 +100,7 @@ export async function runService(platform = process.platform, env = process.env)
94
100
  capabilities: () => control.capabilities(),
95
101
  diagnose: () => control.diagnose(),
96
102
  manage: () => control.manage(),
97
- login: (domain) => core.login(domain),
103
+ login: (domain, browser) => core.login(domain, browser),
98
104
  requestAllow: (domain, loginUrl, item, selectors, options) => core.requestAllow(domain, loginUrl, item, selectors, options),
99
105
  revoke: (domain) => core.revoke(domain),
100
106
  listAllowed: () => core.listAllowed(),
@@ -26,12 +26,13 @@ export class ManagementService {
26
26
  const grants = all.slice(page * 20, page * 20 + 20);
27
27
  const lines = ['Vault: ' + status, lastAction, 'Grant page ' + (page + 1) + ': ' + grants.length + ' of ' + entries.length + ' shown. Use Previous/Next for more.'];
28
28
  lines.push('Service version: ' + (safeVersion(checks?.serviceVersion) ?? 'unknown'), 'Service browser: ' + (['pass', 'fail'].includes(checks?.browser ?? '') ? checks?.browser : 'unknown'));
29
+ lines.push('Observed desktop browser: ' + (checks?.browserKind === 'edge' ? 'Edge' : checks?.browserKind === 'chrome' ? 'Chrome' : 'unknown'));
29
30
  if (status === 'locked')
30
31
  lines.push('Next step: use Unlock to approve a protected vault unlock.');
31
32
  if (checks?.assets !== 'pass' || checks?.permissions !== 'pass')
32
33
  lines.push('Next step: run u-chan doctor; repair from a trusted administrator terminal.');
33
34
  if (checks?.browser !== 'pass')
34
- lines.push('Next step: start dedicated Chrome and run u-chan setup.');
35
+ lines.push('Next step: start the selected dedicated browser and run u-chan setup.');
35
36
  for (const e of grants) {
36
37
  let label = e.expiresAt ? 'until ' + e.expiresAt : e.remainingUses !== undefined ? e.remainingUses + ' attempts remaining' : 'permanent';
37
38
  try {
@@ -42,7 +43,7 @@ export class ManagementService {
42
43
  }
43
44
  lines.push(clean(e.domain) + ' — ' + label);
44
45
  }
45
- lines.push('Revocation stops future logins. It does not log out existing Chrome sessions.', 'Revocation waits for any earlier login to finish.', 'Diagnostics: assets ' + (checks?.assets ?? 'unknown') + '; permissions ' + (checks?.permissions ?? 'unknown'), 'Recent activity:');
46
+ lines.push('Revocation stops future logins. It does not log out existing browser sessions.', 'Revocation waits for any earlier login to finish.', 'Diagnostics: assets ' + (checks?.assets ?? 'unknown') + '; permissions ' + (checks?.permissions ?? 'unknown'), 'Recent activity:');
46
47
  for (const e of events.slice(-10)) {
47
48
  const ts = Number.isFinite(Date.parse(e.ts)) ? new Date(e.ts).toISOString() : 'unknown time';
48
49
  const action = ['login', 'request_allow', 'revoke', 'unlock', 'lock', 'mode', 'import', 'update'].includes(e.action) ? e.action : 'event';
@@ -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.1",
4
4
  "description": "U-Chan Secrets: local MCP server for Bitwarden-backed logins",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,110 @@
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-Item -LiteralPath ('Registry::HKEY_USERS\'+$OwnerSid+'\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders') -ErrorAction SilentlyContinue
76
+ if ($shell) {
77
+ # Read REG_EXPAND_SZ literally; Registry providers otherwise expand USERPROFILE as SYSTEM before substitution.
78
+ try {
79
+ $rawRoaming = $shell.GetValue('AppData', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
80
+ $rawLocal = $shell.GetValue('Local AppData', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
81
+ } finally { $shell.Close() }
82
+ if ($rawRoaming) { $roaming = [Environment]::ExpandEnvironmentVariables(($rawRoaming -ireplace '%USERPROFILE%', $profile)) }
83
+ if ($rawLocal) { $local = [Environment]::ExpandEnvironmentVariables(($rawLocal -ireplace '%USERPROFILE%', $profile)) }
84
+ }
85
+ $desktopHome = if ($env:U_CHAN_DESKTOP_HOME) { $env:U_CHAN_DESKTOP_HOME } else { Join-Path $roaming 'u-chan-secrets' }
86
+ return Get-UChanBrowserConfiguration $Browser $desktopHome $OwnerSid $local
87
+ }
88
+
89
+ function Get-UChanBrowserState($Configuration) {
90
+ try {
91
+ $listeners = @(Get-NetTCPConnection -State Listen -ErrorAction Stop | Where-Object LocalPort -eq $Configuration.Endpoint.Port)
92
+ if ($listeners.Count -eq 0) { return 'missing' }
93
+ $owners = @($listeners.OwningProcess | Select-Object -Unique)
94
+ if ($owners.Count -ne 1) { return 'fail' }
95
+ $process = Get-CimInstance Win32_Process -Filter ("ProcessId=" + $owners[0]) -ErrorAction Stop
96
+ if (-not $process) { return 'unknown' }
97
+ $owner = Invoke-CimMethod -InputObject $process -MethodName GetOwnerSid -ErrorAction Stop
98
+ $observation = @{ ExecutablePath=$process.ExecutablePath; OwnerSid=$owner.Sid; CommandLine=$process.CommandLine; Addresses=@($listeners.LocalAddress) }
99
+ if (-not $process.ExecutablePath) { return 'unknown' }
100
+ if ($process.ExecutablePath -notin $Configuration.Executables) { return 'fail' }
101
+ $policy = Test-UChanBrowserProcess $observation $process.ExecutablePath $Configuration.Profile $Configuration.OwnerSid $Configuration.Endpoint.Port
102
+ if ($policy -ne 'pass') { return $policy }
103
+ $version = Invoke-RestMethod -Uri ([Uri]::new($Configuration.Endpoint,'/json/version')) -TimeoutSec 2 -ErrorAction Stop
104
+ $socket = [Uri]$version.webSocketDebuggerUrl
105
+ 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' }
106
+ $edge = $version.'User-Agent' -match '(?:Edg|Edge)/'
107
+ if (($Configuration.Browser -eq 'edge') -ne $edge) { return 'fail' }
108
+ return 'pass'
109
+ } catch { return 'unknown' }
110
+ }
@@ -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)