@monoes/monobrowse 1.0.3 → 1.0.5

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 (73) hide show
  1. package/dist/src/__tests__/batch-eval-parsing.test.d.ts +2 -0
  2. package/dist/src/__tests__/batch-eval-parsing.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/batch-eval-parsing.test.js +47 -0
  4. package/dist/src/__tests__/batch-eval-parsing.test.js.map +1 -0
  5. package/dist/src/__tests__/browser-launch.test.d.ts +2 -0
  6. package/dist/src/__tests__/browser-launch.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/browser-launch.test.js +94 -0
  8. package/dist/src/__tests__/browser-launch.test.js.map +1 -0
  9. package/dist/src/__tests__/ref-cache.test.js +36 -0
  10. package/dist/src/__tests__/ref-cache.test.js.map +1 -1
  11. package/dist/src/browser/action-builder/analyzer.js.map +1 -1
  12. package/dist/src/browser/actions.d.ts +1 -1
  13. package/dist/src/browser/actions.d.ts.map +1 -1
  14. package/dist/src/browser/actions.js +19 -2
  15. package/dist/src/browser/actions.js.map +1 -1
  16. package/dist/src/browser/browser.d.ts +10 -0
  17. package/dist/src/browser/browser.d.ts.map +1 -1
  18. package/dist/src/browser/browser.js +158 -5
  19. package/dist/src/browser/browser.js.map +1 -1
  20. package/dist/src/browser/cdp.d.ts.map +1 -1
  21. package/dist/src/browser/cdp.js +1 -1
  22. package/dist/src/browser/cdp.js.map +1 -1
  23. package/dist/src/browser/dashboard/server.d.ts.map +1 -1
  24. package/dist/src/browser/dashboard/server.js +37 -2
  25. package/dist/src/browser/dashboard/server.js.map +1 -1
  26. package/dist/src/browser/dashboard/ui.html +1 -1
  27. package/dist/src/browser/find.js.map +1 -1
  28. package/dist/src/browser/network.d.ts +7 -0
  29. package/dist/src/browser/network.d.ts.map +1 -1
  30. package/dist/src/browser/network.js +10 -0
  31. package/dist/src/browser/network.js.map +1 -1
  32. package/dist/src/browser/pdf.js.map +1 -1
  33. package/dist/src/browser/profiler.js.map +1 -1
  34. package/dist/src/browser/record.d.ts +3 -0
  35. package/dist/src/browser/record.d.ts.map +1 -1
  36. package/dist/src/browser/record.js +37 -5
  37. package/dist/src/browser/record.js.map +1 -1
  38. package/dist/src/browser/ref-cache.d.ts +20 -0
  39. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  40. package/dist/src/browser/ref-cache.js +50 -0
  41. package/dist/src/browser/ref-cache.js.map +1 -1
  42. package/dist/src/browser/screenshot.js.map +1 -1
  43. package/dist/src/browser/session.d.ts.map +1 -1
  44. package/dist/src/browser/session.js +12 -7
  45. package/dist/src/browser/session.js.map +1 -1
  46. package/dist/src/browser/snapshot.js.map +1 -1
  47. package/dist/src/browser/trace.js.map +1 -1
  48. package/dist/src/browser/types.d.ts +5 -0
  49. package/dist/src/browser/types.d.ts.map +1 -1
  50. package/dist/src/browser/types.js.map +1 -1
  51. package/dist/src/cli/action.js.map +1 -1
  52. package/dist/src/cli/commands.d.ts +22 -0
  53. package/dist/src/cli/commands.d.ts.map +1 -1
  54. package/dist/src/cli/commands.js +246 -27
  55. package/dist/src/cli/commands.js.map +1 -1
  56. package/dist/src/cli/output.d.ts.map +1 -1
  57. package/dist/src/cli.js.map +1 -1
  58. package/dist/tsconfig.tsbuildinfo +1 -1
  59. package/package.json +2 -2
  60. package/src/__tests__/batch-eval-parsing.test.ts +52 -0
  61. package/src/__tests__/browser-launch.test.ts +102 -0
  62. package/src/__tests__/ref-cache.test.ts +41 -0
  63. package/src/browser/actions.ts +22 -2
  64. package/src/browser/browser.ts +163 -4
  65. package/src/browser/cdp.ts +1 -1
  66. package/src/browser/dashboard/server.ts +37 -2
  67. package/src/browser/dashboard/ui.html +1 -1
  68. package/src/browser/network.ts +11 -0
  69. package/src/browser/record.ts +43 -6
  70. package/src/browser/ref-cache.ts +51 -0
  71. package/src/browser/session.ts +13 -7
  72. package/src/browser/types.ts +5 -0
  73. package/src/cli/commands.ts +239 -27
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseBatchCommandLine } from '../cli/commands.js';
3
+
4
+ describe('parseBatchCommandLine — eval expressions inside a batch command string', () => {
5
+ it('does not strip string-literal quotes from a raw JS expression passed to eval', () => {
6
+ // Regression: tokenizeBatchCommand's shell-style quote-stripping (correct for
7
+ // space-separated args like `fill @e1 "some value"`) previously ran on eval's
8
+ // expression too, silently deleting the expression's OWN string-literal quotes
9
+ // and turning `document.querySelector('a')` into `document.querySelector(a)` —
10
+ // a reference to an undefined identifier instead of a string literal.
11
+ const input = "eval document.querySelector('meta[name=mm-token]').content";
12
+ const { subName, subArgs } = parseBatchCommandLine(input);
13
+ expect(subName).toBe('eval');
14
+ expect(subArgs).toEqual(["document.querySelector('meta[name=mm-token]').content"]);
15
+ });
16
+
17
+ it('preserves double-quoted string literals in the expression too', () => {
18
+ const input = 'eval document.querySelector("body").tagName';
19
+ const { subName, subArgs } = parseBatchCommandLine(input);
20
+ expect(subName).toBe('eval');
21
+ expect(subArgs).toEqual(['document.querySelector("body").tagName']);
22
+ });
23
+
24
+ it('preserves an expression with multiple quoted segments and internal spaces', () => {
25
+ const input = "eval [...document.querySelectorAll('li')].map(x => x.textContent.trim())";
26
+ const { subName, subArgs } = parseBatchCommandLine(input);
27
+ expect(subName).toBe('eval');
28
+ expect(subArgs).toEqual(["[...document.querySelectorAll('li')].map(x => x.textContent.trim())"]);
29
+ });
30
+
31
+ it('still recognizes --json and --max-output flags placed before the expression', () => {
32
+ const input = "eval --json --max-output 100 document.querySelector('a').href";
33
+ const { subName, subArgs, flags } = parseBatchCommandLine(input);
34
+ expect(subName).toBe('eval');
35
+ expect(flags).toMatchObject({ json: true, 'max-output': 100 });
36
+ expect(subArgs).toEqual(["document.querySelector('a').href"]);
37
+ });
38
+
39
+ it('leaves non-eval commands tokenized exactly as before (no behavior change)', () => {
40
+ const input = 'fill @e1 "some value with spaces"';
41
+ const { subName, subArgs } = parseBatchCommandLine(input);
42
+ expect(subName).toBe('fill');
43
+ expect(subArgs).toEqual(['@e1', 'some value with spaces']);
44
+ });
45
+
46
+ it('leaves a simple multi-token command tokenized exactly as before', () => {
47
+ const input = 'open https://example.com --port 9333';
48
+ const { subName, subArgs } = parseBatchCommandLine(input);
49
+ expect(subName).toBe('open');
50
+ expect(subArgs).toEqual(['https://example.com', '--port', '9333']);
51
+ });
52
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Unit tests for launchBrowser's port-scan/attach decisions (browser.ts).
3
+ * Deliberately scoped to branches reachable WITHOUT spawning a real Chrome —
4
+ * each scenario below resolves via attach or a thrown error before
5
+ * launchBrowser would ever exec a browser binary, so these run fast and
6
+ * don't depend on Chrome being installed in CI.
7
+ *
8
+ * Fixed port range (23470-23479) chosen to avoid colliding with real
9
+ * services; each test binds/tears down its own listeners.
10
+ */
11
+ import { describe, it, expect, afterEach } from 'vitest';
12
+ import { createServer as createTcpServer, type Server as TcpServer, type Socket } from 'net';
13
+ import { createServer as createHttpServer, type Server as HttpServer } from 'http';
14
+ import { launchBrowser } from '../browser/browser.js';
15
+
16
+ const BASE = 23470;
17
+
18
+ let servers: Array<TcpServer | HttpServer> = [];
19
+ let sockets: Socket[] = [];
20
+
21
+ afterEach(async () => {
22
+ // server.close() only stops accepting NEW connections — it waits for
23
+ // already-open ones (the fetches that hung until their AbortSignal fired)
24
+ // to close on their own, which can outlast the test. Destroy explicitly.
25
+ for (const sock of sockets) sock.destroy();
26
+ sockets = [];
27
+ await Promise.all(servers.map(s => new Promise<void>(resolve => s.close(() => resolve()))));
28
+ servers = [];
29
+ }, 5000);
30
+
31
+ /** Bind a bare TCP listener — accepts connections but speaks no HTTP/CDP. */
32
+ function occupyNonChrome(port: number): Promise<void> {
33
+ return new Promise((resolve, reject) => {
34
+ const s = createTcpServer(sock => { sockets.push(sock); /* accept and do nothing — no CDP response */ });
35
+ s.once('error', reject);
36
+ s.listen(port, '127.0.0.1', () => { servers.push(s); resolve(); });
37
+ });
38
+ }
39
+
40
+ /** Bind an HTTP server that answers /json/version like a real Chrome would. */
41
+ function occupyChrome(port: number): Promise<void> {
42
+ return new Promise((resolve, reject) => {
43
+ const s = createHttpServer((req, res) => {
44
+ if (req.url === '/json/version') {
45
+ res.writeHead(200, { 'Content-Type': 'application/json' });
46
+ res.end(JSON.stringify({ Browser: 'Chrome/999.0.0.0' }));
47
+ } else {
48
+ res.writeHead(404); res.end();
49
+ }
50
+ });
51
+ s.on('connection', sock => sockets.push(sock));
52
+ s.once('error', reject);
53
+ s.listen(port, '127.0.0.1', () => { servers.push(s); resolve(); });
54
+ });
55
+ }
56
+
57
+ describe('launchBrowser — port scan/attach decisions', () => {
58
+ it('attaches immediately when the EXACT requested port already identifies as Chrome', async () => {
59
+ const port = BASE + 0;
60
+ await occupyChrome(port);
61
+ await expect(launchBrowser({ port })).resolves.toBe(port);
62
+ });
63
+
64
+ it('strictPort: throws immediately on an occupied non-Chrome requested port, never scans', async () => {
65
+ const port = BASE + 1;
66
+ await occupyNonChrome(port);
67
+ await occupyChrome(port + 1); // would succeed if scanning happened — must not be reached
68
+ await expect(launchBrowser({ port, strictPort: true }))
69
+ .rejects.toThrow(/does not identify as Chrome/);
70
+ });
71
+
72
+ it('strictPort: attaches on an occupied Chrome requested port (identical to non-strict)', async () => {
73
+ const port = BASE + 2;
74
+ await occupyChrome(port);
75
+ await expect(launchBrowser({ port, strictPort: true })).resolves.toBe(port);
76
+ });
77
+
78
+ it('all candidates occupied by non-Chrome processes: throws a clear range error', async () => {
79
+ const port = BASE + 3;
80
+ // Occupy the full 10-port scan window with non-Chrome listeners.
81
+ for (let i = 0; i < 10; i++) await occupyNonChrome(port + i);
82
+ await expect(launchBrowser({ port })).rejects.toThrow(
83
+ new RegExp(`Ports ${port}-${port + 9} are all occupied`)
84
+ );
85
+ }, 15000); // generous margin — 10 candidates, each a fast isTcpPortOpen check
86
+
87
+ it('a Chrome instance on a SCANNED (not the originally requested) port is skipped, not attached to', async () => {
88
+ // Security-relevant case: the attach-if-Chrome shortcut must apply only
89
+ // to the exact port the caller asked for. A Chrome instance sitting on a
90
+ // later candidate the caller never named must not be silently attached
91
+ // to — occupied candidates (Chrome or not) beyond the first are simply
92
+ // skipped, so if every candidate is occupied the call still fails even
93
+ // though one of them is an attachable Chrome.
94
+ const port = BASE + 4;
95
+ await occupyNonChrome(port); // requested port: occupied, not Chrome
96
+ await occupyChrome(port + 1); // scanned candidate: IS Chrome — must be skipped, not attached
97
+ for (let i = 2; i < 10; i++) await occupyNonChrome(port + i); // remaining candidates: occupied
98
+ await expect(launchBrowser({ port })).rejects.toThrow(
99
+ new RegExp(`Ports ${port}-${port + 9} are all occupied`)
100
+ );
101
+ }, 15000); // generous margin — 10 candidates, each a fast isTcpPortOpen check
102
+ });
@@ -132,3 +132,44 @@ describe('ref-cache', () => {
132
132
  expect(loaded).toBeNull();
133
133
  });
134
134
  });
135
+
136
+ describe('active-port persistence', () => {
137
+ it('round-trip: save then load returns the port; launched defaults true', async () => {
138
+ const mod = await importRefCache();
139
+ await mod.saveActivePort(9333);
140
+ expect(await mod.loadActivePort()).toBe(9333);
141
+ expect(await mod.loadActivePortInfo()).toEqual({ port: 9333, launched: true });
142
+ });
143
+
144
+ it('connect provenance: launched:false survives the round-trip', async () => {
145
+ const mod = await importRefCache();
146
+ await mod.saveActivePort(9229, { launched: false });
147
+ expect(await mod.loadActivePortInfo()).toEqual({ port: 9229, launched: false });
148
+ });
149
+
150
+ it('clear removes the file; load returns null afterwards', async () => {
151
+ const mod = await importRefCache();
152
+ await mod.saveActivePort(9333);
153
+ await mod.clearActivePort();
154
+ expect(await mod.loadActivePort()).toBeNull();
155
+ expect(await mod.loadActivePortInfo()).toBeNull();
156
+ });
157
+
158
+ it('clear with no file resolves without throwing', async () => {
159
+ const mod = await importRefCache();
160
+ await mod.clearActivePort();
161
+ await expect(mod.clearActivePort()).resolves.toBeUndefined();
162
+ });
163
+
164
+ it('rejects out-of-range or non-integer persisted ports', async () => {
165
+ const mod = await importRefCache();
166
+ const { writeFile, mkdir } = await import('fs/promises');
167
+ const dir = join(process.cwd(), '.monomind', 'monobrowse');
168
+ await mkdir(dir, { recursive: true });
169
+ for (const bad of [80, 70000, 1.5, '9222', null]) {
170
+ await writeFile(join(dir, 'active-port.json'), JSON.stringify({ port: bad }));
171
+ expect(await mod.loadActivePort()).toBeNull();
172
+ expect(await mod.loadActivePortInfo()).toBeNull();
173
+ }
174
+ });
175
+ });
@@ -496,12 +496,22 @@ export async function removeInitScript(client: CdpClient, sessionId: string, ide
496
496
  await client.send('Page.removeScriptToEvaluateOnNewDocument', { identifier }, sessionId);
497
497
  }
498
498
 
499
+ // Default cap on how long an evaluateJs() call waits for Runtime.evaluate to
500
+ // resolve. Because this always passes `awaitPromise: true`, an expression
501
+ // like `new Promise(() => {})` never settles — the underlying CdpClient.send()
502
+ // has no timeout of its own (the CDP_RESPONSE_SIZE_LIMIT in cdp.ts only guards
503
+ // the HTTP /json/* endpoints, not WebSocket command responses), so without
504
+ // this the caller (e.g. the `eval` CLI command) would hang forever with
505
+ // nothing to kill it.
506
+ const DEFAULT_EVAL_TIMEOUT_MS = 30_000;
507
+
499
508
  export async function evaluateJs(
500
509
  client: CdpClient,
501
510
  sessionId: string,
502
- expression: string
511
+ expression: string,
512
+ timeoutMs: number = DEFAULT_EVAL_TIMEOUT_MS
503
513
  ): Promise<unknown> {
504
- const result = await client.send<{
514
+ const evalPromise = client.send<{
505
515
  result: { value?: unknown; type: string; description?: string };
506
516
  exceptionDetails?: { text: string; exception?: { description?: string } };
507
517
  }>('Runtime.evaluate', {
@@ -510,6 +520,16 @@ export async function evaluateJs(
510
520
  awaitPromise: true,
511
521
  }, sessionId);
512
522
 
523
+ const result = await (timeoutMs > 0
524
+ ? Promise.race([
525
+ evalPromise,
526
+ new Promise<never>((_, reject) => {
527
+ const t = setTimeout(() => reject(new Error(`JS evaluation timed out after ${timeoutMs}ms`)), timeoutMs);
528
+ t.unref?.();
529
+ }),
530
+ ])
531
+ : evalPromise);
532
+
513
533
  if (result.exceptionDetails) {
514
534
  throw new Error(`JS evaluation error: ${result.exceptionDetails.exception?.description ?? result.exceptionDetails.text}`);
515
535
  }
@@ -2,6 +2,7 @@ import { spawn, execSync } from 'child_process';
2
2
  import { existsSync } from 'fs';
3
3
  import { tmpdir } from 'os';
4
4
  import { join } from 'path';
5
+ import { connect } from 'net';
5
6
  import { CdpClient, fetchTargets, fetchNewTarget } from './cdp.js';
6
7
  import type { BrowserConfig, CdpTarget } from './types.js';
7
8
  import { CHROME_EXECUTABLES } from './types.js';
@@ -11,6 +12,14 @@ import { setupDialogAutoHandling } from './dialog.js';
11
12
  const DEFAULT_PORT = 9222;
12
13
  const LAUNCH_TIMEOUT = 10_000;
13
14
  const POLL_INTERVAL = 200;
15
+ const BROWSER_CLOSE_TIMEOUT_MS = 3000;
16
+
17
+ // Tracks the PID of Chrome instances *we* spawned, keyed by CDP port, so
18
+ // closeBrowser() has a kill fallback when the graceful `Browser.close` CDP
19
+ // command fails or times out (e.g. a hung renderer). Ports we merely attached
20
+ // to (already-running browser) are never recorded here — we only ever kill
21
+ // processes we launched ourselves.
22
+ const launchedPids = new Map<number, number>();
14
23
 
15
24
  function findChrome(executablePath?: string): string {
16
25
  if (executablePath) {
@@ -42,18 +51,105 @@ export async function isPortOpen(port: number): Promise<boolean> {
42
51
  }
43
52
  }
44
53
 
54
+ /**
55
+ * Confirm the CDP endpoint on `port` actually identifies as Chrome/Chromium
56
+ * via `/json/version`'s `Browser` field, rather than assuming any CDP-speaking
57
+ * responder is "ours". Reduces (does not eliminate) the risk of silently
58
+ * attaching to an unrelated real browser that happens to be listening there.
59
+ */
60
+ async function isChromeIdentity(port: number): Promise<boolean> {
61
+ try {
62
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(1000) });
63
+ if (!res.ok) return false;
64
+ const info = (await res.json()) as { Browser?: string };
65
+ return typeof info.Browser === 'string' && /chrom(e|ium)/i.test(info.Browser);
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /** Raw TCP connect probe — distinguishes "nothing listening" from "something listening that isn't CDP". */
72
+ function isTcpPortOpen(port: number, timeoutMs = 1000): Promise<boolean> {
73
+ return new Promise((resolve) => {
74
+ const socket = connect({ host: '127.0.0.1', port, timeout: timeoutMs });
75
+ const done = (result: boolean) => {
76
+ socket.removeAllListeners();
77
+ socket.destroy();
78
+ resolve(result);
79
+ };
80
+ socket.once('connect', () => done(true));
81
+ socket.once('timeout', () => done(false));
82
+ socket.once('error', () => done(false));
83
+ });
84
+ }
85
+
86
+ /** Ports scanned when the requested one is occupied by a non-Chrome process
87
+ * (e.g. another local tool that happens to reuse Chrome's conventional CDP
88
+ * default, 9222 — mirrors the auto-increment convention this monorepo's own
89
+ * dashboard server uses in bindServer/server.mjs). Only occupied-by-a-
90
+ * DIFFERENT-process is worked around; an already-attachable Chrome on the
91
+ * requested port is still returned as-is (existing "attach, don't relaunch"
92
+ * behavior). */
93
+ const LAUNCH_PORT_SCAN_TRIES = 10;
94
+
45
95
  export async function launchBrowser(config: BrowserConfig = {}): Promise<number> {
46
96
  const rawPort = config.port ?? DEFAULT_PORT;
47
97
  // Validate port is in a safe range for localhost CDP debugging
48
98
  if (!Number.isInteger(rawPort) || rawPort < 1024 || rawPort > 65535) {
49
99
  throw new Error(`Invalid port: ${rawPort}. Must be an integer between 1024 and 65535.`);
50
100
  }
51
- const port = rawPort;
52
101
 
53
- if (await isPortOpen(port)) {
54
- return port;
102
+ // strictPort: fail fast on the exact requested port, matching the old
103
+ // behavior (Vite has the same escape hatch for the same reason) — for
104
+ // callers that treat the error as a signal ("this port is taken by
105
+ // something else, bail") rather than consuming the returned port.
106
+ if (config.strictPort) {
107
+ if (await isTcpPortOpen(rawPort)) {
108
+ if (await isChromeIdentity(rawPort)) return rawPort;
109
+ throw new Error(
110
+ `Port ${rawPort} is occupied by a process that does not identify as Chrome/Chromium. ` +
111
+ `Refusing to attach — pass a different port or free port ${rawPort}.`
112
+ );
113
+ }
114
+ return launchOnFreePort(config, rawPort);
115
+ }
116
+
117
+ const candidates: number[] = [];
118
+ for (let i = 0; i < LAUNCH_PORT_SCAN_TRIES && rawPort + i <= 65535; i++) candidates.push(rawPort + i);
119
+
120
+ // Attach-if-already-Chrome only applies to the EXACT requested port — the
121
+ // original, deliberate, single-port risk ("don't silently take over an
122
+ // unrelated real browser that happens to be on this port"). Scanning past
123
+ // an occupied default must not let that same shortcut attach to a
124
+ // DIFFERENT Chrome instance the caller never named; forward candidates are
125
+ // launch-only (skip if anything is there, Chrome or not).
126
+ if (await isTcpPortOpen(rawPort)) {
127
+ if (await isChromeIdentity(rawPort)) return rawPort;
128
+ } else {
129
+ return launchOnFreePort(config, rawPort);
130
+ }
131
+
132
+ for (const candidate of candidates.slice(1)) {
133
+ // TCP-level check for "is anything at all listening" — isPortOpen()
134
+ // does a full CDP /json fetch, which returns false BOTH for a genuinely
135
+ // free port and for one occupied by a non-CDP process (that ambiguity is
136
+ // exactly what the post-spawn isTcpPortOpen fallback below exists to
137
+ // resolve, the hard way, after a 10s launch timeout). Checking the raw
138
+ // socket first tells free and occupied apart up front, so the scan can
139
+ // skip an occupied candidate instead of trying to spawn Chrome on top of
140
+ // it and only discovering the conflict after a timeout.
141
+ if (!(await isTcpPortOpen(candidate))) return launchOnFreePort(config, candidate);
142
+ // Occupied (by anything — not just non-Chrome, per the note above) —
143
+ // try the next candidate instead of failing outright, same as a normal
144
+ // EADDRINUSE retry would.
55
145
  }
146
+ throw new Error(
147
+ `Ports ${candidates[0]}-${candidates[candidates.length - 1]} are all occupied and port ${candidates[0]} ` +
148
+ `isn't a Chrome/Chromium instance to attach to. Pass a different --port.`
149
+ );
150
+ }
56
151
 
152
+ async function launchOnFreePort(config: BrowserConfig, port: number): Promise<number> {
57
153
  const chromePath = findChrome(config.executablePath);
58
154
  const userDataDir = config.userDataDir ?? join(tmpdir(), `monomind-browser-${port}`);
59
155
 
@@ -89,11 +185,28 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
89
185
  stdio: 'ignore',
90
186
  });
91
187
  child.unref();
188
+ if (child.pid) launchedPids.set(port, child.pid);
92
189
 
93
190
  const deadline = Date.now() + LAUNCH_TIMEOUT;
94
191
  while (Date.now() < deadline) {
95
192
  await sleep(POLL_INTERVAL);
96
- if (await isPortOpen(port)) return port;
193
+ if (await isPortOpen(port)) {
194
+ if (await isChromeIdentity(port)) return port;
195
+ throw new Error(
196
+ `Port ${port} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
197
+ `Refusing to attach — pass a different port or free port ${port}.`
198
+ );
199
+ }
200
+ }
201
+
202
+ // Timed out waiting for our Chrome to come up on the port. Distinguish
203
+ // "nothing is listening" (real launch failure) from "something non-CDP is
204
+ // squatting the port" (confusing generic timeout otherwise) via a raw TCP probe.
205
+ if (await isTcpPortOpen(port)) {
206
+ throw new Error(
207
+ `Port ${port} is occupied by a non-Chrome process (TCP connection succeeds but no CDP response within ${LAUNCH_TIMEOUT}ms). ` +
208
+ `Free the port or pass a different one.`
209
+ );
97
210
  }
98
211
 
99
212
  throw new Error(`Chrome failed to start on port ${port} within ${LAUNCH_TIMEOUT}ms`);
@@ -140,6 +253,52 @@ export async function connectToTarget(port: number, targetId?: string): Promise<
140
253
  return { client, target, sessionId };
141
254
  }
142
255
 
256
+ /**
257
+ * Cleanly terminate a Chrome/Chromium instance we launched on `port`.
258
+ * Sends the `Browser.close` CDP command (the correct graceful shutdown —
259
+ * closes all tabs and exits the process cleanly) over `client`'s connection.
260
+ * If that command fails, times out, or the connection is already gone,
261
+ * falls back to killing the tracked PID directly so a headed/interactive
262
+ * browser window (e.g. one spawned for a login/CAPTCHA flow) never lingers
263
+ * as a visible, authenticated, still-debuggable orphan process.
264
+ */
265
+ export async function closeBrowser(client: CdpClient, port: number): Promise<void> {
266
+ let gracefullyClosed = false;
267
+ try {
268
+ await Promise.race([
269
+ client.send('Browser.close', {}),
270
+ new Promise<never>((_, reject) => {
271
+ const t = setTimeout(() => reject(new Error('Browser.close timed out')), BROWSER_CLOSE_TIMEOUT_MS);
272
+ t.unref?.();
273
+ }),
274
+ ]);
275
+ gracefullyClosed = true;
276
+ } catch {
277
+ // fall through to PID-kill fallback below
278
+ }
279
+
280
+ const pid = launchedPids.get(port);
281
+ if (pid === undefined) return; // not a process we launched — nothing to kill
282
+ launchedPids.delete(port);
283
+
284
+ if (!gracefullyClosed) {
285
+ try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ }
286
+ return;
287
+ }
288
+
289
+ // Browser.close was acknowledged — give the process a moment to exit on
290
+ // its own, then verify and force-kill as a safety net in case it hung.
291
+ const t = setTimeout(() => {
292
+ try {
293
+ process.kill(pid, 0); // throws if the process is already gone
294
+ process.kill(pid, 'SIGKILL');
295
+ } catch {
296
+ /* already exited — expected path */
297
+ }
298
+ }, 1000);
299
+ t.unref?.();
300
+ }
301
+
143
302
  export async function openUrl(client: CdpClient, sessionId: string, url: string): Promise<void> {
144
303
  // Cap to 2 MB to prevent OOM in CDP message serializer (e.g. data: URI attacks)
145
304
  if (url.length > 2_097_152) throw new Error('URL exceeds 2 MB limit');
@@ -151,7 +151,7 @@ export async function fetchTargets(port: number): Promise<CdpTarget[]> {
151
151
 
152
152
  export async function fetchNewTarget(port: number, url: string): Promise<CdpTarget> {
153
153
  // Chrome v92+ requires PUT for /json/new; GET returns 405. URL must be encoded.
154
- const res = await fetch(`http://127.0.0.1:${port}/json/new?${url}`, { method: 'PUT' });
154
+ const res = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' });
155
155
  if (!res.ok) throw new Error(`Failed to create target: ${res.statusText}`);
156
156
  return readCdpJson(res) as Promise<CdpTarget>;
157
157
  }
@@ -1,10 +1,11 @@
1
1
  import { createServer, IncomingMessage, ServerResponse } from 'node:http';
2
- import { readFileSync, existsSync } from 'node:fs';
2
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
3
3
  import { readFile, writeFile, mkdir } from 'node:fs/promises';
4
4
  import { join, dirname } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { homedir } from 'node:os';
7
7
  import { createRequire } from 'node:module';
8
+ import { randomBytes } from 'node:crypto';
8
9
 
9
10
  export interface StepEvent {
10
11
  type: string;
@@ -57,6 +58,21 @@ let instance: DashboardServer | null = null;
57
58
  export function startDashboard(port = DEFAULT_PORT): DashboardServer {
58
59
  if (instance) return instance;
59
60
 
61
+ // ── Security: per-process auth credential for mutating (non-GET) requests ──
62
+ // Generated once per server start and written to a well-known location so
63
+ // trusted local callers (CLI) can read it and pass it back via an auth
64
+ // header or query param on non-GET routes. Mirrors the pattern used by the
65
+ // main CLI dashboard (packages/@monomind/cli/src/ui/server.ts).
66
+ const dashboardAuthValue = randomBytes(24).toString('hex');
67
+ try {
68
+ const authFileDir = join(homedir(), '.monomind');
69
+ mkdirSync(authFileDir, { recursive: true });
70
+ writeFileSync(join(authFileDir, 'monobrowse-dashboard-token'), dashboardAuthValue, { mode: 0o600 });
71
+ } catch {
72
+ // best-effort: if we can't persist the token, mutating routes still
73
+ // enforce it in-memory — trusted callers just won't be able to read it.
74
+ }
75
+
60
76
  const runHistory: RunRecord[] = [];
61
77
  const stopRequests = new Set<string>();
62
78
  /** Map from client (WebSocket or SSE response) to the subscribed project dir,
@@ -79,6 +95,13 @@ export function startDashboard(port = DEFAULT_PORT): DashboardServer {
79
95
  } catch {
80
96
  uiHtml = `<!DOCTYPE html><html><head><title>monobrowse dashboard</title></head><body style="background:#0f0f1a;color:#ccc;font-family:system-ui;padding:20px"><h1>monobrowse dashboard</h1><p>Dashboard UI not found. Run the build to include ui.html.</p><script>let _retryDelay=1000;function connectSSE(){const es=new EventSource('/events');es.onmessage=e=>{console.log(JSON.parse(e.data));_retryDelay=1000;};es.onerror=()=>{es.close();setTimeout(connectSSE,Math.min(_retryDelay*=2,30000));};};connectSSE();</script></body></html>`;
81
97
  }
98
+ // Hand the per-process auth token to the served page so its own JS (e.g. the
99
+ // stop button) can pass it back on mutating requests. Injected once at
100
+ // startup — the loopback-only page is the sole reader.
101
+ const tokenScript = `<script>window.__MONOBROWSE_TOKEN__=${JSON.stringify(dashboardAuthValue)};</script>`;
102
+ uiHtml = uiHtml.includes('</head>')
103
+ ? uiHtml.replace('</head>', `${tokenScript}</head>`)
104
+ : tokenScript + uiHtml;
82
105
 
83
106
  const server = createServer(async (req, res) => {
84
107
  const url = req.url ?? '/';
@@ -100,8 +123,20 @@ export function startDashboard(port = DEFAULT_PORT): DashboardServer {
100
123
  return;
101
124
  }
102
125
 
126
+ // ── Security: require a matching auth token on all mutating routes ──────
127
+ // Binding to loopback only stops remote attackers, not a malicious page
128
+ // open in the user's own browser (DNS rebinding / same-origin fetch to
129
+ // 127.0.0.1). Require the per-process token via header or query param on
130
+ // every non-GET/HEAD route; GET/SSE reads stay open on the loopback baseline.
103
131
  if (url.startsWith('/stop/') && req.method === 'POST') {
104
- const runId = url.slice(6);
132
+ const parsedAuth = new URL(url, 'http://localhost');
133
+ const suppliedAuth = req.headers['x-monobrowse-token'] || parsedAuth.searchParams.get('token') || '';
134
+ if (suppliedAuth !== dashboardAuthValue) {
135
+ res.writeHead(401, { 'Content-Type': 'application/json' });
136
+ res.end(JSON.stringify({ error: 'Unauthorized: missing or invalid auth token' }));
137
+ return;
138
+ }
139
+ const runId = url.slice(6).split('?')[0];
105
140
  stopRequests.add(runId);
106
141
  res.writeHead(200, { 'Content-Type': 'application/json' });
107
142
  res.end(JSON.stringify({ ok: true, runId }));
@@ -678,7 +678,7 @@ function renderCards() {
678
678
  '</div>';
679
679
  }).join('');
680
680
  }
681
- function stopRun(id) { fetch('/stop/' + id, { method: 'POST' }).catch(function() {}); }
681
+ function stopRun(id) { fetch('/stop/' + id, { method: 'POST', headers: { 'x-monobrowse-token': (window.__MONOBROWSE_TOKEN__ || '') } }).catch(function() {}); }
682
682
 
683
683
  // ═══════════════════════════════════════════════════════════════════
684
684
  // NODE PALETTE DEFINITIONS
@@ -6,6 +6,17 @@ export async function getCookies(client: CdpClient, sessionId: string): Promise<
6
6
  return result.cookies ?? [];
7
7
  }
8
8
 
9
+ /**
10
+ * Get cookies scoped to specific page URLs via `Network.getCookies`, instead of
11
+ * `Network.getAllCookies` which returns cookies for every origin in the browser
12
+ * profile. Used for session save/export so we don't leak session tokens for
13
+ * unrelated domains the user happens to be logged into.
14
+ */
15
+ export async function getCookiesForUrls(client: CdpClient, sessionId: string, urls: string[]): Promise<CdpCookie[]> {
16
+ const result = await client.send<{ cookies: CdpCookie[] }>('Network.getCookies', { urls }, sessionId);
17
+ return result.cookies ?? [];
18
+ }
19
+
9
20
  export async function setCookies(client: CdpClient, sessionId: string, cookies: CdpCookie[]): Promise<void> {
10
21
  await client.send('Network.setCookies', { cookies }, sessionId);
11
22
  }
@@ -15,8 +15,20 @@ export interface RecordOptions {
15
15
  export interface RecordingState {
16
16
  frames: string[];
17
17
  offScreencast: (() => void) | null;
18
+ totalBytes: number;
19
+ autoStopped: boolean;
18
20
  }
19
21
 
22
+ // Unlike har.ts's MAX_REQUESTS_PER_SESSION cap on the analogous network-request
23
+ // buffer, the screencast frame buffer previously had no size or count cap at
24
+ // all — recording for even a few minutes at normal frame rates can accumulate
25
+ // hundreds of MB of base64-encoded JPEG data in memory, and stopRecording()'s
26
+ // single JSON.stringify() of the whole frame array can exceed V8's max string
27
+ // length and crash the process. Cap total accumulated base64 bytes (simpler to
28
+ // check per-push than re-measuring the whole array) and auto-stop recording
29
+ // once the budget is exhausted.
30
+ const MAX_SCREENCAST_BYTES = 200 * 1024 * 1024; // ~200 MB of accumulated base64 frame data
31
+
20
32
  const _sessions = new Map<string, RecordingState>();
21
33
 
22
34
  export async function startRecording(
@@ -24,17 +36,38 @@ export async function startRecording(
24
36
  sessionId: string,
25
37
  options: RecordOptions = {}
26
38
  ): Promise<void> {
27
- if (_sessions.has(sessionId)) {
39
+ const existing = _sessions.get(sessionId);
40
+ if (existing && !existing.autoStopped) {
28
41
  throw new Error('Recording already in progress for this session');
29
42
  }
43
+ if (existing) {
44
+ // Previous recording hit the buffer cap and auto-stopped but was never
45
+ // explicitly saved via stopRecording() — drop it so a fresh recording
46
+ // can start (its frames are lost; the auto-stop log already warned the
47
+ // caller to save via "record stop" before starting a new recording).
48
+ _sessions.delete(sessionId);
49
+ }
30
50
 
31
- const state: RecordingState = { frames: [], offScreencast: null };
51
+ const state: RecordingState = { frames: [], offScreencast: null, totalBytes: 0, autoStopped: false };
32
52
  _sessions.set(sessionId, state);
33
53
 
34
54
  state.offScreencast = client.on('Page.screencastFrame', async (params, sid) => {
35
55
  if (sid !== sessionId) return;
36
56
  const { data, sessionId: frameSessionId } = params as { data: string; sessionId: number };
37
- state.frames.push(data);
57
+ if (!state.autoStopped) {
58
+ state.frames.push(data);
59
+ state.totalBytes += data.length;
60
+ if (state.totalBytes >= MAX_SCREENCAST_BYTES) {
61
+ state.autoStopped = true;
62
+ state.offScreencast?.();
63
+ // eslint-disable-next-line no-console
64
+ console.error(
65
+ `[monobrowse] Screen recording auto-stopped: reached ${Math.round(MAX_SCREENCAST_BYTES / (1024 * 1024))}MB ` +
66
+ `of buffered frame data (${state.frames.length} frames). Call "record stop" to save what was captured.`
67
+ );
68
+ await client.send('Page.stopScreencast', {}, sessionId).catch(() => {});
69
+ }
70
+ }
38
71
  await client.send('Page.screencastFrameAck', { sessionId: frameSessionId }, sessionId).catch(() => {});
39
72
  });
40
73
 
@@ -62,7 +95,11 @@ export async function stopRecording(
62
95
  if (!state) throw new Error('No active recording for this session');
63
96
 
64
97
  try {
65
- await client.send('Page.stopScreencast', {}, sessionId);
98
+ // If auto-stop already sent Page.stopScreencast and unsubscribed, don't
99
+ // send it again — Chrome errors on stopping an already-stopped screencast.
100
+ if (!state.autoStopped) {
101
+ await client.send('Page.stopScreencast', {}, sessionId);
102
+ }
66
103
  } finally {
67
104
  state.offScreencast?.();
68
105
  _sessions.delete(sessionId);
@@ -73,9 +110,9 @@ export async function stopRecording(
73
110
  return path;
74
111
  }
75
112
 
76
- export function getRecordingStatus(sessionId: string): { recording: boolean; frames: number } {
113
+ export function getRecordingStatus(sessionId: string): { recording: boolean; frames: number; autoStopped: boolean } {
77
114
  const state = _sessions.get(sessionId);
78
- return { recording: !!state, frames: state?.frames.length ?? 0 };
115
+ return { recording: !!state && !state.autoStopped, frames: state?.frames.length ?? 0, autoStopped: state?.autoStopped ?? false };
79
116
  }
80
117
 
81
118
  export async function saveFrameAsPng(