u-chan-secrets 3.0.2 → 4.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 CHANGED
@@ -1,11 +1,13 @@
1
- # U-Chan Secrets V3
1
+ # U-Chan Secrets V4
2
2
 
3
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.
4
4
 
5
- V3 adds diagnostics, temporary approvals, explicit login outcomes, and a native management window. The source version is `3.0.2`; merging this repository does not publish an npm package or update an installed service.
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.
6
6
 
7
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).
8
8
 
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
+
9
11
  ## What V3 changes
10
12
 
11
13
  - **Diagnose:** `u-chan doctor` explains service, vault, permission, Chrome, and browser-sharing problems without fetching credentials or repairing configuration.
@@ -16,6 +18,33 @@ V3 adds diagnostics, temporary approvals, explicit login outcomes, and a native
16
18
 
17
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.
18
20
 
21
+ ## What V4 adds
22
+
23
+ Use a normal, non-administrator terminal after installing the matching trusted service:
24
+
25
+ ```powershell
26
+ u-chan setup --json
27
+ u-chan setup --client codex --apply
28
+ u-chan setup --client codex --apply --replace-existing --start-browser
29
+ ```
30
+
31
+ 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
+
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.
34
+
35
+ 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
+
37
+ 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
+
39
+ Propose a site grant with an explicit success condition:
40
+
41
+ ```sh
42
+ u-chan allow example.com --login-url https://example.com/login --item "Example account" --once --success-url https://example.com/dashboard
43
+ u-chan allow example.com --login-url https://example.com/login --item "Example account" --minutes 30 --success-selector "button[data-action='sign-out']"
44
+ ```
45
+
46
+ The service still requires protected approval. The CLI defaults to one login attempt; timed approvals accept 1–1440 whole minutes, and `--permanent` requests permanent future logins. URL and selector success conditions are mutually exclusive. Without one, login may return `authentication_unconfirmed`; independently verify the signed-in page. Grant expiry does not log out an already transferred browser session.
47
+
19
48
  ## Architecture
20
49
 
21
50
  ```mermaid
@@ -49,7 +78,7 @@ Install from a trusted shell before granting an AI unrestricted user-account acc
49
78
 
50
79
  ## Build and install from source
51
80
 
52
- Use the checkout for the V3 source version. Unversioned npm installation can select a different published version; the package metadata in this repository is not proof of npm publication.
81
+ Use a reviewed checkout for the desired source version. Unversioned npm installation can select a different published version; the package metadata in this repository is not proof of npm publication.
53
82
 
54
83
  ```sh
55
84
  git clone https://github.com/anchuwithdeadline/u-chan-secrets.git
@@ -126,9 +155,13 @@ Check the phrase in the genuine protected prompt before entering a password. Nev
126
155
 
127
156
  The table uses the globally installed `u-chan` spelling; from source use `node dist/cli.js` with the same arguments.
128
157
 
158
+ In Windows PowerShell, use `u-chan.cmd` if execution policy blocks npm's `.ps1` shim, for example `u-chan.cmd doctor --json`. No machine execution-policy change is needed.
159
+
129
160
  | Command | Purpose |
130
161
  |---|---|
131
162
  | `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 |
164
+ | `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 |
132
165
  | `manage` | Native status, grant pages, recent activity, lock/unlock, and revocation |
133
166
  | `unlock` | Request the protected master-password prompt |
134
167
  | `lock` | Forget the service's vault session and request vault lock |
@@ -200,6 +233,8 @@ Supported V3 tooling refuses V2 downgrade after migration. Historical V2 adminis
200
233
 
201
234
  Package updates verify registry signatures and integrity and retain private dependencies. Changed dependencies or native helpers require a trusted administrator reinstall. Expired/exhausted grants and already transferred website sessions are separate from vault lock and upgrade state.
202
235
 
236
+ Versions 3.0.0–3.0.2 have an npm signature-verification defect and cannot reliably bootstrap their own update. Install the matching corrected package from a trusted shell, then use the administrator reinstall above. Version 3.0.3 requires SHA-512 integrity and a valid signature from its pinned npm key; unsigned repository-attestation JSON cannot replace that signature. Future signing-key rotation fails closed and requires a trusted verifier upgrade.
237
+
203
238
  For V1 data, use `u-chan import-v1 [path]`: each entry requires protected approval. Retire user-side Bitwarden sessions and the old `bw-session` file through a trusted migration process; never copy them into the service as trusted authorization.
204
239
 
205
240
  ## Validation and security
package/dist/cli.js CHANGED
@@ -7,8 +7,13 @@ import { fileURLToPath } from 'node:url';
7
7
  import { IpcClient } from './ipc/client.js';
8
8
  import { defaultEndpoint } from './ipc/endpoint.js';
9
9
  import { MAX_FRAME, encode } from './ipc/protocol.js';
10
- import { runDoctor, doctorExitCode, checkPlaywrightNow, chromeState, SERVICE_DIAGNOSTIC_TIMEOUT_MS } from './core/doctor.js';
10
+ import { runDoctor, doctorExitCode, checkPlaywrightNow, chromeState, safeVersion, SERVICE_DIAGNOSTIC_TIMEOUT_MS } from './core/doctor.js';
11
11
  import { loadConfig } from './core/config.js';
12
+ import { execFile } from 'node:child_process';
13
+ import { join } from 'node:path';
14
+ import { configureCodex, isElevated } from './client/codex-setup.js';
15
+ import { runSetup, setupExitCode } from './client/setup.js';
16
+ import { parseAllowArgs } from './client/allow.js';
12
17
  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';
13
18
  /** import-v1 shows one approval window per entry, each open for up to 2 minutes, one after another. */
14
19
  export const IMPORT_PROMPT_MS = 120000;
@@ -25,6 +30,9 @@ export async function main(argv, d) {
25
30
  let call;
26
31
  const doctor = d.doctor ?? (() => runDoctor({
27
32
  platform: d.platform,
33
+ clientVersion: d.clientVersion ?? 'unknown',
34
+ clientEndpoint: loadConfig(d.env, d.platform).cdpEndpoint,
35
+ elevation: d.platform === 'win32' ? async () => (await import('./platform/windows/doctor.js')).checkWindowsUac() : undefined,
28
36
  registration: async () => d.platform === 'win32' ? (await import('./platform/windows/doctor.js')).checkWindowsRegistration() : d.platform === 'darwin' ? (await import('./platform/darwin/doctor.js')).checkDarwinRegistration() : 'fail',
29
37
  service: async () => {
30
38
  const r = await d.connect({ timeoutMs: SERVICE_DIAGNOSTIC_TIMEOUT_MS }).diagnose();
@@ -39,11 +47,74 @@ export async function main(argv, d) {
39
47
  playwright: async () => {
40
48
  if (d.platform !== 'win32')
41
49
  return 'unknown';
42
- const state = await checkPlaywrightNow();
50
+ const state = await checkPlaywrightNow(process.pid, loadConfig(d.env, d.platform).cdpEndpoint);
43
51
  return state === 'ok' ? 'pass' : state === 'missing' ? 'fail' : 'unknown';
44
52
  },
45
53
  }));
46
54
  const printDoctor = (r) => d.out(r.checks.map(c => `${c.state.toUpperCase()} ${c.id}: ${c.message}${c.nextAction ? ' ' + c.nextAction : ''}`).join('\n'));
55
+ if (cmd === 'allow') {
56
+ const proposal = parseAllowArgs(argv.slice(1));
57
+ if (!proposal) {
58
+ d.err('usage: u-chan allow <domain> --login-url <https-url> --item <label> [--once|--minutes <1..1440>|--permanent] [--success-url <https-url>|--success-selector <selector>]');
59
+ return 1;
60
+ }
61
+ const r = await d.connect({ timeoutMs: 180000 }).requestAllow(proposal.domain, proposal.loginUrl, proposal.item, undefined, proposal.options);
62
+ if (r.ok) {
63
+ d.out(r.message);
64
+ if (!proposal.options.success)
65
+ d.out('No success condition supplied. Login may return authentication_unconfirmed; independently verify the signed-in page.');
66
+ }
67
+ else
68
+ d.err(r.message);
69
+ return r.ok ? 0 : 1;
70
+ }
71
+ if (cmd === 'setup') {
72
+ const args = argv.slice(1), seen = new Set();
73
+ let valid = true;
74
+ for (let i = 0; i < args.length; i++) {
75
+ const flag = args[i];
76
+ if (seen.has(flag) || !['--client', '--apply', '--replace-existing', '--start-browser', '--json'].includes(flag)) {
77
+ valid = false;
78
+ break;
79
+ }
80
+ seen.add(flag);
81
+ if (flag === '--client' && args[++i] !== 'codex') {
82
+ valid = false;
83
+ break;
84
+ }
85
+ }
86
+ if (!valid || (seen.has('--replace-existing') && !seen.has('--apply'))) {
87
+ d.err('usage: u-chan setup [--client codex] [--apply] [--replace-existing] [--start-browser] [--json]');
88
+ return 1;
89
+ }
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;
92
+ 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') }),
95
+ 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
+ });
110
+ }),
111
+ });
112
+ if (seen.has('--json'))
113
+ d.out(JSON.stringify(r));
114
+ else
115
+ d.out([`Setup: ${r.state}; sharing: ${r.sharing}; restart required: ${r.restartRequired}`, ...r.actions.map(a => a.message), ...(r.backupPath ? ['Configuration backup: ' + r.backupPath] : [])].join('\n'));
116
+ return setupExitCode(r);
117
+ }
47
118
  if (cmd === 'doctor' && (arg === undefined || arg === '--json') && rest.length === 0) {
48
119
  const r = await doctor();
49
120
  if (arg === '--json')
@@ -183,7 +254,13 @@ const isEntry = (() => {
183
254
  }
184
255
  })();
185
256
  if (isEntry) {
257
+ let clientVersion = 'unknown';
258
+ try {
259
+ clientVersion = safeVersion(JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version) ?? 'unknown';
260
+ }
261
+ catch { }
186
262
  const code = await main(process.argv.slice(2), {
263
+ clientVersion,
187
264
  connect: (opts) => new IpcClient(defaultEndpoint(process.env, process.platform), opts),
188
265
  env: process.env,
189
266
  platform: process.platform,
@@ -0,0 +1,64 @@
1
+ import { buildEntry, clean, normalizeDomain } from '../core/allowlist.js';
2
+ import { validOptions } from '../core/policy.js';
3
+ function https(value) {
4
+ if (value.length > 2048 || clean(value) !== value)
5
+ return false;
6
+ try {
7
+ const u = new URL(value);
8
+ return u.protocol === 'https:' && !u.username && !u.password;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ export function parseAllowArgs(args) {
15
+ const [domain, ...flags] = args;
16
+ if (!domain || domain.length > 253 || clean(domain) !== domain || /[@:/\\?#\s]/.test(domain) || !normalizeDomain(domain))
17
+ return null;
18
+ const seen = new Set(), values = new Map();
19
+ for (let i = 0; i < flags.length; i++) {
20
+ const flag = flags[i];
21
+ if (seen.has(flag) || !['--login-url', '--item', '--once', '--permanent', '--minutes', '--success-url', '--success-selector'].includes(flag))
22
+ return null;
23
+ seen.add(flag);
24
+ if (flag === '--once' || flag === '--permanent')
25
+ continue;
26
+ const value = flags[++i];
27
+ if (!value || value.startsWith('--') || clean(value) !== value)
28
+ return null;
29
+ values.set(flag, value);
30
+ }
31
+ const loginUrl = values.get('--login-url'), item = values.get('--item');
32
+ if (!loginUrl || !https(loginUrl) || !item?.trim() || item.length > 1000)
33
+ return null;
34
+ if (['--once', '--permanent', '--minutes'].filter(f => seen.has(f)).length > 1)
35
+ return null;
36
+ if (seen.has('--success-url') && seen.has('--success-selector'))
37
+ return null;
38
+ const options = { grant: { kind: 'once' } };
39
+ if (seen.has('--permanent'))
40
+ options.grant = { kind: 'permanent' };
41
+ if (seen.has('--minutes')) {
42
+ const raw = values.get('--minutes');
43
+ if (!/^[1-9]\d{0,3}$/.test(raw))
44
+ return null;
45
+ options.grant = { kind: 'timed', minutes: Number(raw) };
46
+ }
47
+ if (seen.has('--success-url')) {
48
+ const url = values.get('--success-url');
49
+ if (!https(url))
50
+ return null;
51
+ options.success = { url };
52
+ }
53
+ if (seen.has('--success-selector'))
54
+ options.success = { selector: values.get('--success-selector').trim() };
55
+ if (!validOptions(options))
56
+ return null;
57
+ try {
58
+ const entry = buildEntry(domain, loginUrl, item);
59
+ return { domain: entry.domain, loginUrl, item, options };
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
@@ -0,0 +1,140 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { chmod, copyFile, lstat, mkdir, realpath } from 'node:fs/promises';
4
+ import { basename, join, resolve } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { safeCdpOrigin } from '../core/doctor.js';
7
+ import { defaultEndpoint } from '../ipc/endpoint.js';
8
+ /** Fail closed when privilege inspection is unavailable. Never execute user-owned clients as administrator. */
9
+ export async function isElevated() {
10
+ if (process.platform !== 'win32')
11
+ return process.getuid?.() === 0;
12
+ return new Promise(resolve => execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
13
+ '([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)'], { timeout: 5000, windowsHide: true, maxBuffer: 1024 }, (err, out) => resolve(!!err || out.trim() !== 'False')));
14
+ }
15
+ function simple(entry, name) {
16
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
17
+ return false;
18
+ const keys = ['name', 'enabled', 'disabled_reason', 'transport', 'enabled_tools', 'disabled_tools', 'startup_timeout_sec', 'tool_timeout_sec'];
19
+ if (Object.keys(entry).some(k => !keys.includes(k)) || entry.enabled !== true || entry.disabled_reason != null)
20
+ return false;
21
+ for (const key of ['enabled_tools', 'disabled_tools', 'startup_timeout_sec', 'tool_timeout_sec']) {
22
+ if (entry[key] != null)
23
+ return false;
24
+ }
25
+ const t = entry.transport;
26
+ if (!t || typeof t !== 'object' || Object.keys(t).some(k => !['type', 'command', 'args', 'env', 'env_vars', 'cwd'].includes(k)))
27
+ return false;
28
+ if (t.type !== 'stdio' || typeof t.command !== 'string' || !Array.isArray(t.args) || !t.args.every((a) => typeof a === 'string'))
29
+ 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
+ return false;
32
+ const command = basename(t.command.replaceAll('\\', '/')).toLowerCase();
33
+ if (name === 'u_chan_secrets')
34
+ return ['node', 'node.exe'].includes(command) && t.args.length === 1 && /(?:^|[\\/])index\.js$/.test(t.args[0]);
35
+ return ['npx', 'npx.cmd', 'npx.exe'].includes(command) && /^@playwright\/mcp(?:@[^\s]+)?$/.test(t.args[0] ?? '')
36
+ && (t.args.length === 1 || (t.args.length === 3 && t.args[1] === '--cdp-endpoint' && !!safeCdpOrigin(t.args[2])));
37
+ }
38
+ async function backup(configDir) {
39
+ const root = resolve(configDir);
40
+ const info = await lstat(root);
41
+ if (!info.isDirectory() || info.isSymbolicLink() || (await realpath(root)).toLowerCase() !== root.toLowerCase())
42
+ throw new Error('redirected config');
43
+ const config = join(root, 'config.toml');
44
+ try {
45
+ const file = await lstat(config);
46
+ if (!file.isFile() || file.isSymbolicLink())
47
+ throw new Error('redirected config');
48
+ }
49
+ catch (err) {
50
+ if (err.code === 'ENOENT')
51
+ return undefined;
52
+ throw err;
53
+ }
54
+ const dir = join(root, '#0 backup');
55
+ await mkdir(dir, { mode: 0o700 }).catch(err => { if (err.code !== 'EEXIST')
56
+ throw err; });
57
+ const target = await lstat(dir);
58
+ if (!target.isDirectory() || target.isSymbolicLink() || (await realpath(dir)).toLowerCase() !== dir.toLowerCase())
59
+ throw new Error('redirected backup');
60
+ const path = join(dir, `u-chan-setup-${randomUUID()}.toml`);
61
+ await copyFile(config, path, constants.COPYFILE_EXCL);
62
+ await chmod(path, 0o600);
63
+ return path;
64
+ }
65
+ /** Inspect through the official Codex CLI; never parse or rewrite TOML, or return its contents. */
66
+ export async function configureCodex(o, injected) {
67
+ let changed = false, backupPath;
68
+ const result = (state, message) => ({ state, changed, ...(backupPath ? { backupPath } : {}), message });
69
+ const d = injected ?? { elevated: isElevated, exec: args => new Promise(resolve => {
70
+ execFile('codex', args, { timeout: 15000, windowsHide: true, maxBuffer: 1024 * 1024, env: { ...process.env, CODEX_HOME: o.configDir } }, (err, stdout, stderr) => resolve({ code: err ? 1 : 0, stdout, stderr }));
71
+ }) };
72
+ try {
73
+ if (!safeCdpOrigin(o.endpoint) || (o.replaceExisting && !o.apply))
74
+ return result('manual', 'Use a safe loopback browser target and explicit apply flags.');
75
+ if (await d.elevated())
76
+ return result('unavailable', 'Run setup from a non-administrator desktop terminal.');
77
+ const desired = [
78
+ { name: 'u_chan_secrets', command: o.nodePath, args: [o.entryPath] },
79
+ { name: 'playwright', command: 'npx', args: ['@playwright/mcp@latest', '--cdp-endpoint', safeCdpOrigin(o.endpoint)] },
80
+ ];
81
+ const pending = [];
82
+ for (const entry of desired) {
83
+ const r = await d.exec(['mcp', 'get', entry.name, '--json']);
84
+ if (r.code !== 0) {
85
+ if (r.stderr.trim().split(/\r?\n/).at(-1) !== `Error: No MCP server named '${entry.name}' found.`)
86
+ return result('unavailable', 'Could not inspect Codex registrations.');
87
+ pending.push({ entry, replace: false });
88
+ continue;
89
+ }
90
+ const current = JSON.parse(r.stdout);
91
+ const env = current?.transport?.env;
92
+ const inherited = current?.transport?.env_vars;
93
+ const ipc = o.ipcEndpoint ?? defaultEndpoint(process.env, process.platform);
94
+ if ((env && Object.keys(env).some(key => {
95
+ const name = key.toUpperCase();
96
+ return name === 'U_CHAN_CDP' ? safeCdpOrigin(env[key]) !== safeCdpOrigin(o.endpoint)
97
+ : 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())))) {
99
+ return result('manual', 'Custom target environment preserved; align browser and service targets manually.');
100
+ }
101
+ if (current?.enabled === true && current.transport?.type === 'stdio'
102
+ && current.transport.command === entry.command && JSON.stringify(current.transport.args) === JSON.stringify(entry.args))
103
+ continue;
104
+ if (!simple(current, entry.name))
105
+ return result('manual', 'Custom Codex settings preserved; configure this registration manually.');
106
+ pending.push({ entry, replace: true });
107
+ }
108
+ if (pending.length === 0)
109
+ return result('ready', 'Codex registrations match; active session ownership is unverified.');
110
+ const replaces = pending.some(p => p.replace);
111
+ if (!o.apply || (replaces && !o.replaceExisting))
112
+ return result(replaces ? 'needs_update' : 'missing', 'Explicit setup apply is required; replacements also require --replace-existing.');
113
+ // No whole-file rollback: the official CLI or another process may edit unrelated settings concurrently.
114
+ try {
115
+ await lstat(o.configDir);
116
+ }
117
+ catch (err) {
118
+ if (err.code !== 'ENOENT')
119
+ throw err;
120
+ await mkdir(o.configDir, { recursive: true, mode: 0o700 });
121
+ }
122
+ backupPath = await backup(o.configDir);
123
+ for (const { entry, replace } of pending) {
124
+ if (replace) {
125
+ const removed = await d.exec(['mcp', 'remove', entry.name]);
126
+ if (removed.code !== 0)
127
+ return result('unavailable', 'Codex change failed; inspect configuration before retrying.');
128
+ changed = true;
129
+ }
130
+ const added = await d.exec(['mcp', 'add', entry.name, '--', entry.command, ...entry.args]);
131
+ if (added.code !== 0)
132
+ return result('unavailable', 'Codex change failed; completed changes and backup were retained.');
133
+ changed = true;
134
+ }
135
+ return result('ready', 'Codex registrations saved. Restart the AI session; active ownership remains unverified.');
136
+ }
137
+ catch {
138
+ return result('unavailable', 'Codex setup unavailable; inspect configuration and any retained backup before retrying.');
139
+ }
140
+ }
@@ -0,0 +1,67 @@
1
+ import { bounded, safeCdpOrigin } from '../core/doctor.js';
2
+ export async function runSetup(o, d) {
3
+ const r = { version: 1, state: 'needs_attention', sharing: 'unobserved', restartRequired: false, checks: [], actions: [], client: 'unavailable', changed: false };
4
+ const action = (id, message, required = true) => r.actions.push({ id, message, required });
5
+ try {
6
+ if (await bounded(d.elevated, true)) {
7
+ action('configure_mcp', 'Run setup from a non-administrator desktop terminal.');
8
+ return r;
9
+ }
10
+ if (!safeCdpOrigin(d.endpoint)) {
11
+ action('configure_mcp', 'Configure a safe HTTP loopback browser target.');
12
+ return r;
13
+ }
14
+ r.checks = (await d.doctor()).checks;
15
+ if (d.platform !== 'win32') {
16
+ action('configure_mcp', 'Automatic setup supports Windows and Codex only; configure other platforms manually.');
17
+ r.client = 'manual';
18
+ return r;
19
+ }
20
+ if (r.checks.some(c => c.id === 'browser_target' && c.state === 'fail')) {
21
+ r.sharing = 'mismatch';
22
+ action('configure_mcp', 'Align the desktop and service loopback browser targets before applying setup.');
23
+ return r;
24
+ }
25
+ const configured = await d.configure({ apply: o.apply, replaceExisting: o.replaceExisting });
26
+ r.client = configured.state;
27
+ r.changed = configured.changed;
28
+ r.backupPath = configured.backupPath;
29
+ let attemptedStart = false, started = true;
30
+ if (o.startBrowser && !r.checks.some(c => c.id === 'chrome' && c.state === 'pass')) {
31
+ attemptedStart = true;
32
+ started = await bounded(d.startBrowser, false, 15000);
33
+ }
34
+ if (r.changed || attemptedStart)
35
+ r.checks = (await d.doctor()).checks;
36
+ const pw = r.checks.find(c => c.id === 'playwright');
37
+ const mismatch = r.checks.some(c => c.id === 'browser_target' && c.state === 'fail');
38
+ r.sharing = mismatch || pw?.state === 'fail' ? 'mismatch' : pw?.state === 'pass' ? 'process_configured' : 'unobserved';
39
+ r.restartRequired = r.changed || pw?.state === 'fail';
40
+ const ids = { registration: 'install_service', service: 'install_service', ipc: 'install_service',
41
+ 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' };
43
+ for (const c of r.checks)
44
+ if (c.nextAction) {
45
+ const id = c.id === 'vault' ? c.state === 'pass' ? 'unlock_vault' : 'authenticate_vault' : ids[c.id] ?? 'configure_mcp';
46
+ action(id, c.nextAction, c.required && c.state !== 'pass');
47
+ }
48
+ if (r.client !== 'ready')
49
+ action('configure_mcp', 'Inspect Codex configuration; apply missing registrations explicitly and preserve custom settings.');
50
+ if (!started)
51
+ action('start_browser', 'Dedicated Chrome did not become reachable; inspect the launcher and run setup again.');
52
+ if (r.restartRequired)
53
+ action('restart_client', 'Restart the AI session to load saved MCP registrations.', false);
54
+ action('configure_mcp', 'Active AI-session browser ownership is unverified; check the current client browser connection.', false);
55
+ 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)
57
+ r.state = 'configured';
58
+ return r;
59
+ }
60
+ catch {
61
+ action('configure_mcp', 'Setup inspection failed; run u-chan doctor and inspect retained configuration changes.');
62
+ return r;
63
+ }
64
+ }
65
+ export function setupExitCode(r) {
66
+ return r.state === 'needs_attention' ? 1 : r.restartRequired ? 2 : 0;
67
+ }
@@ -5,7 +5,7 @@ const IS_PLAYWRIGHT = /@playwright[\\/]mcp/i;
5
5
  * Finds the AI client that launched us (first non-shell ancestor), then checks whether that
6
6
  * client's Playwright MCP was started with --cdp-endpoint (i.e. shares our Chrome).
7
7
  */
8
- export function checkPlaywrightCdp(procs, selfPid) {
8
+ export function checkPlaywrightCdp(procs, selfPid, expectedEndpoint) {
9
9
  const byPid = new Map(procs.map((p) => [p.pid, p]));
10
10
  const ancestors = (pid) => {
11
11
  const out = [];
@@ -22,18 +22,26 @@ export function checkPlaywrightCdp(procs, selfPid) {
22
22
  const ours = procs.filter((p) => IS_PLAYWRIGHT.test(p.cmd) && ancestors(p.pid).some((a) => a.pid === client.pid));
23
23
  if (ours.length === 0)
24
24
  return 'not_found';
25
+ if (expectedEndpoint !== undefined) {
26
+ const expected = safeCdpOrigin(expectedEndpoint);
27
+ return expected && ours.every(p => {
28
+ const matches = [...p.cmd.matchAll(/(?:^|\s)--cdp-endpoint(?:=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s]+))/g)];
29
+ const match = matches[0];
30
+ return matches.length === 1 && match && safeCdpOrigin(match[1] ?? match[2] ?? match[3]) === expected;
31
+ }) ? 'ok' : 'missing';
32
+ }
25
33
  return ours.some((p) => p.cmd.includes('--cdp-endpoint')) ? 'ok' : 'missing';
26
34
  }
27
35
  const PS_LIST = 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress';
28
36
  /** Lists processes via PowerShell/CIM; returns 'unknown' on any failure. */
29
- export async function checkPlaywrightNow(selfPid = process.pid) {
37
+ export async function checkPlaywrightNow(selfPid = process.pid, expectedEndpoint) {
30
38
  if (process.platform !== 'win32')
31
39
  return 'unknown';
32
40
  try {
33
41
  const stdout = await new Promise((resolve, reject) => execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', PS_LIST], { timeout: 15000, windowsHide: true, maxBuffer: 64 * 1024 * 1024 }, (err, out) => (err ? reject(err) : resolve(String(out)))));
34
42
  const raw = JSON.parse(stdout);
35
43
  const procs = raw.map((r) => ({ pid: r.ProcessId, ppid: r.ParentProcessId, name: r.Name ?? '', cmd: r.CommandLine ?? '' }));
36
- return checkPlaywrightCdp(procs, selfPid);
44
+ return checkPlaywrightCdp(procs, selfPid, expectedEndpoint);
37
45
  }
38
46
  catch {
39
47
  return 'unknown';
@@ -41,6 +49,21 @@ export async function checkPlaywrightNow(selfPid = process.pid) {
41
49
  }
42
50
  // Leave room for the service's full native ACL scan and IPC overhead.
43
51
  export const SERVICE_DIAGNOSTIC_TIMEOUT_MS = 25000;
52
+ export function safeVersion(value) {
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
+ }
55
+ export function safeCdpOrigin(value) {
56
+ if (typeof value !== 'string' || !/^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::[1-9]\d{0,4})?\/?$/i.test(value))
57
+ return undefined;
58
+ try {
59
+ const url = new URL(value);
60
+ return url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
61
+ && !url.username && !url.password && url.pathname === '/' && !url.search && !url.hash ? url.origin : undefined;
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
44
67
  export async function bounded(probe, fallback, timeoutMs = 5000) {
45
68
  let timer;
46
69
  try {
@@ -54,11 +77,12 @@ export async function bounded(probe, fallback, timeoutMs = 5000) {
54
77
  }
55
78
  }
56
79
  export async function runDoctor(d) {
57
- const [registration, service, chrome, playwright] = await Promise.all([
80
+ const [registration, service, chrome, playwright, elevation] = await Promise.all([
58
81
  bounded(d.registration, 'unknown'),
59
82
  bounded(d.service, null, SERVICE_DIAGNOSTIC_TIMEOUT_MS),
60
83
  bounded(d.chrome, 'unknown'),
61
84
  bounded(d.playwright, 'unknown'),
85
+ bounded(d.elevation ?? (async () => 'unknown'), 'unknown'),
62
86
  ]);
63
87
  const checks = [];
64
88
  const add = (id, state, message, nextAction, required = true) => {
@@ -70,6 +94,17 @@ export async function runDoctor(d) {
70
94
  add('service', service ? 'pass' : 'fail', service ? 'Service is reachable.' : 'Service is unreachable.', 'Check service status; run u-chan install if absent.');
71
95
  add('ipc', service ? 'pass' : 'unknown', 'Authenticated service connection checked.', 'Check the installed owner account and service.');
72
96
  const state = (value) => value === 'pass' || value === 'fail' ? value : 'unknown';
97
+ if (d.clientVersion !== undefined) {
98
+ const client = safeVersion(d.clientVersion), installed = safeVersion(service?.serviceVersion);
99
+ add('version', !client || !installed ? 'unknown' : client === installed ? 'pass' : 'fail', 'CLI and installed service versions checked.', 'Install the matching trusted service package.');
100
+ }
101
+ if (d.clientEndpoint !== undefined) {
102
+ const client = safeCdpOrigin(d.clientEndpoint), installed = safeCdpOrigin(service?.browserEndpoint);
103
+ add('service_browser', state(service?.browser), 'Service browser endpoint checked.', 'Start dedicated Chrome and check the service browser target.');
104
+ 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
+ if (d.platform === 'win32' && d.elevation)
107
+ add('uac', elevation, 'Windows administrator prompt policy checked.', 'Set UAC to Always notify in Windows settings.');
73
108
  add('assets', state(service?.assets), 'Private service assets checked.', 'Repair the installation from a trusted administrator terminal.');
74
109
  add('permissions', state(service?.permissions), 'Protected service permissions checked.', 'Repair protected permissions from a trusted administrator terminal.');
75
110
  const v = service?.vault;
@@ -91,12 +126,15 @@ export async function commandState(file, args, passes, timeoutMs = 5000) {
91
126
  });
92
127
  });
93
128
  }
94
- export async function chromeState(endpoint) {
129
+ export async function chromeState(endpoint, timeoutMs = 5000) {
95
130
  try {
96
- const url = new URL('/json/version', endpoint);
131
+ const origin = safeCdpOrigin(endpoint);
132
+ if (!origin)
133
+ return 'fail';
134
+ const url = new URL('/json/version', origin);
97
135
  if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.protocol !== 'http:')
98
136
  return 'fail';
99
- const r = await fetch(url, { signal: AbortSignal.timeout(5000), redirect: 'error' });
137
+ const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'error' });
100
138
  const json = await r.json();
101
139
  return r.ok && typeof json.webSocketDebuggerUrl === 'string' ? 'pass' : 'fail';
102
140
  }
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
4
  import { IpcClient } from './ipc/client.js';
5
5
  import { defaultEndpoint } from './ipc/endpoint.js';
6
6
  import { checkPlaywrightNow } from './core/doctor.js';
7
+ import { loadConfig } from './core/config.js';
7
8
  import { createMcpServer, withPlaywrightStatus } from './mcp/server.js';
8
9
  // Thin MCP client: every privileged action goes to the U-Chan service over IPC.
9
10
  // stdout is the MCP channel: log only to stderr.
@@ -13,4 +14,4 @@ try {
13
14
  }
14
15
  catch { }
15
16
  const client = new IpcClient(defaultEndpoint(process.env, process.platform));
16
- await createMcpServer(withPlaywrightStatus(client, checkPlaywrightNow), version).connect(new StdioServerTransport());
17
+ await createMcpServer(withPlaywrightStatus(client, () => checkPlaywrightNow(process.pid, loadConfig(process.env, process.platform).cdpEndpoint)), version).connect(new StdioServerTransport());
@@ -19,8 +19,8 @@ const INSTRUCTIONS = [
19
19
  'If login fails, call status: a locked vault means the user must run u-chan unlock themselves. If login says "needs you", ask the user to sign in by hand.',
20
20
  ].join('\n');
21
21
  const PLAYWRIGHT_MSG = {
22
- ok: 'shared chrome',
23
- missing: 'missing --cdp-endpoint (using its own Chrome; restart the AI session)',
22
+ ok: 'shared chrome process configuration observed; active AI session ownership unverified',
23
+ missing: 'missing or mismatched --cdp-endpoint (restart the AI session)',
24
24
  not_found: 'not found',
25
25
  unknown: 'unknown',
26
26
  };