u-chan-secrets 4.0.0 → 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.
- package/README.md +30 -6
- package/dist/cli.js +30 -23
- package/dist/client/browser-choice.js +49 -0
- package/dist/client/codex-setup.js +11 -4
- package/dist/client/setup.js +17 -5
- package/dist/core/browser.js +90 -22
- package/dist/core/config.js +13 -0
- package/dist/core/doctor.js +26 -5
- package/dist/core/login-filler.js +25 -25
- package/dist/core/login-success.js +24 -0
- package/dist/core/service.js +6 -4
- package/dist/core/types.js +1 -1
- package/dist/index.js +2 -2
- package/dist/ipc/client.js +20 -1
- package/dist/ipc/server.js +1 -1
- package/dist/mcp/server.js +4 -4
- package/dist/platform/darwin/manage-script.js +30 -30
- package/dist/platform/windows/desktop-browser.js +14 -0
- package/dist/platform/windows/doctor.js +1 -1
- package/dist/service/control.js +1 -1
- package/dist/service/doctor.js +5 -4
- package/dist/service/isolated-login.js +33 -10
- package/dist/service/main.js +9 -3
- package/dist/service/manage.js +3 -2
- package/dist/service/private-network.js +97 -0
- package/package.json +1 -1
- package/scripts/debug-install.bat +69 -69
- package/scripts/desktop-browser.ps1 +103 -0
- package/scripts/login-bw.bat +21 -21
- package/scripts/migrate.bat +4 -4
- package/scripts/start-browser.ps1 +33 -0
- package/scripts/start-chrome.ps1 +4 -38
- package/scripts/verify-windows-adversarial.mjs +11 -1
- package/scripts/windows-security/browser-worker.mjs +50 -10
- package/scripts/windows-security/desktop-matrix.mjs +54 -0
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# U-Chan Secrets
|
|
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
|
|
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 `
|
|
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-
|
|
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,30 @@ 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
|
+
Choose your browser from a normal Windows terminal:
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
u-chan setup --browser edge --json
|
|
27
|
+
u-chan setup --browser edge --apply --replace-existing --start-browser
|
|
28
|
+
u-chan setup --browser chrome --apply --replace-existing --start-browser
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
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.
|
|
38
|
+
|
|
39
|
+
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.
|
|
40
|
+
|
|
41
|
+
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.
|
|
42
|
+
|
|
43
|
+
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.
|
|
44
|
+
|
|
21
45
|
## What V4 adds
|
|
22
46
|
|
|
23
47
|
Use a normal, non-administrator terminal after installing the matching trusted service:
|
|
@@ -30,7 +54,7 @@ u-chan setup --client codex --apply --replace-existing --start-browser
|
|
|
30
54
|
|
|
31
55
|
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
56
|
|
|
33
|
-
`--start-browser` explicitly starts the dedicated
|
|
57
|
+
`--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
58
|
|
|
35
59
|
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
60
|
|
|
@@ -160,7 +184,7 @@ In Windows PowerShell, use `u-chan.cmd` if execution policy blocks npm's `.ps1`
|
|
|
160
184
|
| Command | Purpose |
|
|
161
185
|
|---|---|
|
|
162
186
|
| `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 |
|
|
187
|
+
| `setup [--client codex] [--browser chrome\|edge] [--apply] [--replace-existing] [--start-browser] [--json]` | Windows desktop inspection and explicit Codex/browser setup |
|
|
164
188
|
| `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
189
|
| `manage` | Native status, grant pages, recent activity, lock/unlock, and revocation |
|
|
166
190
|
| `unlock` | Request the protected master-password prompt |
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ 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';
|
|
17
18
|
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
19
|
/** import-v1 shows one approval window per entry, each open for up to 2 minutes, one after another. */
|
|
19
20
|
export const IMPORT_PROMPT_MS = 120000;
|
|
@@ -28,10 +29,16 @@ function defaultV1Path(env, platform) {
|
|
|
28
29
|
export async function main(argv, d) {
|
|
29
30
|
const [cmd, arg, ...rest] = argv;
|
|
30
31
|
let call;
|
|
32
|
+
let diagnosticEnv = d.env;
|
|
31
33
|
const doctor = d.doctor ?? (() => runDoctor({
|
|
32
34
|
platform: d.platform,
|
|
33
35
|
clientVersion: d.clientVersion ?? 'unknown',
|
|
34
|
-
clientEndpoint: loadConfig(
|
|
36
|
+
clientEndpoint: loadConfig(diagnosticEnv, d.platform).cdpEndpoint,
|
|
37
|
+
browser: loadConfig(diagnosticEnv, d.platform).browser,
|
|
38
|
+
browserIdentity: d.platform === 'win32' ? async () => {
|
|
39
|
+
const cfg = loadConfig(diagnosticEnv, d.platform);
|
|
40
|
+
return (await import('./platform/windows/desktop-browser.js')).inspectDesktopBrowser({ endpoint: cfg.cdpEndpoint, browser: cfg.browser, profile: cfg.browserProfile }, diagnosticEnv);
|
|
41
|
+
} : undefined,
|
|
35
42
|
elevation: d.platform === 'win32' ? async () => (await import('./platform/windows/doctor.js')).checkWindowsUac() : undefined,
|
|
36
43
|
registration: async () => d.platform === 'win32' ? (await import('./platform/windows/doctor.js')).checkWindowsRegistration() : d.platform === 'darwin' ? (await import('./platform/darwin/doctor.js')).checkDarwinRegistration() : 'fail',
|
|
37
44
|
service: async () => {
|
|
@@ -43,11 +50,11 @@ export async function main(argv, d) {
|
|
|
43
50
|
throw new Error('invalid diagnostics');
|
|
44
51
|
return s;
|
|
45
52
|
},
|
|
46
|
-
chrome: () => chromeState(loadConfig(
|
|
53
|
+
chrome: () => chromeState(loadConfig(diagnosticEnv, d.platform).cdpEndpoint),
|
|
47
54
|
playwright: async () => {
|
|
48
55
|
if (d.platform !== 'win32')
|
|
49
56
|
return 'unknown';
|
|
50
|
-
const state = await checkPlaywrightNow(process.pid, loadConfig(
|
|
57
|
+
const state = await checkPlaywrightNow(process.pid, loadConfig(diagnosticEnv, d.platform).cdpEndpoint);
|
|
51
58
|
return state === 'ok' ? 'pass' : state === 'missing' ? 'fail' : 'unknown';
|
|
52
59
|
},
|
|
53
60
|
}));
|
|
@@ -71,9 +78,10 @@ export async function main(argv, d) {
|
|
|
71
78
|
if (cmd === 'setup') {
|
|
72
79
|
const args = argv.slice(1), seen = new Set();
|
|
73
80
|
let valid = true;
|
|
81
|
+
let browser;
|
|
74
82
|
for (let i = 0; i < args.length; i++) {
|
|
75
83
|
const flag = args[i];
|
|
76
|
-
if (seen.has(flag) || !['--client', '--apply', '--replace-existing', '--start-browser', '--json'].includes(flag)) {
|
|
84
|
+
if (seen.has(flag) || !['--client', '--browser', '--apply', '--replace-existing', '--start-browser', '--json'].includes(flag)) {
|
|
77
85
|
valid = false;
|
|
78
86
|
break;
|
|
79
87
|
}
|
|
@@ -82,31 +90,30 @@ export async function main(argv, d) {
|
|
|
82
90
|
valid = false;
|
|
83
91
|
break;
|
|
84
92
|
}
|
|
93
|
+
if (flag === '--browser') {
|
|
94
|
+
const value = args[++i];
|
|
95
|
+
if (value !== 'chrome' && value !== 'edge') {
|
|
96
|
+
valid = false;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
;
|
|
100
|
+
browser = value;
|
|
101
|
+
}
|
|
85
102
|
}
|
|
86
103
|
if (!valid || (seen.has('--replace-existing') && !seen.has('--apply'))) {
|
|
87
|
-
d.err('usage: u-chan setup [--client codex] [--apply] [--replace-existing] [--start-browser] [--json]');
|
|
104
|
+
d.err('usage: u-chan setup [--client codex] [--browser chrome|edge] [--apply] [--replace-existing] [--start-browser] [--json]');
|
|
88
105
|
return 1;
|
|
89
106
|
}
|
|
90
|
-
const options = { apply: seen.has('--apply'), replaceExisting: seen.has('--replace-existing'), startBrowser: seen.has('--start-browser') };
|
|
91
|
-
|
|
107
|
+
const options = { apply: seen.has('--apply'), replaceExisting: seen.has('--replace-existing'), startBrowser: seen.has('--start-browser'), ...(browser ? { browser } : {}) };
|
|
108
|
+
if (browser)
|
|
109
|
+
diagnosticEnv = { ...d.env, U_CHAN_BROWSER: browser };
|
|
110
|
+
const cfg = loadConfig(diagnosticEnv, d.platform), endpoint = cfg.cdpEndpoint;
|
|
92
111
|
const r = d.setup ? await d.setup(options) : await runSetup(options, {
|
|
93
|
-
platform: d.platform, endpoint, elevated: isElevated, doctor,
|
|
94
|
-
|
|
112
|
+
platform: d.platform, endpoint, browser: cfg.browser, elevated: isElevated, doctor,
|
|
113
|
+
selectBrowser: choice => saveBrowserChoice(cfg.browserChoicePath, choice),
|
|
114
|
+
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
115
|
startBrowser: () => new Promise(resolve => {
|
|
96
|
-
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', fileURLToPath(new URL('../scripts/start-
|
|
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
|
-
});
|
|
116
|
+
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
117
|
}),
|
|
111
118
|
});
|
|
112
119
|
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.
|
|
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
|
|
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;
|
package/dist/client/setup.js
CHANGED
|
@@ -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.');
|
|
@@ -22,14 +24,24 @@ export async function runSetup(o, d) {
|
|
|
22
24
|
action('configure_mcp', 'Align the desktop and service loopback browser targets before applying setup.');
|
|
23
25
|
return r;
|
|
24
26
|
}
|
|
25
|
-
const configured = await d.configure({ apply: o.apply, replaceExisting: o.replaceExisting });
|
|
27
|
+
const configured = await d.configure({ apply: o.apply, replaceExisting: o.replaceExisting, ...(o.browser ? { browser: o.browser } : {}) });
|
|
26
28
|
r.client = configured.state;
|
|
27
29
|
r.changed = configured.changed;
|
|
28
30
|
r.backupPath = configured.backupPath;
|
|
31
|
+
r.restartRequired = r.changed;
|
|
32
|
+
if (o.browser && o.apply && r.client === 'ready') {
|
|
33
|
+
if (!d.selectBrowser)
|
|
34
|
+
throw new Error('browser selection unavailable');
|
|
35
|
+
const selected = await d.selectBrowser(o.browser);
|
|
36
|
+
r.browserChanged = selected.changed;
|
|
37
|
+
r.browserBackupPath = selected.backupPath;
|
|
38
|
+
r.changed ||= selected.changed;
|
|
39
|
+
r.restartRequired ||= selected.changed;
|
|
40
|
+
}
|
|
29
41
|
let attemptedStart = false, started = true;
|
|
30
|
-
if (o.startBrowser && !r.checks.some(c => c.id === 'chrome' && c.state === 'pass')) {
|
|
42
|
+
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
43
|
attemptedStart = true;
|
|
32
|
-
started = await bounded(d.startBrowser, false,
|
|
44
|
+
started = await bounded(d.startBrowser, false, 25000);
|
|
33
45
|
}
|
|
34
46
|
if (r.changed || attemptedStart)
|
|
35
47
|
r.checks = (await d.doctor()).checks;
|
|
@@ -39,7 +51,7 @@ export async function runSetup(o, d) {
|
|
|
39
51
|
r.restartRequired = r.changed || pw?.state === 'fail';
|
|
40
52
|
const ids = { registration: 'install_service', service: 'install_service', ipc: 'install_service',
|
|
41
53
|
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' };
|
|
54
|
+
browser_target: 'configure_mcp', browser_identity: 'start_browser', playwright: 'configure_mcp', uac: 'review_uac' };
|
|
43
55
|
for (const c of r.checks)
|
|
44
56
|
if (c.nextAction) {
|
|
45
57
|
const id = c.id === 'vault' ? c.state === 'pass' ? 'unlock_vault' : 'authenticate_vault' : ids[c.id] ?? 'configure_mcp';
|
|
@@ -48,7 +60,7 @@ export async function runSetup(o, d) {
|
|
|
48
60
|
if (r.client !== 'ready')
|
|
49
61
|
action('configure_mcp', 'Inspect Codex configuration; apply missing registrations explicitly and preserve custom settings.');
|
|
50
62
|
if (!started)
|
|
51
|
-
action('start_browser', '
|
|
63
|
+
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
64
|
if (r.restartRequired)
|
|
53
65
|
action('restart_client', 'Restart the AI session to load saved MCP registrations.', false);
|
|
54
66
|
action('configure_mcp', 'Active AI-session browser ownership is unverified; check the current client browser connection.', false);
|
package/dist/core/browser.js
CHANGED
|
@@ -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('
|
|
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
|
|
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('
|
|
67
|
-
const
|
|
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
|
|
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('
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
}
|
package/dist/core/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join, posix, win32 } from 'node:path';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { defaultEndpoint } from '../ipc/endpoint.js';
|
|
4
|
+
import { browserKind, readBrowserChoice } from '../client/browser-choice.js';
|
|
4
5
|
/** Paths owned by the privileged service. Never read by the MCP client. */
|
|
5
6
|
export function serviceConfig(env, platform) {
|
|
6
7
|
const path = platform === 'win32' ? win32 : posix;
|
|
@@ -26,8 +27,20 @@ export function loadConfig(env = process.env, platform = process.platform) {
|
|
|
26
27
|
? join(homedir(), 'Library', 'Application Support')
|
|
27
28
|
: join(homedir(), 'AppData', 'Roaming'));
|
|
28
29
|
const home = env.U_CHAN_HOME ?? join(base, 'u-chan-secrets');
|
|
30
|
+
const browserChoicePath = join(home, 'browser.json');
|
|
31
|
+
const browser = env.U_CHAN_BROWSER === undefined ? readBrowserChoice(browserChoicePath) ?? 'chrome' : browserKind(env.U_CHAN_BROWSER);
|
|
29
32
|
return {
|
|
33
|
+
browser,
|
|
34
|
+
browserProfile: join(home, `${browser}-profile`),
|
|
35
|
+
browserChoicePath,
|
|
30
36
|
chromeProfile: join(home, 'chrome-profile'),
|
|
31
37
|
cdpEndpoint: env.U_CHAN_CDP ?? 'http://127.0.0.1:9222',
|
|
32
38
|
};
|
|
33
39
|
}
|
|
40
|
+
/** An already-running MCP process must not silently use a selection replaced by later setup. */
|
|
41
|
+
export function resolveLoginBrowser(env = process.env, platform = process.platform) {
|
|
42
|
+
const config = loadConfig(env, platform), saved = readBrowserChoice(config.browserChoicePath);
|
|
43
|
+
if (env.U_CHAN_BROWSER !== undefined && saved !== undefined && config.browser !== saved)
|
|
44
|
+
throw new Error('Browser selection changed; restart the AI session before login.');
|
|
45
|
+
return config.browser;
|
|
46
|
+
}
|
package/dist/core/doctor.js
CHANGED
|
@@ -48,7 +48,7 @@ export async function checkPlaywrightNow(selfPid = process.pid, expectedEndpoint
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
// Leave room for the service's full native ACL scan and IPC overhead.
|
|
51
|
-
export const SERVICE_DIAGNOSTIC_TIMEOUT_MS =
|
|
51
|
+
export const SERVICE_DIAGNOSTIC_TIMEOUT_MS = 70000;
|
|
52
52
|
export function safeVersion(value) {
|
|
53
53
|
return typeof value === 'string' && value.length <= 64 && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value) ? value : undefined;
|
|
54
54
|
}
|
|
@@ -77,12 +77,13 @@ export async function bounded(probe, fallback, timeoutMs = 5000) {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
export async function runDoctor(d) {
|
|
80
|
-
const [registration, service, chrome, playwright, elevation] = await Promise.all([
|
|
80
|
+
const [registration, service, chrome, playwright, elevation, identity] = await Promise.all([
|
|
81
81
|
bounded(d.registration, 'unknown'),
|
|
82
82
|
bounded(d.service, null, SERVICE_DIAGNOSTIC_TIMEOUT_MS),
|
|
83
83
|
bounded(d.chrome, 'unknown'),
|
|
84
84
|
bounded(d.playwright, 'unknown'),
|
|
85
85
|
bounded(d.elevation ?? (async () => 'unknown'), 'unknown'),
|
|
86
|
+
bounded(d.browserIdentity ?? (async () => 'unknown'), 'unknown', 20000),
|
|
86
87
|
]);
|
|
87
88
|
const checks = [];
|
|
88
89
|
const add = (id, state, message, nextAction, required = true) => {
|
|
@@ -100,7 +101,7 @@ export async function runDoctor(d) {
|
|
|
100
101
|
}
|
|
101
102
|
if (d.clientEndpoint !== undefined) {
|
|
102
103
|
const client = safeCdpOrigin(d.clientEndpoint), installed = safeCdpOrigin(service?.browserEndpoint);
|
|
103
|
-
add('service_browser', state(service?.browser), 'Service browser endpoint checked.', 'Start dedicated
|
|
104
|
+
add('service_browser', state(service?.browser), 'Service browser endpoint checked.', 'Start the selected dedicated browser and check the service browser target.');
|
|
104
105
|
add('browser_target', !client || !installed ? 'unknown' : client === installed ? 'pass' : 'fail', 'Desktop and service browser targets checked.', 'Align the desktop and service loopback browser targets.');
|
|
105
106
|
}
|
|
106
107
|
if (d.platform === 'win32' && d.elevation)
|
|
@@ -111,9 +112,29 @@ export async function runDoctor(d) {
|
|
|
111
112
|
add('vault', v === 'locked' ? 'pass' : v === 'unauthenticated' ? 'fail' : state(v), v === 'locked' ? 'Vault is locked.' : v === 'unauthenticated' ? 'Vault is not authenticated.' : 'Vault availability checked.', 'Complete Bitwarden service login during trusted setup.');
|
|
112
113
|
if (v === 'locked')
|
|
113
114
|
checks[checks.length - 1].nextAction = 'Run u-chan unlock.';
|
|
114
|
-
|
|
115
|
+
const label = d.browser === 'edge' ? 'Edge' : d.browser === 'chrome' ? 'Chrome' : 'browser';
|
|
116
|
+
add('chrome', chrome, `Dedicated ${label} CDP endpoint checked.`, 'Start the selected dedicated browser with the provided platform launcher.');
|
|
117
|
+
const kindMismatch = d.browser && (service?.browserKind === 'chrome' || service?.browserKind === 'edge') && service.browserKind !== d.browser;
|
|
118
|
+
if (d.browserIdentity)
|
|
119
|
+
add('browser_identity', kindMismatch ? 'fail' : identity, `Dedicated ${label} process, owner and profile checked.`, 'Check the selected browser, dedicated profile and occupied endpoint; close only the previous dedicated U-Chan instance before switching.');
|
|
115
120
|
add('playwright', playwright, 'Playwright shared-browser configuration checked.', 'Configure Playwright MCP with --cdp-endpoint and restart the AI session.', playwright !== 'unknown');
|
|
116
|
-
return { version: 1, checks };
|
|
121
|
+
return { version: 1, checks, ...(d.browser ? { browser: d.browser, observedBrowser: service?.browserKind === 'edge' || service?.browserKind === 'chrome' ? service.browserKind : 'unknown' } : {}) };
|
|
122
|
+
}
|
|
123
|
+
export async function observeCdpBrowser(endpoint) {
|
|
124
|
+
try {
|
|
125
|
+
const origin = safeCdpOrigin(endpoint);
|
|
126
|
+
if (!origin)
|
|
127
|
+
return 'unknown';
|
|
128
|
+
const response = await fetch(new URL('/json/version', origin), { signal: AbortSignal.timeout(5000), redirect: 'error' });
|
|
129
|
+
if (!response.ok)
|
|
130
|
+
return 'unknown';
|
|
131
|
+
const metadata = await response.json();
|
|
132
|
+
const agent = typeof metadata['User-Agent'] === 'string' ? metadata['User-Agent'] : '';
|
|
133
|
+
return /(?:Edg|Edge)\//.test(agent) ? 'edge' : /Chrome\//.test(agent) ? 'chrome' : 'unknown';
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return 'unknown';
|
|
137
|
+
}
|
|
117
138
|
}
|
|
118
139
|
export function doctorExitCode(report) {
|
|
119
140
|
const required = report.checks.filter(c => c.required);
|