u-chan-secrets 0.0.0-stage → 3.0.2

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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +218 -3
  3. package/dist/cli.js +195 -0
  4. package/dist/core/abort.js +30 -0
  5. package/dist/core/allowlist.js +139 -0
  6. package/dist/core/audit.js +41 -0
  7. package/dist/core/browser.js +131 -0
  8. package/dist/core/config.js +33 -0
  9. package/dist/core/doctor.js +106 -0
  10. package/dist/core/login-filler.js +170 -0
  11. package/dist/core/policy.js +36 -0
  12. package/dist/core/redact.js +7 -0
  13. package/dist/core/service.js +167 -0
  14. package/dist/core/totp.js +72 -0
  15. package/dist/core/types.js +10 -0
  16. package/dist/core/vault.js +157 -0
  17. package/dist/index.js +16 -0
  18. package/dist/ipc/client.js +79 -0
  19. package/dist/ipc/endpoint.js +7 -0
  20. package/dist/ipc/protocol.js +30 -0
  21. package/dist/ipc/server.js +108 -0
  22. package/dist/mcp/server.js +72 -0
  23. package/dist/platform/darwin/authorize.js +37 -0
  24. package/dist/platform/darwin/doctor.js +13 -0
  25. package/dist/platform/darwin/lifecycle.js +182 -0
  26. package/dist/platform/darwin/manage-script.js +33 -0
  27. package/dist/platform/darwin/peercred.js +69 -0
  28. package/dist/platform/darwin/plist.js +28 -0
  29. package/dist/platform/darwin/prompt-script.js +38 -0
  30. package/dist/platform/darwin/prompt.js +65 -0
  31. package/dist/platform/index.js +30 -0
  32. package/dist/platform/runtime-assets.js +37 -0
  33. package/dist/platform/windows/acl.js +45 -0
  34. package/dist/platform/windows/assets/auth-src.js +191 -0
  35. package/dist/platform/windows/assets/compiler.js +45 -0
  36. package/dist/platform/windows/assets/prompt-src.js +558 -0
  37. package/dist/platform/windows/assets/service-src.js +148 -0
  38. package/dist/platform/windows/authorize.js +45 -0
  39. package/dist/platform/windows/doctor.js +7 -0
  40. package/dist/platform/windows/index.js +26 -0
  41. package/dist/platform/windows/install.js +217 -0
  42. package/dist/platform/windows/migration.js +49 -0
  43. package/dist/platform/windows/prompt.js +110 -0
  44. package/dist/platform/windows/service-manager.js +75 -0
  45. package/dist/platform/windows/uac.js +61 -0
  46. package/dist/service/attempts.js +29 -0
  47. package/dist/service/control.js +145 -0
  48. package/dist/service/doctor.js +25 -0
  49. package/dist/service/grants.js +89 -0
  50. package/dist/service/isolated-login.js +108 -0
  51. package/dist/service/main.js +125 -0
  52. package/dist/service/manage.js +81 -0
  53. package/dist/service/policy-approver.js +45 -0
  54. package/dist/service/session-transfer.js +16 -0
  55. package/dist/service/settings.js +29 -0
  56. package/dist/service/state.js +88 -0
  57. package/dist/service/update/updater.js +137 -0
  58. package/dist/service/update/verifier.js +191 -0
  59. package/package.json +33 -6
  60. package/scripts/debug-install.bat +69 -0
  61. package/scripts/login-bw.bat +21 -0
  62. package/scripts/migrate.bat +4 -0
  63. package/scripts/start-chrome.ps1 +33 -0
  64. package/scripts/start-chrome.sh +32 -0
  65. package/scripts/verify-windows-prompt-desktop.mjs +50 -0
@@ -0,0 +1,131 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { chromium } from 'playwright';
3
+ import { UserFacingError } from './types.js';
4
+ import { abortable } from './abort.js';
5
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
+ /** Runs scripts/start-chrome.ps1 (idempotent: it exits early if the port is already open). */
7
+ export const startChromeScript = (scriptPath) => () => new Promise((resolve, reject) => {
8
+ execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath], { timeout: 30000, windowsHide: true }, (err) => (err ? reject(err) : resolve()));
9
+ });
10
+ /** Attaches to the user's Chrome over CDP so cookies are shared with Playwright MCP. Never closes the browser. */
11
+ export class CdpBrowserProvider {
12
+ endpoint;
13
+ opts;
14
+ disconnecting;
15
+ browser;
16
+ connecting;
17
+ constructor(endpoint, opts = {}) {
18
+ this.endpoint = endpoint;
19
+ this.opts = opts;
20
+ }
21
+ async getBrowser(autoLaunch) {
22
+ if (this.browser?.isConnected())
23
+ return this.browser;
24
+ this.connecting ??= this.connect(autoLaunch)
25
+ .then((b) => (this.browser = b))
26
+ .finally(() => {
27
+ this.connecting = undefined;
28
+ });
29
+ return this.connecting;
30
+ }
31
+ async connect(autoLaunch) {
32
+ try {
33
+ return await chromium.connectOverCDP(this.endpoint, { timeout: 5000 });
34
+ }
35
+ catch {
36
+ if (!autoLaunch || !this.opts.launch)
37
+ throw new UserFacingError('Chrome is not running — run scripts/start-chrome.ps1');
38
+ }
39
+ const fail = () => new UserFacingError('could not start Chrome — run scripts/start-chrome.ps1');
40
+ await this.opts.launch().catch(() => {
41
+ throw fail();
42
+ });
43
+ const deadline = Date.now() + (this.opts.launchTimeoutMs ?? 15000);
44
+ while (Date.now() < deadline) {
45
+ try {
46
+ return await chromium.connectOverCDP(this.endpoint, { timeout: 2000 });
47
+ }
48
+ catch {
49
+ await sleep(300);
50
+ }
51
+ }
52
+ throw fail();
53
+ }
54
+ disconnect() {
55
+ return this.disconnecting ??= (async () => {
56
+ const browser = this.browser ?? await this.connecting?.catch(() => undefined);
57
+ this.browser = undefined;
58
+ // For connectOverCDP, close disconnects this transport, not the user's Chrome process.
59
+ await browser?.close().catch(() => { });
60
+ })().finally(() => { this.disconnecting = undefined; });
61
+ }
62
+ async withPage(fn, signal) {
63
+ const browser = await abortable(this.getBrowser(true), signal, () => this.disconnect(), b => b.close());
64
+ const context = browser.contexts()[0];
65
+ 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());
68
+ page.setDefaultTimeout(5000);
69
+ try {
70
+ signal?.throwIfAborted();
71
+ return await abortable(fn(page), signal, () => this.disconnect());
72
+ }
73
+ finally {
74
+ await abortable(page.close(), signal, () => this.disconnect()).catch(() => { });
75
+ }
76
+ }
77
+ async withContext(fn, signal) {
78
+ const browser = await abortable(this.getBrowser(true), signal, () => this.disconnect(), b => b.close());
79
+ const context = browser.contexts()[0];
80
+ if (!context)
81
+ throw new UserFacingError('Chrome has no default window — open Chrome with scripts/start-chrome.ps1');
82
+ signal?.throwIfAborted();
83
+ return abortable(fn(context), signal, () => this.disconnect());
84
+ }
85
+ async connected() {
86
+ try {
87
+ await this.getBrowser(false);
88
+ return true;
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ }
94
+ }
95
+ /** Receives a session (cookies + localStorage) into the user's Chrome over CDP. */
96
+ export class CdpSessionTarget {
97
+ provider;
98
+ constructor(provider) {
99
+ this.provider = provider;
100
+ }
101
+ userAgent(signal) {
102
+ return this.provider.withPage((page) => page.evaluate(() => navigator.userAgent), signal);
103
+ }
104
+ cancel() { return this.provider.disconnect(); }
105
+ inject(state, signal) {
106
+ return this.provider.withContext(async (context) => {
107
+ signal?.throwIfAborted();
108
+ await context.addCookies(state.cookies);
109
+ for (const o of state.origins) {
110
+ signal?.throwIfAborted();
111
+ if (!o.localStorage.length)
112
+ continue;
113
+ const page = await context.newPage();
114
+ page.setDefaultTimeout(5000);
115
+ try {
116
+ await page.goto(o.origin, { waitUntil: 'commit' });
117
+ signal?.throwIfAborted();
118
+ await page.evaluate(({ origin, items }) => {
119
+ if (location.origin !== origin)
120
+ throw new Error('origin mismatch');
121
+ for (const i of items)
122
+ localStorage.setItem(i.name, i.value);
123
+ }, { origin: o.origin, items: o.localStorage });
124
+ }
125
+ finally {
126
+ await page.close().catch(() => { });
127
+ }
128
+ }
129
+ }, signal);
130
+ }
131
+ }
@@ -0,0 +1,33 @@
1
+ import { join, posix, win32 } from 'node:path';
2
+ import { homedir } from 'node:os';
3
+ import { defaultEndpoint } from '../ipc/endpoint.js';
4
+ /** Paths owned by the privileged service. Never read by the MCP client. */
5
+ export function serviceConfig(env, platform) {
6
+ const path = platform === 'win32' ? win32 : posix;
7
+ const home = env.U_CHAN_HOME ??
8
+ (platform === 'win32'
9
+ ? path.join(env.ProgramData ?? 'C:\\ProgramData', 'u-chan-secrets')
10
+ : '/Library/Application Support/u-chan-secrets');
11
+ return {
12
+ home,
13
+ allowlistPath: path.join(home, 'allowlist.json'),
14
+ auditPath: path.join(home, 'audit.log'),
15
+ settingsPath: path.join(home, 'settings.json'),
16
+ bwDataDir: path.join(home, 'bw-data'),
17
+ endpoint: defaultEndpoint(env, platform),
18
+ programDir: path.join(home, 'program'),
19
+ stagingDir: path.join(home, 'staging'),
20
+ };
21
+ }
22
+ /** User-side values only (the user's Chrome). */
23
+ export function loadConfig(env = process.env, platform = process.platform) {
24
+ const base = env.APPDATA ??
25
+ (platform === 'darwin'
26
+ ? join(homedir(), 'Library', 'Application Support')
27
+ : join(homedir(), 'AppData', 'Roaming'));
28
+ const home = env.U_CHAN_HOME ?? join(base, 'u-chan-secrets');
29
+ return {
30
+ chromeProfile: join(home, 'chrome-profile'),
31
+ cdpEndpoint: env.U_CHAN_CDP ?? 'http://127.0.0.1:9222',
32
+ };
33
+ }
@@ -0,0 +1,106 @@
1
+ import { execFile } from 'node:child_process';
2
+ const SHELLS = new Set(['node.exe', 'cmd.exe', 'powershell.exe', 'pwsh.exe', 'conhost.exe', 'node', 'sh', 'bash']);
3
+ const IS_PLAYWRIGHT = /@playwright[\\/]mcp/i;
4
+ /**
5
+ * Finds the AI client that launched us (first non-shell ancestor), then checks whether that
6
+ * client's Playwright MCP was started with --cdp-endpoint (i.e. shares our Chrome).
7
+ */
8
+ export function checkPlaywrightCdp(procs, selfPid) {
9
+ const byPid = new Map(procs.map((p) => [p.pid, p]));
10
+ const ancestors = (pid) => {
11
+ const out = [];
12
+ const seen = new Set();
13
+ for (let p = byPid.get(byPid.get(pid)?.ppid ?? -1); p && !seen.has(p.pid); p = byPid.get(p.ppid)) {
14
+ seen.add(p.pid);
15
+ out.push(p);
16
+ }
17
+ return out;
18
+ };
19
+ const client = ancestors(selfPid).find((p) => !SHELLS.has(p.name.toLowerCase()));
20
+ if (!client)
21
+ return 'not_found';
22
+ const ours = procs.filter((p) => IS_PLAYWRIGHT.test(p.cmd) && ancestors(p.pid).some((a) => a.pid === client.pid));
23
+ if (ours.length === 0)
24
+ return 'not_found';
25
+ return ours.some((p) => p.cmd.includes('--cdp-endpoint')) ? 'ok' : 'missing';
26
+ }
27
+ const PS_LIST = 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress';
28
+ /** Lists processes via PowerShell/CIM; returns 'unknown' on any failure. */
29
+ export async function checkPlaywrightNow(selfPid = process.pid) {
30
+ if (process.platform !== 'win32')
31
+ return 'unknown';
32
+ try {
33
+ 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
+ const raw = JSON.parse(stdout);
35
+ const procs = raw.map((r) => ({ pid: r.ProcessId, ppid: r.ParentProcessId, name: r.Name ?? '', cmd: r.CommandLine ?? '' }));
36
+ return checkPlaywrightCdp(procs, selfPid);
37
+ }
38
+ catch {
39
+ return 'unknown';
40
+ }
41
+ }
42
+ // Leave room for the service's full native ACL scan and IPC overhead.
43
+ export const SERVICE_DIAGNOSTIC_TIMEOUT_MS = 25000;
44
+ export async function bounded(probe, fallback, timeoutMs = 5000) {
45
+ let timer;
46
+ try {
47
+ return await Promise.race([Promise.resolve().then(probe), new Promise(resolve => { timer = setTimeout(() => resolve(fallback), timeoutMs); })]);
48
+ }
49
+ catch {
50
+ return fallback;
51
+ }
52
+ finally {
53
+ clearTimeout(timer);
54
+ }
55
+ }
56
+ export async function runDoctor(d) {
57
+ const [registration, service, chrome, playwright] = await Promise.all([
58
+ bounded(d.registration, 'unknown'),
59
+ bounded(d.service, null, SERVICE_DIAGNOSTIC_TIMEOUT_MS),
60
+ bounded(d.chrome, 'unknown'),
61
+ bounded(d.playwright, 'unknown'),
62
+ ]);
63
+ const checks = [];
64
+ const add = (id, state, message, nextAction, required = true) => {
65
+ checks.push({ id, state, message, nextAction: state === 'pass' ? '' : nextAction, required });
66
+ };
67
+ const supported = d.platform === 'win32' || d.platform === 'darwin';
68
+ add('platform', supported ? 'pass' : 'fail', supported ? 'Platform is supported.' : 'Platform is unsupported.', 'Use Windows or macOS.');
69
+ add('registration', registration, 'Service registration checked.', 'Run u-chan install from a trusted administrator terminal.');
70
+ add('service', service ? 'pass' : 'fail', service ? 'Service is reachable.' : 'Service is unreachable.', 'Check service status; run u-chan install if absent.');
71
+ add('ipc', service ? 'pass' : 'unknown', 'Authenticated service connection checked.', 'Check the installed owner account and service.');
72
+ const state = (value) => value === 'pass' || value === 'fail' ? value : 'unknown';
73
+ add('assets', state(service?.assets), 'Private service assets checked.', 'Repair the installation from a trusted administrator terminal.');
74
+ add('permissions', state(service?.permissions), 'Protected service permissions checked.', 'Repair protected permissions from a trusted administrator terminal.');
75
+ const v = service?.vault;
76
+ 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.');
77
+ if (v === 'locked')
78
+ checks[checks.length - 1].nextAction = 'Run u-chan unlock.';
79
+ add('chrome', chrome, 'Dedicated Chrome CDP endpoint checked.', 'Start dedicated Chrome with the provided platform launcher.');
80
+ add('playwright', playwright, 'Playwright shared-browser configuration checked.', 'Configure Playwright MCP with --cdp-endpoint and restart the AI session.', playwright !== 'unknown');
81
+ return { version: 1, checks };
82
+ }
83
+ export function doctorExitCode(report) {
84
+ const required = report.checks.filter(c => c.required);
85
+ return required.some(c => c.state === 'fail') ? 1 : required.some(c => c.state === 'unknown') ? 2 : 0;
86
+ }
87
+ export async function commandState(file, args, passes, timeoutMs = 5000) {
88
+ return new Promise(resolve => {
89
+ execFile(file, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 65536 }, (err, stdout) => {
90
+ resolve(err ? 'unknown' : passes(stdout) ? 'pass' : 'fail');
91
+ });
92
+ });
93
+ }
94
+ export async function chromeState(endpoint) {
95
+ try {
96
+ const url = new URL('/json/version', endpoint);
97
+ if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.protocol !== 'http:')
98
+ return 'fail';
99
+ const r = await fetch(url, { signal: AbortSignal.timeout(5000), redirect: 'error' });
100
+ const json = await r.json();
101
+ return r.ok && typeof json.webSocketDebuggerUrl === 'string' ? 'pass' : 'fail';
102
+ }
103
+ catch {
104
+ return 'fail';
105
+ }
106
+ }
@@ -0,0 +1,170 @@
1
+ import { isHostAllowed } from './allowlist.js';
2
+ import { freshTotpCode } from './totp.js';
3
+ import { UserFacingError } from './types.js';
4
+ export const NEEDS_YOU = 'needs you — sign in by hand this time';
5
+ const CHALLENGE_CSS = 'iframe[src*="recaptcha"], iframe[src*="hcaptcha"], iframe[src*="challenges.cloudflare.com"], iframe[src*="arkoselabs"]';
6
+ const USERNAME_CSS = '[autocomplete=username], [type=email], [name*=email i], [name*=user i], [id*=email i]';
7
+ const OTP_CSS = 'input[autocomplete="one-time-code"], input[name*="otp" i], input[id*="otp" i], input[name*="2fa" i], input[name="code"], input[id="code"]';
8
+ const visible = (l) => l.filter({ visible: true }).first();
9
+ /**
10
+ * Playwright puts the filled value into its error call log, so any action that
11
+ * handles credentials is rethrown with a fixed message and no cause.
12
+ */
13
+ async function guard(what, fn) {
14
+ try {
15
+ return await fn();
16
+ }
17
+ catch {
18
+ throw new UserFacingError(`failed to ${what}`);
19
+ }
20
+ }
21
+ /**
22
+ * Fills a login form only while the page host passes `hostCheck`. A synchronous
23
+ * check runs immediately before every fill/click, and fills go through element
24
+ * handles so a navigation after the check makes the action throw instead of
25
+ * landing in a different document. Outcomes and errors never contain credentials.
26
+ */
27
+ export async function fillLogin(page, entry, getCreds, opts = {}) {
28
+ const timeout = opts.timeoutMs ?? 15000;
29
+ const hostCheck = opts.hostCheck ?? ((url) => isHostAllowed(entry, url));
30
+ const badHost = () => {
31
+ const url = page.url();
32
+ if (hostCheck(url))
33
+ return undefined;
34
+ let host = 'unknown';
35
+ try {
36
+ host = new URL(url).hostname;
37
+ }
38
+ catch { /* keep unknown */ }
39
+ return { status: 'failed', reason: `unexpected host ${host}` };
40
+ };
41
+ await page.goto(entry.loginUrl, { waitUntil: 'load', timeout });
42
+ const hostFail = badHost();
43
+ if (hostFail)
44
+ return hostFail;
45
+ const sel = entry.selectors;
46
+ const userBox = visible(page.locator(sel?.username ?? USERNAME_CSS));
47
+ const passBox = visible(page.locator(sel?.password ?? 'input[type=password]'));
48
+ const submitBox = visible(page.locator(sel?.submit ?? 'button[type=submit], input[type=submit]'));
49
+ const otpBox = visible(page.locator(sel?.otp ?? OTP_CSS));
50
+ const handleOf = async (l) => (await l.isVisible()) ? l.elementHandle({ timeout }) : null;
51
+ // Forms may render late; wait briefly before concluding there is none.
52
+ await userBox.or(passBox).first().waitFor({ state: 'visible', timeout: Math.min(timeout, 3000) }).catch(() => {
53
+ // No field appeared in the window: handled below as already logged in.
54
+ });
55
+ const [hasUser, hasPass] = await Promise.all([userBox.isVisible(), passBox.isVisible()]);
56
+ const afterWaitHost = badHost();
57
+ if (afterWaitHost)
58
+ return afterWaitHost;
59
+ if (!hasUser && !hasPass)
60
+ return { status: 'already_logged_in' };
61
+ if (!hasUser)
62
+ return { status: 'failed', reason: 'username field not found' };
63
+ if (await visible(page.locator(CHALLENGE_CSS)).isVisible())
64
+ return { status: 'failed', reason: NEEDS_YOU };
65
+ const creds = await getCreds();
66
+ // Each action: sync host check, then only the action itself (no awaits in between).
67
+ const submit = async (h) => {
68
+ const fail = badHost();
69
+ if (fail)
70
+ return fail;
71
+ await guard('submit', () => (h ? h.click({ timeout }) : page.keyboard.press('Enter')));
72
+ return undefined;
73
+ };
74
+ const userH = await handleOf(userBox);
75
+ if (!userH)
76
+ return { status: 'failed', reason: 'username field not found' };
77
+ let passH = hasPass ? await handleOf(passBox) : null;
78
+ let submitH = await handleOf(submitBox);
79
+ let fail;
80
+ let otpFilled = false;
81
+ // 2FA code, at most once per login so a bad seed cannot lock the account.
82
+ const fillOtp = async () => {
83
+ if (!creds.totp)
84
+ return { status: 'failed', reason: 'the page asks for a 2FA code but the Bitwarden item has no TOTP' };
85
+ const code = await freshTotpCode(creds.totp);
86
+ const otpH = await handleOf(otpBox);
87
+ if (!otpH)
88
+ return { status: 'failed', reason: '2FA field not found' };
89
+ const f = badHost();
90
+ if (f)
91
+ return f;
92
+ await guard('fill 2FA field', () => otpH.fill(code, { timeout }));
93
+ otpFilled = true;
94
+ return undefined;
95
+ };
96
+ // Some sites put the 2FA field in the same form as the password.
97
+ const fillInlineOtp = async () => ((await otpBox.isVisible()) ? fillOtp() : undefined);
98
+ if (passH) {
99
+ if ((fail = badHost()))
100
+ return fail;
101
+ await guard('fill username field', () => userH.fill(creds.username, { timeout }));
102
+ if ((fail = badHost()))
103
+ return fail;
104
+ await guard('fill password field', () => passH.fill(creds.password, { timeout }));
105
+ if ((fail = await fillInlineOtp()))
106
+ return fail;
107
+ if ((fail = await submit(submitH)))
108
+ return fail;
109
+ }
110
+ else {
111
+ if ((fail = badHost()))
112
+ return fail;
113
+ await guard('fill username field', () => userH.fill(creds.username, { timeout }));
114
+ if ((fail = await submit(submitH)))
115
+ return fail;
116
+ try {
117
+ await passBox.waitFor({ state: 'visible', timeout });
118
+ }
119
+ catch {
120
+ return { status: 'failed', reason: 'password field did not appear' };
121
+ }
122
+ passH = await handleOf(passBox);
123
+ submitH = await handleOf(submitBox); // re-measured for step 2
124
+ if (!passH)
125
+ return { status: 'failed', reason: 'password field did not appear' };
126
+ if ((fail = badHost()))
127
+ return fail;
128
+ await guard('fill password field', () => passH.fill(creds.password, { timeout }));
129
+ if ((fail = await fillInlineOtp()))
130
+ return fail;
131
+ if ((fail = await submit(submitH)))
132
+ return fail;
133
+ }
134
+ // Wait for the login form to go away, then judge the final page. Wait on locators, not
135
+ // the filled handles: SPAs often re-render the form on submit, detaching those nodes
136
+ // long before the app navigates away.
137
+ await userBox.or(passBox).first().waitFor({ state: 'hidden', timeout }).catch(() => {
138
+ // Timeout: the state check below decides.
139
+ });
140
+ await page.waitForLoadState('load', { timeout }).catch(() => { });
141
+ await page.waitForLoadState('networkidle', { timeout }).catch(() => { });
142
+ if ((fail = badHost()))
143
+ return fail;
144
+ if (await visible(page.locator(CHALLENGE_CSS)).isVisible())
145
+ return { status: 'failed', reason: NEEDS_YOU };
146
+ if ((await userBox.isVisible()) || (await passBox.isVisible())) {
147
+ const reason = otpFilled ? 'login form still present (wrong password or 2FA code?)' : 'login form still present (wrong password?)';
148
+ return { status: 'failed', reason };
149
+ }
150
+ // Separate 2FA step after the password.
151
+ if (await otpBox.isVisible()) {
152
+ if (otpFilled)
153
+ return { status: 'failed', reason: '2FA code rejected (wrong TOTP in Bitwarden?)' };
154
+ const otpSubmitH = await handleOf(submitBox);
155
+ if ((fail = await fillOtp()))
156
+ return fail;
157
+ if ((fail = await submit(otpSubmitH)))
158
+ return fail;
159
+ await otpBox.waitFor({ state: 'hidden', timeout }).catch(() => {
160
+ // Timeout: the check below decides.
161
+ });
162
+ await page.waitForLoadState('load', { timeout }).catch(() => { });
163
+ await page.waitForLoadState('networkidle', { timeout }).catch(() => { });
164
+ if ((fail = badHost()))
165
+ return fail;
166
+ if (await otpBox.isVisible())
167
+ return { status: 'failed', reason: '2FA code rejected (wrong TOTP in Bitwarden?)' };
168
+ }
169
+ return { status: 'logged_in' };
170
+ }
@@ -0,0 +1,36 @@
1
+ const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
2
+ export function validOptions(v) {
3
+ if (!object(v) || Object.keys(v).some(k => k !== 'grant' && k !== 'success'))
4
+ return false;
5
+ if (v.grant !== undefined) {
6
+ const g = v.grant;
7
+ if (!object(g))
8
+ return false;
9
+ if (g.kind === 'timed') {
10
+ if (Object.keys(g).some(k => k !== 'kind' && k !== 'minutes') || !Number.isInteger(g.minutes) || g.minutes < 1 || g.minutes > 1440)
11
+ return false;
12
+ }
13
+ else if ((g.kind !== 'once' && g.kind !== 'permanent') || Object.keys(g).length !== 1)
14
+ return false;
15
+ }
16
+ if (v.success !== undefined) {
17
+ const s = v.success;
18
+ if (!object(s) || Object.keys(s).length !== 1)
19
+ return false;
20
+ if ('url' in s) {
21
+ if (typeof s.url !== 'string' || s.url.length > 2048)
22
+ return false;
23
+ try {
24
+ const u = new URL(s.url);
25
+ if (u.protocol !== 'https:' || u.username || u.password)
26
+ return false;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ else if (typeof s.selector !== 'string' || !s.selector.trim() || s.selector.length > 1000)
33
+ return false;
34
+ }
35
+ return true;
36
+ }
@@ -0,0 +1,7 @@
1
+ export function redact(text, secrets) {
2
+ let out = text;
3
+ for (const s of secrets)
4
+ if (s)
5
+ out = out.split(s).join('[REDACTED]');
6
+ return out;
7
+ }
@@ -0,0 +1,167 @@
1
+ import { buildEntry, clean, cleanSelectors, normalizeDomain, selectorsLine } from './allowlist.js';
2
+ import { redact } from './redact.js';
3
+ import { validOptions } from './policy.js';
4
+ import { isHostAllowed } from './allowlist.js';
5
+ import { GrantCoordinator } from '../service/grants.js';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { abortable } from './abort.js';
8
+ import { UserFacingError, } from './types.js';
9
+ /** Normalized domain for lookups/audit; throws on unusable input. */
10
+ function domainOf(input) {
11
+ const d = normalizeDomain(clean(input));
12
+ if (!d)
13
+ throw new UserFacingError('domain is empty or invalid');
14
+ return d;
15
+ }
16
+ export class CoreService {
17
+ deps;
18
+ grants;
19
+ constructor(deps) {
20
+ this.deps = deps;
21
+ this.grants = deps.grants ?? new GrantCoordinator(deps.allowlist);
22
+ }
23
+ /** Runs `fn`, maps errors to ToolResults (never leaking details) and audits the outcome. */
24
+ guarded(action, domain, secrets, fn) {
25
+ return this.grants.run(() => this.runGuarded(action, domain, secrets, fn));
26
+ }
27
+ async runGuarded(action, domain, secrets, fn) {
28
+ let result;
29
+ let audit;
30
+ try {
31
+ ;
32
+ ({ result, audit } = await fn());
33
+ }
34
+ catch (err) {
35
+ const message = err instanceof UserFacingError ? err.message : 'internal error';
36
+ if (!(err instanceof UserFacingError)) {
37
+ const e = err;
38
+ console.error(redact(String(e?.stack ?? err), secrets));
39
+ }
40
+ result = { ok: false, message, ...(err instanceof UserFacingError && err.code ? { code: err.code } : {}) };
41
+ audit = `failed: ${message}`;
42
+ }
43
+ if (action) {
44
+ try {
45
+ await this.deps.audit.record({ action, domain: normalizeDomain(clean(domain)) || clean(domain), result: audit });
46
+ }
47
+ catch {
48
+ console.error('audit write failed');
49
+ }
50
+ }
51
+ return result;
52
+ }
53
+ login(domain) {
54
+ const secrets = [];
55
+ return this.guarded('login', domain, secrets, async () => {
56
+ const entry = await this.deps.allowlist.get(domainOf(domain));
57
+ if (!entry)
58
+ throw new UserFacingError('domain is not allowlisted — use request_allow', 'grant_missing');
59
+ this.grants.check(entry);
60
+ const signal = AbortSignal.timeout(this.deps.loginTimeoutMs ?? 60000);
61
+ const within = async (work) => {
62
+ try {
63
+ return await abortable(work, signal, async () => { await this.deps.login.cancel?.(); });
64
+ }
65
+ catch (e) {
66
+ if (signal.aborted)
67
+ throw new UserFacingError('login timed out; retry if needed', 'login_timeout');
68
+ throw e;
69
+ }
70
+ };
71
+ const status = await within(this.deps.vault.status(signal));
72
+ if (status !== 'unlocked')
73
+ throw new UserFacingError(status === 'locked' ? 'vault locked — run u-chan unlock' : status === 'unauthenticated' ? 'complete Bitwarden service login during setup' : 'Bitwarden is unavailable', status === 'locked' ? 'vault_locked' : status === 'unauthenticated' ? 'vault_unauthenticated' : 'vault_unavailable');
74
+ if (this.deps.login.preflight)
75
+ await within(this.deps.login.preflight(signal));
76
+ signal.throwIfAborted();
77
+ await this.grants.reserve(entry.domain);
78
+ const getCreds = async () => {
79
+ this.grants.check(entry, true);
80
+ const c = await within(this.deps.vault.getLogin(entry.item, signal));
81
+ secrets.push(c.username, c.password, ...(c.totp ? [c.totp] : []));
82
+ return c;
83
+ };
84
+ const out = await this.deps.login.run(entry, getCreds, { signal, beforeTransfer: async () => { signal.throwIfAborted(); this.grants.check(entry, true); } });
85
+ if (out.status === 'logged_in')
86
+ return { result: { ok: true, message: 'logged in' }, audit: 'ok' };
87
+ if (out.status === 'already_logged_in')
88
+ return { result: { ok: true, message: 'already logged in' }, audit: 'already' };
89
+ const message = out.reason ?? 'login failed';
90
+ return { result: { ok: false, message, ...(out.code ? { code: out.code } : {}) }, audit: `failed: ${message}` };
91
+ });
92
+ }
93
+ requestAllow(domain, loginUrl, item, selectors, options) {
94
+ return this.guarded('request_allow', domain, [], async () => {
95
+ if (options !== undefined && !validOptions(options))
96
+ throw new UserFacingError('invalid approval policy or success condition', 'invalid_policy');
97
+ const d = domainOf(domain);
98
+ const existing = await this.deps.allowlist.get(d);
99
+ let entry = buildEntry(d, clean(loginUrl), clean(item), existing);
100
+ const epoch = this.grants.epoch();
101
+ const grant = options?.grant ?? { kind: 'permanent' };
102
+ delete entry.expiresAt;
103
+ delete entry.remainingUses;
104
+ entry.grantId = randomUUID();
105
+ if (grant.kind === 'once')
106
+ entry.remainingUses = 1;
107
+ if (grant.kind === 'timed')
108
+ entry.expiresAt = new Date(this.grants.now() + grant.minutes * 60000).toISOString();
109
+ const sel = cleanSelectors(selectors);
110
+ if (sel)
111
+ entry = { ...entry, selectors: sel };
112
+ if (options?.success)
113
+ entry = { ...entry, success: options.success };
114
+ if (entry.success && 'url' in entry.success && !isHostAllowed(entry, entry.success.url))
115
+ throw new UserFacingError('success URL must use an approved HTTPS host', 'invalid_policy');
116
+ const approved = await this.deps.approver.ask('U-Chan Secrets', [
117
+ 'Allow the AI to sign in?',
118
+ `Domain: ${entry.domain}`,
119
+ `Login URL: ${new URL(entry.loginUrl).href}`,
120
+ `Bitwarden item: ${entry.item}`,
121
+ `Hosts: ${entry.hosts.join(', ')}`,
122
+ ...(entry.selectors ? [selectorsLine(entry.selectors)] : []),
123
+ ...(entry.success ? [`Success condition: ${JSON.stringify(entry.success)}`] : []),
124
+ entry.remainingUses !== undefined ? 'Duration: one login attempt; a failed reserved attempt also counts.' : entry.expiresAt ? `Duration: until ${entry.expiresAt}` : 'Duration: permanent future logins.',
125
+ 'Expiry and revocation do not log out sessions already transferred to Chrome.',
126
+ ]);
127
+ if (!approved)
128
+ return { result: { ok: false, message: 'denied' }, audit: 'denied' };
129
+ this.grants.approve(entry, epoch);
130
+ await this.deps.allowlist.upsert(entry);
131
+ return { result: { ok: true, message: 'approved' }, audit: `approved; ${grant.kind}${entry.expiresAt ? '; expires ' + entry.expiresAt : ''}` };
132
+ });
133
+ }
134
+ revoke(domain) {
135
+ return this.guarded('revoke', domain, [], async () => {
136
+ const removed = await this.deps.allowlist.remove(domainOf(domain));
137
+ return removed
138
+ ? { result: { ok: true, message: 'revoked' }, audit: 'ok' }
139
+ : { result: { ok: false, message: 'domain is not in the allowlist' }, audit: 'not found' };
140
+ });
141
+ }
142
+ listAllowed() {
143
+ return this.guarded(undefined, '', [], async () => {
144
+ const all = await this.deps.allowlist.list();
145
+ if (all.length === 0)
146
+ return { result: { ok: true, message: 'allowlist is empty' }, audit: '' };
147
+ const message = all.map((e) => {
148
+ let policy = e.expiresAt ? 'expires ' + e.expiresAt : e.remainingUses !== undefined ? e.remainingUses + ' attempts remaining' : 'permanent';
149
+ try {
150
+ this.grants.check(e);
151
+ }
152
+ catch (err) {
153
+ policy = err instanceof UserFacingError && err.code === 'grant_exhausted' ? 'exhausted' : 'expired';
154
+ }
155
+ return `${e.domain} — ${e.item} — ${e.hosts.join(', ')} — ${policy}`;
156
+ }).join('\n');
157
+ return { result: { ok: true, message }, audit: '' };
158
+ });
159
+ }
160
+ status() {
161
+ return this.guarded(undefined, '', [], async () => {
162
+ const vault = await this.deps.vault.status();
163
+ const message = `vault: ${vault}; mode: ${await this.deps.mode()}`;
164
+ return { result: { ok: vault === 'unlocked', message }, audit: '' };
165
+ });
166
+ }
167
+ }