@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.
- package/dist/src/__tests__/batch-eval-parsing.test.d.ts +2 -0
- package/dist/src/__tests__/batch-eval-parsing.test.d.ts.map +1 -0
- package/dist/src/__tests__/batch-eval-parsing.test.js +47 -0
- package/dist/src/__tests__/batch-eval-parsing.test.js.map +1 -0
- package/dist/src/__tests__/browser-launch.test.d.ts +2 -0
- package/dist/src/__tests__/browser-launch.test.d.ts.map +1 -0
- package/dist/src/__tests__/browser-launch.test.js +94 -0
- package/dist/src/__tests__/browser-launch.test.js.map +1 -0
- package/dist/src/__tests__/ref-cache.test.js +36 -0
- package/dist/src/__tests__/ref-cache.test.js.map +1 -1
- package/dist/src/browser/action-builder/analyzer.js.map +1 -1
- package/dist/src/browser/actions.d.ts +1 -1
- package/dist/src/browser/actions.d.ts.map +1 -1
- package/dist/src/browser/actions.js +19 -2
- package/dist/src/browser/actions.js.map +1 -1
- package/dist/src/browser/browser.d.ts +10 -0
- package/dist/src/browser/browser.d.ts.map +1 -1
- package/dist/src/browser/browser.js +158 -5
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/src/browser/cdp.d.ts.map +1 -1
- package/dist/src/browser/cdp.js +1 -1
- package/dist/src/browser/cdp.js.map +1 -1
- package/dist/src/browser/dashboard/server.d.ts.map +1 -1
- package/dist/src/browser/dashboard/server.js +37 -2
- package/dist/src/browser/dashboard/server.js.map +1 -1
- package/dist/src/browser/dashboard/ui.html +1 -1
- package/dist/src/browser/find.js.map +1 -1
- package/dist/src/browser/network.d.ts +7 -0
- package/dist/src/browser/network.d.ts.map +1 -1
- package/dist/src/browser/network.js +10 -0
- package/dist/src/browser/network.js.map +1 -1
- package/dist/src/browser/pdf.js.map +1 -1
- package/dist/src/browser/profiler.js.map +1 -1
- package/dist/src/browser/record.d.ts +3 -0
- package/dist/src/browser/record.d.ts.map +1 -1
- package/dist/src/browser/record.js +37 -5
- package/dist/src/browser/record.js.map +1 -1
- package/dist/src/browser/ref-cache.d.ts +20 -0
- package/dist/src/browser/ref-cache.d.ts.map +1 -1
- package/dist/src/browser/ref-cache.js +50 -0
- package/dist/src/browser/ref-cache.js.map +1 -1
- package/dist/src/browser/screenshot.js.map +1 -1
- package/dist/src/browser/session.d.ts.map +1 -1
- package/dist/src/browser/session.js +12 -7
- package/dist/src/browser/session.js.map +1 -1
- package/dist/src/browser/snapshot.js.map +1 -1
- package/dist/src/browser/trace.js.map +1 -1
- package/dist/src/browser/types.d.ts +5 -0
- package/dist/src/browser/types.d.ts.map +1 -1
- package/dist/src/browser/types.js.map +1 -1
- package/dist/src/cli/action.js.map +1 -1
- package/dist/src/cli/commands.d.ts +22 -0
- package/dist/src/cli/commands.d.ts.map +1 -1
- package/dist/src/cli/commands.js +246 -27
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/output.d.ts.map +1 -1
- package/dist/src/cli.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/__tests__/batch-eval-parsing.test.ts +52 -0
- package/src/__tests__/browser-launch.test.ts +102 -0
- package/src/__tests__/ref-cache.test.ts +41 -0
- package/src/browser/actions.ts +22 -2
- package/src/browser/browser.ts +163 -4
- package/src/browser/cdp.ts +1 -1
- package/src/browser/dashboard/server.ts +37 -2
- package/src/browser/dashboard/ui.html +1 -1
- package/src/browser/network.ts +11 -0
- package/src/browser/record.ts +43 -6
- package/src/browser/ref-cache.ts +51 -0
- package/src/browser/session.ts +13 -7
- package/src/browser/types.ts +5 -0
- 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
|
+
});
|
package/src/browser/actions.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/browser/browser.ts
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
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))
|
|
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');
|
package/src/browser/cdp.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/browser/network.ts
CHANGED
|
@@ -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
|
}
|
package/src/browser/record.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|