teamshare-bridge 0.26.12 → 0.26.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/cli/commands/research.js +15 -0
  2. package/dist/cli/commands/research.js.map +1 -1
  3. package/dist/lib/browser/batch.d.ts +47 -2
  4. package/dist/lib/browser/batch.js +48 -5
  5. package/dist/lib/browser/batch.js.map +1 -1
  6. package/dist/lib/browser/browse-cdp.js +5 -1
  7. package/dist/lib/browser/browse-cdp.js.map +1 -1
  8. package/dist/lib/browser/browse-editor.js +8 -0
  9. package/dist/lib/browser/browse-editor.js.map +1 -1
  10. package/dist/lib/browser/cdp.d.ts +2 -0
  11. package/dist/lib/browser/cdp.js +8 -0
  12. package/dist/lib/browser/cdp.js.map +1 -1
  13. package/dist/lib/browser/index.d.ts +3 -1
  14. package/dist/lib/browser/index.js +12 -1
  15. package/dist/lib/browser/index.js.map +1 -1
  16. package/dist/lib/browser/login.d.ts +67 -25
  17. package/dist/lib/browser/login.js +681 -174
  18. package/dist/lib/browser/login.js.map +1 -1
  19. package/dist/lib/browser/network-log.d.ts +9 -0
  20. package/dist/lib/browser/network-log.js +21 -0
  21. package/dist/lib/browser/network-log.js.map +1 -1
  22. package/dist/lib/browser/page-context.d.ts +176 -0
  23. package/dist/lib/browser/page-context.js +637 -0
  24. package/dist/lib/browser/page-context.js.map +1 -0
  25. package/dist/lib/browser/page-events.d.ts +39 -0
  26. package/dist/lib/browser/page-events.js +128 -0
  27. package/dist/lib/browser/page-events.js.map +1 -0
  28. package/dist/lib/browser/screen-change.js +3 -0
  29. package/dist/lib/browser/screen-change.js.map +1 -1
  30. package/dist/lib/browser/session.d.ts +101 -1
  31. package/dist/lib/browser/session.js +321 -2
  32. package/dist/lib/browser/session.js.map +1 -1
  33. package/dist/lib/orchestrator/handlers/browser.js +200 -31
  34. package/dist/lib/orchestrator/handlers/browser.js.map +1 -1
  35. package/package.json +1 -1
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FIND_LOGIN_FIELDS_JS = void 0;
3
4
  exports.findLoginFields = findLoginFields;
5
+ exports.loginStepOf = loginStepOf;
4
6
  exports.typeTextVerified = typeTextVerified;
5
7
  exports.fillFieldVerified = fillFieldVerified;
6
8
  exports.performLogin = performLogin;
@@ -14,17 +16,21 @@ exports.isCredentialFocused = isCredentialFocused;
14
16
  * module is the explicit, OPT-IN escape hatch (`allowCredentials`), authored so
15
17
  * the LLM never has to type a secret itself:
16
18
  *
17
- * - `findLoginFields(page)` locates the username / password / submit controls
18
- * with plain DOM heuristics (autocomplete -> input type -> label/text).
19
- * - `performLogin(page, { username, password })` fills them, handles the common
20
- * two-step email -> Continue -> password flow, submits, and returns a
21
- * structured result. It is LLM-free.
22
- * - `fillFieldVerified(page, x, y, value)` is the hardened insert: it focuses
23
- * the field, replaces its contents, and VERIFIES the value actually landed,
24
- * falling back to character-by-character key events when `Input.insertText`
25
- * is swallowed (a controlled/echoing password widget).
19
+ * - `findLoginFields(page)` locates the username / password / submit / OTP
20
+ * controls with DOM heuristics. It scopes the search to the TOPMOST OPEN
21
+ * DIALOG when one exists, because a login opened in a modal must not be
22
+ * confused with a footer newsletter field or the page's nav "Login" button.
23
+ * - `performLogin(page, { username, password, otp? })` clears every field,
24
+ * fills it, verifies the exact value, submits, and follows the site's real
25
+ * flow: email → password, email → OTP, email → password → OTP, password →
26
+ * OTP, OTP only. It NEVER reports success while an OTP field is visible.
27
+ * - `fillFieldVerified(page, control, value)` always clears first (select-all
28
+ * + Backspace + native value setter), verifies the field is EMPTY, then
29
+ * types and verifies the value EXACTLY. The old version compared only the
30
+ * length, so an autofilled value plus the typed text could pass as
31
+ * "filled" - or fail forever.
26
32
  * - `typeTextVerified(page, text)` is the same verification for the generic
27
- * `type` action, so a password typed by the agent is confirmed too.
33
+ * `type` action; `clearFirst` makes it replace instead of append.
28
34
  *
29
35
  * The auto-answer loop (`browse --auto-answer`) is a DIFFERENT code path and
30
36
  * still never touches credentials - see `personal-fields.ts`.
@@ -32,34 +38,60 @@ exports.isCredentialFocused = isCredentialFocused;
32
38
  * NO secret is ever logged here.
33
39
  */
34
40
  const cdp_1 = require("./cdp");
35
- /** DOM heuristic: locate the login controls. Runs entirely in the page. */
36
- const FIND_LOGIN_FIELDS_JS = `(() => {
41
+ /** DOM heuristic: locate the login controls, scoped to the topmost modal. */
42
+ exports.FIND_LOGIN_FIELDS_JS = `(() => {
37
43
  const vis = (el) => {
38
44
  if (!el) return false;
39
45
  const r = el.getBoundingClientRect();
40
46
  if (r.width === 0 || r.height === 0) return false;
41
47
  const cs = getComputedStyle(el);
42
- return cs.visibility !== 'hidden' && cs.display !== 'none';
43
- };
44
- const box = (el, hint) => {
45
- const r = el.getBoundingClientRect();
46
- return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), hint: hint || '' };
48
+ return cs.visibility !== 'hidden' && cs.display !== 'none' && cs.opacity !== '0';
47
49
  };
48
50
  const hintOf = (el) => [el.getAttribute('name'), el.id, el.getAttribute('autocomplete')].filter(Boolean).join('/');
49
- const inputs = [...document.querySelectorAll('input, textarea')].filter(vis);
50
51
  const lowerType = (el) => (el.type || '').toLowerCase();
52
+ const autocomplete = (el) => (el.getAttribute('autocomplete') || '').toLowerCase();
53
+
54
+ // MODAL SCOPE (2026-10): a login opened in a dialog must win over a footer
55
+ // newsletter input or the page's nav Login button. Prefer the first visible
56
+ // dialog/frame that actually contains a login-ish field; otherwise the last
57
+ // visible dialog; otherwise the document.
58
+ const dialogSel = 'dialog[open],[role="dialog"],[aria-modal="true"],' +
59
+ '[class*="modal"],[class*="Modal"],[class*="dialog"],[class*="Dialog"]';
60
+ const dialogEls = [...document.querySelectorAll(dialogSel)].filter(vis);
61
+ const hasStrongLogin = (d) => !!d.querySelector('input[type="password"],input[autocomplete="username"],input[autocomplete="current-password"],input[autocomplete="new-password"],input[autocomplete="one-time-code"]');
62
+ const hasEmailAndContinue = (d) => {
63
+ if (!d.querySelector('input[type="email"],input[autocomplete="email"]')) return false;
64
+ return [...d.querySelectorAll('button,[role="button"],input[type="submit"]')].some((el) =>
65
+ /continue|next|sign|log\\s?in|verify|submit/i.test((el.innerText || el.textContent || el.value || '').trim()),
66
+ );
67
+ };
68
+ let scope =
69
+ dialogEls.find(hasStrongLogin) ||
70
+ dialogEls.find(hasEmailAndContinue) ||
71
+ dialogEls.find((d) => d.querySelector('input,textarea')) ||
72
+ null;
73
+ if (!scope && dialogEls.length) scope = dialogEls[dialogEls.length - 1];
74
+ const root = scope || document;
75
+
76
+ // Clear the previous scan's tags so a re-rendered modal cannot be addressed
77
+ // through a stale element.
78
+ document.querySelectorAll('[data-ts-login]').forEach((e) => e.removeAttribute('data-ts-login'));
79
+
80
+ const inputs = [...root.querySelectorAll('input,textarea')]
81
+ .filter(vis)
82
+ .filter((el) => lowerType(el) !== 'hidden' && !el.disabled && !el.readOnly);
51
83
 
52
84
  const pw =
53
85
  inputs.find((el) => lowerType(el) === 'password') ||
54
- inputs.find((el) => ['current-password', 'new-password'].includes((el.getAttribute('autocomplete') || '').toLowerCase())) ||
86
+ inputs.find((el) => ['current-password', 'new-password'].includes(autocomplete(el))) ||
55
87
  null;
56
88
 
57
89
  let user =
58
- inputs.find((el) => (el.getAttribute('autocomplete') || '').toLowerCase() === 'username') ||
90
+ inputs.find((el) => autocomplete(el) === 'username') ||
59
91
  inputs.find((el) => lowerType(el) === 'email') ||
60
92
  null;
61
93
  if (!user) {
62
- const textish = inputs.filter((el) => ['', 'text', 'email', 'tel'].includes(lowerType(el)) && lowerType(el) !== 'password');
94
+ const textish = inputs.filter((el) => ['', 'text', 'email', 'tel'].includes(lowerType(el)));
63
95
  if (pw) {
64
96
  user = textish.find((el) => (el.compareDocumentPosition(pw) & Node.DOCUMENT_POSITION_PRECEDING)) || null;
65
97
  }
@@ -67,44 +99,83 @@ const FIND_LOGIN_FIELDS_JS = `(() => {
67
99
  }
68
100
 
69
101
  const otp =
70
- inputs.find((el) => (el.getAttribute('autocomplete') || '').toLowerCase() === 'one-time-code') ||
71
- inputs.find((el) => /otp|one[- ]?time|verif|code/i.test(hintOf(el))) ||
102
+ inputs.find((el) => autocomplete(el) === 'one-time-code') ||
103
+ inputs.find((el) => /otp|one[- ]?time|verif|code/i.test(hintOf(el) || '')) ||
72
104
  null;
73
105
 
74
- const submitRe = /sign\\s?in|log\\s?in|login|continue|next|submit|log\\s?on|enter|proceed/i;
75
- const clickable = [...document.querySelectorAll('button, input[type=submit], input[type=button], [role=button], a')].filter(vis);
106
+ // The same element can match several heuristics; one role wins.
107
+ if (user && otp && user === otp) user = null;
108
+ if (user && pw && user === pw) user = null;
109
+
110
+ const submitRe = /sign\\s?in|log\\s?in|login|continue|next|submit|log\\s?on|enter|proceed|verify|confirm/i;
111
+ const clickable = [...root.querySelectorAll('button,input[type=submit],input[type=button],[role=button],a')]
112
+ .filter(vis)
113
+ .filter((el) => el.disabled !== true && el.getAttribute('aria-disabled') !== 'true');
76
114
  let submit =
77
115
  clickable.find((el) => (el.getAttribute('type') || '').toLowerCase() === 'submit') ||
78
116
  clickable.find((el) => submitRe.test((el.innerText || el.textContent || el.value || '').trim())) ||
79
- clickable.find((el) => /submit/i.test(hintOf(el))) ||
117
+ clickable.find((el) => /submit/i.test(hintOf(el) || '')) ||
80
118
  null;
81
119
 
120
+ const box = (el, hint, role) => {
121
+ el.setAttribute('data-ts-login', role);
122
+ const r = el.getBoundingClientRect();
123
+ return {
124
+ x: Math.round(r.x + r.width / 2),
125
+ y: Math.round(r.y + r.height / 2),
126
+ hint: hint || '',
127
+ role: role,
128
+ };
129
+ };
130
+
82
131
  return {
83
- username: user && vis(user) ? box(user, hintOf(user)) : null,
84
- password: pw && vis(pw) ? box(pw, hintOf(pw)) : null,
85
- submit: submit ? box(submit, (submit.innerText || submit.textContent || submit.value || '').trim().slice(0, 40)) : null,
86
- otp: otp ? box(otp, hintOf(otp)) : null,
132
+ username: user && vis(user) ? box(user, hintOf(user), 'username') : null,
133
+ password: pw && vis(pw) ? box(pw, hintOf(pw), 'password') : null,
134
+ submit: submit ? box(submit, (submit.innerText || submit.textContent || submit.value || '').trim().slice(0, 40), 'submit') : null,
135
+ otp: otp && vis(otp) ? box(otp, hintOf(otp), 'otp') : null,
136
+ modal: !!scope,
87
137
  };
88
138
  })()`;
89
139
  async function findLoginFields(page) {
140
+ const empty = {
141
+ username: null,
142
+ password: null,
143
+ submit: null,
144
+ otp: null,
145
+ modal: false,
146
+ };
90
147
  try {
91
- const value = await (0, cdp_1.evaluateJs)(page, FIND_LOGIN_FIELDS_JS);
92
- if (!value || typeof value !== 'object') {
93
- return { username: null, password: null, submit: null, otp: null };
94
- }
148
+ const value = await (0, cdp_1.evaluateJs)(page, exports.FIND_LOGIN_FIELDS_JS);
149
+ if (!value || typeof value !== 'object')
150
+ return empty;
151
+ const v = value;
95
152
  return {
96
- username: value.username ?? null,
97
- password: value.password ?? null,
98
- submit: value.submit ?? null,
99
- otp: value.otp ?? null,
153
+ username: v.username ?? null,
154
+ password: v.password ?? null,
155
+ submit: v.submit ?? null,
156
+ otp: v.otp ?? null,
157
+ modal: v.modal === true,
100
158
  };
101
159
  }
102
160
  catch {
103
- return { username: null, password: null, submit: null, otp: null };
161
+ return empty;
104
162
  }
105
163
  }
106
- /** Read the active element's value length (password fields expose .value to JS). */
107
- async function activeValue(page) {
164
+ /**
165
+ * The login phase a `waitFor {for:"loginStep"}` caller can wait for. Password
166
+ * wins over OTP (a password+OTP screen is still the password step), then OTP,
167
+ * then username; no fields means the step is finished.
168
+ */
169
+ function loginStepOf(fields) {
170
+ if (fields.password)
171
+ return 'password';
172
+ if (fields.otp)
173
+ return 'otp';
174
+ if (fields.username)
175
+ return 'username';
176
+ return 'done';
177
+ }
178
+ /** Read the active element's value (input/textarea; contenteditable is null). */ async function activeValue(page) {
108
179
  try {
109
180
  const v = await (0, cdp_1.evaluateJs)(page, `(() => { const a = document.activeElement; return a && typeof a.value === 'string' ? a.value : null; })()`);
110
181
  return typeof v === 'string' ? v : null;
@@ -113,6 +184,19 @@ async function activeValue(page) {
113
184
  return null;
114
185
  }
115
186
  }
187
+ /** Read a tagged login control's value. Never logged or returned to the agent. */
188
+ async function controlValue(page, control) {
189
+ const role = control.role;
190
+ if (!role || role === 'submit')
191
+ return null;
192
+ try {
193
+ const v = await (0, cdp_1.evaluateJs)(page, `(() => { const el = document.querySelector('[data-ts-login="${role}"]'); return el && typeof el.value === 'string' ? el.value : null; })()`);
194
+ return typeof v === 'string' ? v : null;
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
116
200
  /** CDP key/code for a printable char (local copy - avoids a browse-editor cycle). */
117
201
  function charKey(c) {
118
202
  if (/^[a-z]$/.test(c))
@@ -185,36 +269,16 @@ async function typeCharByChar(page, text) {
185
269
  await (0, cdp_1.sleep)(12);
186
270
  }
187
271
  }
188
- /** Select the focused control's current contents so the next insert replaces it. */
272
+ /** Select the focused control's current contents. */
189
273
  async function selectActive(page) {
190
274
  try {
191
- await (0, cdp_1.evaluateJs)(page, `(() => { const a = document.activeElement; if (a && a.select) { try { a.select(); return true; } catch (e) {} } if (a && a.setSelectionRange) { try { a.setSelectionRange(0, a.value ? a.value.length : 0); return true; } catch (e) {} } return false; })()`);
275
+ await (0, cdp_1.evaluateJs)(page, `(() => { const a = document.activeElement; if (!a) return false; if (a.select) { try { a.select(); return true; } catch (e) {} } if (a.setSelectionRange && typeof a.value === 'string') { try { a.setSelectionRange(0, a.value.length); return true; } catch (e) {} } if (a.isContentEditable) { const sel = window.getSelection(); if (sel) { const r = document.createRange(); r.selectNodeContents(a); sel.removeAllRanges(); sel.addRange(r); return true; } } return false; })()`);
192
276
  }
193
277
  catch {
194
278
  /* best-effort */
195
279
  }
196
280
  }
197
- /**
198
- * Type text into the currently focused control and VERIFY it landed.
199
- * `Input.insertText` first (fires a proper `input` event for controlled
200
- * components); on a shortfall, retry character-by-character via key events.
201
- * Returns the value actually observed in the field.
202
- */
203
- async function typeTextVerified(page, text) {
204
- const before = (await activeValue(page)) ?? '';
205
- // Insert path: replaces any current selection.
206
- try {
207
- await (0, cdp_1.cdpSend)(page, 'Input.insertText', { text });
208
- }
209
- catch {
210
- /* fall through to key events */
211
- }
212
- await (0, cdp_1.sleep)(60);
213
- let after = (await activeValue(page)) ?? '';
214
- if (after.length >= before.length + text.length)
215
- return true;
216
- // Fallback: clear, then key-event each character.
217
- await selectActive(page);
281
+ async function pressBackspace(page) {
218
282
  try {
219
283
  await (0, cdp_1.cdpSend)(page, 'Input.dispatchKeyEvent', {
220
284
  type: 'keyDown',
@@ -234,17 +298,29 @@ async function typeTextVerified(page, text) {
234
298
  catch {
235
299
  /* best-effort */
236
300
  }
237
- await (0, cdp_1.sleep)(40);
238
- await typeCharByChar(page, text);
239
- await (0, cdp_1.sleep)(60);
240
- after = (await activeValue(page)) ?? '';
241
- return after.length >= text.length || after.length > before.length;
242
301
  }
243
- /**
244
- * Fill a specific control (by click coords) with a value and verify it landed.
245
- * Clears the field first (select-all then insert / backspace).
246
- */
247
- async function fillFieldVerified(page, control, value) {
302
+ /** Set a tagged input's value through the prototype setter (React-safe). */
303
+ async function setNativeControlValue(page, role, value) {
304
+ try {
305
+ const ok = await (0, cdp_1.evaluateJs)(page, `(() => {
306
+ const el = document.querySelector('[data-ts-login="${role}"]');
307
+ if (!el || typeof el.value !== 'string') return false;
308
+ const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
309
+ const desc = Object.getOwnPropertyDescriptor(proto, 'value');
310
+ if (!desc || !desc.set) return false;
311
+ desc.set.call(el, ${JSON.stringify(value)});
312
+ el.dispatchEvent(new Event('input', { bubbles: true }));
313
+ el.dispatchEvent(new Event('change', { bubbles: true }));
314
+ return true;
315
+ })()`);
316
+ return ok === true;
317
+ }
318
+ catch {
319
+ return false;
320
+ }
321
+ }
322
+ /** Focus the exact tagged control (click + JS focus, not a coordinate guess). */
323
+ async function focusControl(page, control) {
248
324
  try {
249
325
  await (0, cdp_1.cdpSend)(page, 'Input.dispatchMouseEvent', {
250
326
  type: 'mousePressed',
@@ -260,41 +336,167 @@ async function fillFieldVerified(page, control, value) {
260
336
  button: 'left',
261
337
  clickCount: 1,
262
338
  });
263
- await (0, cdp_1.sleep)(80);
264
- // Focus the element directly too (a click can land on a wrapping label).
265
- await (0, cdp_1.evaluateJs)(page, `(() => { const el = document.elementFromPoint(${control.x}, ${control.y}); const f = el && (el.closest ? el.closest('input,textarea') : null); if (f) { try { f.focus(); } catch (e) {} } return !!f; })()`);
266
- await selectActive(page);
267
- await (0, cdp_1.cdpSend)(page, 'Input.insertText', { text: value });
268
339
  await (0, cdp_1.sleep)(60);
269
- let current = (await activeValue(page)) ?? '';
270
- if (current === value || current.length === value.length)
340
+ const role = control.role;
341
+ if (!role)
271
342
  return true;
272
- // Fallback: clear + per-char key events.
343
+ const ok = await (0, cdp_1.evaluateJs)(page, `(() => { const el = document.querySelector('[data-ts-login="${role}"]'); if (!el) return false; try { el.focus(); } catch (e) {} return true; })()`);
344
+ return ok === true;
345
+ }
346
+ catch {
347
+ return false;
348
+ }
349
+ }
350
+ /**
351
+ * Clear a login control and VERIFY it is empty. Tries selection+Backspace
352
+ * first, then the native value setter, up to 3 times - because Chrome/site
353
+ * autofill can re-populate a field right after it is cleared.
354
+ */
355
+ async function clearControl(page, control) {
356
+ const role = control.role;
357
+ if (!role || role === 'submit')
358
+ return false;
359
+ for (let attempt = 0; attempt < 3; attempt++) {
360
+ await focusControl(page, control);
273
361
  await selectActive(page);
274
- await (0, cdp_1.cdpSend)(page, 'Input.dispatchKeyEvent', {
275
- type: 'keyDown',
276
- key: 'Backspace',
277
- code: 'Backspace',
278
- windowsVirtualKeyCode: 8,
279
- nativeVirtualKeyCode: 8,
280
- });
281
- await (0, cdp_1.cdpSend)(page, 'Input.dispatchKeyEvent', {
282
- type: 'keyUp',
283
- key: 'Backspace',
284
- code: 'Backspace',
285
- windowsVirtualKeyCode: 8,
286
- nativeVirtualKeyCode: 8,
287
- });
362
+ await pressBackspace(page);
363
+ await (0, cdp_1.sleep)(50);
364
+ let current = await controlValue(page, control);
365
+ if (current === '')
366
+ return true;
367
+ await setNativeControlValue(page, role, '');
368
+ await (0, cdp_1.sleep)(50);
369
+ current = await controlValue(page, control);
370
+ if (current === '')
371
+ return true;
372
+ await (0, cdp_1.sleep)(80);
373
+ }
374
+ return (await controlValue(page, control)) === '';
375
+ }
376
+ /** Clear the currently focused element (generic type path). */
377
+ async function clearActiveField(page) {
378
+ for (let attempt = 0; attempt < 3; attempt++) {
379
+ await selectActive(page);
380
+ await pressBackspace(page);
288
381
  await (0, cdp_1.sleep)(40);
289
- await typeCharByChar(page, value);
290
- await (0, cdp_1.sleep)(60);
291
- current = (await activeValue(page)) ?? '';
292
- return current.length === value.length;
382
+ let current = await activeValue(page);
383
+ if (current === '')
384
+ return true;
385
+ if (current === null) {
386
+ // Contenteditable: the selection path above may have selected it; one
387
+ // more Backspace removes the selection.
388
+ await pressBackspace(page);
389
+ await (0, cdp_1.sleep)(40);
390
+ if ((await activeValue(page)) === null)
391
+ return true;
392
+ }
393
+ try {
394
+ await (0, cdp_1.evaluateJs)(page, `(() => {
395
+ const a = document.activeElement;
396
+ if (!a || typeof a.value !== 'string') return false;
397
+ const proto = a.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
398
+ const desc = Object.getOwnPropertyDescriptor(proto, 'value');
399
+ if (!desc || !desc.set) return false;
400
+ desc.set.call(a, '');
401
+ a.dispatchEvent(new Event('input', { bubbles: true }));
402
+ a.dispatchEvent(new Event('change', { bubbles: true }));
403
+ return true;
404
+ })()`);
405
+ }
406
+ catch {
407
+ /* best-effort */
408
+ }
409
+ await (0, cdp_1.sleep)(40);
410
+ current = await activeValue(page);
411
+ if (current === '' || current === null)
412
+ return true;
413
+ }
414
+ return false;
415
+ }
416
+ /**
417
+ * Type text into the currently focused control and VERIFY it landed.
418
+ * With `clearFirst`, the field is emptied and verified empty BEFORE the insert.
419
+ * Success is an EXACT value match (never a length comparison).
420
+ */
421
+ async function typeTextVerified(page, text, opts) {
422
+ const before = await activeValue(page);
423
+ const clearFirst = opts?.clearFirst === true;
424
+ if (clearFirst && !(await clearActiveField(page)))
425
+ return false;
426
+ try {
427
+ await (0, cdp_1.cdpSend)(page, 'Input.insertText', { text });
293
428
  }
294
429
  catch {
430
+ /* fall through to key events */
431
+ }
432
+ await (0, cdp_1.sleep)(80);
433
+ let after = await activeValue(page);
434
+ if (after === null)
435
+ return true; // contenteditable: no readable value
436
+ const expected = clearFirst ? text : `${before ?? ''}${text}`;
437
+ if (after === expected)
438
+ return true;
439
+ if (clearFirst && after === text)
440
+ return true;
441
+ // Generic `type` may insert at a cursor in the middle of an existing value:
442
+ // accept a value that grew by at least the typed text (the old semantics).
443
+ if (!clearFirst && after.length >= (before?.length ?? 0) + text.length) {
444
+ return true;
445
+ }
446
+ // Fallback: clear, then key-event each character.
447
+ if (!(await clearActiveField(page)))
448
+ return false;
449
+ await typeCharByChar(page, text);
450
+ await (0, cdp_1.sleep)(100);
451
+ after = await activeValue(page);
452
+ return after === text || after === null;
453
+ }
454
+ /**
455
+ * Fill a specific login control with a value and verify it EXACTLY.
456
+ *
457
+ * ALWAYS clears first (the old version only selected), verifies the field is
458
+ * empty, inserts, and compares the observed value to the expected one. If the
459
+ * site/autofill fights the insert, it retries with key events and finally with
460
+ * the native value setter. Returns false when the value could not be made to
461
+ * stick.
462
+ */
463
+ async function fillFieldVerified(page, control, value) {
464
+ if (!control.role || control.role === 'submit')
295
465
  return false;
466
+ // 1. Read (for diagnostics only), clear, verify EMPTY.
467
+ if (!(await clearControl(page, control)))
468
+ return false;
469
+ // 2. Native insert (fires the real input event React observes).
470
+ await focusControl(page, control);
471
+ try {
472
+ await (0, cdp_1.cdpSend)(page, 'Input.insertText', { text: value });
296
473
  }
474
+ catch {
475
+ /* fall through */
476
+ }
477
+ await (0, cdp_1.sleep)(80);
478
+ let current = await controlValue(page, control);
479
+ if (current === value)
480
+ return true;
481
+ // 3. Clear + per-character key events.
482
+ if (!(await clearControl(page, control)))
483
+ return false;
484
+ await focusControl(page, control);
485
+ await typeCharByChar(page, value);
486
+ await (0, cdp_1.sleep)(100);
487
+ current = await controlValue(page, control);
488
+ if (current === value)
489
+ return true;
490
+ // 4. Last resort: native setter + input/change (controlled components that
491
+ // reject synthetic key events still accept this).
492
+ if (!(await clearControl(page, control)))
493
+ return false;
494
+ await setNativeControlValue(page, control.role, value);
495
+ await (0, cdp_1.sleep)(80);
496
+ current = await controlValue(page, control);
497
+ return current === value;
297
498
  }
499
+ /** Click a tagged control by its coordinates. */
298
500
  async function clickControl(page, control) {
299
501
  if (!control)
300
502
  return false;
@@ -336,125 +538,430 @@ async function pressEnter(page) {
336
538
  nativeVirtualKeyCode: 13,
337
539
  });
338
540
  }
339
- /** Poll for a control to appear, up to `timeoutMs`. */
340
- async function waitForPasswordField(page, timeoutMs) {
341
- const deadline = Date.now() + timeoutMs;
541
+ /**
542
+ * Read a visible validation/credential error. Scoped to the top modal when one
543
+ * is open, so the page's unrelated "error" banner is not mistaken for a login
544
+ * rejection. Never returns a field value.
545
+ */
546
+ async function visibleLoginError(page) {
547
+ try {
548
+ const value = await (0, cdp_1.evaluateJs)(page, `(() => {
549
+ const vis = (el) => {
550
+ if (!el) return false;
551
+ const r = el.getBoundingClientRect();
552
+ if (r.width === 0 || r.height === 0) return false;
553
+ const cs = getComputedStyle(el);
554
+ return cs.visibility !== 'hidden' && cs.display !== 'none';
555
+ };
556
+ const dialogs = [...document.querySelectorAll('dialog[open],[role="dialog"],[aria-modal="true"]')].filter(vis);
557
+ const scope = dialogs.length ? dialogs[dialogs.length - 1] : document;
558
+ const out = [];
559
+ const add = (t) => {
560
+ const text = String(t == null ? '' : t).replace(/\\s+/g, ' ').trim().slice(0, 240);
561
+ if (text.length < 3) return;
562
+ if (/^(error|errors|warning|warnings)$/i.test(text)) return;
563
+ if (!out.includes(text) && out.length < 3) out.push(text);
564
+ };
565
+ for (const el of [...scope.querySelectorAll('[role="alert"],[aria-invalid="true"]')].filter(vis).slice(0, 4)) {
566
+ add(el.innerText || el.textContent || el.getAttribute('aria-label') || '');
567
+ }
568
+ const sels = '[class*="field-error"],[class*="form-error"],[class*="validation-error"],[class*="error-message"],[class*="invalid-feedback"],[class*="error"],[class*="danger"]';
569
+ for (const el of [...scope.querySelectorAll(sels)].filter(vis).slice(0, 12)) {
570
+ add(el.innerText || el.textContent || '');
571
+ }
572
+ return out.join(' | ');
573
+ })()`);
574
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
575
+ }
576
+ catch {
577
+ return null;
578
+ }
579
+ }
580
+ /** Poll for the next login step after a submit. */
581
+ async function waitForLoginChange(page, opts) {
582
+ const deadline = Date.now() + opts.timeoutMs;
583
+ let startUrl = '';
584
+ try {
585
+ startUrl = String(await (0, cdp_1.evaluateJs)(page, 'location.href'));
586
+ }
587
+ catch {
588
+ startUrl = '';
589
+ }
590
+ let last = await findLoginFields(page);
591
+ while (Date.now() < deadline) {
592
+ await (0, cdp_1.sleep)(250);
593
+ const fields = await findLoginFields(page);
594
+ let url = '';
595
+ try {
596
+ url = String(await (0, cdp_1.evaluateJs)(page, 'location.href'));
597
+ }
598
+ catch {
599
+ url = '';
600
+ }
601
+ // The dialog that held the form closed: the sign-in was accepted (or the
602
+ // modal was dismissed). Do not fall through to a footer email field.
603
+ if (opts.wasModal && !fields.modal && !fields.password && !fields.otp) {
604
+ return { fields, closed: true };
605
+ }
606
+ if (url &&
607
+ startUrl &&
608
+ url !== startUrl &&
609
+ !fields.password &&
610
+ !fields.otp) {
611
+ return { fields, closed: true };
612
+ }
613
+ if (fields.password || fields.otp)
614
+ return { fields, closed: false };
615
+ if (fields.username && !last.username)
616
+ return { fields, closed: false };
617
+ if (!fields.username && !fields.password && !fields.otp) {
618
+ return { fields, closed: true };
619
+ }
620
+ last = fields;
621
+ }
622
+ return { fields: last, closed: false };
623
+ }
624
+ /** Fill a role, re-scanning once if the modal re-rendered during the clear. */
625
+ async function fillRole(page, role, value) {
342
626
  let fields = await findLoginFields(page);
343
- while (!fields.password && Date.now() < deadline) {
344
- await (0, cdp_1.sleep)(300);
345
- fields = await findLoginFields(page);
627
+ let control = fields[role];
628
+ if (!control)
629
+ return false;
630
+ if (await fillFieldVerified(page, control, value))
631
+ return true;
632
+ // A controlled modal can replace the input while clearControl is running;
633
+ // the tagged element then no longer exists. Re-scan and try the new one.
634
+ fields = await findLoginFields(page);
635
+ control = fields[role];
636
+ return control ? await fillFieldVerified(page, control, value) : false;
637
+ }
638
+ /** Submit the current step: click its tagged control, else Enter in a field. */
639
+ async function submitLogin(page) {
640
+ // Re-scan before clicking: the control coordinates may be stale after a
641
+ // React re-render triggered by the value change.
642
+ const fields = await findLoginFields(page);
643
+ if (fields.submit) {
644
+ await clickControl(page, fields.submit);
645
+ return true;
346
646
  }
347
- return fields;
647
+ const target = fields.password ?? fields.username ?? fields.otp;
648
+ if (target) {
649
+ await focusControl(page, target);
650
+ await pressEnter(page);
651
+ return true;
652
+ }
653
+ return false;
348
654
  }
349
- /**
350
- * Deterministic login. Fills whatever credentials are present, submits, and
351
- * handles the common two-step (email -> Continue -> password) flow. Returns a
352
- * structured result; it NEVER throws and NEVER logs a secret.
353
- */
354
- async function performLogin(page, opts) {
355
- const submit = opts.submit !== false;
356
- const stepTimeoutMs = opts.stepTimeoutMs ?? 6000;
357
- const maxSteps = opts.maxSteps ?? 2;
358
- const result = {
655
+ function baseResult() {
656
+ return {
359
657
  ok: false,
360
658
  usernameFilled: false,
361
659
  passwordFilled: false,
362
660
  otpFilled: false,
363
661
  submitted: false,
364
662
  twoStep: false,
663
+ phase: 'unknown',
664
+ mfaRequired: false,
665
+ };
666
+ }
667
+ function mfaResult(result, detail) {
668
+ return {
669
+ ...result,
670
+ ok: false,
671
+ phase: 'otp',
672
+ mfaRequired: true,
673
+ error: 'MFA_REQUIRED',
674
+ detail,
365
675
  };
676
+ }
677
+ /**
678
+ * Deterministic login. Clears and fills every credential field, submits, and
679
+ * follows the real multi-step flow (including a modal):
680
+ *
681
+ * email → password
682
+ * email → OTP
683
+ * email → password → OTP
684
+ * password → OTP
685
+ * OTP only
686
+ *
687
+ * It NEVER reports ok while an OTP field is visible. It returns the visible
688
+ * site error on a rejection and a `phase` on every outcome, so the agent knows
689
+ * where it stopped instead of improvising with raw `evaluate`.
690
+ */
691
+ async function performLogin(page, opts) {
692
+ const submit = opts.submit !== false;
693
+ const stepTimeoutMs = opts.stepTimeoutMs ?? 6000;
694
+ const maxSteps = opts.maxSteps ?? 4;
695
+ const result = baseResult();
366
696
  let fields = await findLoginFields(page);
367
- // OTP challenge on screen. With a supplied code (a resumed ask_human
368
- // answer) fill and submit it; otherwise pause for a human.
369
- if (fields.otp && !fields.password) {
370
- if (opts.otp && submit) {
371
- result.otpFilled = await fillFieldVerified(page, fields.otp, opts.otp);
372
- if (!result.otpFilled) {
697
+ if (!fields.username && !fields.password && !fields.otp) {
698
+ const err = await visibleLoginError(page);
699
+ return {
700
+ ...result,
701
+ error: 'LOGIN_FIELD_NOT_FOUND',
702
+ ...(err ? { visibleError: err } : {}),
703
+ detail: 'No username, password or one-time-code field was found on this page (including any open modal).',
704
+ };
705
+ }
706
+ for (let step = 0; step < maxSteps; step++) {
707
+ const visibleError = await visibleLoginError(page);
708
+ if (visibleError) {
709
+ return {
710
+ ...result,
711
+ error: 'LOGIN_REJECTED',
712
+ visibleError,
713
+ detail: `The site rejected the sign-in: ${visibleError}`,
714
+ };
715
+ }
716
+ fields = await findLoginFields(page);
717
+ // ── OTP-only challenge ────────────────────────────────────────────────
718
+ if (fields.otp && !fields.password && !fields.username) {
719
+ result.phase = 'otp';
720
+ result.mfaRequired = true;
721
+ if (!opts.otp) {
722
+ return mfaResult(result, 'A one-time-code field is on screen. Read the code with wait_for_qa_email (fresh/reuse) or ask the human with ask_human, then END YOUR TURN; call login again with {otp} after the resume.');
723
+ }
724
+ const filled = await fillRole(page, 'otp', opts.otp);
725
+ if (!filled) {
373
726
  return {
374
727
  ...result,
375
728
  error: 'CREDENTIAL_FILL_FAILED',
729
+ phase: 'otp',
376
730
  detail: 'The one-time-code field did not accept the value.',
377
731
  };
378
732
  }
379
- result.submitted = (await clickControl(page, fields.submit)) || false;
380
- if (!result.submitted) {
381
- await pressEnter(page);
382
- result.submitted = true;
733
+ result.otpFilled = true;
734
+ result.mfaRequired = false;
735
+ if (!submit) {
736
+ return {
737
+ ...result,
738
+ ok: true,
739
+ phase: 'otp',
740
+ detail: 'Code filled; submit was disabled.',
741
+ };
742
+ }
743
+ const submitted = await submitLogin(page);
744
+ result.submitted = submitted;
745
+ if (!submitted) {
746
+ return {
747
+ ...result,
748
+ error: 'LOGIN_FIELD_NOT_FOUND',
749
+ detail: 'No code submit control was found.',
750
+ };
751
+ }
752
+ const next = await waitForLoginChange(page, {
753
+ timeoutMs: stepTimeoutMs,
754
+ wasModal: fields.modal,
755
+ });
756
+ const nextError = await visibleLoginError(page);
757
+ const advancedOtp = next.closed ||
758
+ (!next.fields.otp && !next.fields.password && !next.fields.username);
759
+ if (nextError && !advancedOtp) {
760
+ return {
761
+ ...result,
762
+ error: 'LOGIN_REJECTED',
763
+ phase: 'otp',
764
+ visibleError: nextError,
765
+ detail: `The site rejected the code: ${nextError}`,
766
+ };
767
+ }
768
+ if (next.fields.otp) {
769
+ return mfaResult(result, 'Another one-time-code challenge is on screen. Get the new code and call login again with {otp}.');
383
770
  }
384
- result.ok = true;
385
- return result;
771
+ return {
772
+ ...result,
773
+ ok: true,
774
+ phase: 'submitted',
775
+ detail: 'Code submitted. Verify the destination page.',
776
+ };
386
777
  }
387
- return {
388
- ...result,
389
- error: 'MFA_REQUIRED',
390
- detail: 'A one-time-code field is on screen. Ask the human for the code with ' +
391
- 'ask_human (it pauses this run), then END YOUR TURN. Their answer ' +
392
- 'resumes you; call login again with the code in `otp`.',
393
- };
394
- }
395
- if (!fields.username && !fields.password) {
396
- return {
397
- ...result,
398
- error: 'LOGIN_FIELD_NOT_FOUND',
399
- detail: 'No username or password field found on this page.',
400
- };
401
- }
402
- for (let step = 1; step <= maxSteps; step++) {
403
- // Fill username on the first step that offers it.
404
- if (opts.username && fields.username && !result.usernameFilled) {
405
- result.usernameFilled = await fillFieldVerified(page, fields.username, opts.username);
778
+ // ── Username / email step ─────────────────────────────────────────────
779
+ if (fields.username && opts.username && !result.usernameFilled) {
780
+ const filled = await fillRole(page, 'username', opts.username);
781
+ if (!filled) {
782
+ const err = await visibleLoginError(page);
783
+ return {
784
+ ...result,
785
+ error: 'CREDENTIAL_FILL_FAILED',
786
+ phase: 'username',
787
+ ...(err ? { visibleError: err } : {}),
788
+ detail: 'The username/email field did not accept the value (it may be autofilled, readonly or a custom widget). The field was cleared and retyped.',
789
+ };
790
+ }
791
+ result.usernameFilled = true;
792
+ result.phase = 'username';
406
793
  }
407
- // Password present -> fill it and submit. Done.
794
+ // ── Password step ─────────────────────────────────────────────────────
408
795
  if (fields.password) {
409
- result.passwordFilled = await fillFieldVerified(page, fields.password, opts.password);
410
- if (!result.passwordFilled) {
796
+ if (!opts.password) {
797
+ return {
798
+ ...result,
799
+ error: 'CREDENTIAL_FILL_FAILED',
800
+ phase: 'password',
801
+ detail: 'A password field is on screen but no password was supplied. Pass password, or use the email/OTP-only flow when the site sends a code.',
802
+ };
803
+ }
804
+ const filled = await fillRole(page, 'password', opts.password);
805
+ if (!filled) {
806
+ const err = await visibleLoginError(page);
411
807
  return {
412
808
  ...result,
413
809
  error: 'CREDENTIAL_FILL_FAILED',
414
- detail: 'The password field did not accept the value.',
810
+ phase: 'password',
811
+ ...(err ? { visibleError: err } : {}),
812
+ detail: 'The password field did not accept the value. It was cleared and retyped, but the value did not stick - usually an autofilled field or a custom widget.',
813
+ };
814
+ }
815
+ result.passwordFilled = true;
816
+ result.phase = 'password';
817
+ if (!submit) {
818
+ return {
819
+ ...result,
820
+ ok: true,
821
+ phase: 'password',
822
+ detail: 'Credentials filled; submit was disabled.',
823
+ };
824
+ }
825
+ const submitted = await submitLogin(page);
826
+ result.submitted = submitted;
827
+ if (!submitted) {
828
+ return {
829
+ ...result,
830
+ error: 'LOGIN_FIELD_NOT_FOUND',
831
+ detail: 'No submit/sign-in control was found.',
415
832
  };
416
833
  }
417
- if (submit) {
418
- result.submitted = (await clickControl(page, fields.submit)) || false;
419
- if (!result.submitted) {
420
- await pressEnter(page);
421
- result.submitted = true;
834
+ const next = await waitForLoginChange(page, {
835
+ timeoutMs: stepTimeoutMs,
836
+ wasModal: fields.modal,
837
+ });
838
+ const nextError = await visibleLoginError(page);
839
+ const advancedPassword = next.closed ||
840
+ !!next.fields.otp ||
841
+ (!next.fields.password && !next.fields.username);
842
+ if (nextError && !advancedPassword) {
843
+ return {
844
+ ...result,
845
+ error: 'LOGIN_REJECTED',
846
+ phase: 'password',
847
+ visibleError: nextError,
848
+ detail: `The site rejected the sign-in: ${nextError}`,
849
+ };
850
+ }
851
+ if (next.fields.otp) {
852
+ if (!opts.otp) {
853
+ return mfaResult(result, 'The password was submitted and a one-time-code challenge appeared. Read the code with wait_for_qa_email (fresh/reuse) or ask the human with ask_human, then END YOUR TURN; call login again with {otp} after the resume.');
422
854
  }
855
+ fields = next.fields;
856
+ continue;
857
+ }
858
+ if (next.fields.password || next.fields.username) {
859
+ return {
860
+ ...result,
861
+ error: 'LOGIN_REJECTED',
862
+ phase: 'password',
863
+ detail: 'The password step did not advance and no visible error was found. Check the page manually - the site may require a different field or a captcha.',
864
+ };
423
865
  }
424
- result.ok = true;
425
- return result;
426
- }
427
- // No password yet: this is a multi-step form. Submit step 1 and wait.
428
- result.twoStep = true;
429
- if (!submit) {
430
866
  return {
431
867
  ...result,
432
- error: 'LOGIN_FIELD_NOT_FOUND',
433
- detail: 'Password field is not present and submit was disabled.',
868
+ ok: true,
869
+ phase: 'submitted',
870
+ detail: 'Submitted. Verify the destination page (the sign-in dialog closed or the URL changed).',
434
871
  };
435
872
  }
436
- await clickControl(page, fields.submit);
437
- await pressEnter(page).catch(() => undefined);
438
- fields = await waitForPasswordField(page, stepTimeoutMs);
439
- if (!fields.password) {
440
- if (fields.otp) {
873
+ // ── Email-only step: submit and wait for password/OTP ─────────────────
874
+ if (fields.username && !fields.password) {
875
+ if (!submit) {
876
+ return {
877
+ ...result,
878
+ ok: true,
879
+ phase: 'username',
880
+ detail: 'Email filled; submit was disabled.',
881
+ };
882
+ }
883
+ const submitted = await submitLogin(page);
884
+ result.submitted = submitted;
885
+ if (!submitted) {
441
886
  return {
442
887
  ...result,
443
- error: 'MFA_REQUIRED',
444
- detail: 'A one-time-code field appeared; a human must complete it.',
888
+ error: 'LOGIN_FIELD_NOT_FOUND',
889
+ detail: 'No continue/submit control was found.',
890
+ };
891
+ }
892
+ result.twoStep = true;
893
+ const next = await waitForLoginChange(page, {
894
+ timeoutMs: stepTimeoutMs,
895
+ wasModal: fields.modal,
896
+ });
897
+ const nextError = await visibleLoginError(page);
898
+ const advancedEmail = next.closed ||
899
+ !!next.fields.password ||
900
+ !!next.fields.otp ||
901
+ (!!next.fields.username &&
902
+ !!fields.username &&
903
+ next.fields.username.hint !== fields.username.hint) ||
904
+ (!next.fields.username && !next.fields.password && !next.fields.otp);
905
+ if (nextError && !advancedEmail) {
906
+ return {
907
+ ...result,
908
+ error: 'LOGIN_REJECTED',
909
+ phase: 'username',
910
+ visibleError: nextError,
911
+ detail: `The site rejected the email: ${nextError}`,
912
+ };
913
+ }
914
+ if (next.fields.username &&
915
+ fields.username &&
916
+ next.fields.username.hint === fields.username.hint &&
917
+ !next.fields.password &&
918
+ !next.fields.otp) {
919
+ // Submitting the email again would double-submit a form that simply did
920
+ // not advance (captcha, disabled button, slow validation).
921
+ return {
922
+ ...result,
923
+ error: 'LOGIN_REJECTED',
924
+ phase: 'username',
925
+ detail: 'The email step did not advance and no visible error was found. The site may require a captcha, a different Continue control, or a moment longer - take a snapshot and check before retrying.',
926
+ };
927
+ }
928
+ if (!next.fields.username && !next.fields.password && !next.fields.otp) {
929
+ return {
930
+ ...result,
931
+ ok: true,
932
+ phase: 'submitted',
933
+ detail: 'Submitted. Verify the destination page; if a code field appears, call login again with {otp}.',
934
+ };
935
+ }
936
+ fields = next.fields;
937
+ continue;
938
+ }
939
+ // ── Nothing editable left ─────────────────────────────────────────────
940
+ if (!fields.username && !fields.password && !fields.otp) {
941
+ if (result.submitted) {
942
+ return {
943
+ ...result,
944
+ ok: true,
945
+ phase: 'submitted',
946
+ detail: 'Submitted. Verify the destination page.',
445
947
  };
446
948
  }
447
949
  return {
448
950
  ...result,
449
951
  error: 'LOGIN_FIELD_NOT_FOUND',
450
- detail: 'The password field never appeared after submitting the username.',
952
+ detail: 'No editable login field was found.',
451
953
  };
452
954
  }
955
+ // An OTP is visible together with a credential field and no code was
956
+ // supplied - do not loop; the human/code is required next.
957
+ if (fields.otp && !opts.otp) {
958
+ return mfaResult(result, 'A one-time-code field is on screen. Read the code with wait_for_qa_email (fresh/reuse) or ask the human with ask_human, then END YOUR TURN; call login again with {otp} after the resume.');
959
+ }
453
960
  }
454
961
  return {
455
962
  ...result,
456
963
  error: 'LOGIN_FIELD_NOT_FOUND',
457
- detail: 'Login did not reach a password field within the step budget.',
964
+ detail: 'Login did not settle within the step budget. Take a snapshot and inspect the dialog/page before retrying.',
458
965
  };
459
966
  }
460
967
  /** True when the currently focused element is a password/credential control. */