poltertab 1.3.0 → 1.5.0-beta.1

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.
@@ -0,0 +1,593 @@
1
+ // The bridge: this process's connection to the Chrome extension, and its
2
+ // arbitration with any other MCP server that wants the same browser.
3
+ //
4
+ // Two roles. Whoever binds the port is Primary and owns the extension socket;
5
+ // everyone else becomes a Secondary and proxies commands through it. A Primary
6
+ // that dies frees the port, and the orphaned Secondaries race (with jitter) to
7
+ // replace it. No lockfile, no supervisor.
8
+ //
9
+ // Everything mutable about that arrangement lives in this file. index.js sees
10
+ // four verbs — sendCommand, readNetworkState, isSecondary, noteActiveTab — and
11
+ // none of the state behind them.
12
+
13
+ const WebSocket = require("ws");
14
+ const crypto = require("crypto");
15
+ const {
16
+ resolvePort,
17
+ DEFAULT_WS_PORT,
18
+ COMMAND_TIMEOUT_MS,
19
+ HEARTBEAT_MS,
20
+ NETWORK_TTL_MS,
21
+ NETWORK_GC_INTERVAL_MS,
22
+ NETWORK_MAX_REQUESTS,
23
+ NETWORK_MAX_BODY_BYTES,
24
+ PROMOTION_DELAY_MS,
25
+ PROMOTION_JITTER_MS,
26
+ MAX_SECONDARIES,
27
+ POLTERTAB_HOME,
28
+ } = require("./config.js");
29
+ const updates = require("./update-check.js");
30
+ const OWN_VERSION = require("./../package.json").version;
31
+
32
+ let WS_PORT = resolvePort();
33
+
34
+ // Populated when the extension connects; stays null until then, which is itself
35
+ // worth reporting — a skew warning must not fire on "no extension yet".
36
+ let extensionVersion = null;
37
+
38
+ // Global state
39
+ let extensionSocket = null;
40
+ const pendingCommands = new Map();
41
+ const networkState = new Map(); // tabId -> { lastUpdated: number, requests: array }
42
+ const secondaryClients = new Map();
43
+ let primaryLastTabId = null;
44
+
45
+ let isSecondary = false;
46
+ let nodeId = null;
47
+ let wss = null;
48
+ let httpServer = null;
49
+
50
+ function broadcastSessionState() {
51
+ if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
52
+ const sessions = [];
53
+ sessions.push({
54
+ id: "Primary Agent",
55
+ nodeId: "primary",
56
+ lastTabId: primaryLastTabId || null,
57
+ });
58
+ for (const [nodeId, clientWs] of secondaryClients.entries()) {
59
+ sessions.push({
60
+ id: "Secondary Agent",
61
+ nodeId: nodeId,
62
+ lastTabId: clientWs.lastTabId || null,
63
+ });
64
+ }
65
+ extensionSocket.send(
66
+ JSON.stringify({
67
+ type: "state_update",
68
+ sessions,
69
+ }),
70
+ );
71
+ }
72
+ }
73
+
74
+ // The bridge listens on localhost, and localhost is reachable from any page the
75
+ // user happens to visit — browsers allow ws:// to loopback from an https origin.
76
+ // Nothing below the handshake distinguishes callers, so a drive-by page could
77
+ // send {type:"ping"}, be installed as `extensionSocket` (the nodeId-less branch
78
+ // in setupPrimaryWss), and thereby drop the real extension *and* receive every
79
+ // subsequent command — URLs, selectors — answering each with whatever it liked.
80
+ //
81
+ // Origin is the one thing the page cannot lie about: browsers set it themselves
82
+ // on the handshake request. The extension sends chrome-extension://<its id>, and
83
+ // a Node client (a Secondary MCP node) sends none at all. Both are us; a page is
84
+ // never either.
85
+ //
86
+ // ponytail: origin check only. It stops the drive-by, which is the reachable
87
+ // attack. It does not authenticate a hostile *local process* — that needs a
88
+ // shared token in POLTERTAB_HOME, and any local process able to make one is
89
+ // already able to read the browser profile directly.
90
+ function allowOrigin(info, cb) {
91
+ const origin = info.origin || info.req.headers.origin;
92
+ if (!origin || origin.startsWith("chrome-extension://")) return cb(true);
93
+ // Loud on purpose: a rejected handshake is otherwise indistinguishable from
94
+ // an extension that never connected, which is the worst thing to debug.
95
+ console.error(
96
+ `[PolterTab MCP] Refused WebSocket handshake from origin ${origin}`,
97
+ );
98
+ return cb(false, 403, "Forbidden");
99
+ }
100
+
101
+ function setupPrimaryServer() {
102
+ const http = require("http");
103
+ httpServer = http.createServer();
104
+
105
+ httpServer.on("error", (e) => {
106
+ if (e.code === "EADDRINUSE") {
107
+ console.error(
108
+ `[PolterTab MCP] Port ${WS_PORT} in use, switching to Secondary Mode...`,
109
+ );
110
+ startSecondaryMode();
111
+ } else {
112
+ console.error(`[PolterTab MCP] HTTP server error: ${e.message}`);
113
+ }
114
+ });
115
+
116
+ httpServer.listen(WS_PORT, () => {
117
+ console.error(
118
+ `[PolterTab MCP] Primary WebSocket server listening on ws://localhost:${WS_PORT}`,
119
+ );
120
+ wss = new WebSocket.WebSocketServer({
121
+ server: httpServer,
122
+ verifyClient: allowOrigin,
123
+ perMessageDeflate: {
124
+ zlibDeflateOptions: {
125
+ chunkSize: 1024,
126
+ memLevel: 7,
127
+ level: 1,
128
+ },
129
+ zlibInflateOptions: {
130
+ chunkSize: 10 * 1024,
131
+ },
132
+ clientNoContextTakeover: true,
133
+ serverNoContextTakeover: true,
134
+ serverMaxWindowBits: 10,
135
+ concurrencyLimit: 10,
136
+ threshold: 1024,
137
+ },
138
+ });
139
+
140
+ // Heartbeat mechanism to detect dead connections
141
+ const interval = setInterval(() => {
142
+ wss.clients.forEach((ws) => {
143
+ if (ws.isAlive === false) {
144
+ console.error(
145
+ `[PolterTab MCP] Terminating dead connection: ${ws.nodeId || "Extension/Unknown"}`,
146
+ );
147
+ return ws.terminate();
148
+ }
149
+ ws.isAlive = false;
150
+ ws.ping();
151
+ });
152
+ }, HEARTBEAT_MS);
153
+
154
+ wss.on("close", () => {
155
+ clearInterval(interval);
156
+ });
157
+
158
+ // Network State Garbage Collector (TTL: 5 minutes)
159
+ setInterval(() => {
160
+ const now = Date.now();
161
+ for (const [tabId, state] of networkState.entries()) {
162
+ if (now - state.lastUpdated > NETWORK_TTL_MS) {
163
+ console.error(
164
+ `[PolterTab MCP] Garbage collecting network state for tab ${tabId}`,
165
+ );
166
+ networkState.delete(tabId);
167
+ }
168
+ }
169
+ }, NETWORK_GC_INTERVAL_MS);
170
+
171
+ setupPrimaryWss(wss);
172
+ });
173
+ }
174
+
175
+ function startSecondaryMode() {
176
+ isSecondary = true;
177
+ nodeId = crypto.randomUUID();
178
+ console.error(
179
+ `[PolterTab MCP] Node ${nodeId} running as Secondary. Connecting to Primary...`,
180
+ );
181
+
182
+ connectToPrimary();
183
+ }
184
+
185
+ function connectToPrimary() {
186
+ extensionSocket = new WebSocket(`ws://localhost:${WS_PORT}`, {
187
+ perMessageDeflate: true,
188
+ });
189
+
190
+ extensionSocket.on("open", () => {
191
+ console.error("[PolterTab MCP] Connected to Primary MCP Server.");
192
+ extensionSocket.send(JSON.stringify({ type: "secondary_mcp", nodeId }));
193
+ });
194
+
195
+ extensionSocket.on("message", (message) => {
196
+ try {
197
+ const msg = JSON.parse(message);
198
+ if (msg.id && pendingCommands.has(msg.id)) {
199
+ const { resolve, reject, timer } = pendingCommands.get(msg.id);
200
+ clearTimeout(timer);
201
+ pendingCommands.delete(msg.id);
202
+
203
+ if (msg.success) {
204
+ resolve(msg.data);
205
+ } else {
206
+ reject(new Error(msg.error || "Unknown extension error"));
207
+ }
208
+ }
209
+ } catch (err) {
210
+ console.error(
211
+ "[PolterTab MCP] Failed to parse message from Primary",
212
+ err,
213
+ );
214
+ }
215
+ });
216
+
217
+ extensionSocket.on("close", () => {
218
+ console.error(
219
+ "[PolterTab MCP] Connection to Primary lost. Attempting to become Primary...",
220
+ );
221
+ extensionSocket = null;
222
+
223
+ // Reject all pending commands
224
+ for (const [id, { reject, timer }] of pendingCommands) {
225
+ clearTimeout(timer);
226
+ reject(new Error("Primary node disconnected. Command aborted."));
227
+ pendingCommands.delete(id);
228
+ }
229
+
230
+ // Try to become primary with exponential backoff & jitter
231
+ const delay =
232
+ PROMOTION_DELAY_MS + Math.floor(Math.random() * PROMOTION_JITTER_MS);
233
+
234
+ setTimeout(() => {
235
+ isSecondary = false;
236
+ setupPrimaryServer();
237
+ }, delay);
238
+ });
239
+
240
+ extensionSocket.on("error", (err) => {
241
+ console.error(`[PolterTab MCP] Secondary WebSocket error: ${err.message}`);
242
+ });
243
+ }
244
+
245
+ // Reading the capture buffer needs a tab, and the caller may not have named one
246
+ // — so ask the extension which tab it is actually on. Written out twice before
247
+ // (Primary answering a Secondary, and Primary answering itself), which is why
248
+ // the `clear` default and the envelope shape had to be kept in sync by hand.
249
+ async function readNetworkState(params = {}) {
250
+ let tabId = params.tabId;
251
+ if (!tabId) {
252
+ const tabInfo = await sendCommand("get_url", params);
253
+ tabId = tabInfo.tabId;
254
+ }
255
+
256
+ let data = [];
257
+ if (tabId && networkState.has(tabId)) {
258
+ data = networkState.get(tabId).requests;
259
+ // Reading is destructive by default: the buffer is a tail of what happened
260
+ // since the last read, and leaving it in place makes every later read
261
+ // re-report traffic the caller already saw.
262
+ if (params.clear !== false) {
263
+ networkState.get(tabId).requests = [];
264
+ }
265
+ }
266
+
267
+ return { tabId, capturedRequests: data.length, data };
268
+ }
269
+
270
+ function setupPrimaryWss(wss) {
271
+ wss.on("connection", (ws) => {
272
+ ws.isAlive = true;
273
+ ws.on("pong", () => {
274
+ ws.isAlive = true;
275
+ });
276
+
277
+ ws.on("message", (message) => {
278
+ try {
279
+ const msg = JSON.parse(message);
280
+
281
+ if (msg.type === "secondary_mcp") {
282
+ if (secondaryClients.size >= MAX_SECONDARIES) {
283
+ console.error(
284
+ `[PolterTab MCP] Rejecting secondary node ${msg.nodeId} - limit reached.`,
285
+ );
286
+ ws.close();
287
+ return;
288
+ }
289
+ secondaryClients.set(msg.nodeId, ws);
290
+ ws.nodeId = msg.nodeId;
291
+ console.error(
292
+ `[PolterTab MCP] Secondary node connected: ${msg.nodeId}. Total secondaries: ${secondaryClients.size}`,
293
+ );
294
+ broadcastSessionState();
295
+ return;
296
+ }
297
+
298
+ if (msg.type === "request_full_state") {
299
+ broadcastSessionState();
300
+ return;
301
+ }
302
+
303
+ if (msg.type === "proxy_command") {
304
+ const { id, action, params } = msg;
305
+ if (params && params.tabId) {
306
+ ws.lastTabId = params.tabId;
307
+ broadcastSessionState();
308
+ }
309
+
310
+ if (action === "get_network_state") {
311
+ (async () => {
312
+ try {
313
+ const data = await readNetworkState(params);
314
+ if (ws.readyState === WebSocket.OPEN) {
315
+ ws.send(JSON.stringify({ id, success: true, data }));
316
+ }
317
+ } catch (err) {
318
+ if (ws.readyState === WebSocket.OPEN) {
319
+ ws.send(
320
+ JSON.stringify({ id, success: false, error: err.message }),
321
+ );
322
+ }
323
+ }
324
+ })();
325
+ return;
326
+ }
327
+
328
+ if (
329
+ !extensionSocket ||
330
+ extensionSocket.readyState !== WebSocket.OPEN
331
+ ) {
332
+ if (ws.readyState === WebSocket.OPEN) {
333
+ ws.send(
334
+ JSON.stringify({
335
+ id,
336
+ success: false,
337
+ error: "Browser extension not connected to Primary.",
338
+ }),
339
+ );
340
+ }
341
+ return;
342
+ }
343
+
344
+ const timer = setTimeout(() => {
345
+ pendingCommands.delete(id);
346
+ if (ws.readyState === WebSocket.OPEN) {
347
+ ws.send(
348
+ JSON.stringify({
349
+ id,
350
+ success: false,
351
+ error: `Command '${action}' timed out.`,
352
+ }),
353
+ );
354
+ }
355
+ }, COMMAND_TIMEOUT_MS);
356
+
357
+ pendingCommands.set(id, { isProxy: true, sourceWs: ws, timer });
358
+ extensionSocket.send(JSON.stringify({ id, action, ...params }));
359
+ return;
360
+ }
361
+
362
+ // Handle keepalive pings
363
+ if (msg.type === "ping" || msg.type === "extension_ready") {
364
+ if (!ws.nodeId) {
365
+ // if it's the extension
366
+ if (
367
+ extensionSocket &&
368
+ extensionSocket !== ws &&
369
+ extensionSocket.readyState === WebSocket.OPEN
370
+ ) {
371
+ console.error(
372
+ "[PolterTab MCP] Another extension trying to connect. Dropping old connection.",
373
+ );
374
+ extensionSocket.close();
375
+ }
376
+ if (extensionSocket !== ws) {
377
+ extensionSocket = ws;
378
+ console.error("[PolterTab MCP] Chrome extension connected.");
379
+ }
380
+ // The extension has always sent its version here and we always
381
+ // dropped it, so the one mismatch that actually breaks commands was
382
+ // invisible to both halves.
383
+ if (msg.version && msg.version !== extensionVersion) {
384
+ extensionVersion = msg.version;
385
+ updates.recordExtension(POLTERTAB_HOME, msg.version);
386
+ const sk = updates.skew(OWN_VERSION, msg.version);
387
+ console.error(
388
+ `[PolterTab MCP] Extension v${msg.version} (server v${OWN_VERSION})` +
389
+ (sk ? ` — VERSION SKEW: ${sk.kind}` : ""),
390
+ );
391
+ // Let the extension show the mismatch in its popup; it cannot
392
+ // know the server's version otherwise.
393
+ try {
394
+ ws.send(
395
+ JSON.stringify({ type: "server_version", version: OWN_VERSION }),
396
+ );
397
+ } catch (_) {
398
+ // A socket that died mid-handshake reconnects on its own.
399
+ }
400
+ }
401
+ }
402
+ return;
403
+ }
404
+
405
+ // Handle captured network data
406
+ if (msg.type === "network_data") {
407
+ const { tabId, url, body } = msg;
408
+ if (!networkState.has(tabId)) {
409
+ networkState.set(tabId, { lastUpdated: Date.now(), requests: [] });
410
+ }
411
+ const state = networkState.get(tabId);
412
+ state.lastUpdated = Date.now();
413
+
414
+ try {
415
+ // Truncate bodies over 1MB
416
+ let processedBody = body;
417
+ if (body && body.length > NETWORK_MAX_BODY_BYTES) {
418
+ processedBody =
419
+ '{"error": "Payload exceeded 1MB limit and was truncated"}';
420
+ }
421
+
422
+ const jsonBody = JSON.parse(processedBody);
423
+ state.requests.push({ url, timestamp: Date.now(), data: jsonBody });
424
+ } catch (e) {
425
+ state.requests.push({ url, timestamp: Date.now(), data: body });
426
+ }
427
+
428
+ if (state.requests.length > NETWORK_MAX_REQUESTS) {
429
+ state.requests.shift();
430
+ }
431
+ return;
432
+ }
433
+
434
+ // Handle tab closed
435
+ if (msg.type === "tab_closed") {
436
+ const { tabId } = msg;
437
+ if (networkState.has(tabId)) {
438
+ console.error(
439
+ `[PolterTab MCP] Clearing network state for closed tab ${tabId}`,
440
+ );
441
+ networkState.delete(tabId);
442
+ }
443
+ return;
444
+ }
445
+
446
+ // Response from Extension
447
+ if (msg.id && pendingCommands.has(msg.id)) {
448
+ const cmd = pendingCommands.get(msg.id);
449
+ clearTimeout(cmd.timer);
450
+ pendingCommands.delete(msg.id);
451
+
452
+ if (cmd.isProxy) {
453
+ if (cmd.sourceWs.readyState === WebSocket.OPEN) {
454
+ cmd.sourceWs.send(JSON.stringify(msg));
455
+ }
456
+ } else if (msg.success) {
457
+ cmd.resolve(msg.data);
458
+ } else {
459
+ cmd.reject(new Error(msg.error || "Unknown extension error"));
460
+ }
461
+ }
462
+ } catch (err) {
463
+ console.error("[PolterTab MCP] Failed to parse message", err);
464
+ }
465
+ });
466
+
467
+ ws.on("close", () => {
468
+ if (ws.nodeId) {
469
+ console.error(
470
+ `[PolterTab MCP] Secondary node disconnected: ${ws.nodeId}`,
471
+ );
472
+ secondaryClients.delete(ws.nodeId);
473
+ broadcastSessionState();
474
+ } else if (extensionSocket === ws) {
475
+ console.error("[PolterTab MCP] Chrome extension disconnected.");
476
+ extensionSocket = null;
477
+ }
478
+ });
479
+
480
+ ws.on("error", (err) => {
481
+ console.error(`[PolterTab MCP] WebSocket error: ${err.message}`);
482
+ });
483
+ });
484
+ }
485
+
486
+ function shutdown() {
487
+ console.error("[PolterTab MCP] Shutting down...");
488
+ if (wss) wss.close();
489
+ if (httpServer) httpServer.close();
490
+ process.exit(0);
491
+ }
492
+ // Helper to wait for WebSocket connection
493
+ async function waitForConnection(timeoutMs = 5000) {
494
+ const start = Date.now();
495
+ while (Date.now() - start < timeoutMs) {
496
+ if (extensionSocket && extensionSocket.readyState === WebSocket.OPEN) {
497
+ return true;
498
+ }
499
+ await new Promise((resolve) => setTimeout(resolve, 100));
500
+ }
501
+ return false;
502
+ }
503
+
504
+ // Register a pending command, arm its timeout, send it, and unwind all three if
505
+ // the socket throws. This was spelled out twice below — once for the Secondary
506
+ // proxy path and once for the direct one — differing only in the envelope and
507
+ // the wording of two errors, which left the timeout as two independent facts.
508
+ // The third copy (Primary answering a Secondary, in setupPrimaryWss) replies
509
+ // over a socket instead of settling a promise, so it stays where it is and
510
+ // shares only the constant.
511
+ function awaitReply(envelope, { timedOut, sendFailed }) {
512
+ const { id } = envelope;
513
+ return new Promise((resolve, reject) => {
514
+ const timer = setTimeout(() => {
515
+ pendingCommands.delete(id);
516
+ reject(new Error(timedOut));
517
+ }, COMMAND_TIMEOUT_MS);
518
+
519
+ pendingCommands.set(id, { resolve, reject, timer });
520
+
521
+ try {
522
+ extensionSocket.send(JSON.stringify(envelope));
523
+ } catch (err) {
524
+ clearTimeout(timer);
525
+ pendingCommands.delete(id);
526
+ reject(new Error(sendFailed(err)));
527
+ }
528
+ });
529
+ }
530
+
531
+ // Helper to send command to the extension
532
+ async function sendCommand(action, params) {
533
+ const isConnected = await waitForConnection(5000);
534
+
535
+ if (isSecondary) {
536
+ if (!isConnected) {
537
+ throw new Error("Secondary node not connected to Primary MCP server.");
538
+ }
539
+
540
+ // Auto-inject a unique session to prevent collisions over shared tabs
541
+ if (!params.session) {
542
+ params.session = "agent_" + nodeId;
543
+ }
544
+
545
+ return awaitReply(
546
+ { type: "proxy_command", id: crypto.randomUUID(), action, params },
547
+ {
548
+ timedOut: `Proxy Command '${action}' timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds.`,
549
+ sendFailed: (e) =>
550
+ `Failed to proxy command to Primary over WebSocket: ${e.message}`,
551
+ },
552
+ );
553
+ }
554
+
555
+ if (!isConnected) {
556
+ throw new Error(
557
+ `Browser extension not connected. Please ensure the extension is installed, enabled, and pointing to the correct port (default: ${DEFAULT_WS_PORT}).`,
558
+ );
559
+ }
560
+
561
+ return awaitReply(
562
+ { id: crypto.randomUUID(), action, ...params },
563
+ {
564
+ timedOut: `Command '${action}' timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds.`,
565
+ sendFailed: (e) => `Failed to send command over WebSocket: ${e.message}`,
566
+ },
567
+ );
568
+ }
569
+
570
+ // The Primary's view of which tab the agent is working in, shown in the
571
+ // extension popup. Collapses what used to be four copies of
572
+ // `primaryLastTabId = x; broadcastSessionState();` scattered through the tool
573
+ // dispatch, each with its own guard.
574
+ function noteActiveTab(tabId) {
575
+ if (isSecondary || !tabId) return;
576
+ primaryLastTabId = tabId;
577
+ broadcastSessionState();
578
+ }
579
+
580
+ function start() {
581
+ setupPrimaryServer();
582
+ process.on("SIGINT", shutdown);
583
+ process.on("SIGTERM", shutdown);
584
+ }
585
+
586
+ module.exports = {
587
+ start,
588
+ sendCommand,
589
+ readNetworkState,
590
+ noteActiveTab,
591
+ isSecondary: () => isSecondary,
592
+ extensionVersion: () => extensionVersion,
593
+ };
@@ -0,0 +1,85 @@
1
+ // Every tunable the Node half has, in one place, because several of them are
2
+ // only correct relative to each other and that relationship was previously
3
+ // unwritten — three copies of a 35s timeout in one file, a port in five.
4
+ //
5
+ // ── The timeout budget ──────────────────────────────────────────────────────
6
+ //
7
+ // A DOM command crosses four processes, and each layer waits on the one below:
8
+ //
9
+ // MCP server COMMAND_TIMEOUT_MS 35s ← must be the largest
10
+ // extension navigation wait 30s + 500ms settle (background.js)
11
+ // frame postToFrame 10s (background.js)
12
+ // page ELEMENT_WAIT_MS 3s (content_script.js)
13
+ //
14
+ // The invariant: **each layer's budget must exceed the sum of what it waits
15
+ // on.** Break it and a slow-but-successful page load surfaces at the top as a
16
+ // timeout, which the caller can only answer by guessing whether to retry — the
17
+ // single most expensive wrong answer this system can give, because the retry
18
+ // re-runs a scrape that already worked.
19
+ //
20
+ // The extension's half of these numbers cannot be required from here: it runs
21
+ // in Chrome, there is no build step, and MV3 forbids remote code. They are
22
+ // mirrored at the top of background.js with a pointer back to this comment. If
23
+ // you change one, change both.
24
+
25
+ const os = require("os");
26
+ const path = require("path");
27
+
28
+ // Anything the user accumulates lives outside the package. Under a global npm
29
+ // install __dirname resolves inside node_modules/poltertab/, so site memory
30
+ // written next to the code is destroyed by the next `npm update -g` — the
31
+ // upgrade would read as amnesia. Downloads have it worse: output_file exists to
32
+ // keep large payloads out of the context window, and burying them in
33
+ // node_modules makes them hard to find and just as easy to lose.
34
+ //
35
+ // POLTERTAB_HOME exists so the test suite can point this somewhere disposable
36
+ // instead of writing into the real one.
37
+ const POLTERTAB_HOME =
38
+ process.env.POLTERTAB_HOME || path.join(os.homedir(), ".poltertab");
39
+
40
+ // Port: MCP_BROWSER_WS_PORT env, then --port CLI arg, then the default.
41
+ const DEFAULT_WS_PORT = 7822;
42
+ function resolvePort(argv = process.argv, env = process.env) {
43
+ const i = argv.indexOf("--port");
44
+ if (i !== -1 && argv[i + 1]) return parseInt(argv[i + 1], 10);
45
+ if (env.MCP_BROWSER_WS_PORT) return parseInt(env.MCP_BROWSER_WS_PORT, 10);
46
+ return DEFAULT_WS_PORT;
47
+ }
48
+
49
+ module.exports = {
50
+ POLTERTAB_HOME,
51
+ MEMORY_DIR: path.join(POLTERTAB_HOME, "navigation_memory"),
52
+ DOWNLOADS_DIR: path.join(POLTERTAB_HOME, "downloads"),
53
+
54
+ DEFAULT_WS_PORT,
55
+ resolvePort,
56
+
57
+ // See the budget above before changing this.
58
+ COMMAND_TIMEOUT_MS: 35000,
59
+
60
+ // Dead-connection detection. A socket that misses a ping/pong round is
61
+ // terminated so the extension's own reconnect can take over.
62
+ HEARTBEAT_MS: 60000,
63
+
64
+ // The capture buffer is a bounded tail, not a log. Both caps exist to stop a
65
+ // chatty tab from turning the bridge into a memory leak: the server holds
66
+ // response bodies for tabs it may never be asked about again.
67
+ NETWORK_TTL_MS: 5 * 60 * 1000,
68
+ NETWORK_GC_INTERVAL_MS: 60000,
69
+ NETWORK_MAX_REQUESTS: 500,
70
+ NETWORK_MAX_BODY_BYTES: 1024 * 1024,
71
+
72
+ // Promotion race: every orphaned Secondary tries to bind the freed port at
73
+ // once, so they stagger themselves rather than all failing but one.
74
+ PROMOTION_DELAY_MS: 500,
75
+ PROMOTION_JITTER_MS: 2000,
76
+
77
+ // More concurrent agents than this sharing one browser stops being useful and
78
+ // starts being a way to lose track of which tab belongs to whom.
79
+ MAX_SECONDARIES: 5,
80
+
81
+ // Lazy-loaded content arrives after the scroll returns. Long enough for the
82
+ // XHR it triggered to land in the capture buffer, short enough that a
83
+ // scroll-and-read loop is not dominated by waiting.
84
+ SMART_SCROLL_SETTLE_MS: 2000,
85
+ };