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.
- package/README.md +77 -23
- package/mcp-server/bridge.js +593 -0
- package/mcp-server/config.js +85 -0
- package/mcp-server/extract-all.js +159 -0
- package/mcp-server/index.js +68 -931
- package/mcp-server/memory.js +81 -0
- package/mcp-server/output.js +103 -0
- package/mcp-server/tools.js +439 -0
- package/package.json +1 -2
- package/skills/browser-navigation-strategy/SKILL.md +162 -34
|
@@ -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
|
+
};
|