@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.
- package/README.md +14 -2
- package/package.json +52 -3
- package/src/artifact-backfill.ts +253 -0
- package/src/artifact-uploads.ts +370 -0
- package/src/auth/index.ts +294 -0
- package/src/browser.ts +440 -0
- package/src/build-marker.ts +197 -0
- package/src/capture-session.ts +873 -0
- package/src/capture.ts +348 -0
- package/src/critic-dom.ts +951 -0
- package/src/critic-vision.ts +205 -0
- package/src/critic.ts +73 -0
- package/src/daemon-routes.ts +333 -0
- package/src/durability.ts +18 -0
- package/src/ensure-chromium.ts +118 -0
- package/src/export.ts +256 -0
- package/src/fill.ts +149 -0
- package/src/flow-store.ts +207 -0
- package/src/flow.ts +2869 -0
- package/src/frames.ts +195 -0
- package/src/gate.ts +123 -0
- package/src/http.ts +175 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +741 -0
- package/src/mcp.ts +562 -0
- package/src/motion-probe.ts +653 -0
- package/src/password-in-url.ts +106 -0
- package/src/pdf-text.ts +514 -0
- package/src/pixelmatch.d.ts +21 -0
- package/src/preflight.ts +76 -0
- package/src/readiness.ts +282 -0
- package/src/record-run.ts +427 -0
- package/src/record.ts +387 -0
- package/src/sandbox.ts +22 -0
- package/src/schema.ts +300 -0
- package/src/scrape.ts +116 -0
- package/src/seed-storage.ts +149 -0
- package/src/service.ts +1483 -0
- package/src/storage.ts +159 -0
- package/src/store.ts +267 -0
- package/src/swallowed-errors.ts +252 -0
- package/src/validate-vision.ts +97 -0
- package/src/verify.ts +411 -0
- package/src/video.ts +211 -0
|
@@ -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
|
+
}
|