@broberg/lens-daemon 0.0.0-stage → 0.1.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.
@@ -0,0 +1,873 @@
1
+ // F240.6 — headed capture-session.
2
+ //
3
+ // Opens a VISIBLE (headed) Chromium so a human can log in to a site that needs
4
+ // interactive auth — 2FA / CAPTCHA (Google Play Console, Apple App Store Connect)
5
+ // — then dumps the resulting Playwright storageState (cookies + origins) to a
6
+ // LOCAL file that another local tool (e.g. storeform's daemon, via `--state`)
7
+ // reuses for headless automation.
8
+ //
9
+ // This is the canonical F112 answer to "Lens can't capture a 2FA login": the
10
+ // capability lives INSIDE the daemon (which owns `playwright` directly), never a
11
+ // raw one-off `playwright open` script. The daemon's warm browser (browser.ts)
12
+ // is headless-only + short-lived; an interactive login is a long-lived,
13
+ // human-paced session, so it gets its OWN headed browser launched here.
14
+ //
15
+ // SECURITY: the storageState holds Christian's real Google/Apple session. It is
16
+ // written to a gitignored local file, never returned over the wire (the finish
17
+ // response carries only the path + cookie/origin COUNTS) and never logged. Writes
18
+ // are confined to ~/.lens/state so a bad state_path can't clobber an arbitrary
19
+ // file. The endpoint is loopback-only (127.0.0.1) behind the daemon's auth.
20
+
21
+ import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
22
+ import { z } from 'zod';
23
+ import { mkdirSync, existsSync, copyFileSync, writeFileSync } from 'node:fs';
24
+ import { dirname, isAbsolute, resolve, sep } from 'node:path';
25
+ import { homedir } from 'node:os';
26
+ import { randomUUID } from 'node:crypto';
27
+ import { fillAndVerify } from './fill.ts';
28
+
29
+ interface Session {
30
+ browser: Browser;
31
+ context: BrowserContext;
32
+ page: Page;
33
+ url: string;
34
+ statePath: string;
35
+ startedAt: number;
36
+ timer: ReturnType<typeof setTimeout>;
37
+ /** F240.8 — `finish` is running. The session stays in the registry while the
38
+ * write is in flight, so `cancel` can say IN PROGRESS instead of "unknown". */
39
+ finishing?: boolean;
40
+ }
41
+
42
+ /** F240.8 — the narrow shape `finish` actually needs, so the lifecycle can be
43
+ * tested without launching Chrome. Same reason `StepPage` exists below: a test
44
+ * that cannot construct a session cannot seal an ORDERING property, and the
45
+ * ordering is half this card. */
46
+ export interface FinishableSession {
47
+ browser: { close(): Promise<void> };
48
+ context: { storageState(): Promise<{ cookies: unknown[]; origins: unknown[] }> };
49
+ statePath: string;
50
+ timer: ReturnType<typeof setTimeout>;
51
+ finishing?: boolean;
52
+ }
53
+
54
+ /** Launch a HEADED browser that Google's "this browser may not be secure" bot-check
55
+ * won't refuse. The defeat recipe: use the REAL installed Chrome (channel:'chrome',
56
+ * not Playwright's fingerprinted Chromium), strip the --enable-automation flag, and
57
+ * disable the AutomationControlled blink feature. --disable-gpu = software compositing
58
+ * (a launchd-context window otherwise paints black). Falls back to bundled chromium
59
+ * (still de-flagged) if real Chrome isn't installed. */
60
+ async function launchHeaded(): Promise<{ browser: Browser; appName: string }> {
61
+ const opts = {
62
+ headless: false,
63
+ ignoreDefaultArgs: ['--enable-automation'],
64
+ args: ['--disable-blink-features=AutomationControlled', '--disable-gpu'],
65
+ };
66
+ try {
67
+ // F240.10 — the macOS app NAME comes back with the browser. Playwright's
68
+ // Browser handle exposes no process, so the name is the only handle we have
69
+ // on the thing that has to come to the front — and it differs by channel.
70
+ return { browser: await chromium.launch({ ...opts, channel: 'chrome' }), appName: 'Google Chrome' };
71
+ } catch {
72
+ return { browser: await chromium.launch(opts), appName: 'Chromium' };
73
+ }
74
+ }
75
+
76
+ /** Best-effort Google sign-in fill so the human only does 2FA. Google's form is
77
+ * multi-page (email → Next → password → Next → 2FA); each step is guarded so a
78
+ * changed layout or an early bot-block never throws — the window stays open for
79
+ * the human to finish/take over. Returns what actually happened. */
80
+ async function fillGoogleLogin(
81
+ page: Page,
82
+ email: string,
83
+ password: string,
84
+ ): Promise<{ emailFilled: boolean; passwordFilled: boolean; url: string; note: string }> {
85
+ const out = { emailFilled: false, passwordFilled: false, url: page.url(), note: '' };
86
+ // The deep Play Console URL redirects to accounts.google.com; let the sign-in
87
+ // page actually land + settle before hunting for the field — otherwise the fill
88
+ // races the redirect and times out on a not-yet-rendered input.
89
+ await page.waitForURL(/accounts\.google\.com/, { timeout: 30_000 }).catch(() => {});
90
+ await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {});
91
+ try {
92
+ const emailInput = page.locator('input[type="email"], input#identifierId').first();
93
+ await emailInput.waitFor({ state: 'visible', timeout: 30_000 });
94
+ await emailInput.fill(email);
95
+ out.emailFilled = true;
96
+ await page.locator('#identifierNext button, #identifierNext').first().click({ timeout: 5_000 }).catch(async () => {
97
+ await emailInput.press('Enter');
98
+ });
99
+ } catch (err) {
100
+ out.note = `email step: ${err instanceof Error ? err.message : String(err)}`;
101
+ out.url = page.url();
102
+ return out;
103
+ }
104
+ try {
105
+ const pwInput = page.locator('input[type="password"]');
106
+ await pwInput.waitFor({ state: 'visible', timeout: 15_000 });
107
+ await pwInput.fill(password);
108
+ out.passwordFilled = true;
109
+ await page.locator('#passwordNext button, #passwordNext').first().click({ timeout: 5_000 }).catch(async () => {
110
+ await pwInput.press('Enter');
111
+ });
112
+ } catch (err) {
113
+ out.note = `password step: ${err instanceof Error ? err.message : String(err)}`;
114
+ }
115
+ await page.waitForTimeout(1500);
116
+ out.url = page.url();
117
+ return out;
118
+ }
119
+
120
+ const sessions = new Map<string, Session>();
121
+
122
+ // Auto-close an abandoned headed login so we never leak a visible browser window
123
+ // on the host (the human closed the tab, forgot, walked away).
124
+ const SESSION_TTL_MS = 15 * 60 * 1000;
125
+
126
+ // Confine storageState writes to a known-local dir so a bad/hostile state_path
127
+ // can't clobber an arbitrary file on the host. Both fleet-agreed paths for this
128
+ // capability live under ~/.lens/state.
129
+ const STATE_ROOT = resolve(homedir(), '.lens', 'state');
130
+
131
+ /** Validate + normalise the storageState target path. Absolute-only, confined to
132
+ * STATE_ROOT, and a `.json` file. Pure + exported so the confinement gate is
133
+ * unit-sealed (the load-bearing security property — no arbitrary-file write). */
134
+ export function validateStatePath(statePath: string): string {
135
+ if (typeof statePath !== 'string' || statePath.length === 0) {
136
+ throw new Error('state_path required');
137
+ }
138
+ if (!isAbsolute(statePath)) throw new Error('state_path must be an absolute path');
139
+ const abs = resolve(statePath);
140
+ if (abs !== STATE_ROOT && !abs.startsWith(STATE_ROOT + sep)) {
141
+ throw new Error(`state_path must be under ${STATE_ROOT}`);
142
+ }
143
+ if (!abs.endsWith('.json')) throw new Error('state_path must end in .json');
144
+ return abs;
145
+ }
146
+
147
+ /**
148
+ * F240.8 — how long `finish` may spend asking the browser for its storageState.
149
+ *
150
+ * storeform measured a `finish` that hung for OVER TWO MINUTES on a login-walled
151
+ * session (#20803). `context.storageState()` carries no timeout of its own, so
152
+ * the request simply never answered — and because the session had already been
153
+ * removed from the registry, `cancel` could not reach it either. A bound turns a
154
+ * hang into an error a caller can act on.
155
+ */
156
+ const STORAGE_STATE_TIMEOUT_MS = 30_000;
157
+
158
+ /**
159
+ * F240.8 — where the pre-overwrite copy of a state file goes.
160
+ *
161
+ * The suffix is appended to an ALREADY-validated path, so it cannot escape
162
+ * STATE_ROOT by construction — and it is re-checked anyway. "It cannot escape by
163
+ * construction" is an argument; this file holds a real Google session, and the
164
+ * confinement is the load-bearing security property of this whole module.
165
+ *
166
+ * Note it deliberately does NOT go back through validateStatePath: that function
167
+ * requires a `.json` suffix, which `.bak` is not. Reusing it wholesale would
168
+ * throw on every backup — the check that matters here is the ROOT confinement,
169
+ * not the extension.
170
+ */
171
+ export function backupPathFor(statePath: string): string {
172
+ const abs = validateStatePath(statePath);
173
+ const bak = `${abs}.bak`;
174
+ if (!bak.startsWith(STATE_ROOT + sep)) {
175
+ throw new Error(`backup path escaped ${STATE_ROOT}: ${bak}`);
176
+ }
177
+ return bak;
178
+ }
179
+
180
+ /**
181
+ * F240.8 — copy the existing state aside BEFORE it is overwritten.
182
+ *
183
+ * WHY A BACKUP AND NOT A "does this look like a login wall?" HEURISTIC, which is
184
+ * the other option storeform offered: the two failure modes are not symmetric.
185
+ * A heuristic that guesses wrong in the SKIP direction throws away a good,
186
+ * freshly-captured session — the thing a human just did 2FA for, and the
187
+ * expensive thing to recreate. A backup that turns out to be unnecessary costs
188
+ * one file. So the mechanism must not be able to choose the expensive mistake.
189
+ *
190
+ * Returns nulls rather than throwing: losing the human's 2FA work because the
191
+ * SAFETY net failed would be the bug wearing a different hat.
192
+ */
193
+ export function backupExistingState(statePath: string): { backupPath: string | null; backupError: string | null } {
194
+ // A first capture has nothing to preserve. Writing an empty .bak here would
195
+ // fabricate a recovery point that recovers nothing, which is worse than none:
196
+ // it answers "is there a backup?" with yes.
197
+ if (!existsSync(statePath)) return { backupPath: null, backupError: null };
198
+ try {
199
+ const bak = backupPathFor(statePath);
200
+ copyFileSync(statePath, bak);
201
+ return { backupPath: bak, backupError: null };
202
+ } catch (err) {
203
+ return { backupPath: null, backupError: err instanceof Error ? err.message : String(err) };
204
+ }
205
+ }
206
+
207
+ /**
208
+ * F240.8 — bound a promise that has none of its own.
209
+ *
210
+ * The loser of the race keeps running; a handler is attached so its eventual
211
+ * rejection is not reported as unhandled. We cannot cancel Playwright's call,
212
+ * but closing the browser in the caller's `finally` makes it settle.
213
+ */
214
+ export async function withTimeout<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
215
+ let timer: ReturnType<typeof setTimeout> | undefined;
216
+ p.catch(() => {});
217
+ try {
218
+ return await Promise.race([
219
+ p,
220
+ new Promise<never>((_, reject) => {
221
+ timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms);
222
+ timer.unref?.();
223
+ }),
224
+ ]);
225
+ } finally {
226
+ if (timer) clearTimeout(timer);
227
+ }
228
+ }
229
+
230
+ /** F240.7 — validate a storageState path to LOAD into the browser so it opens
231
+ * ALREADY logged in (skip the whole login, drive only the step-up 2FA). Same
232
+ * confinement as the save path, but the file MUST already exist. */
233
+ export function validateLoadStatePath(loadStatePath: string): string {
234
+ const abs = validateStatePath(loadStatePath);
235
+ if (!existsSync(abs)) throw new Error(`load_state_path does not exist: ${abs}`);
236
+ return abs;
237
+ }
238
+
239
+ /**
240
+ * F240.10 — bring the window forward, and be HONEST about whether we know it is
241
+ * on screen.
242
+ *
243
+ * storeform (#20803), while a human waited to do 2FA: *"start answers ok, the
244
+ * browser reports its URL — but whether there is a window he can click in, I
245
+ * cannot measure from here. I had to write to him that I could not prove it."*
246
+ * `ok` proves a process launched and a page navigated. It proves nothing about a
247
+ * human's screen.
248
+ *
249
+ * The same trap is already written into this fleet's own CLAUDE.md as the
250
+ * VINDUES-FÆLDE: `open -a` returns exit 0 even when the app opens NO window, and
251
+ * Read.md did exactly that while Christian was told his file was open.
252
+ *
253
+ * SO THE FIELD MUST BE ABLE TO SAY "I DON'T KNOW". `window_visible: true` where
254
+ * nothing was checked would be worse than today's silence, because it SOUNDS like
255
+ * proof. Three states, and the caller can tell them apart:
256
+ *
257
+ * true — every step we can take returned without error, and `window_check`
258
+ * says which steps those were and what they do NOT prove
259
+ * false — a step failed, and `window_check` says which one and why
260
+ * null — no check exists on this platform. An absent verdict, never a pass.
261
+ */
262
+ export interface WindowVerdict {
263
+ window_visible: boolean | null;
264
+ window_check: string;
265
+ }
266
+
267
+ /**
268
+ * macOS activation, by application name.
269
+ *
270
+ * Deliberately `tell application "<name>" to activate` rather than a System
271
+ * Events process poke: the latter needs Accessibility, which a daemon-launched
272
+ * process frequently does not have — storeform's probe came back empty for
273
+ * exactly that reason. This route asks the app itself (Automation permission),
274
+ * and `page.bringToFront()` has already raised OUR window within that app, so
275
+ * activating the app surfaces the right window rather than an unrelated one.
276
+ *
277
+ * A refusal is reported, never swallowed. That is the entire point of the card.
278
+ */
279
+ async function activateOnDarwin(appName: string): Promise<void> {
280
+ // The name comes from launchHeaded's own two constants, never from a caller.
281
+ const proc = Bun.spawn(['osascript', '-e', `tell application "${appName}" to activate`], {
282
+ stdout: 'pipe',
283
+ stderr: 'pipe',
284
+ });
285
+ const [code, err] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
286
+ if (code !== 0) throw new Error(err.trim().split('\n')[0] || `osascript exited ${code}`);
287
+ }
288
+
289
+ /**
290
+ * Raise the page's window and report what could actually be established.
291
+ *
292
+ * `bringToFront` moves the tab to the front WITHIN the browser app; it does not
293
+ * put the app above the terminal that launched it. Both steps are needed, and
294
+ * neither is allowed to fail `start`: the human may still be able to click the
295
+ * window, and losing a session over a focus call would be the expensive mistake.
296
+ */
297
+ export async function surfaceWindow(opts: {
298
+ bringToFront: () => Promise<void>;
299
+ appName?: string;
300
+ platform?: NodeJS.Platform;
301
+ activate?: (appName: string) => Promise<void>;
302
+ }): Promise<WindowVerdict> {
303
+ const platform = opts.platform ?? process.platform;
304
+ const steps: string[] = [];
305
+
306
+ try {
307
+ await opts.bringToFront();
308
+ steps.push('page.bringToFront()');
309
+ } catch (err) {
310
+ return {
311
+ window_visible: false,
312
+ window_check: `page.bringToFront() failed: ${msgOf(err)} — the window may exist but could not be raised`,
313
+ };
314
+ }
315
+
316
+ if (platform !== 'darwin') {
317
+ // Not a pass. There is no app-activation step here, so the honest answer is
318
+ // that nothing beyond the tab-level raise was established.
319
+ return {
320
+ window_visible: null,
321
+ window_check: `page.bringToFront() succeeded; no app-activation check exists on ${platform}`,
322
+ };
323
+ }
324
+ if (!opts.appName) {
325
+ return {
326
+ window_visible: null,
327
+ window_check: 'page.bringToFront() succeeded; the browser application name was unavailable, so the app could not be activated',
328
+ };
329
+ }
330
+
331
+ try {
332
+ await (opts.activate ?? activateOnDarwin)(opts.appName);
333
+ steps.push(`activate "${opts.appName}"`);
334
+ } catch (err) {
335
+ return {
336
+ window_visible: false,
337
+ window_check: `page.bringToFront() succeeded but activating the app failed: ${msgOf(err)} — commonly a missing Accessibility permission; the window may be behind another app`,
338
+ };
339
+ }
340
+
341
+ // Deliberately not the word "verified". Both calls returning without error is
342
+ // the strongest thing obtainable without photographing the display, and saying
343
+ // more than that here is how the field becomes a better-dressed version of the
344
+ // problem it was added to fix.
345
+ return {
346
+ window_visible: true,
347
+ window_check: `${steps.join(' + ')} both returned without error (not a pixel-level proof that the display shows it)`,
348
+ };
349
+ }
350
+
351
+ const msgOf = (err: unknown): string => (err instanceof Error ? err.message : String(err));
352
+
353
+ /** Open a headed browser at `url` and hold it for an interactive login. If
354
+ * `email`+`password` are supplied, best-effort auto-fills the Google sign-in so
355
+ * the human only does 2FA. Returns a session_id the caller passes to finish/cancel. */
356
+ export async function startCaptureSession(input: {
357
+ url: string;
358
+ statePath: string;
359
+ email?: string;
360
+ password?: string;
361
+ /** F240.7 — a storageState file to LOAD so the browser opens already logged in. */
362
+ loadStatePath?: string;
363
+ }): Promise<{
364
+ sessionId: string;
365
+ url: string;
366
+ statePath: string;
367
+ loaded: boolean;
368
+ login?: { emailFilled: boolean; passwordFilled: boolean; url: string; note: string };
369
+ /** F240.10 — what could actually be established about the window being on
370
+ * screen. `null` means no verdict, which is not a pass. */
371
+ windowVisible: boolean | null;
372
+ windowCheck: string;
373
+ }> {
374
+ let parsed: URL;
375
+ try {
376
+ parsed = new URL(input.url);
377
+ } catch {
378
+ throw new Error(`invalid url: ${input.url}`);
379
+ }
380
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
381
+ throw new Error(`unsupported protocol: ${parsed.protocol} (only http/https)`);
382
+ }
383
+ const statePath = validateStatePath(input.statePath);
384
+ mkdirSync(dirname(statePath), { recursive: true });
385
+ const loadStatePath = input.loadStatePath ? validateLoadStatePath(input.loadStatePath) : undefined;
386
+
387
+ const { browser, appName } = await launchHeaded();
388
+ // Load an existing storageState so the browser opens ALREADY authenticated
389
+ // (skip the whole login; drive only the agreement + step-up 2FA). F240.7.
390
+ const context = await browser.newContext(loadStatePath ? { storageState: loadStatePath } : undefined);
391
+ // Mask the most-checked automation signal before any page script runs — pairs
392
+ // with the de-flagged launch so Google's "browser may not be secure" bot-check
393
+ // treats this like a real user's Chrome.
394
+ await context.addInitScript(() => {
395
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
396
+ });
397
+ const page = await context.newPage();
398
+ // Best-effort nav — a slow login portal shouldn't fail start; the human is
399
+ // driving from here and can navigate themselves if the first load stalls.
400
+ await page.goto(input.url, { waitUntil: 'domcontentloaded', timeout: 60_000 }).catch(() => {});
401
+
402
+ let login: { emailFilled: boolean; passwordFilled: boolean; url: string; note: string } | undefined;
403
+ if (input.email && input.password) {
404
+ login = await fillGoogleLogin(page, input.email, input.password);
405
+ }
406
+
407
+ // F240.10 — raise the window and report what we actually know. Never allowed to
408
+ // fail start: the human may still be able to click it, and losing the session
409
+ // over a focus call is the expensive mistake.
410
+ const verdict = await surfaceWindow({
411
+ bringToFront: () => page.bringToFront(),
412
+ appName,
413
+ }).catch((err) => ({
414
+ window_visible: null as boolean | null,
415
+ window_check: `the window check itself failed: ${msgOf(err)}`,
416
+ }));
417
+
418
+ const sessionId = randomUUID();
419
+ const timer = setTimeout(() => {
420
+ void cancelCaptureSession(sessionId).catch(() => {});
421
+ }, SESSION_TTL_MS);
422
+ timer.unref?.();
423
+ sessions.set(sessionId, { browser, context, page, url: input.url, statePath, startedAt: Date.now(), timer });
424
+ return {
425
+ sessionId,
426
+ url: input.url,
427
+ statePath,
428
+ loaded: Boolean(loadStatePath),
429
+ login,
430
+ windowVisible: verdict.window_visible,
431
+ windowCheck: verdict.window_check,
432
+ };
433
+ }
434
+
435
+ /**
436
+ * Dump the live context's storageState to the session's local file, close the
437
+ * browser, and return the path + counts (never the cookie bytes).
438
+ *
439
+ * F240.8 — THIS FUNCTION USED TO DESTROY THE STATE IT REPLACED, TWICE OVER.
440
+ *
441
+ * storeform hit both halves in one incident (#20798, #20803) with FD Sundhed due
442
+ * in both app stores. They called finish on a DEAD Play session and the
443
+ * logged-OUT storageState went straight over their good file. Their sentence is
444
+ * the one that matters: "The file was probably worthless already, but I can no
445
+ * longer PROVE it was." The harm is not the bad file — it is that the evidence
446
+ * went with it, collapsing "was it already dead?" and "did finish kill it?" into
447
+ * one unanswerable question.
448
+ *
449
+ * The second half is the same mistake one level up. `sessions.delete` ran BEFORE
450
+ * the risky await, so while finish hung for two minutes, `cancel` answered
451
+ * "unknown session_id (already finished, cancelled, or timed out)" — three states
452
+ * it could not tell apart, none of them the true one — and the TTL timer had
453
+ * already been cleared, so nothing would ever reclaim it. The browser was
454
+ * orphaned with no way back.
455
+ *
456
+ * Both are the same shape: THE FUNCTION REMOVED WHAT YOU WOULD NEED AFTERWARDS,
457
+ * BEFORE DOING THE THING THAT CAN FAIL. So: copy first, delete last, and bound
458
+ * the await.
459
+ */
460
+ export async function finishCaptureSession(input: {
461
+ sessionId: string;
462
+ /** F240.8 — injectable so the ORDERING can be tested without launching Chrome. */
463
+ registry?: Map<string, FinishableSession>;
464
+ /** F240.8 — injectable so the TIMEOUT is proven on the real path, not only on
465
+ * the helper. A suite that must sit for 30s to check a bound gets the bound
466
+ * lowered until it is not really tested. */
467
+ timeoutMs?: number;
468
+ }): Promise<{
469
+ ok: true;
470
+ path: string;
471
+ cookies: number;
472
+ origins: number;
473
+ backup_path: string | null;
474
+ backup_error?: string;
475
+ }> {
476
+ const reg = input.registry ?? (sessions as unknown as Map<string, FinishableSession>);
477
+ const s = reg.get(input.sessionId);
478
+ if (!s) throw new Error('unknown session_id (already finished, cancelled, or timed out)');
479
+ clearTimeout(s.timer);
480
+ // Stays in the registry, MARKED. This is the line the incident turned on: a
481
+ // caller whose finish is hanging must be able to learn that from cancel.
482
+ s.finishing = true;
483
+ try {
484
+ // Before the overwrite, never after — a .bak written afterwards holds the new
485
+ // bytes, passes an existence check, and preserves nothing.
486
+ const { backupPath, backupError } = backupExistingState(s.statePath);
487
+ // storageState() WITHOUT `path`: we write the file ourselves, so a timeout
488
+ // cannot leave a half-written state, and on failure the existing file is
489
+ // untouched because nothing was opened for writing at all.
490
+ const state = await withTimeout(s.context.storageState(), input.timeoutMs ?? STORAGE_STATE_TIMEOUT_MS, 'storageState');
491
+ writeFileSync(s.statePath, JSON.stringify(state));
492
+ return {
493
+ ok: true,
494
+ path: s.statePath,
495
+ cookies: state.cookies.length,
496
+ origins: state.origins.length,
497
+ backup_path: backupPath,
498
+ // Reported, never thrown. Losing the human's 2FA work because the SAFETY
499
+ // net failed would be this same bug wearing a different hat.
500
+ ...(backupError ? { backup_error: backupError } : {}),
501
+ };
502
+ } finally {
503
+ reg.delete(input.sessionId);
504
+ await s.browser.close().catch(() => {});
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Discard a session without saving (human aborted, or the TTL fired).
510
+ *
511
+ * F240.8 — `status` names WHICH of the three states it found, because the single
512
+ * "unknown session_id" string conflated them and named none correctly.
513
+ */
514
+ export async function cancelCaptureSession(
515
+ sessionId: string,
516
+ registry?: Map<string, FinishableSession>,
517
+ ): Promise<{ ok: true; status: 'cancelled' | 'finish_in_progress' | 'unknown' }> {
518
+ const reg = registry ?? (sessions as unknown as Map<string, FinishableSession>);
519
+ const s = reg.get(sessionId);
520
+ if (!s) return { ok: true, status: 'unknown' };
521
+ // Do NOT close a browser that finish is mid-write on: that is how a hang
522
+ // becomes a corrupted save. Report it and let finish's own timeout end it.
523
+ if (s.finishing) return { ok: true, status: 'finish_in_progress' };
524
+ clearTimeout(s.timer);
525
+ reg.delete(sessionId);
526
+ await s.browser.close().catch(() => {});
527
+ return { ok: true, status: 'cancelled' };
528
+ }
529
+
530
+ /** Open sessions (for /status + the finish/cancel caller to see what's live). */
531
+ export function listCaptureSessions(): Array<{ session_id: string; url: string; started_at: number }> {
532
+ return [...sessions.entries()].map(([id, s]) => ({ session_id: id, url: s.url, started_at: s.startedAt }));
533
+ }
534
+
535
+ // ── F240.7 — drive steps against the OPEN persistent capture-session page ────
536
+ //
537
+ // A consumer (storeform) opens a headed session (start), then drives the login +
538
+ // 2FA by sending fill/click steps IN BATCHES against the ONE live page, pausing
539
+ // between batches while a human reads the SMS code. runFlow can't do this (it
540
+ // uses a fresh context per call → the 2FA challenge resets). Credential VALUES
541
+ // (password/OTP) ride in step.value over localhost ONLY and are NEVER logged or
542
+ // echoed — the result carries only type/selector/status/detail.
543
+
544
+ /**
545
+ * F074.50 — the vocabulary as a runtime VALUE, with the type derived from it.
546
+ *
547
+ * It was a TypeScript union, which vanishes at runtime, so the rejection message
548
+ * could only name the caller's guess. Declaring the list here and deriving
549
+ * ExecStep['type'] from it means adding a verb is ONE edit and both the type and
550
+ * the error follow — a hand-written list inside the message would be correct the
551
+ * day it was typed and quietly wrong at the next verb.
552
+ *
553
+ * Deliberately NOT the same list as flow.ts's STEP_TYPES: this surface has
554
+ * `jsclick` and `eval` and has no `conditional`. Implying one vocabulary for both
555
+ * would replace a silence with a wrong answer.
556
+ */
557
+ export const EXEC_STEP_TYPES = [
558
+ 'fill', 'click', 'jsclick', 'waitFor', 'waitForText', 'press', 'goto', 'screenshot', 'eval',
559
+ ] as const;
560
+
561
+ export interface ExecStep {
562
+ type: (typeof EXEC_STEP_TYPES)[number];
563
+ /** Target by ONE of: raw css `selector`, `role`(+`name`), or `text`. Apple's
564
+ * sign-in + agreement modals have no stable css → role/text is essential. */
565
+ selector?: string;
566
+ role?: string;
567
+ name?: string;
568
+ text?: string;
569
+ /** Secret over localhost for `fill` — never logged, never returned. */
570
+ value?: string;
571
+ key?: string;
572
+ url?: string;
573
+ /** `eval` only — a JS body (may `return`) run in the page. */
574
+ js?: string;
575
+ timeout?: number;
576
+ /** F074.51 — the snake_case alias. Every other name on this wire is snake_case
577
+ * (session_id, state_path, load_state_path), so this is the one a caller
578
+ * reaches for; it was silently dropped until it was read here. Wins over
579
+ * `timeout` when both are present. */
580
+ timeout_ms?: number;
581
+ /** click only — skip Playwright's actionability/interception checks. Needed
582
+ * when a transparent overlay (e.g. ASC's agreement-modal fade layer) steals
583
+ * pointer events from a button a human can click fine. */
584
+ force?: boolean;
585
+ /** F240.9 — `waitFor` only: wait this many milliseconds and resolve, with no
586
+ * target at all. A human-paced 2FA login genuinely needs a plain time wait,
587
+ * and there was no way to express one: storeform sent `{type:'waitFor',
588
+ * ms:8000}`, the field was silently dropped, and the step then failed with
589
+ * "waitFor needs a selector, role(+name), or text" — an error about a
590
+ * different problem entirely. */
591
+ ms?: number;
592
+ }
593
+
594
+ /**
595
+ * F240.9 — the body of POST /lens/capture-session/exec, PARSED rather than cast.
596
+ *
597
+ * The route used to write `(await c.req.json()) as { steps?: ExecStep[] }`, and a
598
+ * cast is not a check: a key TypeScript has never heard of arrives, matches
599
+ * nothing, and disappears. storeform sent `{type:'waitFor', ms:8000}` and got back
600
+ * an error about a missing selector — a message describing a different problem,
601
+ * on a call whose real defect was a field that did nothing.
602
+ *
603
+ * THIS IS THE SECOND HALF OF A BUG WE THOUGHT WE HAD CLOSED. Two fields above
604
+ * `ms`, `timeout_ms` carries the F074.51 comment saying it "was silently dropped
605
+ * until it was read here" — the same reporter, the same type, the next field, a
606
+ * week later. Reading one more field closes an instance; refusing unknown fields
607
+ * closes the class.
608
+ *
609
+ * `.strict()` on both levels, so an unknown key is a named 400 instead of silence.
610
+ */
611
+ export const EXEC_STEP_SCHEMA = z
612
+ .object({
613
+ type: z.enum(EXEC_STEP_TYPES),
614
+ selector: z.string().optional(),
615
+ role: z.string().optional(),
616
+ name: z.string().optional(),
617
+ text: z.string().optional(),
618
+ value: z.string().optional(),
619
+ key: z.string().optional(),
620
+ url: z.string().optional(),
621
+ js: z.string().optional(),
622
+ timeout: z.number().optional(),
623
+ timeout_ms: z.number().optional(),
624
+ force: z.boolean().optional(),
625
+ ms: z.number().int().nonnegative().optional(),
626
+ })
627
+ .strict();
628
+
629
+ export const EXEC_BODY_SCHEMA = z
630
+ .object({
631
+ session_id: z.string().min(1),
632
+ steps: z.array(EXEC_STEP_SCHEMA).min(1),
633
+ })
634
+ .strict();
635
+
636
+ /** The fields a step may carry, DERIVED from the schema — so the help text in an
637
+ * error cannot drift from what is actually accepted. A hand-written list would
638
+ * be right the day it was typed and quietly wrong at the next field. */
639
+ export const EXEC_STEP_FIELDS = Object.keys(EXEC_STEP_SCHEMA.shape);
640
+
641
+ export type ExecBody = z.infer<typeof EXEC_BODY_SCHEMA>;
642
+
643
+ /**
644
+ * Parse an /exec body and, on failure, say something a caller can act on ALONE.
645
+ *
646
+ * Zod's own text ("Unrecognized key(s) in object: 'ms'") names the key but not
647
+ * WHICH step, and a batch is usually a dozen steps long. So the message is built
648
+ * here: the step index, its type, the offending key, and the fields that ARE
649
+ * accepted — the last one derived from the schema, never hand-listed.
650
+ *
651
+ * The two pre-existing messages are preserved verbatim ('session_id required',
652
+ * 'steps[] required'): this card tightens what is REJECTED, and quietly renaming
653
+ * the errors a caller already matches on would be a second, unannounced change.
654
+ */
655
+ export function parseExecBody(raw: unknown): { ok: true; value: ExecBody } | { ok: false; error: string } {
656
+ const r = EXEC_BODY_SCHEMA.safeParse(raw);
657
+ if (r.success) {
658
+ const bad = r.data.steps.findIndex((s) => s.ms !== undefined && (s.type !== 'waitFor' || targetLabel(s) !== undefined));
659
+ if (bad >= 0) {
660
+ const s = r.data.steps[bad]!;
661
+ return {
662
+ ok: false,
663
+ error:
664
+ s.type !== 'waitFor'
665
+ ? `step ${bad}: "ms" is only valid on waitFor, not on "${s.type}"`
666
+ : // Two different waits in one step is a caller mistake, and choosing
667
+ // one for them silently is exactly how timeout vs timeout_ms became
668
+ // a bug. Refuse, and name both halves.
669
+ `step ${bad} (waitFor): "ms" and a target (${targetLabel(s)}) are two different waits — pass one`,
670
+ };
671
+ }
672
+ return { ok: true, value: r.data };
673
+ }
674
+
675
+ for (const issue of r.error.issues) {
676
+ const [head, index] = issue.path;
677
+ if (head === 'session_id') return { ok: false, error: 'session_id required' };
678
+ if (head === 'steps' && issue.path.length === 1) return { ok: false, error: 'steps[] required' };
679
+ if (issue.code === 'unrecognized_keys') {
680
+ const where = typeof index === 'number' ? `step ${index}` : 'the request body';
681
+ const keys = issue.keys.map((k) => `"${k}"`).join(', ');
682
+ return {
683
+ ok: false,
684
+ error:
685
+ typeof index === 'number'
686
+ ? `${where}: unknown field ${keys} — accepted: ${EXEC_STEP_FIELDS.join(', ')}`
687
+ : `${where}: unknown field ${keys}`,
688
+ };
689
+ }
690
+ }
691
+ const first = r.error.issues[0];
692
+ const at = first?.path.length ? ` at ${first.path.join('.')}` : '';
693
+ return { ok: false, error: `invalid exec body${at}: ${first?.message ?? 'unknown error'}` };
694
+ }
695
+
696
+ export interface ExecStepResult {
697
+ type: string;
698
+ target?: string;
699
+ status: 'pass' | 'fail';
700
+ detail?: string;
701
+ /** Set on a `screenshot` step — a daemon artifact URL the caller can fetch. */
702
+ screenshot_url?: string;
703
+ }
704
+
705
+ /** The narrow slice of a Playwright Page the runner uses — so runExecSteps is
706
+ * unit-testable with a fake page (and the credential-safety property sealed). */
707
+ export interface StepLocator {
708
+ first(): {
709
+ fill(value: string, opts?: { timeout?: number }): Promise<void>;
710
+ click(opts?: { timeout?: number; force?: boolean }): Promise<void>;
711
+ waitFor(opts?: { state?: 'visible'; timeout?: number }): Promise<void>;
712
+ press(key: string, opts?: { timeout?: number }): Promise<void>;
713
+ focus(opts?: { timeout?: number }): Promise<void>;
714
+ /** Run a fn against the matched element IN the page — used by `jsclick` to
715
+ * invoke the element's own click handler directly (no pointer hit-testing,
716
+ * so a transparent overlay can't intercept it), and by F213.7's fill
717
+ * read-back, which needs the RETURN value. Hence the generic. */
718
+ evaluate<R>(pageFunction: (el: HTMLElement) => R, arg?: unknown, opts?: { timeout?: number }): Promise<R>;
719
+ };
720
+ }
721
+ export interface StepPage {
722
+ locator(selector: string): StepLocator;
723
+ getByRole(role: string, opts?: { name?: string }): StepLocator;
724
+ getByText(text: string): StepLocator;
725
+ goto(url: string, opts?: { timeout?: number }): Promise<unknown>;
726
+ keyboard: { press(key: string): Promise<void> };
727
+ screenshot(opts?: { fullPage?: boolean }): Promise<Buffer>;
728
+ /** Run code IN the page (used by the `eval` step) — e.g. neutralise a
729
+ * pointer-events-stealing overlay so a real trusted click can land. */
730
+ evaluate(pageFunction: (code: string) => unknown, arg: string): Promise<unknown>;
731
+ url(): string;
732
+ }
733
+
734
+ /** Persist a screenshot buffer + return a fetchable URL. Injected by the route
735
+ * (which owns the daemon storage + port) so this module stays decoupled. */
736
+ export type SaveShot = (buffer: Buffer, index: number) => Promise<string>;
737
+
738
+ const DEFAULT_STEP_TIMEOUT_MS = 15_000;
739
+
740
+ /** F240.9 — a plain wait. Not page.waitForTimeout, because that needs a live
741
+ * Playwright page and this step deliberately has no target; keeping it here also
742
+ * keeps the runner unit-testable with the fake page the other steps use. */
743
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
744
+
745
+ /** A short, credential-free label of what a step targeted, for the result. */
746
+ function targetLabel(step: ExecStep): string | undefined {
747
+ if (step.selector) return step.selector;
748
+ if (step.role) return `role=${step.role}${step.name ? ` name=${JSON.stringify(step.name)}` : ''}`;
749
+ if (step.text) return `text=${JSON.stringify(step.text)}`;
750
+ return undefined;
751
+ }
752
+
753
+ /** Resolve a step's target by ONE of css selector / role(+name) / visible text. */
754
+ function resolveStepLocator(page: StepPage, step: ExecStep): StepLocator {
755
+ if (step.selector) return page.locator(step.selector);
756
+ if (step.role) return page.getByRole(step.role, step.name !== undefined ? { name: step.name } : undefined);
757
+ if (step.text) return page.getByText(step.text);
758
+ throw new Error(`${step.type} needs a selector, role(+name), or text`);
759
+ }
760
+
761
+ /** Run steps in order against a live page. Stops on the first failure (so the
762
+ * caller can adjust + retry). NEVER puts step.value in a result — the caller's
763
+ * password/OTP must not surface in the payload or any log. Playwright errors
764
+ * reference the selector + state, not the fill value. `saveShot` persists a
765
+ * `screenshot` step's PNG and yields its URL. */
766
+ export async function runExecSteps(
767
+ page: StepPage,
768
+ steps: ExecStep[],
769
+ saveShot?: SaveShot,
770
+ ): Promise<ExecStepResult[]> {
771
+ const results: ExecStepResult[] = [];
772
+ let index = 0;
773
+ for (const step of steps) {
774
+ const i = index++;
775
+ const base: ExecStepResult = { type: step.type, target: targetLabel(step), status: 'pass' };
776
+ try {
777
+ const t = step.timeout_ms ?? step.timeout ?? DEFAULT_STEP_TIMEOUT_MS;
778
+ switch (step.type) {
779
+ case 'fill':
780
+ // F213.7 — same read-back as the flow engine. The two must not disagree
781
+ // about whether a write happened.
782
+ await fillAndVerify(resolveStepLocator(page, step).first(), step.value ?? '', t);
783
+ break;
784
+ case 'click':
785
+ await resolveStepLocator(page, step).first().click({ timeout: t, force: step.force });
786
+ break;
787
+ case 'jsclick':
788
+ // Invoke the element's click handler DIRECTLY in the DOM. A real mouse
789
+ // click (even force:true) still lands on the top element at the
790
+ // coordinate — a transparent overlay (ASC's agreement-modal fade layer)
791
+ // eats it. el.click() bypasses hit-testing entirely.
792
+ await resolveStepLocator(page, step).first().evaluate((el) => el.click());
793
+ break;
794
+ case 'waitFor':
795
+ // F240.9 — a plain time wait, with no target. The 2FA case is real: a
796
+ // human is reading an SMS and there is nothing on the page to wait FOR
797
+ // yet. `ms` and a target together are refused at parse time rather than
798
+ // resolved by a silent precedence rule.
799
+ if (step.ms !== undefined) {
800
+ await sleep(step.ms);
801
+ break;
802
+ }
803
+ await resolveStepLocator(page, step).first().waitFor({ state: 'visible', timeout: t });
804
+ break;
805
+ case 'waitForText':
806
+ if (!step.text) throw new Error('waitForText needs text');
807
+ await page.getByText(step.text).first().waitFor({ state: 'visible', timeout: t });
808
+ break;
809
+ case 'press':
810
+ if (step.selector || step.role || step.text) {
811
+ await resolveStepLocator(page, step).first().press(step.key ?? 'Enter', { timeout: t });
812
+ } else {
813
+ await page.keyboard.press(step.key ?? 'Enter');
814
+ }
815
+ break;
816
+ case 'goto':
817
+ if (!step.url) throw new Error('goto needs a url');
818
+ await page.goto(step.url, { timeout: t });
819
+ break;
820
+ case 'screenshot': {
821
+ const buf = await page.screenshot({ fullPage: false });
822
+ if (saveShot) base.screenshot_url = await saveShot(buf, i);
823
+ break;
824
+ }
825
+ case 'eval': {
826
+ // Run arbitrary JS IN the page (Chromium sandbox, NOT the daemon) — the
827
+ // general escape hatch: neutralise an overlay, read live DOM, etc. The
828
+ // caller's `return`-style body runs via new Function so `return x` works.
829
+ // Loopback-only + caller already owns this authed session.
830
+ if (!step.js) throw new Error('eval needs a js string');
831
+ const result = await page.evaluate((code) => {
832
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
833
+ const out = new Function(code)();
834
+ return out === undefined ? null : out;
835
+ }, step.js);
836
+ const asText = typeof result === 'string' ? result : JSON.stringify(result);
837
+ base.detail = asText === undefined ? undefined : asText.slice(0, 1000);
838
+ break;
839
+ }
840
+ default:
841
+ throw new Error(
842
+ `unknown step type: ${JSON.stringify((step as ExecStep).type)} — known: ${EXEC_STEP_TYPES.join(', ')}`,
843
+ );
844
+ }
845
+ results.push(base);
846
+ } catch (err) {
847
+ results.push({ ...base, status: 'fail', detail: err instanceof Error ? err.message : String(err) });
848
+ break; // stop the batch on the first failing step
849
+ }
850
+ }
851
+ return results;
852
+ }
853
+
854
+ /** Drive a batch of steps against an open capture-session's live page. Resets the
855
+ * session TTL so a human-paced OTP pause between batches never auto-cancels the
856
+ * browser. Returns per-step pass/fail + the current url (never a credential). */
857
+ export async function execCaptureSession(input: {
858
+ sessionId: string;
859
+ steps: ExecStep[];
860
+ saveShot?: SaveShot;
861
+ }): Promise<{ ok: true; url: string; steps: ExecStepResult[] }> {
862
+ const s = sessions.get(input.sessionId);
863
+ if (!s) throw new Error('unknown session_id (already finished, cancelled, or timed out)');
864
+ // Reset the abandon-TTL: an active driven login must not be killed mid-flow
865
+ // while the human is reading the SMS code.
866
+ clearTimeout(s.timer);
867
+ s.timer = setTimeout(() => {
868
+ void cancelCaptureSession(input.sessionId).catch(() => {});
869
+ }, SESSION_TTL_MS);
870
+ s.timer.unref?.();
871
+ const steps = await runExecSteps(s.page as unknown as StepPage, input.steps, input.saveShot);
872
+ return { ok: true, url: s.page.url(), steps };
873
+ }