@monoes/monobrowse 1.0.22 → 1.0.25

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 (38) hide show
  1. package/dist/src/__tests__/browse-concurrency.test.js +94 -1
  2. package/dist/src/__tests__/browse-concurrency.test.js.map +1 -1
  3. package/dist/src/__tests__/browser-launch.test.js +137 -7
  4. package/dist/src/__tests__/browser-launch.test.js.map +1 -1
  5. package/dist/src/__tests__/session-discovery.test.js +81 -1
  6. package/dist/src/__tests__/session-discovery.test.js.map +1 -1
  7. package/dist/src/browser/browser.d.ts.map +1 -1
  8. package/dist/src/browser/browser.js +34 -13
  9. package/dist/src/browser/browser.js.map +1 -1
  10. package/dist/src/browser/ref-cache.d.ts +6 -0
  11. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  12. package/dist/src/browser/ref-cache.js +4 -0
  13. package/dist/src/browser/ref-cache.js.map +1 -1
  14. package/dist/src/cli/attention-wall.d.ts +12 -0
  15. package/dist/src/cli/attention-wall.d.ts.map +1 -0
  16. package/dist/src/cli/attention-wall.js +166 -0
  17. package/dist/src/cli/attention-wall.js.map +1 -0
  18. package/dist/src/cli/commands-session.d.ts.map +1 -1
  19. package/dist/src/cli/commands-session.js +24 -11
  20. package/dist/src/cli/commands-session.js.map +1 -1
  21. package/dist/src/cli/commands.d.ts.map +1 -1
  22. package/dist/src/cli/commands.js +21 -8
  23. package/dist/src/cli/commands.js.map +1 -1
  24. package/dist/src/cli/session.d.ts +17 -3
  25. package/dist/src/cli/session.d.ts.map +1 -1
  26. package/dist/src/cli/session.js +72 -162
  27. package/dist/src/cli/session.js.map +1 -1
  28. package/dist/tsconfig.tsbuildinfo +1 -1
  29. package/package.json +1 -1
  30. package/src/__tests__/browse-concurrency.test.ts +106 -1
  31. package/src/__tests__/browser-launch.test.ts +142 -7
  32. package/src/__tests__/session-discovery.test.ts +112 -2
  33. package/src/browser/browser.ts +32 -11
  34. package/src/browser/ref-cache.ts +16 -1
  35. package/src/cli/attention-wall.ts +202 -0
  36. package/src/cli/commands-session.ts +25 -12
  37. package/src/cli/commands.ts +22 -8
  38. package/src/cli/session.ts +78 -198
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Login / CAPTCHA walls — how `open` notices one, and the round trip to a
3
+ * visible browser window that lets a person get past it before the session
4
+ * carries on headless with the cookies and localStorage they earned.
5
+ *
6
+ * Split out of session.ts, which keeps the session runtime itself.
7
+ */
8
+
9
+ import { randomUUID } from 'node:crypto';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import type { CdpClient } from '../index.js';
13
+ import { output } from './output.js';
14
+ import { getBrowser, session } from './session.js';
15
+
16
+ // URL patterns that signal an unambiguous login/auth wall.
17
+ // Excludes 'auth', 'oauth', 'sso', 'saml' — their callback/ACS/token paths
18
+ // (/auth/callback, /sso/callback, /saml/acs) are completion endpoints, not walls.
19
+ // DOM detection (password field, CAPTCHA widgets) handles SSO/SAML login pages.
20
+ const ATTENTION_URL_RE =
21
+ /\/(login|log-in|signin|sign-in|captcha|mfa|2fa|account\/login|accounts\/login|session\/new|users\/sign_in)(?:[/?#]|$)/i;
22
+
23
+ export async function detectAttentionNeeded(
24
+ client: CdpClient,
25
+ sessionId: string,
26
+ url: string,
27
+ ): Promise<'login' | 'captcha' | null> {
28
+ if (ATTENTION_URL_RE.test(url)) return 'login';
29
+ try {
30
+ const hasPassword = await client
31
+ .send<{ result: { value: boolean } }>(
32
+ 'Runtime.evaluate',
33
+ {
34
+ expression: '!!document.querySelector("input[type=password]")',
35
+ returnByValue: true,
36
+ },
37
+ sessionId,
38
+ )
39
+ .then((r) => r.result?.value === true)
40
+ .catch(() => false);
41
+ if (hasPassword) return 'login';
42
+
43
+ const hasCaptcha = await client
44
+ .send<{ result: { value: boolean } }>(
45
+ 'Runtime.evaluate',
46
+ {
47
+ expression:
48
+ '!!(document.querySelector("iframe[src*=recaptcha]") || document.querySelector("iframe[src*=hcaptcha]") || document.querySelector(".g-recaptcha") || document.querySelector(".h-captcha") || document.querySelector("[data-sitekey]"))',
49
+ returnByValue: true,
50
+ },
51
+ sessionId,
52
+ )
53
+ .then((r) => r.result?.value === true)
54
+ .catch(() => false);
55
+ if (hasCaptcha) return 'captcha';
56
+ } catch {
57
+ /* ignore CDP errors */
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export function waitForEnter(): Promise<void> {
63
+ if (!process.stdin.isTTY) {
64
+ // Non-interactive context (MCP server, piped input, CI) — cannot safely read stdin.
65
+ // Auto-continue after a short grace period so automation isn't blocked.
66
+ output.printWarning(
67
+ 'Non-interactive mode: auto-continuing in 30s. Switch to headed manually if needed.',
68
+ );
69
+ return new Promise((resolve) => setTimeout(resolve, 30_000));
70
+ }
71
+ return new Promise((resolve) => {
72
+ process.stdin.resume();
73
+ process.stdin.setEncoding('utf8');
74
+ const onData = () => {
75
+ process.stdin.pause();
76
+ process.stdin.off('data', onData);
77
+ resolve();
78
+ };
79
+ process.stdin.once('data', onData);
80
+ });
81
+ }
82
+
83
+ export async function switchToHeaded(url: string, port: number): Promise<void> {
84
+ const browser = await getBrowser();
85
+
86
+ // Snapshot cookies from current headless session before closing
87
+ let savedCookies: unknown[] = [];
88
+ if (session.client && session.sessionId) {
89
+ try {
90
+ savedCookies = await browser.getCookies(session.client, session.sessionId);
91
+ } catch {
92
+ /* ignore */
93
+ }
94
+ browser.teardownRouteInterception(session.sessionId);
95
+ browser.stopRequestCapture(session.sessionId);
96
+ browser.teardownDialogHandling(session.sessionId);
97
+ browser.teardownConsoleCapture(session.sessionId);
98
+ session.client.close();
99
+ session.client = null;
100
+ session.sessionId = '';
101
+ session.parentSessionId = '';
102
+ session.targetId = '';
103
+ }
104
+
105
+ // Kernel-assigned free port and a profile of its own, so this temporary
106
+ // headed window cannot collide with any other session (#318) — a
107
+ // neighbouring port (port + 10) could well be another session's.
108
+ let headedPort = 0;
109
+ try {
110
+ headedPort = await browser.launchBrowser({
111
+ port: 0,
112
+ headless: false,
113
+ userDataDir: join(
114
+ tmpdir(),
115
+ `monomind-browse-headed-${process.pid}-${randomUUID().slice(0, 8)}`,
116
+ ),
117
+ });
118
+ } catch (err) {
119
+ // Headed launch failed (no display, locked down env). Restore headless and rethrow.
120
+ session.port = await browser.launchBrowser({ port, headless: true });
121
+ const fallback = await browser.connectToTarget(session.port);
122
+ session.client = fallback.client;
123
+ session.sessionId = fallback.sessionId;
124
+ session.targetId = fallback.target.id;
125
+ session.refs = new Map();
126
+ throw new Error(
127
+ `Cannot open headed browser: ${err instanceof Error ? err.message : String(err)}`,
128
+ );
129
+ }
130
+ const conn = await browser.connectToTarget(headedPort);
131
+ session.client = conn.client;
132
+ session.sessionId = conn.sessionId;
133
+ session.targetId = conn.target.id;
134
+ session.refs = new Map();
135
+
136
+ if (savedCookies.length) {
137
+ try {
138
+ await browser.setCookies(
139
+ session.client,
140
+ session.sessionId,
141
+ savedCookies as Parameters<typeof browser.setCookies>[2],
142
+ );
143
+ } catch {
144
+ /* ignore */
145
+ }
146
+ }
147
+ await browser.openUrl(session.client, session.sessionId, url);
148
+
149
+ output.printInfo(
150
+ 'Browser window opened. Complete the required action (login / CAPTCHA), then press Enter to continue in headless mode...',
151
+ );
152
+ await waitForEnter();
153
+
154
+ // Capture post-auth cookies
155
+ const authCookies = await browser
156
+ .getCookies(session.client, session.sessionId)
157
+ .catch(() => [] as unknown[]);
158
+ const authLocalStorage = await browser
159
+ .getLocalStorage(session.client, session.sessionId)
160
+ .catch(() => ({}) as Record<string, string>);
161
+
162
+ // Close headed session — actually terminate the underlying Chrome process
163
+ // (Browser.close CDP command, PID-kill fallback), not just our CDP client
164
+ // connection, so the visible authenticated window doesn't linger forever.
165
+ browser.teardownRouteInterception(session.sessionId);
166
+ browser.stopRequestCapture(session.sessionId);
167
+ browser.teardownDialogHandling(session.sessionId);
168
+ browser.teardownConsoleCapture(session.sessionId);
169
+ await browser.closeBrowser(session.client, headedPort);
170
+ session.client.close();
171
+ session.client = null;
172
+ session.sessionId = '';
173
+ session.parentSessionId = '';
174
+ session.targetId = '';
175
+
176
+ // Relaunch headless and restore session
177
+ session.port = await browser.launchBrowser({ port, headless: true });
178
+ const headlessConn = await browser.connectToTarget(session.port);
179
+ session.client = headlessConn.client;
180
+ session.sessionId = headlessConn.sessionId;
181
+ session.targetId = headlessConn.target.id;
182
+ session.refs = new Map();
183
+
184
+ if (authCookies.length) {
185
+ try {
186
+ await browser.setCookies(
187
+ session.client,
188
+ session.sessionId,
189
+ authCookies as Parameters<typeof browser.setCookies>[2],
190
+ );
191
+ } catch {
192
+ /* ignore */
193
+ }
194
+ }
195
+ if (authLocalStorage && Object.keys(authLocalStorage).length) {
196
+ try {
197
+ await browser.setLocalStorage(session.client, session.sessionId, authLocalStorage);
198
+ } catch {
199
+ /* ignore */
200
+ }
201
+ }
202
+ }
@@ -8,17 +8,16 @@
8
8
  * differently: it must never kill a Chrome it did not launch.
9
9
  */
10
10
 
11
+ import { detectAttentionNeeded, switchToHeaded } from './attention-wall.js';
11
12
  import { output } from './output.js';
12
13
  import {
13
14
  adoptLegacySession,
14
- detectAttentionNeeded,
15
15
  ensureConnected,
16
16
  getBrowser,
17
17
  launchSessionBrowser,
18
18
  pinnedPort,
19
19
  resolveLiveSession,
20
20
  session,
21
- switchToHeaded,
22
21
  } from './session.js';
23
22
  import type { Command, CommandContext, CommandResult } from './types.js';
24
23
 
@@ -39,7 +38,13 @@ export const openCommand: Command = {
39
38
  description: 'Force visible browser window',
40
39
  default: false,
41
40
  },
42
- { name: 'session', short: 's', type: 'string', description: 'Session name to restore' },
41
+ {
42
+ name: 'session',
43
+ short: 's',
44
+ type: 'string',
45
+ description:
46
+ 'Named session: re-open the live one with this name or start it, and restore saved state of that name if any',
47
+ },
43
48
  { name: 'state', type: 'string', description: 'State file to load' },
44
49
  ],
45
50
  action: async (ctx: CommandContext): Promise<CommandResult> => {
@@ -48,10 +53,14 @@ export const openCommand: Command = {
48
53
 
49
54
  // No --port means "start a session of my own on a free port" (#318) —
50
55
  // never "join whatever is on 9222", which is what made two uncoordinated
51
- // `open` calls race for one browser and one profile directory.
52
- const port = pinnedPort(ctx.flags);
53
- const forceHeaded = ctx.flags.headed as boolean;
56
+ // `open` calls race for one browser and one profile directory. A
57
+ // --session name re-opens the live session of that name, if there is one.
54
58
  const browser = await getBrowser();
59
+ const name = session.name || undefined;
60
+ const port =
61
+ pinnedPort(ctx.flags) ??
62
+ (name ? (await resolveLiveSession(browser, { name }))?.port : undefined);
63
+ const forceHeaded = ctx.flags.headed as boolean;
55
64
 
56
65
  if (session.client) {
57
66
  const prevSid = session.sessionId;
@@ -91,7 +100,7 @@ export const openCommand: Command = {
91
100
 
92
101
  // Starts this session's own browser (and records it) unless --port named
93
102
  // one to attach to — see the session rule in session.ts.
94
- session.port = await launchSessionBrowser(browser, { port, headless: !forceHeaded });
103
+ session.port = await launchSessionBrowser(browser, { port, headless: !forceHeaded, name });
95
104
  const conn = await browser.connectToTarget(session.port);
96
105
  session.client = conn.client;
97
106
  session.sessionId = conn.sessionId;
@@ -107,8 +116,10 @@ export const openCommand: Command = {
107
116
  }
108
117
  if (ctx.flags.state) {
109
118
  await browser.loadStateFile(session.client, session.sessionId, ctx.flags.state as string);
110
- } else if (ctx.flags.session) {
111
- await browser.loadSession(session.client, session.sessionId, ctx.flags.session as string);
119
+ } else if (name && (await browser.listSessions()).includes(name)) {
120
+ // Saved state of the same name (`state save <name>`) is restored; a
121
+ // name with none is simply a fresh named session.
122
+ await browser.loadSession(session.client, session.sessionId, name);
112
123
  }
113
124
 
114
125
  await browser.openUrl(session.client, session.sessionId, url);
@@ -228,7 +239,7 @@ export const closeCommand: Command = {
228
239
  port: pinned,
229
240
  launched: true,
230
241
  })
231
- : await resolveLiveSession(browser, { strict: false });
242
+ : await resolveLiveSession(browser, { strict: false, name: session.name || undefined });
232
243
  if (persisted?.launched) {
233
244
  try {
234
245
  const conn = await browser.connectToTarget(persisted.port);
@@ -271,7 +282,9 @@ export const closeCommand: Command = {
271
282
  output.printInfo(
272
283
  persisted
273
284
  ? `Detached from browser on port ${persisted.port} (attached via connect — left running)`
274
- : 'No active browser session',
285
+ : session.name
286
+ ? `No live browse session named "${session.name}"`
287
+ : 'No active browser session',
275
288
  );
276
289
  }
277
290
  if (persisted) {
@@ -370,7 +383,7 @@ export const connectCommand: Command = {
370
383
  // launches a browser of its own instead of reusing this session.
371
384
  // launched:false marks this browser as someone else's — close must never
372
385
  // kill it, and a dead endpoint must not be silently relaunched.
373
- await browser.saveSessionRecord(port, { launched: false });
386
+ await browser.saveSessionRecord(port, { launched: false, name: session.name || undefined });
374
387
  const url = await browser.getCurrentUrl(session.client, session.sessionId);
375
388
  const title = await browser.getCurrentTitle(session.client, session.sessionId);
376
389
  output.printSuccess(`Connected: ${title} (${url})`);
@@ -90,7 +90,7 @@ import {
90
90
  } from './commands-trace.js';
91
91
  import { output } from './output.js';
92
92
  import { createPlatformCommand } from './platform.js';
93
- import { applySessionPortFlag } from './session.js';
93
+ import { applySessionNameFlag, applySessionPortFlag } from './session.js';
94
94
  import type { Command, CommandContext, CommandResult } from './types.js';
95
95
 
96
96
  // Re-exported for direct unit testing — these were exported from this file
@@ -230,16 +230,33 @@ const PORT_OPTION = {
230
230
  description: 'Act on the browse session on this CDP port (default: the newest live session)',
231
231
  } as const;
232
232
 
233
+ const SESSION_OPTION = {
234
+ name: 'session',
235
+ short: 's',
236
+ type: 'string',
237
+ description: 'Act on the live browse session with this name (open/connect start it)',
238
+ } as const;
239
+
233
240
  function withSessionSelector(cmd: Command): Command {
234
241
  const action = cmd.action;
242
+ const options = cmd.options?.some((o) => o.name === 'port')
243
+ ? (cmd.options ?? [])
244
+ : [...(cmd.options ?? []), { ...PORT_OPTION }];
235
245
  return {
236
246
  ...cmd,
237
- options: cmd.options?.some((o) => o.name === 'port')
238
- ? cmd.options
239
- : [...(cmd.options ?? []), { ...PORT_OPTION }],
247
+ // `snapshot`/`scroll` already use -s for --selector: long form only there.
248
+ options: options.some((o) => o.name === 'session')
249
+ ? options
250
+ : [
251
+ ...options,
252
+ options.some((o) => o.short === 's')
253
+ ? { ...SESSION_OPTION, short: undefined }
254
+ : { ...SESSION_OPTION },
255
+ ],
240
256
  action: action
241
257
  ? async (ctx: CommandContext) => {
242
258
  applySessionPortFlag(ctx.flags);
259
+ applySessionNameFlag(ctx.flags);
243
260
  return action(ctx);
244
261
  }
245
262
  : undefined,
@@ -324,10 +341,7 @@ const browseCommand: Command = {
324
341
  name: 'browse',
325
342
  description: 'Native browser automation via Chrome DevTools Protocol',
326
343
  subcommands,
327
- options: [
328
- { ...PORT_OPTION },
329
- { name: 'session', short: 's', type: 'string', description: 'Named session to use' },
330
- ],
344
+ options: [{ ...PORT_OPTION }, { ...SESSION_OPTION }],
331
345
  examples: [
332
346
  { command: 'monomind browse open https://example.com', description: 'Open a URL' },
333
347
  {