@gleapai/kai-bridge 0.9.0 → 0.10.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.
@@ -1,610 +0,0 @@
1
- // Kai Code Verify — seamless preview sign-in (kai-bridge 0.4.0).
2
- //
3
- // The verifier must not ask the user for credentials. Instead the user
4
- // signs in ONCE, in a headed Chrome window this daemon opens at the
5
- // running preview (`bridge.verify.login.start`); the daemon detects the
6
- // sign-in, exports the browser's storage state (cookies + localStorage +
7
- // IndexedDB), FILTERS it to the preview's own origins, and uploads it —
8
- // the Server encrypts it as a `KaiPreviewLogin` record. Every later verify
9
- // turn fetches the record, rewrites its origins onto the ports the preview
10
- // runs on right now, PROBES it headless (still signed in?), and hands the
11
- // Playwright MCP the state via `--isolated --storage-state`. After the run
12
- // the agent's final `browser_storage_state` export refreshes the record.
13
- //
14
- // Everything that decides is a pure function here (testable without a
15
- // browser); the two browser flows (`captureLogin`, `probeLogin`) take the
16
- // Playwright module as a parameter so tests can inject a fake.
17
- //
18
- // Playwright itself is REQUIRED FROM THE MCP's DIRECTORY (the bridge has no
19
- // direct dependency), and launched on the `chrome` channel whenever system
20
- // Chrome exists — the bundled Chromium revision is usually not installed.
21
-
22
- import { createHash } from "node:crypto";
23
- import { existsSync } from "node:fs";
24
- import { createRequire } from "node:module";
25
- import { join } from "node:path";
26
-
27
- import { resolvePreviewBrowser } from "./preview.mjs";
28
-
29
- /** Cookie domains the Server accepts — anything else is not the preview's. */
30
- export const LOCAL_COOKIE_DOMAINS = new Set(["localhost", "127.0.0.1"]);
31
- /** Stable preview ports: hash(repoKey + service) into this window. */
32
- export const STABLE_PORT_MIN = 43000;
33
- export const STABLE_PORT_SPAN = 1000;
34
- /** Values this long or longer count as secrets for redaction. */
35
- export const REDACT_MIN_LENGTH = 16;
36
- export const REDACTED = "[redacted]";
37
- export const LOGIN_CAPTURE_TIMEOUT_MS = 10 * 60_000;
38
- export const PROBE_TIMEOUT_MS = 15_000;
39
- const SETTLE_MS = 1_500;
40
- const DETECTED_GRACE_MS = 3_000;
41
-
42
- /** The `playwright` the bundled MCP ships — `null` when it is not installed. */
43
- export function loadPlaywright(runnerDir) {
44
- try {
45
- const req = createRequire(join(runnerDir, "..", "node_modules", "@playwright", "mcp", "package.json"));
46
- return req("playwright");
47
- } catch {
48
- return null;
49
- }
50
- }
51
-
52
- /** `chrome` when the user's Chrome exists (no download, familiar pages), else the bundled build. */
53
- export function launchOptionsFor({ headless, browser = resolvePreviewBrowser() } = {}) {
54
- const opts = { headless: !!headless };
55
- if (browser) opts.channel = browser;
56
- // Headed capture: without the automation banner Google & co. are a bit
57
- // less hostile, and the window looks like a normal (extension-less) Chrome.
58
- if (!headless) opts.ignoreDefaultArgs = ["--enable-automation"];
59
- return opts;
60
- }
61
-
62
- /** Can this machine open a window? darwin/win32 always; linux needs a DISPLAY. */
63
- export function displayAvailable({ platform = process.platform, env = process.env } = {}) {
64
- if (platform === "darwin" || platform === "win32") return true;
65
- return !!(env.DISPLAY || env.WAYLAND_DISPLAY);
66
- }
67
-
68
- /** `http://Localhost:3000/x?y` → `http://localhost:3000`; null for junk. */
69
- export function normalizeOrigin(url) {
70
- try {
71
- const o = new URL(String(url)).origin;
72
- return o && o !== "null" ? o.toLowerCase() : null;
73
- } catch {
74
- return null;
75
- }
76
- }
77
-
78
- export function pathOf(url) {
79
- try {
80
- return new URL(String(url)).pathname || "/";
81
- } catch {
82
- return null;
83
- }
84
- }
85
-
86
- /**
87
- * Keep only what belongs to the preview: cookies scoped to localhost /
88
- * 127.0.0.1 and `origins[]` entries whose origin is one of the preview
89
- * services'. IdP cookies (accounts.google.com, github.com) never travel.
90
- * Returns the filtered state plus COUNTS — the only thing ever logged.
91
- */
92
- export function filterStorageState(state, previewOrigins) {
93
- const allowed = new Set((previewOrigins || []).map(normalizeOrigin).filter(Boolean));
94
- const cookiesIn = Array.isArray(state?.cookies) ? state.cookies : [];
95
- const originsIn = Array.isArray(state?.origins) ? state.origins : [];
96
- const cookies = cookiesIn.filter((c) => c && LOCAL_COOKIE_DOMAINS.has(String(c.domain || "").toLowerCase().replace(/^\./, "")));
97
- const origins = originsIn
98
- .filter((o) => o && allowed.has(normalizeOrigin(o.origin)))
99
- .map((o) => ({ ...o, origin: normalizeOrigin(o.origin) }));
100
- const counts = {
101
- cookies: cookies.length,
102
- droppedCookies: cookiesIn.length - cookies.length,
103
- origins: origins.length,
104
- droppedOrigins: originsIn.length - origins.length,
105
- localStorage: origins.reduce((n, o) => n + (Array.isArray(o.localStorage) ? o.localStorage.length : 0), 0),
106
- indexedDB: origins.reduce((n, o) => n + (Array.isArray(o.indexedDB) ? o.indexedDB.length : 0), 0),
107
- };
108
- return { state: { cookies, origins }, counts };
109
- }
110
-
111
- /** Anything at all in the state? (An empty export is not a sign-in.) */
112
- export function storageStateIsEmpty(state) {
113
- return !(state?.cookies?.length || (state?.origins || []).some((o) => o?.localStorage?.length || o?.indexedDB?.length));
114
- }
115
-
116
- /** Content hash of a state — "did storage change since the window opened". */
117
- export function storageFingerprint(state) {
118
- const cookies = (state?.cookies || []).map((c) => `${c.domain}|${c.name}|${c.value}`).sort();
119
- const origins = (state?.origins || [])
120
- .map((o) => `${normalizeOrigin(o.origin)}|${JSON.stringify(o.localStorage || [])}|${(o.indexedDB || []).length}`)
121
- .sort();
122
- return createHash("sha256").update(JSON.stringify([cookies, origins])).digest("hex");
123
- }
124
-
125
- /**
126
- * Captured service origins → the origins the same services run on NOW,
127
- * matched by service NAME (the preview picks free ports per session).
128
- * Services that are not running are dropped from the map — their state
129
- * has nowhere to go. `Map<capturedOrigin, currentOrigin>`.
130
- */
131
- export function buildOriginMap(recordServices, runningServices) {
132
- const running = new Map();
133
- for (const s of runningServices || []) {
134
- const origin = normalizeOrigin(s?.url ?? s?.origin);
135
- if (s?.name && origin) running.set(String(s.name), origin);
136
- }
137
- const map = new Map();
138
- for (const s of recordServices || []) {
139
- const from = normalizeOrigin(s?.origin);
140
- const to = s?.name ? running.get(String(s.name)) : null;
141
- if (from && to) map.set(from, to);
142
- }
143
- return map;
144
- }
145
-
146
- /** Inverse map (current → captured) for writing a refreshed state back onto the record's origins. */
147
- export function invertOriginMap(map) {
148
- const out = new Map();
149
- for (const [from, to] of map || []) out.set(to, from);
150
- return out;
151
- }
152
-
153
- const rewriteString = (value, map) => {
154
- if (typeof value !== "string") return value;
155
- for (const [from, to] of map) {
156
- if (from !== to && value.startsWith(from)) return to + value.slice(from.length);
157
- }
158
- return value;
159
- };
160
-
161
- const rewriteDeep = (value, map) => {
162
- if (typeof value === "string") return rewriteString(value, map);
163
- if (Array.isArray(value)) return value.map((v) => rewriteDeep(v, map));
164
- if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, rewriteDeep(v, map)]));
165
- return value;
166
- };
167
-
168
- /**
169
- * Move a state onto the current ports: `origins[].origin` is mapped (an
170
- * origin with no mapping is DROPPED), and string VALUES that start with a
171
- * captured origin (apps persist `apiBase`, redirect URLs, …) follow.
172
- * Cookies stay as they are — localhost cookies are port-agnostic.
173
- */
174
- export function rewriteStorageState(state, originMap) {
175
- const map = originMap instanceof Map ? originMap : new Map(Object.entries(originMap || {}));
176
- const origins = [];
177
- for (const o of Array.isArray(state?.origins) ? state.origins : []) {
178
- const from = normalizeOrigin(o?.origin);
179
- const to = from ? map.get(from) : null;
180
- if (!to) continue;
181
- origins.push({
182
- ...o,
183
- origin: to,
184
- ...(o.localStorage ? { localStorage: o.localStorage.map((e) => ({ ...e, value: rewriteString(e?.value, map) })) } : {}),
185
- ...(o.indexedDB ? { indexedDB: rewriteDeep(o.indexedDB, map) } : {}),
186
- });
187
- }
188
- return { cookies: Array.isArray(state?.cookies) ? state.cookies.map((c) => ({ ...c })) : [], origins };
189
- }
190
-
191
- /**
192
- * Runs INSIDE the page: the raw material for `hasStrongLoginSignal`. Kept
193
- * as a plain function so Playwright serializes it and tests can call it
194
- * against a fake DOM-less object.
195
- */
196
- export function collectLoginSignals() {
197
- const texts = [];
198
- for (const el of document.querySelectorAll('button, input[type="submit"], a[role="button"], [role="button"]')) {
199
- const t = (el.innerText || el.value || el.getAttribute("aria-label") || "").trim();
200
- if (t) texts.push(t.slice(0, 80));
201
- if (texts.length >= 60) break;
202
- }
203
- return {
204
- password: document.querySelectorAll('input[type="password"]').length,
205
- otp: document.querySelectorAll('input[autocomplete="one-time-code"]').length,
206
- submits: texts,
207
- bodyText: String(document.body?.innerText || "").slice(0, 3000),
208
- title: String(document.title || "").slice(0, 200),
209
- };
210
- }
211
-
212
- const LOGIN_SUBMIT_RE = /\b(sign\s?in|log\s?in|continue with|sign in with|log in with)\b/i;
213
-
214
- /** A password field, a one-time-code field, or a sign-in submit = a login wall. */
215
- export function hasStrongLoginSignal(signals) {
216
- if (!signals || typeof signals !== "object") return false;
217
- if (Number(signals.password) > 0 || Number(signals.otp) > 0) return true;
218
- return (Array.isArray(signals.submits) ? signals.submits : []).some((t) => LOGIN_SUBMIT_RE.test(String(t)));
219
- }
220
-
221
- /** Google's "This browser or app may not be secure" interstitial — the IdP refused the automation window. */
222
- export function isIdpRefusedPage({ url, signals } = {}) {
223
- const host = (() => {
224
- try {
225
- return new URL(String(url)).hostname;
226
- } catch {
227
- return "";
228
- }
229
- })();
230
- const text = `${signals?.title || ""}\n${signals?.bodyText || ""}`;
231
- return /(^|\.)accounts\.google\.com$/.test(host) && /(browser or app may not be secure|couldn.t sign you in|this browser may not be secure)/i.test(text);
232
- }
233
-
234
- /**
235
- * A recordable login path: a real segment (`/login`, `/auth/sign-in`) —
236
- * never `/` (a landing page with a "Sign in" button, or a login form
237
- * served at the root, would otherwise mark EVERY path as a wall).
238
- */
239
- export function isRecordableLoginPath(path) {
240
- return typeof path === "string" && path.startsWith("/") && path.replace(/\/+$/, "").length > 0;
241
- }
242
-
243
- /** `path` equals the login path or lives under it (segment-wise); `/` never matches. */
244
- export function matchesLoginPath(path, loginPath) {
245
- if (!isRecordableLoginPath(loginPath) || typeof path !== "string") return false;
246
- const p = loginPath.replace(/\/+$/, "");
247
- return path === p || path === `${p}/` || path.startsWith(`${p}/`);
248
- }
249
-
250
- /**
251
- * The probe's verdict for a page the injected state landed on:
252
- * `needs_login` when the final origin is not a preview origin (bounced to
253
- * an IdP), the path is a known login path, or a strong login signal is
254
- * visible; `ok` otherwise.
255
- */
256
- export function classifyProbe({ finalUrl, previewOrigins, loginPaths, signals }) {
257
- const allowed = new Set((previewOrigins || []).map(normalizeOrigin).filter(Boolean));
258
- const origin = normalizeOrigin(finalUrl);
259
- const path = pathOf(finalUrl);
260
- if (!origin || !allowed.has(origin)) return { result: "needs_login", why: "off_origin", loginPath: path };
261
- if ((loginPaths || []).some((p) => matchesLoginPath(path, p))) {
262
- return { result: "needs_login", why: "login_path", loginPath: path };
263
- }
264
- if (hasStrongLoginSignal(signals)) return { result: "needs_login", why: "login_signal", loginPath: path };
265
- return { result: "ok", why: null, loginPath: null };
266
- }
267
-
268
- const stringLeaves = (value, out, depth = 0) => {
269
- if (depth > 6) return;
270
- if (typeof value === "string") {
271
- out.push(value);
272
- // Persisted auth blobs are usually JSON-in-a-string: the token is a leaf.
273
- if (value.length >= REDACT_MIN_LENGTH && /^[[{]/.test(value.trim())) {
274
- try {
275
- stringLeaves(JSON.parse(value), out, depth + 1);
276
- } catch {
277
- /* not JSON */
278
- }
279
- }
280
- return;
281
- }
282
- if (Array.isArray(value)) for (const v of value) stringLeaves(v, out, depth + 1);
283
- else if (value && typeof value === "object") for (const v of Object.values(value)) stringLeaves(v, out, depth + 1);
284
- };
285
-
286
- const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
287
- const NUMERIC_RE = /^[+-]?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i;
288
- const EMAIL_RE = /^[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+$/;
289
-
290
- /**
291
- * Is this stored value plausibly a secret? Apps persist their API base
292
- * (`http://localhost:43000`), timestamps, numbers and the user's e-mail
293
- * next to the token — scrubbing those would blank every navigate URL and
294
- * the evidence timeline. URLs, ISO-8601 dates, numerics, booleans/null and
295
- * e-mail addresses are never secrets; everything else ≥ 16 chars is.
296
- */
297
- export function isRedactableValue(value) {
298
- if (typeof value !== "string" || value.length < REDACT_MIN_LENGTH) return false;
299
- const text = value.trim();
300
- if (!text) return false;
301
- if (/^(true|false|null|undefined)$/i.test(text) || NUMERIC_RE.test(text) || ISO_DATE_RE.test(text) || EMAIL_RE.test(text)) return false;
302
- if (/^https?:\/\//i.test(text)) {
303
- try {
304
- const u = new URL(text);
305
- if (u.protocol === "http:" || u.protocol === "https:") return false;
306
- } catch {
307
- /* not a URL after all — a token that merely starts like one */
308
- }
309
- }
310
- return true;
311
- }
312
-
313
- /**
314
- * Every cookie / localStorage value (and the string leaves of JSON values)
315
- * of length ≥ 16 from the injected state — what must never reach the
316
- * Server in an event, result or report — minus what is plainly not a
317
- * secret (`isRedactableValue`). Longest first so overlapping secrets
318
- * redact fully.
319
- */
320
- export function redactionSet(state) {
321
- const values = [];
322
- for (const c of state?.cookies || []) if (typeof c?.value === "string") values.push(c.value);
323
- for (const o of state?.origins || []) for (const e of o?.localStorage || []) stringLeaves(e?.value, values);
324
- const set = new Set(values.filter(isRedactableValue));
325
- return new Set([...set].sort((a, b) => b.length - a.length));
326
- }
327
-
328
- /** Deep-copy `value` with every secret occurrence replaced (JSON-escaped forms included). */
329
- export function redactDeep(value, secrets) {
330
- if (!secrets || secrets.size === 0) return value;
331
- const scrub = (s) => {
332
- let out = s;
333
- for (const secret of secrets) {
334
- if (out.includes(secret)) out = out.split(secret).join(REDACTED);
335
- const escaped = JSON.stringify(secret).slice(1, -1);
336
- if (escaped !== secret && out.includes(escaped)) out = out.split(escaped).join(REDACTED);
337
- }
338
- return out;
339
- };
340
- const walk = (v) => {
341
- if (typeof v === "string") return scrub(v);
342
- if (Array.isArray(v)) return v.map(walk);
343
- if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
344
- return v;
345
- };
346
- return walk(value);
347
- }
348
-
349
- /** Deterministic port in [43000, 43999] for a repo's service — the same across sessions. */
350
- export function preferredStablePort(repoKey, serviceName) {
351
- const h = createHash("sha1").update(`${String(repoKey || "").toLowerCase()}${String(serviceName || "")}`).digest();
352
- return STABLE_PORT_MIN + (h.readUInt32BE(0) % STABLE_PORT_SPAN);
353
- }
354
-
355
- /**
356
- * The agent's final export (cookies + localStorage, what the app may have
357
- * rotated) merged over the probe's export (which alone carried IndexedDB
358
- * — the MCP's `browser_storage_state` does not). Per origin.
359
- */
360
- export function mergeFinalState(probeState, finalState) {
361
- if (!finalState || typeof finalState !== "object") return probeState || { cookies: [], origins: [] };
362
- const byOrigin = new Map();
363
- for (const o of probeState?.origins || []) if (o?.origin) byOrigin.set(normalizeOrigin(o.origin), { ...o, origin: normalizeOrigin(o.origin) });
364
- for (const o of finalState.origins || []) {
365
- const key = normalizeOrigin(o?.origin);
366
- if (!key) continue;
367
- const prev = byOrigin.get(key);
368
- byOrigin.set(key, { ...(prev || {}), origin: key, localStorage: Array.isArray(o.localStorage) ? o.localStorage : prev?.localStorage || [], ...(prev?.indexedDB ? { indexedDB: prev.indexedDB } : {}) });
369
- }
370
- return { cookies: Array.isArray(finalState.cookies) ? finalState.cookies : probeState?.cookies || [], origins: [...byOrigin.values()] };
371
- }
372
-
373
- /** Parse a dev.yaml `auth.storageState` command's output: JSON on stdout, or a path to a JSON file. */
374
- export function parseStorageStateOutput(stdout, { exists = existsSync, read } = {}) {
375
- const text = String(stdout || "").trim();
376
- if (!text) return null;
377
- try {
378
- const parsed = JSON.parse(text);
379
- if (parsed && typeof parsed === "object") return parsed;
380
- } catch {
381
- /* not inline JSON */
382
- }
383
- const path = text.split("\n").at(-1).trim();
384
- if (path && exists(path) && read) {
385
- try {
386
- const parsed = JSON.parse(read(path));
387
- if (parsed && typeof parsed === "object") return parsed;
388
- } catch {
389
- /* unreadable */
390
- }
391
- }
392
- return null;
393
- }
394
-
395
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
396
-
397
- /**
398
- * Headless probe: does the (rewritten) state still sign the preview in?
399
- * Resolves `{ result: 'ok'|'needs_login'|'unknown', finalUrl, loginPath,
400
- * storageState }` — `storageState` is the probe context's own export
401
- * (with IndexedDB) on `ok`, the file the MCP gets. `unknown` (timeout,
402
- * launch error) lets the turn proceed: the agent reports a wall itself.
403
- * `loginCheck` (`{ path, selector }` from dev.yaml) replaces the heuristic.
404
- */
405
- export async function probeLogin({ pw, launchOptions, storageState, url, previewOrigins, loginPaths = [], loginCheck = null, timeoutMs = PROBE_TIMEOUT_MS, log = () => {} }) {
406
- if (!pw?.chromium) return { result: "unknown", error: "playwright unavailable", finalUrl: null, loginPath: null, storageState: null };
407
- let browser = null;
408
- let timer = null;
409
- let settled = false;
410
- const timeout = new Promise((resolveP) => {
411
- timer = setTimeout(() => resolveP({ result: "unknown", error: `probe timed out after ${Math.round(timeoutMs / 1000)}s`, finalUrl: null, loginPath: null, storageState: null }), timeoutMs);
412
- });
413
- // The browser is closed HERE, in run's own finally — whoever wins the
414
- // race. A cold Chrome launch that outlives the cap would otherwise leave
415
- // a headless Chrome behind (the caller only saw `browser === null`).
416
- const run = (async () => {
417
- try {
418
- browser = await pw.chromium.launch(launchOptions);
419
- if (settled) return { result: "unknown", error: "probe timed out before the browser launched", finalUrl: null, loginPath: null, storageState: null };
420
- const context = await browser.newContext(storageState ? { storageState } : {});
421
- const page = await context.newPage();
422
- const target = loginCheck?.path ? new URL(loginCheck.path, url).toString() : url;
423
- await page.goto(target, { waitUntil: "load", timeout: Math.max(1_000, timeoutMs - 2_000) });
424
- await sleep(SETTLE_MS);
425
- const finalUrl = page.url();
426
- let verdict;
427
- if (loginCheck?.selector) {
428
- const visible = await page.locator(loginCheck.selector).first().isVisible().catch(() => false);
429
- verdict = visible ? { result: "ok", loginPath: null } : { result: "needs_login", why: "login_check", loginPath: pathOf(finalUrl) };
430
- } else {
431
- const signals = await page.evaluate(collectLoginSignals).catch(() => null);
432
- verdict = classifyProbe({ finalUrl, previewOrigins, loginPaths, signals });
433
- }
434
- const exported = verdict.result === "ok" ? await context.storageState({ indexedDB: true }) : null;
435
- return { ...verdict, finalUrl, storageState: exported };
436
- } catch (err) {
437
- return { result: "unknown", error: err?.message || String(err), finalUrl: null, loginPath: null, storageState: null };
438
- } finally {
439
- await browser?.close().catch(() => {});
440
- }
441
- })();
442
- const res = await Promise.race([run, timeout]);
443
- settled = true;
444
- clearTimeout(timer);
445
- // Timeout won with a browser already up: closing it aborts the page work
446
- // and lets run's finally settle promptly (a second close is a no-op).
447
- await browser?.close().catch(() => {});
448
- log("info", "verify.login.probe", { result: res.result, why: res.why, error: res.error, finalUrl: res.finalUrl ? normalizeOrigin(res.finalUrl) : null });
449
- return res;
450
- }
451
-
452
- /**
453
- * Headed capture: open Chrome at the preview, wait for the user to sign in,
454
- * export + filter, hand the filtered state to `onSaved`. Status transitions
455
- * go to `onStatus(status, extra)`: `waiting_signin` → `detected` → (upload)
456
- * → the caller posts `saved`; `failed` (`timed out`, `idp_refused`, launch
457
- * error) and `cancelled` (window closed) are terminal. Returns a handle:
458
- * `done()` (fallback "Mark done": export now), `cancel()`, `finished`
459
- * (resolves with the terminal outcome).
460
- *
461
- * `previewOrigins` decide what counts as "back on the app" (the detection
462
- * heuristic); `stateOrigins` decide what may be KEPT from storage — the
463
- * dev.yaml `external` origins are part of the app's own state without
464
- * being part of it. Cookies stay localhost-only either way.
465
- */
466
- export async function captureLogin({ pw, launchOptions, url, previewOrigins, stateOrigins = null, services, onStatus = () => {}, onSaved, timeoutMs = LOGIN_CAPTURE_TIMEOUT_MS, log = () => {} }) {
467
- if (!pw?.chromium) throw new Error("Playwright is not installed next to kai-bridge — reinstall @gleapai/kai-bridge.");
468
- const browser = await pw.chromium.launch(launchOptions);
469
- const context = await browser.newContext();
470
- const page = await context.newPage();
471
- const startFp = storageFingerprint(await context.storageState({ indexedDB: true }).catch(() => ({})));
472
- const loginPaths = new Set();
473
- let lastPreviewPath = null;
474
- let finished = false;
475
- let resolveFinished;
476
- const finished$ = new Promise((r) => (resolveFinished = r));
477
- let settleTimer = null;
478
- let deadline = null;
479
- let checking = false;
480
-
481
- const finish = async (outcome) => {
482
- if (finished) return;
483
- finished = true;
484
- clearTimeout(settleTimer);
485
- clearTimeout(deadline);
486
- await browser.close().catch(() => {});
487
- resolveFinished(outcome);
488
- };
489
-
490
- const keptOrigins = [...new Set([...(previewOrigins || []), ...(stateOrigins || [])])];
491
- const exportFiltered = async () => {
492
- const raw = await context.storageState({ indexedDB: true });
493
- const { state, counts } = filterStorageState(raw, keptOrigins);
494
- log("info", "verify.login.export", counts);
495
- return { state, counts };
496
- };
497
-
498
- const save = async ({ forced }) => {
499
- const { state, counts } = await exportFiltered();
500
- const landingUrl = lastPreviewPath ? new URL(lastPreviewPath, url).toString() : url;
501
- // Where the signed-in user landed is by definition not a wall.
502
- loginPaths.delete(pathOf(landingUrl));
503
- try {
504
- await onSaved({ storageState: state, origins: keptOrigins, services, landingUrl, loginPaths: [...loginPaths].filter(isRecordableLoginPath), ...(forced ? { optional: false } : {}) }, counts);
505
- } catch (err) {
506
- // The upload is what makes the sign-in exist — without it the window
507
- // must not sit there looking done.
508
- onStatus("failed", { error: `upload failed: ${err?.message || err}` });
509
- await finish({ status: "failed", error: err?.message || String(err) });
510
- return;
511
- }
512
- await finish({ status: "saved", counts });
513
- };
514
-
515
- const check = async () => {
516
- if (finished || checking) return;
517
- checking = true;
518
- try {
519
- const current = page.url();
520
- const origin = normalizeOrigin(current);
521
- const onPreview = origin && (previewOrigins || []).map(normalizeOrigin).includes(origin);
522
- const signals = await page.evaluate(collectLoginSignals).catch(() => null);
523
- if (!onPreview) {
524
- // IdP hop: the page we left is the login entry (unless it was the root).
525
- if (isRecordableLoginPath(lastPreviewPath)) loginPaths.add(lastPreviewPath);
526
- if (isIdpRefusedPage({ url: current, signals })) {
527
- onStatus("failed", { error: "idp_refused" });
528
- await finish({ status: "failed", error: "idp_refused" });
529
- }
530
- return;
531
- }
532
- const path = pathOf(current);
533
- if (hasStrongLoginSignal(signals)) {
534
- // A path is remembered as a WALL only on a credential field — a
535
- // "Sign in" button alone (landing pages, marketing headers) still
536
- // blocks detection here but must not teach the probe a false wall.
537
- if (isRecordableLoginPath(path) && (Number(signals?.password) > 0 || Number(signals?.otp) > 0)) loginPaths.add(path);
538
- lastPreviewPath = path;
539
- return;
540
- }
541
- lastPreviewPath = path;
542
- const fp = storageFingerprint(await context.storageState({ indexedDB: true }).catch(() => ({})));
543
- if (fp === startFp) return;
544
- onStatus("detected");
545
- await sleep(DETECTED_GRACE_MS);
546
- if (finished) return;
547
- await save({ forced: false });
548
- } catch (err) {
549
- if (!finished) log("warn", "verify.login.check.failed", { error: err?.message });
550
- } finally {
551
- checking = false;
552
- }
553
- };
554
- const scheduleCheck = () => {
555
- if (finished) return;
556
- clearTimeout(settleTimer);
557
- settleTimer = setTimeout(() => void check(), SETTLE_MS);
558
- };
559
-
560
- page.on("framenavigated", (frame) => {
561
- if (frame === page.mainFrame()) scheduleCheck();
562
- });
563
- const onGone = () => {
564
- if (finished) return;
565
- onStatus("cancelled");
566
- void finish({ status: "cancelled" });
567
- };
568
- page.on("close", onGone);
569
- browser.on("disconnected", onGone);
570
- deadline = setTimeout(() => {
571
- if (finished) return;
572
- onStatus("failed", { error: "timed out" });
573
- void finish({ status: "failed", error: "timed out" });
574
- }, timeoutMs);
575
- deadline.unref?.();
576
-
577
- // The window is up — hand the handle back now (the caller acks the
578
- // command) and let the first navigation settle in the background.
579
- void page
580
- .goto(url, { waitUntil: "load", timeout: 30_000 })
581
- .catch((err) => log("warn", "verify.login.open.failed", { error: err?.message }))
582
- .then(async () => {
583
- if (finished) return;
584
- await page.bringToFront().catch(() => {});
585
- onStatus("waiting_signin");
586
- scheduleCheck();
587
- });
588
-
589
- return {
590
- finished: finished$,
591
- /** "Mark done": export whatever is there now (uploaded even when the heuristic disagrees). */
592
- async done() {
593
- if (finished) return;
594
- clearTimeout(settleTimer);
595
- try {
596
- const signals = await page.evaluate(collectLoginSignals).catch(() => null);
597
- const stillWall = hasStrongLoginSignal(signals) || !(previewOrigins || []).map(normalizeOrigin).includes(normalizeOrigin(page.url()));
598
- await save({ forced: stillWall });
599
- } catch (err) {
600
- onStatus("failed", { error: err?.message || String(err) });
601
- await finish({ status: "failed", error: err?.message || String(err) });
602
- }
603
- },
604
- async cancel() {
605
- if (finished) return;
606
- onStatus("cancelled");
607
- await finish({ status: "cancelled" });
608
- },
609
- };
610
- }