poltertab 1.4.0 → 1.5.0-beta.2
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 +7 -16
- package/bin/doctor.js +3 -3
- package/bin/setup.js +10 -1
- 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 +25 -1333
- package/mcp-server/memory.js +81 -0
- package/mcp-server/output.js +103 -0
- package/mcp-server/tools.js +439 -0
- package/mcp-server/update-check.js +27 -4
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -82,8 +82,6 @@ The wizard targets Claude Code. Any other MCP client works too — register
|
|
|
82
82
|
Running from a clone instead of npm? Point the client at the file directly:
|
|
83
83
|
`{"command": "node", "args": ["/absolute/path/to/poltertab/mcp-server/index.js"]}`
|
|
84
84
|
|
|
85
|
-
> **Legacy:** This extension began as a ZeroClaw plugin and still speaks ZeroClaw's built-in REST bridge (`backend = "bridge"` in `~/.zeroclaw/config.toml`). That path is optional and not required for the MCP setup above.
|
|
86
|
-
|
|
87
85
|
## Supported MCP Tools
|
|
88
86
|
|
|
89
87
|
The server exposes 23 tools to the LLM (all prefixed with `browser_`). Every
|
|
@@ -211,20 +209,14 @@ next use.
|
|
|
211
209
|
|
|
212
210
|
Selectors are resolved in order:
|
|
213
211
|
|
|
214
|
-
1. **
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
`zc-browser.sh` lets you send commands directly to the legacy ZeroClaw REST bridge for testing (only relevant if you use the optional ZeroClaw bridge path above):
|
|
212
|
+
1. **Snapshot ref** — `@e12`, as returned by `browser_snapshot`. Resolves to the
|
|
213
|
+
exact node the snapshot described, and beats a generated class chain.
|
|
214
|
+
2. **CSS selector** — `#id`, `.class`, `div > span`
|
|
215
|
+
3. **XPath** — `//div[@class="foo"]`
|
|
216
|
+
4. **Text match** — exact text content of an element
|
|
221
217
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
./zc-browser.sh scrape selector="h1"
|
|
225
|
-
./zc-browser.sh get_title
|
|
226
|
-
./zc-browser.sh health
|
|
227
|
-
```
|
|
218
|
+
A miss on all four is retried with shadow-root piercing, so a selector that only
|
|
219
|
+
exists inside a web component still resolves.
|
|
228
220
|
|
|
229
221
|
## Troubleshooting & Options
|
|
230
222
|
|
|
@@ -288,7 +280,6 @@ poltertab/
|
|
|
288
280
|
│ ├── popup.html/js # Connected-agents UI
|
|
289
281
|
│ ├── options.html/js # Port configuration UI
|
|
290
282
|
│ └── icons/
|
|
291
|
-
├── zc-browser.sh # CLI wrapper (legacy)
|
|
292
283
|
└── README.md
|
|
293
284
|
```
|
|
294
285
|
|
package/bin/doctor.js
CHANGED
|
@@ -119,7 +119,7 @@ async function run(argv = []) {
|
|
|
119
119
|
todo.push(
|
|
120
120
|
`${c.red("The extension is older than the server.")} Browser commands can fail\n` +
|
|
121
121
|
` in ways that look like a missing element. Download and reload it:\n` +
|
|
122
|
-
` ${c.cyan(updates.
|
|
122
|
+
` ${c.cyan(updates.extensionUrl(sk.server))}`,
|
|
123
123
|
);
|
|
124
124
|
} else if (sk && sk.kind === "server-behind") {
|
|
125
125
|
todo.push(
|
|
@@ -131,7 +131,7 @@ async function run(argv = []) {
|
|
|
131
131
|
todo.push(
|
|
132
132
|
`PolterTab ${c.bold(check.latest)} is available:\n` +
|
|
133
133
|
` ${c.cyan("npm update -g poltertab")}\n` +
|
|
134
|
-
` ${c.dim("then reload the extension from")} ${c.cyan(updates.
|
|
134
|
+
` ${c.dim("then reload the extension from")} ${c.cyan(updates.extensionUrl(check.latest))}`,
|
|
135
135
|
);
|
|
136
136
|
}
|
|
137
137
|
if (!listening) {
|
|
@@ -150,7 +150,7 @@ async function run(argv = []) {
|
|
|
150
150
|
console.log(
|
|
151
151
|
`\n ${c.yellow("Nothing wrong found, but the extension has never checked in.")}\n` +
|
|
152
152
|
` ${c.dim("Install it, open a page, and run doctor again to compare versions:")}\n` +
|
|
153
|
-
` ${c.cyan(updates.
|
|
153
|
+
` ${c.cyan(updates.extensionUrl(own))}`,
|
|
154
154
|
);
|
|
155
155
|
} else {
|
|
156
156
|
console.log(`\n ${c.green("Nothing to do — both halves agree and are current.")}`);
|
package/bin/setup.js
CHANGED
|
@@ -24,7 +24,16 @@ const SNIPPET_SRC = path.join(PKG, "assets", "claude-md-snippet.md");
|
|
|
24
24
|
|
|
25
25
|
// Bump to the Chrome Web Store listing once it clears review. The GitHub
|
|
26
26
|
// release stays valid either way, so this is a one-line switch.
|
|
27
|
-
|
|
27
|
+
//
|
|
28
|
+
// Pinned to the release for the version being installed, not "latest". Someone
|
|
29
|
+
// who installed a prerelease (npm i -g poltertab@beta) and followed a /latest
|
|
30
|
+
// link would get the previous stable extension, because GitHub's
|
|
31
|
+
// /releases/latest skips prereleases — a fresh, correctly-followed setup that
|
|
32
|
+
// lands in version skew.
|
|
33
|
+
const updates = require("../mcp-server/update-check.js");
|
|
34
|
+
const EXTENSION_URL = updates.extensionUrl(
|
|
35
|
+
require("../package.json").version,
|
|
36
|
+
);
|
|
28
37
|
|
|
29
38
|
// Identifies our section in a CLAUDE.md we do not own. Presence means
|
|
30
39
|
// "already installed" — this is the whole idempotency check for step 2.
|
|
@@ -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
|
+
};
|