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.
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
- # U-Chan Secrets V4
1
+ # U-Chan Secrets V5
2
2
 
3
- A local MCP server that signs into approved websites with Bitwarden credentials. A privileged service performs credential entry in an isolated browser and transfers filtered session state into a dedicated user Chrome profile.
3
+ A local MCP server that signs into approved websites with Bitwarden credentials. A privileged service performs credential entry in an isolated browser and transfers filtered session state into a dedicated Chrome or Edge profile.
4
4
 
5
- Version `4.0.0` adds guided Windows/Codex setup, installed-version and browser-target checks, clearer native status, and CLI proposals for scoped login approval. Protocol and protected-state schema remain version 3.
5
+ Version `5.0.0` adds selectable Chrome/Edge desktop destinations and checks approved login evidence before and after session transfer. Chrome remains the default when no browser has been selected. Protocol and protected-state schema remain version 3. Source integration, publication, and installed verification are separate events.
6
6
 
7
- **Verification:** Windows/macOS build and test CI is separate from installed-service security verification. Native process identity, input isolation, permissions, and installation rehearsals remain [release gates](docs/attack-checklist.md). See [validation evidence](docs/superpowers/research/2026-10-29-v3-validation.md) and [SECURITY.md](SECURITY.md).
7
+ **Verification:** Windows/macOS build and test CI is separate from installed-service security verification. Native process identity, input isolation, permissions, and installation rehearsals remain [release gates](docs/attack-checklist.md). See [V5 validation evidence](docs/superpowers/research/2026-10-30-v5-validation.md) and [SECURITY.md](SECURITY.md).
8
8
 
9
9
  The [Windows V4 rollout](docs/superpowers/research/2026-10-29-v4-rollout.md) records matching service/CLI versions, preserved protected state, real setup and fresh MCP/browser checks. It builds on the tightened V3.0.3 [native closeout](docs/superpowers/research/2026-10-29-v3-windows-closeout.md). macOS runtime remains unverified; source merge, installed verification, and package publication are recorded separately.
10
10
 
@@ -18,6 +18,43 @@ The [Windows V4 rollout](docs/superpowers/research/2026-10-29-v4-rollout.md) rec
18
18
 
19
19
  Windows Hello and Touch ID remain [research only](docs/superpowers/research/2026-10-29-native-approval.md). Linux, general SSO/OAuth, passkeys, and automated CAPTCHA solving are outside V3.
20
20
 
21
+ ## What V5 adds
22
+
23
+ ### Updating an existing Windows V5 installation to 5.0.1
24
+
25
+ Run these commands from a normal, non-administrator PowerShell terminal:
26
+
27
+ ```powershell
28
+ npm.cmd install --global u-chan-secrets@5.0.1
29
+ u-chan.cmd update
30
+ u-chan.cmd setup --apply
31
+ u-chan doctor
32
+ ```
33
+
34
+ The signed service update restarts U-Chan and clears its in-memory vault unlock. Run `u-chan unlock` afterward and enter the master password only in the protected prompt. The explicit `.cmd` spelling bootstraps setup when npm's PowerShell launcher is blocked; afterward the short `u-chan` command works. Restart the AI session if setup requests it. Existing browser choice, grants, and vault data remain local; a site's success condition must be approved separately on each installation. Older versions with changed dependencies or native helpers may require the trusted administrator reinstall described below.
35
+
36
+ Choose your browser from a normal Windows terminal:
37
+
38
+ ```powershell
39
+ u-chan setup --browser edge --json
40
+ u-chan setup --browser edge --apply --replace-existing --start-browser
41
+ u-chan setup --browser chrome --apply --replace-existing --start-browser
42
+ ```
43
+
44
+ The first command inspects the proposed choice. `--apply` saves it in user-side `browser.json` and registers the matching MCP environment through the official Codex CLI. Later commands without `--browser` keep the saved choice; a new installation defaults to Chrome. Browser and Codex configuration backups are retained under their respective `#0 backup` directories. Each user's choice is independent.
45
+
46
+ Chrome and Edge use separate dedicated profiles. Only one browser owns the configured U-Chan CDP endpoint at a time. When switching, close the previous **dedicated U-Chan instance** and rerun setup; keep your personal browser windows open. U-Chan refuses wrong, unknown, personal-profile, or non-loopback listeners instead of silently attaching, selecting another port/browser, or terminating processes. It does not install browsers or change enterprise debugging policy. The legacy `scripts/start-chrome.ps1` explicitly selects Chrome; `scripts/start-browser.ps1 -Browser edge` selects Edge.
47
+
48
+ The service independently checks the installed owner's SID and dedicated profile before accessing the Windows destination. It derives that user's AppData paths from the profile registry. Advanced installations with a custom desktop profile root must align the trusted service environment's `U_CHAN_DESKTOP_HOME` with the user-side home; changing only a user-side environment variable does not redirect privileged session transfer.
49
+
50
+ An approved exact URL or visible selector must match in both the private login context and a new verification tab in the destination browser before `login` returns success. Existing user tabs are left alone. Missing evidence returns `authentication_unconfirmed`; private success with unverified desktop state returns `destination_unconfirmed`. Transfer failure/cancellation can leave partial session state and does not log the browser out. Expired or consumed grants are not refunded.
51
+
52
+ The private credential-bearing context uses a per-attempt HTTP/CONNECT proxy to block network destinations outside approved hosts, including redirect chains and worker/frame requests. Service workers are disabled. Private Chromium disables HTTP/2 and QUIC so connections cannot coalesce across approved and unapproved origins; HTTP/2-only sites may be unsupported. TLS remains end-to-end with normal browser certificate validation. Some sites requiring third-party resources or external identity providers also remain unsupported; U-Chan does not silently broaden host approval. CAPTCHA, passkey, push approval, or unavailable/rejected TOTP returns `needs_human` with manual sign-in guidance. V5 does not expose or resume the private challenge context, repeat credential submissions, or ask for passwords/codes in AI chat.
53
+
54
+ A V5 client requires the service's `verified_session_transfer` capability before login and binds the request to its selected browser. Update both CLI and service; an older service is refused before a login attempt is sent. An old client without a browser field retains its legacy Chrome destination. A stale MCP browser environment that conflicts with the saved choice is refused until the AI session restarts. Existing grants and vault data remain compatible. Native macOS runtime and ownership by an already-running AI session remain unverified.
55
+
56
+ Other MCP clients may set `U_CHAN_BROWSER=edge` (or `chrome`) and the matching loopback `U_CHAN_CDP` in their own registration, then launch the matching dedicated browser. Automatic registration remains Windows/Codex only; setting a desktop environment variable does not change the privileged service's endpoint.
57
+
21
58
  ## What V4 adds
22
59
 
23
60
  Use a normal, non-administrator terminal after installing the matching trusted service:
@@ -30,12 +67,14 @@ u-chan setup --client codex --apply --replace-existing --start-browser
30
67
 
31
68
  The first command only inspects. `--apply` adds missing Codex MCP registrations through the official Codex CLI. `--replace-existing` also permits replacement of recognized simple registrations. Existing configuration is backed up under the Codex configuration directory's `#0 backup` before mutation. Compatible enabled registrations retain custom environment, working directory, tool filters, timeouts, and other settings. Incompatible custom registrations, conflicting target overrides, disabled entries, and HTTP transports require manual configuration and remain untouched. Partial failures retain completed changes and the backup; setup never restores an obsolete whole configuration over concurrent edits.
32
69
 
33
- `--start-browser` explicitly starts the dedicated Chrome profile in the background when its endpoint is unhealthy. No startup task is added. Setup refuses elevated execution and known service/desktop browser-target mismatches. It never installs the privileged service or changes UAC. Run `u-chan install` separately from a trusted administrator terminal.
70
+ `--start-browser` explicitly starts the selected dedicated browser profile in the background when its endpoint is unhealthy. No startup task is added. Setup refuses elevated execution and known service/desktop browser-target mismatches. It never installs the privileged service or changes UAC. Run `u-chan install` separately from a trusted administrator terminal.
34
71
 
35
72
  Setup reports `configured` or `needs_attention`, plus `sharing` and `restartRequired`. `process_configured` means matching process arguments were observed; it does not verify active AI-session ownership. Unobservable sharing remains `unobserved`. Restart the AI session after registration changes. Exit codes: 1 means attention required, 2 means configured with restart required, and 0 means configured without a known restart requirement. A locked vault remains a valid installed state.
36
73
 
37
74
  Automatic registration supports Codex on Windows only. Configure other clients manually using their documented MCP commands. macOS runtime remains unverified. `doctor` compares the actual CLI and installed service versions, both browser targets, and Windows UAC policy; missing metadata remains unknown. `manage` shows the installed service version and browser status.
38
75
 
76
+ On Windows, `setup --apply` also makes the short `u-chan` command work when PowerShell blocks scripts. It moves the recognized npm-generated `u-chan.ps1` launcher into the npm directory's `#0 backup`, leaving `u-chan.cmd` available for PowerShell to resolve automatically. Execution policy and PowerShell profiles are unchanged. Inspection without `--apply` is read-only, and custom launchers are preserved. If the first setup command is blocked, run `u-chan.cmd setup --apply`. Rerun setup after an npm installation or update recreates the PowerShell launcher. Shell results and the backup path appear in the setup JSON report.
77
+
39
78
  Propose a site grant with an explicit success condition:
40
79
 
41
80
  ```sh
@@ -160,7 +199,7 @@ In Windows PowerShell, use `u-chan.cmd` if execution policy blocks npm's `.ps1`
160
199
  | Command | Purpose |
161
200
  |---|---|
162
201
  | `doctor [--json]` | Read-only diagnostic report and safe next actions |
163
- | `setup [--client codex] [--apply] [--replace-existing] [--start-browser] [--json]` | Windows desktop inspection and explicit Codex/browser setup |
202
+ | `setup [--client codex] [--browser chrome\|edge] [--apply] [--replace-existing] [--start-browser] [--json]` | Windows desktop inspection and explicit Codex/browser setup |
164
203
  | `allow <domain> --login-url <https-url> --item <label> [--once\|--minutes N\|--permanent] [--success-url URL\|--success-selector SELECTOR]` | Propose a scoped grant through protected approval |
165
204
  | `manage` | Native status, grant pages, recent activity, lock/unlock, and revocation |
166
205
  | `unlock` | Request the protected master-password prompt |
package/dist/cli.js CHANGED
@@ -14,6 +14,8 @@ import { join } from 'node:path';
14
14
  import { configureCodex, isElevated } from './client/codex-setup.js';
15
15
  import { runSetup, setupExitCode } from './client/setup.js';
16
16
  import { parseAllowArgs } from './client/allow.js';
17
+ import { saveBrowserChoice } from './client/browser-choice.js';
18
+ import { configureWindowsCommand } from './client/windows-command.js';
17
19
  const USAGE = 'usage: u-chan install [--mode <easy|hardcore>] [--phrase <phrase>] | uninstall | unlock | lock | mode <easy|hardcore> | import-v1 [path] | audit [limit] | update | doctor [--json] | manage';
18
20
  /** import-v1 shows one approval window per entry, each open for up to 2 minutes, one after another. */
19
21
  export const IMPORT_PROMPT_MS = 120000;
@@ -28,10 +30,16 @@ function defaultV1Path(env, platform) {
28
30
  export async function main(argv, d) {
29
31
  const [cmd, arg, ...rest] = argv;
30
32
  let call;
33
+ let diagnosticEnv = d.env;
31
34
  const doctor = d.doctor ?? (() => runDoctor({
32
35
  platform: d.platform,
33
36
  clientVersion: d.clientVersion ?? 'unknown',
34
- clientEndpoint: loadConfig(d.env, d.platform).cdpEndpoint,
37
+ clientEndpoint: loadConfig(diagnosticEnv, d.platform).cdpEndpoint,
38
+ browser: loadConfig(diagnosticEnv, d.platform).browser,
39
+ browserIdentity: d.platform === 'win32' ? async () => {
40
+ const cfg = loadConfig(diagnosticEnv, d.platform);
41
+ return (await import('./platform/windows/desktop-browser.js')).inspectDesktopBrowser({ endpoint: cfg.cdpEndpoint, browser: cfg.browser, profile: cfg.browserProfile }, diagnosticEnv);
42
+ } : undefined,
35
43
  elevation: d.platform === 'win32' ? async () => (await import('./platform/windows/doctor.js')).checkWindowsUac() : undefined,
36
44
  registration: async () => d.platform === 'win32' ? (await import('./platform/windows/doctor.js')).checkWindowsRegistration() : d.platform === 'darwin' ? (await import('./platform/darwin/doctor.js')).checkDarwinRegistration() : 'fail',
37
45
  service: async () => {
@@ -43,11 +51,11 @@ export async function main(argv, d) {
43
51
  throw new Error('invalid diagnostics');
44
52
  return s;
45
53
  },
46
- chrome: () => chromeState(loadConfig(d.env, d.platform).cdpEndpoint),
54
+ chrome: () => chromeState(loadConfig(diagnosticEnv, d.platform).cdpEndpoint),
47
55
  playwright: async () => {
48
56
  if (d.platform !== 'win32')
49
57
  return 'unknown';
50
- const state = await checkPlaywrightNow(process.pid, loadConfig(d.env, d.platform).cdpEndpoint);
58
+ const state = await checkPlaywrightNow(process.pid, loadConfig(diagnosticEnv, d.platform).cdpEndpoint);
51
59
  return state === 'ok' ? 'pass' : state === 'missing' ? 'fail' : 'unknown';
52
60
  },
53
61
  }));
@@ -71,9 +79,10 @@ export async function main(argv, d) {
71
79
  if (cmd === 'setup') {
72
80
  const args = argv.slice(1), seen = new Set();
73
81
  let valid = true;
82
+ let browser;
74
83
  for (let i = 0; i < args.length; i++) {
75
84
  const flag = args[i];
76
- if (seen.has(flag) || !['--client', '--apply', '--replace-existing', '--start-browser', '--json'].includes(flag)) {
85
+ if (seen.has(flag) || !['--client', '--browser', '--apply', '--replace-existing', '--start-browser', '--json'].includes(flag)) {
77
86
  valid = false;
78
87
  break;
79
88
  }
@@ -82,31 +91,31 @@ export async function main(argv, d) {
82
91
  valid = false;
83
92
  break;
84
93
  }
94
+ if (flag === '--browser') {
95
+ const value = args[++i];
96
+ if (value !== 'chrome' && value !== 'edge') {
97
+ valid = false;
98
+ break;
99
+ }
100
+ ;
101
+ browser = value;
102
+ }
85
103
  }
86
104
  if (!valid || (seen.has('--replace-existing') && !seen.has('--apply'))) {
87
- d.err('usage: u-chan setup [--client codex] [--apply] [--replace-existing] [--start-browser] [--json]');
105
+ d.err('usage: u-chan setup [--client codex] [--browser chrome|edge] [--apply] [--replace-existing] [--start-browser] [--json]');
88
106
  return 1;
89
107
  }
90
- const options = { apply: seen.has('--apply'), replaceExisting: seen.has('--replace-existing'), startBrowser: seen.has('--start-browser') };
91
- const endpoint = loadConfig(d.env, d.platform).cdpEndpoint;
108
+ const options = { apply: seen.has('--apply'), replaceExisting: seen.has('--replace-existing'), startBrowser: seen.has('--start-browser'), ...(browser ? { browser } : {}) };
109
+ if (browser)
110
+ diagnosticEnv = { ...d.env, U_CHAN_BROWSER: browser };
111
+ const cfg = loadConfig(diagnosticEnv, d.platform), endpoint = cfg.cdpEndpoint;
92
112
  const r = d.setup ? await d.setup(options) : await runSetup(options, {
93
- platform: d.platform, endpoint, elevated: isElevated, doctor,
94
- configure: opts => configureCodex({ ...opts, endpoint, ipcEndpoint: defaultEndpoint(d.env, d.platform), nodePath: process.execPath, entryPath: fileURLToPath(new URL('./index.js', import.meta.url)), configDir: d.env.CODEX_HOME ?? join(homedir(), '.codex') }),
113
+ platform: d.platform, endpoint, browser: cfg.browser, elevated: isElevated, doctor,
114
+ configureShell: apply => configureWindowsCommand(apply, d.env.PATH ?? d.env.Path ?? ''),
115
+ selectBrowser: choice => saveBrowserChoice(cfg.browserChoicePath, choice),
116
+ configure: opts => configureCodex({ ...opts, browser: cfg.browser, endpoint, ipcEndpoint: defaultEndpoint(d.env, d.platform), nodePath: process.execPath, entryPath: fileURLToPath(new URL('./index.js', import.meta.url)), configDir: d.env.CODEX_HOME ?? join(homedir(), '.codex') }),
95
117
  startBrowser: () => new Promise(resolve => {
96
- execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', fileURLToPath(new URL('../scripts/start-chrome.ps1', import.meta.url)), '-Background'], { timeout: 5000, windowsHide: true, env: d.env, maxBuffer: 65536 }, async (err) => {
97
- if (err) {
98
- resolve(false);
99
- return;
100
- }
101
- for (let i = 0; i < 5; i++) {
102
- if (await chromeState(endpoint, 1000) === 'pass') {
103
- resolve(true);
104
- return;
105
- }
106
- await new Promise(done => setTimeout(done, 300));
107
- }
108
- resolve(false);
109
- });
118
+ execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', fileURLToPath(new URL('../scripts/start-browser.ps1', import.meta.url)), '-Browser', cfg.browser, '-Background'], { timeout: 24000, windowsHide: true, env: diagnosticEnv, maxBuffer: 65536 }, err => resolve(!err));
110
119
  }),
111
120
  });
112
121
  if (seen.has('--json'))
@@ -0,0 +1,49 @@
1
+ import { lstatSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { mkdir, rename, writeFile, lstat, realpath } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ export function browserKind(value) {
6
+ if (value !== 'chrome' && value !== 'edge')
7
+ throw new Error('Invalid browser selection; choose chrome or edge.');
8
+ return value;
9
+ }
10
+ export function readBrowserChoice(file) {
11
+ try {
12
+ const info = lstatSync(file), parent = resolve(dirname(file));
13
+ if (!info.isFile() || info.isSymbolicLink() || realpathSync(parent).toLowerCase() !== parent.toLowerCase())
14
+ throw new Error('redirected');
15
+ const value = JSON.parse(readFileSync(file, 'utf8'));
16
+ if (!value || Array.isArray(value) || Object.keys(value).length !== 1)
17
+ throw new Error('invalid');
18
+ return browserKind(value.browser);
19
+ }
20
+ catch (error) {
21
+ if (error.code === 'ENOENT')
22
+ return undefined;
23
+ throw new Error('Browser preference could not be read safely; inspect browser.json.');
24
+ }
25
+ }
26
+ export async function saveBrowserChoice(file, choice) {
27
+ browserKind(choice);
28
+ const previous = readBrowserChoice(file);
29
+ if (previous === choice)
30
+ return { changed: false };
31
+ const parent = resolve(dirname(file));
32
+ await mkdir(parent, { recursive: true, mode: 0o700 });
33
+ if ((await lstat(parent)).isSymbolicLink() || (await realpath(parent)).toLowerCase() !== parent.toLowerCase())
34
+ throw new Error('Redirected browser preference directory.');
35
+ let backupPath;
36
+ if (previous !== undefined) {
37
+ const backup = join(parent, '#0 backup');
38
+ await mkdir(backup, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST')
39
+ throw error; });
40
+ if ((await lstat(backup)).isSymbolicLink() || (await realpath(backup)).toLowerCase() !== backup.toLowerCase())
41
+ throw new Error('Redirected browser backup directory.');
42
+ backupPath = join(backup, `browser-${randomUUID()}.json`);
43
+ await writeFile(backupPath, readFileSync(file), { flag: 'wx', mode: 0o600 });
44
+ }
45
+ const temporary = join(parent, `browser-${randomUUID()}.tmp`);
46
+ await writeFile(temporary, JSON.stringify({ browser: choice }) + '\n', { flag: 'wx', mode: 0o600 });
47
+ await rename(temporary, file);
48
+ return { changed: true, ...(backupPath ? { backupPath } : {}) };
49
+ }
@@ -5,6 +5,7 @@ import { basename, join, resolve } from 'node:path';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { safeCdpOrigin } from '../core/doctor.js';
7
7
  import { defaultEndpoint } from '../ipc/endpoint.js';
8
+ import { browserKind } from './browser-choice.js';
8
9
  /** Fail closed when privilege inspection is unavailable. Never execute user-owned clients as administrator. */
9
10
  export async function isElevated() {
10
11
  if (process.platform !== 'win32')
@@ -27,7 +28,9 @@ function simple(entry, name) {
27
28
  return false;
28
29
  if (t.type !== 'stdio' || typeof t.command !== 'string' || !Array.isArray(t.args) || !t.args.every((a) => typeof a === 'string'))
29
30
  return false;
30
- if (t.cwd != null || (t.env != null && (typeof t.env !== 'object' || Object.keys(t.env).length > 0)) || (t.env_vars != null && (!Array.isArray(t.env_vars) || t.env_vars.length > 0)))
31
+ if (t.cwd != null || (t.env_vars != null && (!Array.isArray(t.env_vars) || t.env_vars.length > 0)))
32
+ return false;
33
+ if (t.env != null && (typeof t.env !== 'object' || Array.isArray(t.env) || Object.entries(t.env).some(([key, value]) => name !== 'u_chan_secrets' || (key === 'U_CHAN_BROWSER' ? value !== 'chrome' && value !== 'edge' : key === 'U_CHAN_CDP' ? !safeCdpOrigin(value) : true))))
31
34
  return false;
32
35
  const command = basename(t.command.replaceAll('\\', '/')).toLowerCase();
33
36
  if (name === 'u_chan_secrets')
@@ -72,6 +75,8 @@ export async function configureCodex(o, injected) {
72
75
  try {
73
76
  if (!safeCdpOrigin(o.endpoint) || (o.replaceExisting && !o.apply))
74
77
  return result('manual', 'Use a safe loopback browser target and explicit apply flags.');
78
+ if (o.browser !== undefined)
79
+ browserKind(o.browser);
75
80
  if (await d.elevated())
76
81
  return result('unavailable', 'Run setup from a non-administrator desktop terminal.');
77
82
  const desired = [
@@ -95,11 +100,12 @@ export async function configureCodex(o, injected) {
95
100
  const name = key.toUpperCase();
96
101
  return name === 'U_CHAN_CDP' ? safeCdpOrigin(env[key]) !== safeCdpOrigin(o.endpoint)
97
102
  : name === 'U_CHAN_IPC' && env[key] !== ipc;
98
- })) || (Array.isArray(inherited) && inherited.some(key => typeof key === 'string' && ['U_CHAN_CDP', 'U_CHAN_IPC'].includes(key.toUpperCase())))) {
103
+ })) || (Array.isArray(inherited) && inherited.some(key => typeof key === 'string' && ['U_CHAN_CDP', 'U_CHAN_IPC', 'U_CHAN_BROWSER'].includes(key.toUpperCase())))) {
99
104
  return result('manual', 'Custom target environment preserved; align browser and service targets manually.');
100
105
  }
101
106
  if (current?.enabled === true && current.transport?.type === 'stdio'
102
- && current.transport.command === entry.command && JSON.stringify(current.transport.args) === JSON.stringify(entry.args))
107
+ && current.transport.command === entry.command && JSON.stringify(current.transport.args) === JSON.stringify(entry.args)
108
+ && (entry.name !== 'u_chan_secrets' || o.browser === undefined || (env?.U_CHAN_BROWSER === o.browser && safeCdpOrigin(env?.U_CHAN_CDP) === safeCdpOrigin(o.endpoint))))
103
109
  continue;
104
110
  if (!simple(current, entry.name))
105
111
  return result('manual', 'Custom Codex settings preserved; configure this registration manually.');
@@ -127,7 +133,8 @@ export async function configureCodex(o, injected) {
127
133
  return result('unavailable', 'Codex change failed; inspect configuration before retrying.');
128
134
  changed = true;
129
135
  }
130
- const added = await d.exec(['mcp', 'add', entry.name, '--', entry.command, ...entry.args]);
136
+ const browserEnv = entry.name === 'u_chan_secrets' && o.browser ? ['--env', `U_CHAN_BROWSER=${o.browser}`, '--env', `U_CHAN_CDP=${safeCdpOrigin(o.endpoint)}`] : [];
137
+ const added = await d.exec(['mcp', 'add', entry.name, ...browserEnv, '--', entry.command, ...entry.args]);
131
138
  if (added.code !== 0)
132
139
  return result('unavailable', 'Codex change failed; completed changes and backup were retained.');
133
140
  changed = true;
@@ -2,6 +2,8 @@ import { bounded, safeCdpOrigin } from '../core/doctor.js';
2
2
  export async function runSetup(o, d) {
3
3
  const r = { version: 1, state: 'needs_attention', sharing: 'unobserved', restartRequired: false, checks: [], actions: [], client: 'unavailable', changed: false };
4
4
  const action = (id, message, required = true) => r.actions.push({ id, message, required });
5
+ if (d.browser || o.browser)
6
+ r.browser = o.browser ?? d.browser;
5
7
  try {
6
8
  if (await bounded(d.elevated, true)) {
7
9
  action('configure_mcp', 'Run setup from a non-administrator desktop terminal.');
@@ -17,29 +19,44 @@ export async function runSetup(o, d) {
17
19
  r.client = 'manual';
18
20
  return r;
19
21
  }
22
+ if (d.configureShell) {
23
+ r.shell = await d.configureShell(o.apply);
24
+ r.changed = r.shell.changed;
25
+ action('configure_shell', r.shell.message, !r.shell.ready);
26
+ }
20
27
  if (r.checks.some(c => c.id === 'browser_target' && c.state === 'fail')) {
21
28
  r.sharing = 'mismatch';
22
29
  action('configure_mcp', 'Align the desktop and service loopback browser targets before applying setup.');
23
30
  return r;
24
31
  }
25
- const configured = await d.configure({ apply: o.apply, replaceExisting: o.replaceExisting });
32
+ const configured = await d.configure({ apply: o.apply, replaceExisting: o.replaceExisting, ...(o.browser ? { browser: o.browser } : {}) });
26
33
  r.client = configured.state;
27
- r.changed = configured.changed;
34
+ r.changed ||= configured.changed;
28
35
  r.backupPath = configured.backupPath;
36
+ r.restartRequired = configured.changed;
37
+ if (o.browser && o.apply && r.client === 'ready') {
38
+ if (!d.selectBrowser)
39
+ throw new Error('browser selection unavailable');
40
+ const selected = await d.selectBrowser(o.browser);
41
+ r.browserChanged = selected.changed;
42
+ r.browserBackupPath = selected.backupPath;
43
+ r.changed ||= selected.changed;
44
+ r.restartRequired ||= selected.changed;
45
+ }
29
46
  let attemptedStart = false, started = true;
30
- if (o.startBrowser && !r.checks.some(c => c.id === 'chrome' && c.state === 'pass')) {
47
+ if (o.startBrowser && (!r.checks.some(c => c.id === 'chrome' && c.state === 'pass') || r.checks.some(c => c.id === 'browser_identity' && c.state !== 'pass'))) {
31
48
  attemptedStart = true;
32
- started = await bounded(d.startBrowser, false, 15000);
49
+ started = await bounded(d.startBrowser, false, 25000);
33
50
  }
34
51
  if (r.changed || attemptedStart)
35
52
  r.checks = (await d.doctor()).checks;
36
53
  const pw = r.checks.find(c => c.id === 'playwright');
37
54
  const mismatch = r.checks.some(c => c.id === 'browser_target' && c.state === 'fail');
38
55
  r.sharing = mismatch || pw?.state === 'fail' ? 'mismatch' : pw?.state === 'pass' ? 'process_configured' : 'unobserved';
39
- r.restartRequired = r.changed || pw?.state === 'fail';
56
+ r.restartRequired ||= pw?.state === 'fail';
40
57
  const ids = { registration: 'install_service', service: 'install_service', ipc: 'install_service',
41
58
  version: 'update_service', assets: 'repair_permissions', permissions: 'repair_permissions', service_browser: 'start_browser', chrome: 'start_browser',
42
- browser_target: 'configure_mcp', playwright: 'configure_mcp', uac: 'review_uac' };
59
+ browser_target: 'configure_mcp', browser_identity: 'start_browser', playwright: 'configure_mcp', uac: 'review_uac' };
43
60
  for (const c of r.checks)
44
61
  if (c.nextAction) {
45
62
  const id = c.id === 'vault' ? c.state === 'pass' ? 'unlock_vault' : 'authenticate_vault' : ids[c.id] ?? 'configure_mcp';
@@ -48,12 +65,12 @@ export async function runSetup(o, d) {
48
65
  if (r.client !== 'ready')
49
66
  action('configure_mcp', 'Inspect Codex configuration; apply missing registrations explicitly and preserve custom settings.');
50
67
  if (!started)
51
- action('start_browser', 'Dedicated Chrome did not become reachable; inspect the launcher and run setup again.');
68
+ action('start_browser', 'Selected dedicated browser is unavailable or mismatched. Close only the previous dedicated U-Chan instance before switching, then run setup again.');
52
69
  if (r.restartRequired)
53
70
  action('restart_client', 'Restart the AI session to load saved MCP registrations.', false);
54
71
  action('configure_mcp', 'Active AI-session browser ownership is unverified; check the current client browser connection.', false);
55
72
  const requiredOk = r.checks.length > 0 && r.checks.filter(c => c.required).every(c => c.state === 'pass');
56
- if (requiredOk && r.client === 'ready' && started && !mismatch)
73
+ if (requiredOk && r.client === 'ready' && started && !mismatch && r.shell?.ready !== false)
57
74
  r.state = 'configured';
58
75
  return r;
59
76
  }
@@ -0,0 +1,54 @@
1
+ import { lstat, mkdir, readFile, rename, realpath } from 'node:fs/promises';
2
+ import { delimiter, join, resolve } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ /** Let PowerShell resolve the npm .cmd launcher without changing execution policy or loading a profile. */
5
+ export async function configureWindowsCommand(apply, searchPath) {
6
+ const result = (ready, message) => ({ ready, changed: false, message });
7
+ try {
8
+ for (const entry of searchPath.split(delimiter).filter(Boolean)) {
9
+ const directory = resolve(entry.replace(/^"|"$/g, ''));
10
+ const script = join(directory, 'u-chan.ps1'), command = join(directory, 'u-chan.cmd');
11
+ const exists = async (file) => {
12
+ try {
13
+ return await lstat(file);
14
+ }
15
+ catch (error) {
16
+ if (error.code === 'ENOENT')
17
+ return undefined;
18
+ throw error;
19
+ }
20
+ };
21
+ const ps = await exists(script), cmd = await exists(command);
22
+ if (!ps && !cmd)
23
+ continue;
24
+ if (!cmd?.isFile() || cmd.isSymbolicLink() || (ps && (!ps.isFile() || ps.isSymbolicLink()))) {
25
+ return result(false, 'Custom or redirected u-chan launcher preserved; use u-chan.cmd unlock.');
26
+ }
27
+ const batch = await readFile(command, 'utf8');
28
+ if (!batch.includes('"%dp0%\\node_modules\\u-chan-secrets\\dist\\cli.js" %*')) {
29
+ return result(false, 'Unrecognized u-chan launcher preserved; inspect command installation manually.');
30
+ }
31
+ if (!ps)
32
+ return result(true, 'PowerShell can use the u-chan.cmd launcher as u-chan.');
33
+ const source = await readFile(script, 'utf8');
34
+ if (!source.startsWith('#!/usr/bin/env pwsh') || !source.includes('"$basedir/node_modules/u-chan-secrets/dist/cli.js" $args') || !source.trimEnd().endsWith('exit $ret')) {
35
+ return result(false, 'Custom PowerShell launcher preserved; use u-chan.cmd unlock.');
36
+ }
37
+ if (!apply)
38
+ return result(false, 'Run u-chan.cmd setup --apply to make u-chan work without PowerShell scripts.');
39
+ if ((await realpath(directory)).toLowerCase() !== directory.toLowerCase())
40
+ throw new Error('Redirected launcher directory.');
41
+ const backupDirectory = join(directory, '#0 backup');
42
+ await mkdir(backupDirectory, { recursive: true });
43
+ if ((await lstat(backupDirectory)).isSymbolicLink() || (await realpath(backupDirectory)).toLowerCase() !== backupDirectory.toLowerCase())
44
+ throw new Error('Redirected backup directory.');
45
+ const backupPath = join(backupDirectory, `u-chan-${randomUUID()}.ps1`);
46
+ await rename(script, backupPath);
47
+ return { ready: true, changed: true, backupPath, message: 'PowerShell now uses u-chan.cmd as u-chan; original .ps1 launcher retained in #0 backup.' };
48
+ }
49
+ return result(true, 'No npm u-chan launcher found on PATH; source checkout commands remain unchanged.');
50
+ }
51
+ catch {
52
+ return result(false, 'Could not configure the PowerShell command; use u-chan.cmd unlock and inspect launcher permissions.');
53
+ }
54
+ }
@@ -1,8 +1,27 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { chromium } from 'playwright';
3
3
  import { UserFacingError } from './types.js';
4
+ import { isHostAllowed } from './allowlist.js';
5
+ import { waitForLoginSuccess } from './login-success.js';
6
+ import { bounded } from './doctor.js';
4
7
  import { abortable } from './abort.js';
5
8
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+ /** Request-stage CDP interception checks each real main-frame redirect without rewriting responses. */
10
+ async function restrictPageNavigation(page, allowed, signal) {
11
+ const session = await page.context().newCDPSession(page);
12
+ const { frameTree } = await session.send('Page.getFrameTree');
13
+ session.on('Fetch.requestPaused', event => {
14
+ let permit = false;
15
+ try {
16
+ permit = event.frameId !== frameTree.frame.id || (!signal?.aborted && allowed(event.request.url));
17
+ }
18
+ catch { }
19
+ void session.send(permit ? 'Fetch.continueRequest' : 'Fetch.failRequest', permit ? { requestId: event.requestId } : { requestId: event.requestId, errorReason: 'BlockedByClient' }).catch(() => { });
20
+ });
21
+ // Bypass service workers only on this owned tab; existing registrations and user tabs are unchanged.
22
+ await session.send('Network.setBypassServiceWorker', { bypass: true });
23
+ await session.send('Fetch.enable', { patterns: [{ resourceType: 'Document', requestStage: 'Request' }] });
24
+ }
6
25
  /** Runs scripts/start-chrome.ps1 (idempotent: it exits early if the port is already open). */
7
26
  export const startChromeScript = (scriptPath) => () => new Promise((resolve, reject) => {
8
27
  execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath], { timeout: 30000, windowsHide: true }, (err) => (err ? reject(err) : resolve()));
@@ -14,11 +33,18 @@ export class CdpBrowserProvider {
14
33
  disconnecting;
15
34
  browser;
16
35
  connecting;
36
+ ownedPages = new Set();
37
+ openingPages = new Set();
17
38
  constructor(endpoint, opts = {}) {
18
39
  this.endpoint = endpoint;
19
40
  this.opts = opts;
20
41
  }
21
- async getBrowser(autoLaunch) {
42
+ async getBrowser(autoLaunch, selectedBrowser) {
43
+ if (this.opts.validate && !await this.opts.validate(selectedBrowser))
44
+ throw new UserFacingError('selected dedicated browser identity could not be verified', 'browser_unavailable');
45
+ // Validated service operations reconnect so a browser switch cannot keep using an old cached destination.
46
+ if (this.opts.validate)
47
+ await this.disconnect();
22
48
  if (this.browser?.isConnected())
23
49
  return this.browser;
24
50
  this.connecting ??= this.connect(autoLaunch)
@@ -34,9 +60,9 @@ export class CdpBrowserProvider {
34
60
  }
35
61
  catch {
36
62
  if (!autoLaunch || !this.opts.launch)
37
- throw new UserFacingError('Chrome is not running — run scripts/start-chrome.ps1');
63
+ throw new UserFacingError('selected dedicated browser is not running — run u-chan setup --start-browser', 'browser_unavailable');
38
64
  }
39
- const fail = () => new UserFacingError('could not start Chrome — run scripts/start-chrome.ps1');
65
+ const fail = () => new UserFacingError('could not start the selected dedicated browser — run u-chan setup --start-browser', 'browser_unavailable');
40
66
  await this.opts.launch().catch(() => {
41
67
  throw fail();
42
68
  });
@@ -53,32 +79,44 @@ export class CdpBrowserProvider {
53
79
  }
54
80
  disconnect() {
55
81
  return this.disconnecting ??= (async () => {
82
+ await Promise.allSettled([...this.openingPages].map(opening => bounded(() => opening, undefined, 2000)));
83
+ await Promise.allSettled([...this.ownedPages].map(page => bounded(() => page.close(), undefined, 2000)));
84
+ this.ownedPages.clear();
56
85
  const browser = this.browser ?? await this.connecting?.catch(() => undefined);
57
86
  this.browser = undefined;
58
87
  // For connectOverCDP, close disconnects this transport, not the user's Chrome process.
59
88
  await browser?.close().catch(() => { });
60
89
  })().finally(() => { this.disconnecting = undefined; });
61
90
  }
62
- async withPage(fn, signal) {
63
- const browser = await abortable(this.getBrowser(true), signal, () => this.disconnect(), b => b.close());
91
+ async withPage(fn, signal, selectedBrowser) {
92
+ const browser = await abortable(this.getBrowser(true, selectedBrowser), signal, () => this.disconnect(), b => b.close());
64
93
  const context = browser.contexts()[0];
65
94
  if (!context)
66
- throw new UserFacingError('Chrome has no default window — open Chrome with scripts/start-chrome.ps1');
67
- const page = await abortable(context.newPage(), signal, () => this.disconnect(), p => p.close());
95
+ throw new UserFacingError('selected dedicated browser has no default context — run u-chan setup --start-browser', 'browser_unavailable');
96
+ const opening = context.newPage().then(page => { this.ownedPages.add(page); return page; });
97
+ this.openingPages.add(opening);
98
+ let page;
99
+ try {
100
+ page = await abortable(opening, signal, () => this.disconnect(), p => p.close());
101
+ }
102
+ finally {
103
+ this.openingPages.delete(opening);
104
+ }
68
105
  page.setDefaultTimeout(5000);
69
106
  try {
70
107
  signal?.throwIfAborted();
71
108
  return await abortable(fn(page), signal, () => this.disconnect());
72
109
  }
73
110
  finally {
74
- await abortable(page.close(), signal, () => this.disconnect()).catch(() => { });
111
+ await bounded(() => page.close(), undefined, 2000);
112
+ this.ownedPages.delete(page);
75
113
  }
76
114
  }
77
- async withContext(fn, signal) {
78
- const browser = await abortable(this.getBrowser(true), signal, () => this.disconnect(), b => b.close());
115
+ async withContext(fn, signal, selectedBrowser) {
116
+ const browser = await abortable(this.getBrowser(true, selectedBrowser), signal, () => this.disconnect(), b => b.close());
79
117
  const context = browser.contexts()[0];
80
118
  if (!context)
81
- throw new UserFacingError('Chrome has no default window — open Chrome with scripts/start-chrome.ps1');
119
+ throw new UserFacingError('selected dedicated browser has no default context — run u-chan setup --start-browser', 'browser_unavailable');
82
120
  signal?.throwIfAborted();
83
121
  return abortable(fn(context), signal, () => this.disconnect());
84
122
  }
@@ -92,16 +130,45 @@ export class CdpBrowserProvider {
92
130
  }
93
131
  }
94
132
  }
95
- /** Receives a session (cookies + localStorage) into the user's Chrome over CDP. */
133
+ /** Receives filtered session state and checks approved evidence in the user's dedicated browser over CDP. */
96
134
  export class CdpSessionTarget {
97
135
  provider;
98
- constructor(provider) {
136
+ options;
137
+ selectedBrowser = 'chrome';
138
+ constructor(provider, options = {}) {
99
139
  this.provider = provider;
140
+ this.options = options;
141
+ }
142
+ async bindBrowser(browser) {
143
+ if (browser !== 'chrome' && browser !== 'edge')
144
+ return false;
145
+ this.selectedBrowser = browser;
146
+ return true; // The immediately following provider operation validates this exact kind against the native process.
100
147
  }
101
148
  userAgent(signal) {
102
- return this.provider.withPage((page) => page.evaluate(() => navigator.userAgent), signal);
149
+ return this.provider.withPage((page) => page.evaluate(() => navigator.userAgent), signal, this.selectedBrowser);
103
150
  }
104
151
  cancel() { return this.provider.disconnect(); }
152
+ async verify(entry, finalUrl, signal) {
153
+ if (!entry.success)
154
+ return false;
155
+ const allowed = (url) => this.options.hostCheck ? this.options.hostCheck(entry, url) : isHostAllowed(entry, url);
156
+ const url = 'url' in entry.success ? entry.success.url : finalUrl;
157
+ if (!allowed(url))
158
+ return false;
159
+ try {
160
+ return await this.provider.withPage(async (page) => {
161
+ await restrictPageNavigation(page, allowed, signal);
162
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
163
+ return waitForLoginSuccess(page, entry.success, allowed, signal, this.options.verifyTimeoutMs ?? 15000);
164
+ }, signal, this.selectedBrowser);
165
+ }
166
+ catch {
167
+ if (signal?.aborted)
168
+ signal.throwIfAborted();
169
+ return false;
170
+ }
171
+ }
105
172
  inject(state, signal) {
106
173
  return this.provider.withContext(async (context) => {
107
174
  signal?.throwIfAborted();
@@ -110,9 +177,13 @@ export class CdpSessionTarget {
110
177
  signal?.throwIfAborted();
111
178
  if (!o.localStorage.length)
112
179
  continue;
113
- const page = await context.newPage();
114
- page.setDefaultTimeout(5000);
115
- try {
180
+ await this.provider.withPage(async (page) => {
181
+ await restrictPageNavigation(page, url => { try {
182
+ return new URL(url).origin === o.origin;
183
+ }
184
+ catch {
185
+ return false;
186
+ } }, signal);
116
187
  await page.goto(o.origin, { waitUntil: 'commit' });
117
188
  signal?.throwIfAborted();
118
189
  await page.evaluate(({ origin, items }) => {
@@ -121,11 +192,8 @@ export class CdpSessionTarget {
121
192
  for (const i of items)
122
193
  localStorage.setItem(i.name, i.value);
123
194
  }, { origin: o.origin, items: o.localStorage });
124
- }
125
- finally {
126
- await page.close().catch(() => { });
127
- }
195
+ }, signal, this.selectedBrowser);
128
196
  }
129
- }, signal);
197
+ }, signal, this.selectedBrowser);
130
198
  }
131
199
  }