instar 1.3.1255 → 1.3.1256

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.
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { WebSocket } from 'ws';
5
5
  import { SafeFsExecutor } from './SafeFsExecutor.js';
6
+ import { GOOGLE_ORIGIN_POLICY, PASSKEY_SESSION_CHROME_ARGS, isGoogleOrigin, isRpFamilyOrigin, mayAddCredential, mustRemoveCredentialBefore, } from './PasskeyBrowserPolicy.js';
6
7
  /** Closed structural floor for OpenAI's device confirmation; consent prose can never enter this class. */
7
8
  export function isClosedOpenAiDeviceApproval(input) {
8
9
  return input.origin === 'https://auth.openai.com'
@@ -24,6 +25,65 @@ const CHROME_EXECUTABLE_CANDIDATES = [
24
25
  export function resolveChromeExecutable() {
25
26
  return CHROME_EXECUTABLE_CANDIDATES.find((candidate) => fs.existsSync(candidate)) ?? null;
26
27
  }
28
+ class WsTransport {
29
+ socket;
30
+ constructor(socket) {
31
+ this.socket = socket;
32
+ }
33
+ get open() { return this.socket.readyState === WebSocket.OPEN; }
34
+ send(text) { this.socket.send(text); }
35
+ onMessage(cb) { this.socket.on('message', (raw) => cb(raw.toString())); }
36
+ onClose(cb) { this.socket.on('close', cb); }
37
+ close() { this.socket.close(); }
38
+ }
39
+ /** CDP over Chrome's `--remote-debugging-pipe`: NUL-delimited JSON on fds 3 (write) / 4 (read). */
40
+ class PipeTransport {
41
+ writer;
42
+ buffer = '';
43
+ closed = false;
44
+ messageCbs = [];
45
+ closeCbs = [];
46
+ constructor(writer, reader) {
47
+ this.writer = writer;
48
+ reader.setEncoding('utf8');
49
+ reader.on('data', (chunk) => {
50
+ this.buffer += chunk;
51
+ let idx;
52
+ while ((idx = this.buffer.indexOf('\0')) >= 0) {
53
+ const message = this.buffer.slice(0, idx);
54
+ this.buffer = this.buffer.slice(idx + 1);
55
+ for (const cb of this.messageCbs)
56
+ cb(message);
57
+ }
58
+ });
59
+ const onEnd = () => { if (!this.closed) {
60
+ this.closed = true;
61
+ for (const cb of this.closeCbs)
62
+ cb();
63
+ } };
64
+ reader.on('end', onEnd);
65
+ reader.on('close', onEnd);
66
+ reader.on('error', onEnd);
67
+ writer.on('error', onEnd);
68
+ }
69
+ get open() { return !this.closed && !this.writer.destroyed; }
70
+ send(text) { this.writer.write(text + '\0'); }
71
+ onMessage(cb) { this.messageCbs.push(cb); }
72
+ onClose(cb) { this.closeCbs.push(cb); }
73
+ close() { this.closed = true; try {
74
+ this.writer.end();
75
+ }
76
+ catch { /* @silent-fallback-ok — pipe already gone; the process teardown below is authoritative */ } }
77
+ }
78
+ /**
79
+ * Whether a SECONDARY page target (a popup) tolerates the credential being present:
80
+ * every frame is either the holder origin or outside the relying-party family. A target
81
+ * still paused at about:blank has no RP-family frame and passes; one whose top-level
82
+ * frame sits on another RP-family origin fails.
83
+ */
84
+ function mayAddSecondaryTarget(frames, policy) {
85
+ return frames.every((f) => f.origin === policy.holderOrigin || !isRpFamilyOrigin(f.origin, policy));
86
+ }
27
87
  /**
28
88
  * Narrow Chrome DevTools implementation of ReloginBrowserPort.
29
89
  * It returns only a closed page classification; raw DOM, URLs-with-query,
@@ -35,10 +95,30 @@ export class ChromeCdpReloginBrowser {
35
95
  headless;
36
96
  launchTimeoutMs;
37
97
  operationTimeoutMs;
98
+ passkeyMode;
99
+ policy;
38
100
  child = null;
39
- socket = null;
101
+ transport = null;
40
102
  requestId = 0;
41
103
  pending = new Map();
104
+ eventHandlers = new Map();
105
+ /** Pipe mode: every attached target by sessionId. */
106
+ sessions = new Map();
107
+ mainSessionId = null;
108
+ mainSessionReady = null;
109
+ /** The credential currently loaded into the authenticators, or null. */
110
+ credential = null;
111
+ /**
112
+ * Every credential id this browser has EVER loaded into an authenticator. Removal
113
+ * sweeps these from every authenticator regardless of the in-memory flag, so a
114
+ * target that received the key during a prepare/remove interleave can never keep it.
115
+ */
116
+ knownCredentialIds = new Set();
117
+ /** Serialises add / remove / prepare-time load so they can never interleave. */
118
+ credentialLock = Promise.resolve();
119
+ assertedCredentialIds = new Set();
120
+ /** True if a Google-origin document navigation was ever seen before an attach completed. */
121
+ navigationBeforeAttach = false;
42
122
  constructor(options) {
43
123
  this.chromePath = options.chromePath ?? resolveChromeExecutable()
44
124
  ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
@@ -46,14 +126,23 @@ export class ChromeCdpReloginBrowser {
46
126
  this.headless = options.headless ?? false;
47
127
  this.launchTimeoutMs = Math.max(1_000, Math.min(30_000, options.launchTimeoutMs ?? 10_000));
48
128
  this.operationTimeoutMs = Math.max(1_000, Math.min(30_000, options.operationTimeoutMs ?? 10_000));
129
+ this.passkeyMode = options.passkeyMode ?? false;
130
+ this.policy = options.originPolicy ?? GOOGLE_ORIGIN_POLICY;
49
131
  }
132
+ // ── Lifecycle ────────────────────────────────────────────────────────
50
133
  async open(url) {
51
- if (this.socket)
134
+ if (this.transport)
52
135
  throw new Error('relogin-browser-already-open');
53
136
  if (!fs.existsSync(this.chromePath))
54
137
  throw new Error('chrome-not-installed');
55
138
  fs.mkdirSync(this.userDataDir, { recursive: true, mode: 0o700 });
56
139
  fs.chmodSync(this.userDataDir, 0o700);
140
+ if (this.passkeyMode)
141
+ return this.openPipe(url);
142
+ return this.openTcp(url);
143
+ }
144
+ /** Legacy transport: TCP debugging port + a page-level WebSocket (non-passkey sessions). */
145
+ async openTcp(url) {
57
146
  const portFile = path.join(this.userDataDir, 'DevToolsActivePort');
58
147
  if (fs.existsSync(portFile)) {
59
148
  SafeFsExecutor.safeUnlinkSync(portFile, { operation: 'ChromeCdpReloginBrowser stale DevToolsActivePort cleanup' });
@@ -82,11 +171,327 @@ export class ChromeCdpReloginBrowser {
82
171
  });
83
172
  if (!target.webSocketDebuggerUrl)
84
173
  throw new Error('chrome-target-missing-websocket');
85
- await this.connect(target.webSocketDebuggerUrl);
174
+ await this.connectWs(target.webSocketDebuggerUrl);
86
175
  await this.send('Page.enable');
87
176
  await this.send('Runtime.enable');
88
177
  await this.wait(500);
89
178
  }
179
+ /**
180
+ * Passkey transport: the debugging pipe. The initial about:blank page is
181
+ * auto-attached and PREPARED (authenticator, interception, service-worker
182
+ * bypass) before the first navigation; popups are paused until prepared.
183
+ */
184
+ async openPipe(url) {
185
+ const args = [
186
+ `--user-data-dir=${this.userDataDir}`,
187
+ ...PASSKEY_SESSION_CHROME_ARGS,
188
+ '--no-first-run',
189
+ '--no-default-browser-check',
190
+ '--disable-sync',
191
+ '--disable-component-update',
192
+ 'about:blank',
193
+ ];
194
+ if (this.headless)
195
+ args.unshift('--headless=new');
196
+ const child = spawn(this.chromePath, args, { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
197
+ this.child = child;
198
+ const writer = child.stdio[3];
199
+ const reader = child.stdio[4];
200
+ if (!writer || !reader)
201
+ throw new Error('chrome-pipe-unavailable');
202
+ this.attachTransport(new PipeTransport(writer, reader));
203
+ let resolveReady;
204
+ const promise = new Promise((r) => { resolveReady = r; });
205
+ this.mainSessionReady = { resolve: resolveReady, promise };
206
+ this.on('Target.attachedToTarget', (params) => this.onAttached(params));
207
+ this.on('Target.detachedFromTarget', (params) => { this.sessions.delete(String(params.sessionId)); });
208
+ this.on('Fetch.requestPaused', (params, sessionId) => this.onRequestPaused(params, sessionId));
209
+ this.on('WebAuthn.credentialAsserted', (params) => {
210
+ const cred = params.credential;
211
+ if (cred?.credentialId)
212
+ this.assertedCredentialIds.add(cred.credentialId);
213
+ });
214
+ this.on('Page.frameRequestedNavigation', (params, sessionId) => {
215
+ // Diagnostic only: a navigation observed on a session that was never prepared.
216
+ const s = sessionId ? this.sessions.get(sessionId) : undefined;
217
+ if (s && !s.authenticatorId && typeof params.url === 'string' && params.url !== 'about:blank')
218
+ this.navigationBeforeAttach = true;
219
+ });
220
+ await this.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
221
+ await Promise.race([
222
+ promise,
223
+ new Promise((_, reject) => setTimeout(() => reject(new Error('chrome-launch-timeout')), this.launchTimeoutMs)),
224
+ ]);
225
+ await this.send('Page.navigate', { url }, this.mainSessionId ?? undefined);
226
+ await this.wait(500);
227
+ }
228
+ async onAttached(params) {
229
+ const sessionId = String(params.sessionId);
230
+ const info = (params.targetInfo ?? {});
231
+ const waiting = params.waitingForDebugger === true;
232
+ const type = info.type ?? 'other';
233
+ const session = { targetId: String(info.targetId ?? ''), type };
234
+ this.sessions.set(sessionId, session);
235
+ try {
236
+ if (type === 'page' || type === 'iframe' || type === 'webview') {
237
+ await this.prepareSession(sessionId, session);
238
+ }
239
+ }
240
+ catch { /* @silent-fallback-ok — a target we could not prepare is resumed below so the browser never wedges; the credential is only ever added to PREPARED authenticators */ }
241
+ if (waiting) {
242
+ try {
243
+ await this.send('Runtime.runIfWaitingForDebugger', {}, sessionId);
244
+ }
245
+ catch { /* @silent-fallback-ok — target may have gone away */ }
246
+ }
247
+ if (type === 'page' && !this.mainSessionId) {
248
+ this.mainSessionId = sessionId;
249
+ this.mainSessionReady?.resolve();
250
+ }
251
+ }
252
+ /** Everything a target needs BEFORE it is allowed to navigate. */
253
+ async prepareSession(sessionId, session) {
254
+ await this.send('Page.enable', {}, sessionId);
255
+ await this.send('Runtime.enable', {}, sessionId);
256
+ await this.send('Network.enable', {}, sessionId);
257
+ await this.send('Network.setBypassServiceWorker', { bypass: true }, sessionId);
258
+ await this.send('Fetch.enable', { patterns: [{ urlPattern: '*', resourceType: 'Document', requestStage: 'Request' }] }, sessionId);
259
+ await this.send('WebAuthn.enable', { enableUI: false }, sessionId);
260
+ const auth = await this.send('WebAuthn.addVirtualAuthenticator', {
261
+ options: {
262
+ protocol: 'ctap2', ctap2Version: 'ctap2_1', transport: 'internal',
263
+ hasResidentKey: true, hasUserVerification: true, isUserVerified: true,
264
+ automaticPresenceSimulation: true,
265
+ },
266
+ }, sessionId);
267
+ // The authenticator becomes VISIBLE to removal and receives the credential in one
268
+ // locked step: a concurrent removeCredential() either runs entirely before (so the
269
+ // credential is null here and nothing is loaded) or entirely after (so this
270
+ // authenticator is in its sweep). A target is still paused at about:blank here.
271
+ await this.withCredentialLock(async () => {
272
+ session.authenticatorId = String(auth.authenticatorId);
273
+ if (this.credential) {
274
+ await this.send('WebAuthn.addCredential', { authenticatorId: session.authenticatorId, credential: this.credential }, sessionId);
275
+ }
276
+ });
277
+ try {
278
+ const tree = await this.send('Page.getFrameTree', {}, sessionId);
279
+ session.mainFrameId = tree.frameTree?.frame?.id;
280
+ }
281
+ catch { /* @silent-fallback-ok — an OOPIF target has no page frame tree; its documents are never top-level */ }
282
+ // Nested auto-attach so cross-origin iframes of THIS target get the same preparation.
283
+ await this.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }, sessionId);
284
+ }
285
+ async onRequestPaused(params, sessionId) {
286
+ const requestId = String(params.requestId);
287
+ const cont = () => this.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
288
+ if (params.resourceType !== 'Document') {
289
+ await cont();
290
+ return;
291
+ }
292
+ const session = sessionId ? this.sessions.get(sessionId) : undefined;
293
+ const request = (params.request ?? {});
294
+ const url = request.url ?? '';
295
+ // Unknown frame identity (no session record, or Page.getFrameTree failed) is treated
296
+ // as TOP-LEVEL so the stricter rule applies — over-remove, never under-remove.
297
+ const topLevel = session?.type === 'iframe' ? false
298
+ : (session?.mainFrameId === undefined || params.frameId === session.mainFrameId);
299
+ if ((this.credential || this.knownCredentialIds.size > 0) && mustRemoveCredentialBefore({ url, topLevel }, this.policy)) {
300
+ try {
301
+ await this.removeCredential();
302
+ }
303
+ catch {
304
+ // Fail CLOSED: if the credential cannot be removed, the document must not load.
305
+ await this.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }, sessionId).catch(() => { });
306
+ return;
307
+ }
308
+ }
309
+ await cont();
310
+ }
311
+ // ── Passkey operations (spec §3.5 port additions) ─────────────────────
312
+ /**
313
+ * Add the credential to every prepared authenticator. Allowed only while the
314
+ * main target's frames satisfy the origin policy (top-level on the holder
315
+ * origin, no other RP-family frame). The value stays inside this process.
316
+ */
317
+ async addCredential(credential) {
318
+ if (!this.passkeyMode)
319
+ throw new Error('passkey-mode-required');
320
+ await this.withCredentialLock(async () => {
321
+ // EVERY page target (the main page AND every auto-attached popup) must satisfy
322
+ // the policy — a popup that moved to another RP-family origin earlier (and had
323
+ // the key removed then) must not be re-armed by an add on the main page.
324
+ for (const [sid, s] of this.sessions) {
325
+ if (s.type !== 'page' || !s.authenticatorId)
326
+ continue;
327
+ const frames = await this.frameOrigins(sid);
328
+ if (sid === this.mainSessionId) {
329
+ if (!mayAddCredential(frames, this.policy))
330
+ throw new Error('passkey-origin-not-allowed');
331
+ continue;
332
+ }
333
+ // A secondary target may be blank (still paused), on the holder origin, or on an
334
+ // origin OUTSIDE the relying-party family (it cannot claim the key). What it may
335
+ // never be is on another RP-family origin — in any of its frames.
336
+ if (!mayAddSecondaryTarget(frames, this.policy))
337
+ throw new Error('passkey-origin-not-allowed');
338
+ }
339
+ this.credential = { ...credential };
340
+ this.knownCredentialIds.add(credential.credentialId);
341
+ for (const [sessionId, s] of this.sessions) {
342
+ if (!s.authenticatorId)
343
+ continue;
344
+ await this.send('WebAuthn.addCredential', { authenticatorId: s.authenticatorId, credential: this.credential }, sessionId);
345
+ }
346
+ });
347
+ }
348
+ /**
349
+ * Remove every credential this browser ever loaded from EVERY authenticator, and
350
+ * forget the current one. Does not trust the in-memory flag: the sweep runs whenever
351
+ * any id is known, so no target can keep a key the flag says is gone. Idempotent.
352
+ */
353
+ async removeCredential() {
354
+ if (!this.credential && this.knownCredentialIds.size === 0)
355
+ return;
356
+ await this.withCredentialLock(async () => {
357
+ let firstError = null;
358
+ for (const [sessionId, s] of this.sessions) {
359
+ if (!s.authenticatorId)
360
+ continue;
361
+ for (const credentialId of this.knownCredentialIds) {
362
+ try {
363
+ await this.send('WebAuthn.removeCredential', { authenticatorId: s.authenticatorId, credentialId }, sessionId);
364
+ }
365
+ catch (err) {
366
+ // A target that already went away, or an authenticator that never held this
367
+ // id, is fine; anything else is reported after the sweep completes.
368
+ if (!(err instanceof Error && /target|session|closed|detached|not found|unknown credential/i.test(err.message)))
369
+ firstError ??= err;
370
+ }
371
+ }
372
+ }
373
+ this.credential = null;
374
+ if (firstError)
375
+ throw firstError instanceof Error ? firstError : new Error(String(firstError));
376
+ });
377
+ }
378
+ /** Run `fn` with exclusive access to the credential state (add / remove / prepare-time load). */
379
+ withCredentialLock(fn) {
380
+ const run = this.credentialLock.then(fn, fn);
381
+ this.credentialLock = run.then(() => undefined, () => undefined);
382
+ return run;
383
+ }
384
+ /** Whether the credential is currently loaded into the authenticators. */
385
+ hasCredential() { return this.credential !== null; }
386
+ /** True once Google (or the fixture) actually asked for and received an assertion from this credential. */
387
+ observedAssertion(credentialId) { return this.assertedCredentialIds.has(credentialId); }
388
+ /** Export every credential in the MAIN target's authenticator (used at mint). */
389
+ async exportCredentials() {
390
+ if (!this.passkeyMode || !this.mainSessionId)
391
+ throw new Error('passkey-mode-required');
392
+ const s = this.sessions.get(this.mainSessionId);
393
+ if (!s?.authenticatorId)
394
+ throw new Error('authenticator-missing');
395
+ const out = await this.send('WebAuthn.getCredentials', { authenticatorId: s.authenticatorId }, this.mainSessionId);
396
+ return (out.credentials ?? []).map((c) => ({ ...c }));
397
+ }
398
+ /** Credential COUNT per prepared target (never material) — proves every attached target's authenticator is loaded. */
399
+ async credentialCountsByTarget() {
400
+ const counts = [];
401
+ for (const [sid, s] of this.sessions) {
402
+ if (!s.authenticatorId || s.type !== 'page')
403
+ continue;
404
+ try {
405
+ const out = await this.send('WebAuthn.getCredentials', { authenticatorId: s.authenticatorId }, sid);
406
+ counts.push((out.credentials ?? []).length);
407
+ }
408
+ catch { /* @silent-fallback-ok — a target that closed holds nothing */ }
409
+ }
410
+ return counts;
411
+ }
412
+ /** Number of credentials the MAIN authenticator holds (a count, never material). */
413
+ async credentialCount() {
414
+ return (await this.exportCredentials()).length;
415
+ }
416
+ /**
417
+ * Compare the signed-in identity shown on the current page with the expected
418
+ * email INSIDE the browser; only the boolean leaves. Structural first
419
+ * (`data-email` / `data-identifier` / `aria-label`), then an exact-text element.
420
+ */
421
+ async readSignedInIdentityMatches(expected) {
422
+ return this.evaluate(`(() => {
423
+ const expected = ${JSON.stringify(expected)}.trim().toLowerCase();
424
+ if (!expected) return false;
425
+ const attrNodes = Array.from(document.querySelectorAll('[data-email],[data-identifier],[aria-label]'));
426
+ for (const n of attrNodes) {
427
+ for (const a of ['data-email','data-identifier','aria-label']) {
428
+ const v = (n.getAttribute(a) || '').trim().toLowerCase();
429
+ if (v === expected) return true;
430
+ }
431
+ }
432
+ const all = Array.from(document.querySelectorAll('div,span,p,li,td,a,button'));
433
+ return all.some((n) => n.children.length === 0 && (n.textContent || '').trim().toLowerCase() === expected);
434
+ })()`);
435
+ }
436
+ /**
437
+ * Whether any target was seen navigating before its authenticator was attached. A
438
+ * DIAGNOSTIC only: it observes from `Page.enable` onward, so it can confirm a breach
439
+ * it saw but not prove absence. The guarantee itself is Chrome's
440
+ * `waitForDebuggerOnStart` pause, which the popup test exercises.
441
+ */
442
+ sawNavigationBeforeAttach() { return this.navigationBeforeAttach; }
443
+ /**
444
+ * Run an expression in the main page. FIXTURE-ONLY: refused under the production
445
+ * Google policy so no caller can turn the repair browser into an arbitrary-evaluate
446
+ * surface on a real account page.
447
+ */
448
+ async runInPage(expression) {
449
+ this.assertFixturePolicy('run-in-page');
450
+ return this.evaluate(expression);
451
+ }
452
+ assertFixturePolicy(op) {
453
+ if (this.policy.holderOrigin === GOOGLE_ORIGIN_POLICY.holderOrigin || isGoogleOrigin(this.policy.holderOrigin)) {
454
+ throw new Error(`${op}-fixture-only`);
455
+ }
456
+ }
457
+ /** Origins of every frame in ONE page target (its own tree plus its out-of-process iframes). */
458
+ async frameOrigins(pageSessionId = this.mainSessionId) {
459
+ const tree = await this.send('Page.getFrameTree', {}, pageSessionId ?? undefined);
460
+ const out = [];
461
+ const walk = (node, top) => {
462
+ if (!node?.frame)
463
+ return;
464
+ const origin = node.frame.securityOrigin && node.frame.securityOrigin !== '://' ? node.frame.securityOrigin : (() => { try {
465
+ return new URL(node.frame.url ?? '').origin;
466
+ }
467
+ catch {
468
+ return '';
469
+ } })();
470
+ out.push({ origin, topLevel: top });
471
+ for (const child of (node.childFrames ?? []))
472
+ walk(child, false);
473
+ };
474
+ walk(tree.frameTree, true);
475
+ // Out-of-process iframes are separate targets: include them as subframes.
476
+ for (const [sid, s] of this.sessions) {
477
+ if (s.type !== 'iframe' || sid === this.mainSessionId)
478
+ continue;
479
+ try {
480
+ const t = await this.send('Page.getFrameTree', {}, sid);
481
+ const f = t.frameTree?.frame;
482
+ const origin = f?.securityOrigin && f.securityOrigin !== '://' ? f.securityOrigin : (() => { try {
483
+ return new URL(f?.url ?? '').origin;
484
+ }
485
+ catch {
486
+ return '';
487
+ } })();
488
+ out.push({ origin, topLevel: false });
489
+ }
490
+ catch { /* @silent-fallback-ok — a vanished OOPIF cannot hold the credential */ }
491
+ }
492
+ return out;
493
+ }
494
+ // ── ReloginBrowserPort ───────────────────────────────────────────────
90
495
  async snapshot(expectedIdentity) {
91
496
  return this.evaluate(`(() => {
92
497
  const expected = ${JSON.stringify(expectedIdentity)}.trim().toLowerCase();
@@ -136,7 +541,9 @@ export class ChromeCdpReloginBrowser {
136
541
  })()`);
137
542
  }
138
543
  async chooseExpectedAccount(expectedIdentity) {
139
- await this.evaluate(`(() => {
544
+ // A REAL input click (spec §3.5): Google's choice-list items do not respond
545
+ // to a script-level element.click().
546
+ await this.clickReal(`(() => {
140
547
  const expected = ${JSON.stringify(expectedIdentity)}.trim().toLowerCase();
141
548
  const nodes = Array.from(document.querySelectorAll('[data-email],[data-identifier],button,[role="button"],a,[role="link"],li'));
142
549
  const identities = (node) => {
@@ -146,9 +553,9 @@ export class ChromeCdpReloginBrowser {
146
553
  };
147
554
  const matching = nodes.filter((node) => identities(node).includes(expected));
148
555
  const actionable = matching.filter((node) => !matching.some((child) => child !== node && node.contains(child)));
149
- if (actionable.length !== 1 || !(actionable[0] instanceof HTMLElement)) return false;
150
- actionable[0].click(); return true;
151
- })()`, true);
556
+ if (actionable.length !== 1 || !(actionable[0] instanceof HTMLElement)) return null;
557
+ return actionable[0];
558
+ })()`);
152
559
  }
153
560
  async fillPublic(field, value) {
154
561
  const selector = field === 'email'
@@ -166,15 +573,19 @@ export class ChromeCdpReloginBrowser {
166
573
  const pattern = action === 'next' ? '^(next|continue)$'
167
574
  : action === 'google' ? '(continue|sign in|log in) with google'
168
575
  : '^(allow|authorize|approve|continue)$';
169
- await this.evaluate(`(() => {
576
+ await this.clickReal(`(() => {
170
577
  const re = new RegExp(${JSON.stringify(pattern)}, 'i');
171
578
  const nodes = Array.from(document.querySelectorAll('button,[role="button"],a,[role="link"],input[type="submit"]'));
172
579
  const node = nodes.find((entry) => re.test(((entry.textContent || entry.getAttribute('value') || '')).trim()));
173
- if (!(node instanceof HTMLElement)) return false;
174
- node.click(); return true;
175
- })()`, true);
580
+ return node instanceof HTMLElement ? node : null;
581
+ })()`);
176
582
  await this.wait(500);
177
583
  }
584
+ /** Click a fixture/test element by CSS selector with a REAL pointer event. FIXTURE-ONLY (see runInPage). */
585
+ async clickSelector(selector) {
586
+ this.assertFixturePolicy('click-selector');
587
+ await this.clickReal(`(() => { const n = document.querySelector(${JSON.stringify(selector)}); return n instanceof HTMLElement ? n : null; })()`);
588
+ }
178
589
  async readPasteCode() {
179
590
  return this.evaluate(`(() => {
180
591
  const candidates = Array.from(document.querySelectorAll('code,pre,[data-testid*="code" i]'))
@@ -187,19 +598,22 @@ export class ChromeCdpReloginBrowser {
187
598
  }
188
599
  async close() {
189
600
  const child = this.child;
190
- if (this.socket?.readyState === WebSocket.OPEN) {
601
+ this.credential = null;
602
+ if (this.transport?.open) {
191
603
  try {
192
604
  await this.send('Browser.close');
193
605
  }
194
606
  catch { /* @silent-fallback-ok — process may already be gone; local socket/process teardown continues below */ }
195
607
  }
196
- this.socket?.close();
197
- this.socket = null;
608
+ this.transport?.close();
609
+ this.transport = null;
198
610
  for (const entry of this.pending.values()) {
199
611
  clearTimeout(entry.timer);
200
612
  entry.reject(new Error('relogin-browser-closed'));
201
613
  }
202
614
  this.pending.clear();
615
+ this.sessions.clear();
616
+ this.mainSessionId = null;
203
617
  if (child && child.exitCode === null && child.signalCode === null) {
204
618
  child.kill('SIGTERM');
205
619
  if (!await this.waitForChildExit(child, 5_000)) {
@@ -209,6 +623,7 @@ export class ChromeCdpReloginBrowser {
209
623
  }
210
624
  this.child = null;
211
625
  }
626
+ // ── Internals ────────────────────────────────────────────────────────
212
627
  async waitForChildExit(child, timeoutMs) {
213
628
  if (child.exitCode !== null || child.signalCode !== null)
214
629
  return true;
@@ -231,29 +646,63 @@ export class ChromeCdpReloginBrowser {
231
646
  finish(true);
232
647
  });
233
648
  }
649
+ /**
650
+ * Real pointer click: resolve the element with `finderExpression` (must
651
+ * evaluate to an HTMLElement or null), scroll it into view, read its centre,
652
+ * and dispatch mouse pressed/released through the Input domain.
653
+ */
654
+ async clickReal(finderExpression) {
655
+ const point = await this.evaluate(`(() => {
656
+ const el = (${finderExpression});
657
+ if (!(el instanceof HTMLElement)) return null;
658
+ el.scrollIntoView({ block: 'center', inline: 'center' });
659
+ const r = el.getBoundingClientRect();
660
+ if (r.width === 0 || r.height === 0) return null;
661
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
662
+ })()`);
663
+ if (!point)
664
+ throw new Error('browser-element-not-found');
665
+ const sid = this.mainSessionId ?? undefined;
666
+ await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y }, sid);
667
+ await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button: 'left', clickCount: 1 }, sid);
668
+ await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button: 'left', clickCount: 1 }, sid);
669
+ }
234
670
  async fillAndSubmit(selector, value) {
235
- const expression = `(() => {
671
+ const filled = await this.evaluate(`(() => {
236
672
  const node = document.querySelector(${JSON.stringify(selector)});
237
673
  if (!(node instanceof HTMLInputElement)) return false;
238
674
  const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
239
675
  setter?.call(node, ${JSON.stringify(value)});
240
676
  node.dispatchEvent(new Event('input', { bubbles: true }));
241
677
  node.dispatchEvent(new Event('change', { bubbles: true }));
242
- const buttons = Array.from(document.querySelectorAll('button,[role="button"],a,[role="link"],input[type="submit"]'));
243
- const submit = buttons.find((entry) => /^(next|continue|sign in|verify|submit)$/i
244
- .test(((entry.textContent || entry.getAttribute('value') || '')).trim()));
245
- if (submit instanceof HTMLElement) { submit.click(); return true; }
246
- const form = node.closest('form');
247
- if (form instanceof HTMLFormElement) { form.requestSubmit(); return true; }
248
- return true;
249
- })()`;
250
- await this.evaluate(expression, true);
678
+ // Read back: never submit a field whose value did not stick (measured 2026-09-21).
679
+ return node.value.length === ${JSON.stringify(value)}.length;
680
+ })()`, true);
681
+ if (!filled)
682
+ throw new Error('browser-element-not-found');
683
+ try {
684
+ await this.clickReal(`(() => {
685
+ const buttons = Array.from(document.querySelectorAll('button,[role="button"],a,[role="link"],input[type="submit"]'));
686
+ const submit = buttons.find((entry) => /^(next|continue|sign in|verify|submit)$/i
687
+ .test(((entry.textContent || entry.getAttribute('value') || '')).trim()));
688
+ return submit instanceof HTMLElement ? submit : null;
689
+ })()`);
690
+ }
691
+ catch {
692
+ // No submit button: fall back to the form's own submit.
693
+ await this.evaluate(`(() => {
694
+ const node = document.querySelector(${JSON.stringify(selector)});
695
+ const form = node instanceof HTMLElement ? node.closest('form') : null;
696
+ if (form instanceof HTMLFormElement) { form.requestSubmit(); return true; }
697
+ return true;
698
+ })()`);
699
+ }
251
700
  await this.wait(500);
252
701
  }
253
702
  async evaluate(expression, requireTruthy = false) {
254
- const result = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
703
+ const result = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, this.mainSessionId ?? undefined);
255
704
  const remote = result.result;
256
- if (!remote || remote.exceptionDetails)
705
+ if (!remote || result.exceptionDetails)
257
706
  throw new Error('browser-evaluation-failed');
258
707
  if (requireTruthy && !remote.value)
259
708
  throw new Error('browser-element-not-found');
@@ -274,60 +723,77 @@ export class ChromeCdpReloginBrowser {
274
723
  }
275
724
  throw new Error('chrome-launch-timeout');
276
725
  }
277
- async connect(url) {
726
+ async connectWs(url) {
278
727
  const socket = new WebSocket(url, { origin: 'http://127.0.0.1' });
279
- this.socket = socket;
280
- socket.on('message', (raw) => {
728
+ await new Promise((resolve, reject) => {
729
+ const timer = setTimeout(() => reject(new Error('cdp-connect-timeout')), this.operationTimeoutMs);
730
+ socket.once('open', () => { clearTimeout(timer); resolve(); });
731
+ socket.once('error', (error) => { clearTimeout(timer); reject(error); });
732
+ });
733
+ this.attachTransport(new WsTransport(socket));
734
+ }
735
+ attachTransport(transport) {
736
+ this.transport = transport;
737
+ transport.onMessage((raw) => {
281
738
  let message;
282
739
  try {
283
- message = JSON.parse(raw.toString());
740
+ message = JSON.parse(raw);
284
741
  }
285
742
  catch {
286
743
  return;
287
744
  }
288
- if (message.id === undefined)
289
- return;
290
- const entry = this.pending.get(message.id);
291
- if (!entry)
745
+ if (message.id !== undefined) {
746
+ const entry = this.pending.get(message.id);
747
+ if (!entry)
748
+ return;
749
+ this.pending.delete(message.id);
750
+ clearTimeout(entry.timer);
751
+ if (message.error)
752
+ entry.reject(new Error(message.error.message ?? 'cdp-error'));
753
+ else
754
+ entry.resolve(message.result ?? {});
292
755
  return;
293
- this.pending.delete(message.id);
294
- clearTimeout(entry.timer);
295
- if (message.error)
296
- entry.reject(new Error(message.error.message ?? 'cdp-error'));
297
- else
298
- entry.resolve(message.result ?? {});
756
+ }
757
+ if (message.method) {
758
+ for (const handler of this.eventHandlers.get(message.method) ?? []) {
759
+ try {
760
+ const out = handler(message.params ?? {}, message.sessionId);
761
+ if (out && typeof out.catch === 'function')
762
+ out.catch(() => { });
763
+ }
764
+ catch { /* @silent-fallback-ok — an event handler must never take the transport down */ }
765
+ }
766
+ }
299
767
  });
300
- socket.on('close', () => {
768
+ transport.onClose(() => {
301
769
  for (const [id, entry] of this.pending) {
302
770
  this.pending.delete(id);
303
771
  clearTimeout(entry.timer);
304
772
  entry.reject(new Error('cdp-closed'));
305
773
  }
306
774
  });
307
- await new Promise((resolve, reject) => {
308
- const timer = setTimeout(() => reject(new Error('cdp-connect-timeout')), this.operationTimeoutMs);
309
- socket.once('open', () => { clearTimeout(timer); resolve(); });
310
- socket.once('error', (error) => { clearTimeout(timer); reject(error); });
311
- });
312
775
  }
313
- send(method, params = {}) {
314
- const socket = this.socket;
315
- if (!socket || socket.readyState !== WebSocket.OPEN)
776
+ on(method, handler) {
777
+ const list = this.eventHandlers.get(method) ?? [];
778
+ list.push(handler);
779
+ this.eventHandlers.set(method, list);
780
+ }
781
+ send(method, params = {}, sessionId) {
782
+ const transport = this.transport;
783
+ if (!transport || !transport.open)
316
784
  return Promise.reject(new Error('cdp-not-connected'));
317
785
  const id = ++this.requestId;
318
786
  return new Promise((resolve, reject) => {
319
787
  const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`cdp-timeout:${method}`)); }, this.operationTimeoutMs);
320
788
  this.pending.set(id, { resolve, reject, timer });
321
- socket.send(JSON.stringify({ id, method, params }), (error) => {
322
- if (!error)
323
- return;
324
- const entry = this.pending.get(id);
325
- if (!entry)
326
- return;
789
+ try {
790
+ transport.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
791
+ }
792
+ catch (error) {
327
793
  this.pending.delete(id);
328
- clearTimeout(entry.timer);
329
- reject(error);
330
- });
794
+ clearTimeout(timer);
795
+ reject(error instanceof Error ? error : new Error(String(error)));
796
+ }
331
797
  });
332
798
  }
333
799
  }