@monoes/monobrowse 1.0.3 → 1.0.4

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 (64) 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__/ref-cache.test.js +36 -0
  6. package/dist/src/__tests__/ref-cache.test.js.map +1 -1
  7. package/dist/src/browser/action-builder/analyzer.js.map +1 -1
  8. package/dist/src/browser/actions.d.ts +1 -1
  9. package/dist/src/browser/actions.d.ts.map +1 -1
  10. package/dist/src/browser/actions.js +19 -2
  11. package/dist/src/browser/actions.js.map +1 -1
  12. package/dist/src/browser/browser.d.ts +10 -0
  13. package/dist/src/browser/browser.d.ts.map +1 -1
  14. package/dist/src/browser/browser.js +111 -3
  15. package/dist/src/browser/browser.js.map +1 -1
  16. package/dist/src/browser/cdp.d.ts.map +1 -1
  17. package/dist/src/browser/cdp.js +1 -1
  18. package/dist/src/browser/cdp.js.map +1 -1
  19. package/dist/src/browser/dashboard/server.d.ts.map +1 -1
  20. package/dist/src/browser/dashboard/server.js +37 -2
  21. package/dist/src/browser/dashboard/server.js.map +1 -1
  22. package/dist/src/browser/dashboard/ui.html +1 -1
  23. package/dist/src/browser/find.js.map +1 -1
  24. package/dist/src/browser/network.d.ts +7 -0
  25. package/dist/src/browser/network.d.ts.map +1 -1
  26. package/dist/src/browser/network.js +10 -0
  27. package/dist/src/browser/network.js.map +1 -1
  28. package/dist/src/browser/pdf.js.map +1 -1
  29. package/dist/src/browser/profiler.js.map +1 -1
  30. package/dist/src/browser/record.d.ts +3 -0
  31. package/dist/src/browser/record.d.ts.map +1 -1
  32. package/dist/src/browser/record.js +37 -5
  33. package/dist/src/browser/record.js.map +1 -1
  34. package/dist/src/browser/ref-cache.d.ts +20 -0
  35. package/dist/src/browser/ref-cache.d.ts.map +1 -1
  36. package/dist/src/browser/ref-cache.js +50 -0
  37. package/dist/src/browser/ref-cache.js.map +1 -1
  38. package/dist/src/browser/screenshot.js.map +1 -1
  39. package/dist/src/browser/session.d.ts.map +1 -1
  40. package/dist/src/browser/session.js +12 -7
  41. package/dist/src/browser/session.js.map +1 -1
  42. package/dist/src/browser/snapshot.js.map +1 -1
  43. package/dist/src/browser/trace.js.map +1 -1
  44. package/dist/src/cli/action.js.map +1 -1
  45. package/dist/src/cli/commands.d.ts +22 -0
  46. package/dist/src/cli/commands.d.ts.map +1 -1
  47. package/dist/src/cli/commands.js +246 -27
  48. package/dist/src/cli/commands.js.map +1 -1
  49. package/dist/src/cli/output.d.ts.map +1 -1
  50. package/dist/src/cli.js.map +1 -1
  51. package/dist/tsconfig.tsbuildinfo +1 -1
  52. package/package.json +2 -2
  53. package/src/__tests__/batch-eval-parsing.test.ts +52 -0
  54. package/src/__tests__/ref-cache.test.ts +41 -0
  55. package/src/browser/actions.ts +22 -2
  56. package/src/browser/browser.ts +115 -2
  57. package/src/browser/cdp.ts +1 -1
  58. package/src/browser/dashboard/server.ts +37 -2
  59. package/src/browser/dashboard/ui.html +1 -1
  60. package/src/browser/network.ts +11 -0
  61. package/src/browser/record.ts +43 -6
  62. package/src/browser/ref-cache.ts +51 -0
  63. package/src/browser/session.ts +13 -7
  64. 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
+ });
@@ -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,6 +51,38 @@ 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`);
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
+
45
86
  export async function launchBrowser(config: BrowserConfig = {}): Promise<number> {
46
87
  const rawPort = config.port ?? DEFAULT_PORT;
47
88
  // Validate port is in a safe range for localhost CDP debugging
@@ -51,7 +92,16 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
51
92
  const port = rawPort;
52
93
 
53
94
  if (await isPortOpen(port)) {
54
- return port;
95
+ // Something CDP-speaking is already there — verify it's actually Chrome/Chromium
96
+ // before attaching, so we don't silently take over an unrelated real browser
97
+ // (e.g. the user's own personal Chrome) that happens to be on this port.
98
+ if (await isChromeIdentity(port)) {
99
+ return port;
100
+ }
101
+ throw new Error(
102
+ `Port ${port} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
103
+ `Refusing to attach — pass a different port or free port ${port}.`
104
+ );
55
105
  }
56
106
 
57
107
  const chromePath = findChrome(config.executablePath);
@@ -89,11 +139,28 @@ export async function launchBrowser(config: BrowserConfig = {}): Promise<number>
89
139
  stdio: 'ignore',
90
140
  });
91
141
  child.unref();
142
+ if (child.pid) launchedPids.set(port, child.pid);
92
143
 
93
144
  const deadline = Date.now() + LAUNCH_TIMEOUT;
94
145
  while (Date.now() < deadline) {
95
146
  await sleep(POLL_INTERVAL);
96
- if (await isPortOpen(port)) return port;
147
+ if (await isPortOpen(port)) {
148
+ if (await isChromeIdentity(port)) return port;
149
+ throw new Error(
150
+ `Port ${port} is occupied by a CDP-speaking process that does not identify as Chrome/Chromium. ` +
151
+ `Refusing to attach — pass a different port or free port ${port}.`
152
+ );
153
+ }
154
+ }
155
+
156
+ // Timed out waiting for our Chrome to come up on the port. Distinguish
157
+ // "nothing is listening" (real launch failure) from "something non-CDP is
158
+ // squatting the port" (confusing generic timeout otherwise) via a raw TCP probe.
159
+ if (await isTcpPortOpen(port)) {
160
+ throw new Error(
161
+ `Port ${port} is occupied by a non-Chrome process (TCP connection succeeds but no CDP response within ${LAUNCH_TIMEOUT}ms). ` +
162
+ `Free the port or pass a different one.`
163
+ );
97
164
  }
98
165
 
99
166
  throw new Error(`Chrome failed to start on port ${port} within ${LAUNCH_TIMEOUT}ms`);
@@ -140,6 +207,52 @@ export async function connectToTarget(port: number, targetId?: string): Promise<
140
207
  return { client, target, sessionId };
141
208
  }
142
209
 
210
+ /**
211
+ * Cleanly terminate a Chrome/Chromium instance we launched on `port`.
212
+ * Sends the `Browser.close` CDP command (the correct graceful shutdown —
213
+ * closes all tabs and exits the process cleanly) over `client`'s connection.
214
+ * If that command fails, times out, or the connection is already gone,
215
+ * falls back to killing the tracked PID directly so a headed/interactive
216
+ * browser window (e.g. one spawned for a login/CAPTCHA flow) never lingers
217
+ * as a visible, authenticated, still-debuggable orphan process.
218
+ */
219
+ export async function closeBrowser(client: CdpClient, port: number): Promise<void> {
220
+ let gracefullyClosed = false;
221
+ try {
222
+ await Promise.race([
223
+ client.send('Browser.close', {}),
224
+ new Promise<never>((_, reject) => {
225
+ const t = setTimeout(() => reject(new Error('Browser.close timed out')), BROWSER_CLOSE_TIMEOUT_MS);
226
+ t.unref?.();
227
+ }),
228
+ ]);
229
+ gracefullyClosed = true;
230
+ } catch {
231
+ // fall through to PID-kill fallback below
232
+ }
233
+
234
+ const pid = launchedPids.get(port);
235
+ if (pid === undefined) return; // not a process we launched — nothing to kill
236
+ launchedPids.delete(port);
237
+
238
+ if (!gracefullyClosed) {
239
+ try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ }
240
+ return;
241
+ }
242
+
243
+ // Browser.close was acknowledged — give the process a moment to exit on
244
+ // its own, then verify and force-kill as a safety net in case it hung.
245
+ const t = setTimeout(() => {
246
+ try {
247
+ process.kill(pid, 0); // throws if the process is already gone
248
+ process.kill(pid, 'SIGKILL');
249
+ } catch {
250
+ /* already exited — expected path */
251
+ }
252
+ }, 1000);
253
+ t.unref?.();
254
+ }
255
+
143
256
  export async function openUrl(client: CdpClient, sessionId: string, url: string): Promise<void> {
144
257
  // Cap to 2 MB to prevent OOM in CDP message serializer (e.g. data: URI attacks)
145
258
  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(
@@ -16,6 +16,7 @@ import type { ElementRef } from './types.js';
16
16
 
17
17
  const CACHE_DIR = join(process.cwd(), '.monomind', 'monobrowse');
18
18
  const CACHE_FILE = join(CACHE_DIR, 'ax-snapshot.json');
19
+ const PORT_FILE = join(CACHE_DIR, 'active-port.json');
19
20
 
20
21
  /** Snapshot older than this is flagged as possibly stale (page may have changed). */
21
22
  export const REF_CACHE_STALE_MS = 30_000;
@@ -88,3 +89,53 @@ export async function clearRefCache(): Promise<void> {
88
89
  // Nothing to clear, or not writable — non-fatal.
89
90
  }
90
91
  }
92
+
93
+ /**
94
+ * Persist the "active" CDP port so a later CLI invocation (each command is a
95
+ * fresh process — see module header) can find the browser a prior `open
96
+ * --port N` attached to, instead of every subsequent command silently
97
+ * falling back to the hardcoded default port and launching/attaching to a
98
+ * second, unrelated Chrome instance.
99
+ */
100
+ export async function saveActivePort(port: number, opts?: { launched?: boolean }): Promise<void> {
101
+ try {
102
+ await mkdir(CACHE_DIR, { recursive: true });
103
+ // `launched` records provenance: true = monobrowse spawned this Chrome
104
+ // (safe to Browser.close later); false = attached to a browser someone
105
+ // else owns (must never be killed). Absent (old files) reads as launched
106
+ // — matches pre-flag behavior where every persisted port came from open.
107
+ await writeFile(PORT_FILE, JSON.stringify({ port, launched: opts?.launched !== false, savedAt: Date.now() }));
108
+ } catch {
109
+ // Best-effort — persistence failure just means the next process falls
110
+ // back to the hardcoded default port, matching prior behavior.
111
+ }
112
+ }
113
+
114
+ /** Forget the persisted active port (session closed) so later invocations
115
+ * fall back to the default instead of chasing a dead endpoint. */
116
+ export async function clearActivePort(): Promise<void> {
117
+ try {
118
+ await rm(PORT_FILE, { force: true });
119
+ } catch {
120
+ // Best-effort — a stale port file only costs one failed probe later.
121
+ }
122
+ }
123
+
124
+ /** Load the persisted active port, or null if none was ever saved / it's unreadable. */
125
+ export async function loadActivePort(): Promise<number | null> {
126
+ return (await loadActivePortInfo())?.port ?? null;
127
+ }
128
+
129
+ /** Load the persisted active port with its provenance flag. */
130
+ export async function loadActivePortInfo(): Promise<{ port: number; launched: boolean } | null> {
131
+ try {
132
+ const raw = await readFile(PORT_FILE, 'utf8');
133
+ const data = JSON.parse(raw) as { port?: unknown; launched?: unknown };
134
+ if (typeof data.port === 'number' && Number.isInteger(data.port) && data.port >= 1024 && data.port <= 65535) {
135
+ return { port: data.port, launched: data.launched !== false };
136
+ }
137
+ return null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
@@ -3,7 +3,13 @@ import { join, dirname } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import type { SessionState, CdpCookie } from './types.js';
5
5
  import type { CdpClient } from './cdp.js';
6
- import { getCookies, setCookies, getLocalStorage, setLocalStorage, getSessionStorage, setSessionStorage } from './network.js';
6
+ import { getCookiesForUrls, setCookies, getLocalStorage, setLocalStorage, getSessionStorage, setSessionStorage } from './network.js';
7
+
8
+ // Session state files contain cookies/localStorage — treat as secrets:
9
+ // owner-only read/write on the file (0600) and owner-only access on the
10
+ // containing directory (0700) so other local users can't read session tokens.
11
+ const SESSION_FILE_MODE = 0o600;
12
+ const SESSION_DIR_MODE = 0o700;
7
13
 
8
14
  const SESSION_DIR = join(homedir(), '.monomind', 'browser-sessions');
9
15
 
@@ -31,15 +37,15 @@ export async function saveSession(
31
37
  title: string
32
38
  ): Promise<string> {
33
39
  validateSessionName(name);
34
- await mkdir(SESSION_DIR, { recursive: true });
40
+ await mkdir(SESSION_DIR, { recursive: true, mode: SESSION_DIR_MODE });
35
41
 
36
- const cookies = await getCookies(client, sessionId);
42
+ const cookies = await getCookiesForUrls(client, sessionId, [url]);
37
43
  const localStorage = await getLocalStorage(client, sessionId);
38
44
  const sessionStorage = await getSessionStorage(client, sessionId);
39
45
 
40
46
  const state: SessionState = { targetId, sessionId, url, title, cookies, localStorage, sessionStorage };
41
47
  const filePath = join(SESSION_DIR, `${name}.json`);
42
- await writeFile(filePath, JSON.stringify(state, null, 2));
48
+ await writeFile(filePath, JSON.stringify(state, null, 2), { mode: SESSION_FILE_MODE });
43
49
  return filePath;
44
50
  }
45
51
 
@@ -79,12 +85,12 @@ export async function saveStateFile(
79
85
  title: string
80
86
  ): Promise<void> {
81
87
  validateFilePath(filePath);
82
- await mkdir(dirname(filePath), { recursive: true });
83
- const cookies = await getCookies(client, sessionId);
88
+ await mkdir(dirname(filePath), { recursive: true, mode: SESSION_DIR_MODE });
89
+ const cookies = await getCookiesForUrls(client, sessionId, [url]);
84
90
  const localStorage = await getLocalStorage(client, sessionId);
85
91
  const sessionStorage = await getSessionStorage(client, sessionId);
86
92
  const state: SessionState = { targetId, sessionId, url, title, cookies, localStorage, sessionStorage };
87
- await writeFile(filePath, JSON.stringify(state, null, 2));
93
+ await writeFile(filePath, JSON.stringify(state, null, 2), { mode: SESSION_FILE_MODE });
88
94
  }
89
95
 
90
96
  export async function loadStateFile(