poltertab 1.10.0-beta.1 → 1.10.0
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/bin/pair-flow.js +320 -0
- package/bin/setup.js +196 -13
- package/bin/uploads.js +153 -97
- package/mcp-server/bridge.js +66 -2
- package/mcp-server/config.js +7 -1
- package/mcp-server/pair-host.js +184 -0
- package/mcp-server/uploads-config.js +11 -0
- package/package.json +1 -1
package/bin/pair-flow.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Shared pairing-flow primitives used by both `poltertab setup` and
|
|
4
|
+
// `poltertab uploads pair`. These handle the code-exchange protocol but
|
|
5
|
+
// never print command-specific headers — callers print those.
|
|
6
|
+
|
|
7
|
+
const { addTrustedExtensionId } = require("../mcp-server/uploads-config.js");
|
|
8
|
+
|
|
9
|
+
// ── port resolution ────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function resolveWsPort() {
|
|
12
|
+
try {
|
|
13
|
+
return require("../mcp-server/config.js").resolvePort();
|
|
14
|
+
} catch (_) {
|
|
15
|
+
return 7822;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── server connection ──────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
// Resolves with the open socket; rejects with err.code === "ECONNREFUSED"
|
|
22
|
+
// if nothing is listening, or err.code === "ETIMEDOUT" on timeout.
|
|
23
|
+
function tryConnectToServer(port, timeoutMs = 3000) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
let WebSocket;
|
|
26
|
+
try { WebSocket = require("ws"); } catch (_) {
|
|
27
|
+
reject(new Error("ws module not available")); return;
|
|
28
|
+
}
|
|
29
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
30
|
+
const timer = setTimeout(() => {
|
|
31
|
+
ws.terminate();
|
|
32
|
+
reject(Object.assign(new Error("connection timed out"), { code: "ETIMEDOUT" }));
|
|
33
|
+
}, timeoutMs);
|
|
34
|
+
ws.on("open", () => { clearTimeout(timer); resolve(ws); });
|
|
35
|
+
ws.on("error", (err) => { clearTimeout(timer); reject(err); });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── pair via running Primary server ────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
// Uses the running server as the pair host. The server manages codes and
|
|
42
|
+
// sends uploads_pair_result when the user submits a code.
|
|
43
|
+
//
|
|
44
|
+
// rl — an already-open readline.Interface (shared with the wizard).
|
|
45
|
+
//
|
|
46
|
+
// Returns:
|
|
47
|
+
// { ok: true, extensionId } — success
|
|
48
|
+
// { ok: false, reason: 'skipped' } — user pressed Enter with no code
|
|
49
|
+
// { ok: false, reason: 'locked' } — 5-attempt lockout triggered
|
|
50
|
+
// { ok: false, reason: 'error'|... } — connection or server error
|
|
51
|
+
function pairViaRunningServer(serverWs, rl) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
let done = false;
|
|
54
|
+
|
|
55
|
+
const finish = (result) => {
|
|
56
|
+
if (done) return;
|
|
57
|
+
done = true;
|
|
58
|
+
try { serverWs.close(); } catch (_) {}
|
|
59
|
+
resolve(result);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const sigintHandler = () => { finish({ ok: false, reason: "skipped" }); process.exit(0); };
|
|
63
|
+
process.once("SIGINT", sigintHandler);
|
|
64
|
+
|
|
65
|
+
serverWs.on("error", (err) => finish({ ok: false, reason: "error", error: err }));
|
|
66
|
+
serverWs.on("close", () => {
|
|
67
|
+
if (!done) finish({ ok: false, reason: "server_disconnected" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
serverWs.on("message", (raw) => {
|
|
71
|
+
let msg;
|
|
72
|
+
try { msg = JSON.parse(raw); } catch (_) { return; }
|
|
73
|
+
if (msg.type !== "uploads_pair_result") return;
|
|
74
|
+
|
|
75
|
+
if (msg.ok) {
|
|
76
|
+
finish({ ok: true, extensionId: msg.extensionId });
|
|
77
|
+
} else if (msg.reason === "locked_out") {
|
|
78
|
+
const mins = msg.secsLeft ? Math.ceil(msg.secsLeft / 60) : 15;
|
|
79
|
+
process.stderr.write(
|
|
80
|
+
`\n Too many wrong codes. Pairing is locked for ${mins} minutes.\n` +
|
|
81
|
+
" (You can still pair during the lockout with: poltertab uploads pair --id <extension-id>)\n",
|
|
82
|
+
);
|
|
83
|
+
finish({ ok: false, reason: "locked" });
|
|
84
|
+
} else if (msg.reason === "expired") {
|
|
85
|
+
process.stderr.write(
|
|
86
|
+
" That code expired — the popup is showing a fresh one. Try again.\n",
|
|
87
|
+
);
|
|
88
|
+
askCode();
|
|
89
|
+
} else if (msg.reason === "config_write_failed") {
|
|
90
|
+
process.stderr.write(
|
|
91
|
+
" Pairing failed — could not save the extension ID to config.json.\n" +
|
|
92
|
+
" Check that the file is writable, then try again.\n",
|
|
93
|
+
);
|
|
94
|
+
finish({ ok: false, reason: "config_write_failed" });
|
|
95
|
+
} else {
|
|
96
|
+
const triesLeft =
|
|
97
|
+
msg.triesLeft !== undefined ? ` ${msg.triesLeft} tries left.` : "";
|
|
98
|
+
process.stderr.write(` That code didn’t match.${triesLeft}\n`);
|
|
99
|
+
askCode();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
function askCode() {
|
|
104
|
+
rl.question(" Code (Enter to skip): ", (ans) => {
|
|
105
|
+
const code = (ans || "").trim().toUpperCase().replace(/\s+/g, "");
|
|
106
|
+
if (!code) { finish({ ok: false, reason: "skipped" }); return; }
|
|
107
|
+
serverWs.send(JSON.stringify({ type: "uploads_pair_submit", code }));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
askCode();
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── pair via temporary listener (no Primary running) ──────────────────────
|
|
116
|
+
|
|
117
|
+
const TEMP_IDLE_MS = 5 * 60 * 1000; // 5-minute idle timeout
|
|
118
|
+
|
|
119
|
+
// Binds a pairing-only WebSocket server on port. Waits for a
|
|
120
|
+
// chrome-extension:// socket, generates a code for it, then prompts the
|
|
121
|
+
// user to type the code.
|
|
122
|
+
//
|
|
123
|
+
// opts.onStatus(msg) — called when the extension connects; callers use it
|
|
124
|
+
// to print the "extension connected" status line.
|
|
125
|
+
//
|
|
126
|
+
// Returns { ok: true, extensionId } | { ok: false, reason }.
|
|
127
|
+
async function pairViaTempListener(port, rl, { onStatus } = {}) {
|
|
128
|
+
const WebSocket = require("ws");
|
|
129
|
+
const { PairHost } = require("../mcp-server/pair-host.js");
|
|
130
|
+
|
|
131
|
+
const pairHost = new PairHost();
|
|
132
|
+
let idleTimer = null;
|
|
133
|
+
let server = null;
|
|
134
|
+
let resolved = false;
|
|
135
|
+
|
|
136
|
+
const cleanup = () => {
|
|
137
|
+
clearTimeout(idleTimer);
|
|
138
|
+
if (server) {
|
|
139
|
+
try { server.clients.forEach((c) => c.terminate()); } catch (_) {}
|
|
140
|
+
try { server.close(); } catch (_) {}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
server = new WebSocket.WebSocketServer({ host: "127.0.0.1", port });
|
|
145
|
+
|
|
146
|
+
// Wait for the server to start listening before returning.
|
|
147
|
+
await new Promise((resolve, reject) => {
|
|
148
|
+
server.on("listening", resolve);
|
|
149
|
+
server.on("error", (err) => {
|
|
150
|
+
if (err.code === "EADDRINUSE") {
|
|
151
|
+
reject(Object.assign(new Error(`Port ${port} is in use`), { code: "EADDRINUSE" }));
|
|
152
|
+
} else {
|
|
153
|
+
reject(err);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return new Promise((outerResolve) => {
|
|
159
|
+
const finish = (result) => {
|
|
160
|
+
if (resolved) return;
|
|
161
|
+
resolved = true;
|
|
162
|
+
cleanup();
|
|
163
|
+
outerResolve(result);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const resetIdle = () => {
|
|
167
|
+
clearTimeout(idleTimer);
|
|
168
|
+
idleTimer = setTimeout(() => finish({ ok: false, reason: "skipped" }), TEMP_IDLE_MS);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const sigintHandler = () => { finish({ ok: false, reason: "skipped" }); process.exit(0); };
|
|
172
|
+
process.once("SIGINT", sigintHandler);
|
|
173
|
+
|
|
174
|
+
resetIdle();
|
|
175
|
+
|
|
176
|
+
server.on("connection", (ws, req) => {
|
|
177
|
+
const origin = (req.headers && req.headers.origin) || null;
|
|
178
|
+
resetIdle();
|
|
179
|
+
|
|
180
|
+
// Only accept chrome-extension:// sockets; silently close everything else.
|
|
181
|
+
if (!origin || !origin.startsWith("chrome-extension://")) {
|
|
182
|
+
ws.close(4000, "pairing-only listener");
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
ws.on("message", (raw) => {
|
|
187
|
+
resetIdle();
|
|
188
|
+
let msg;
|
|
189
|
+
try { msg = JSON.parse(raw); } catch (_) { return; }
|
|
190
|
+
|
|
191
|
+
if (msg.type === "extension_ready") {
|
|
192
|
+
// Generate a code only on first contact; subsequent pings must not regenerate.
|
|
193
|
+
if (!pairHost._socketCodes.has(ws)) {
|
|
194
|
+
pairHost.generateForSocket(ws, origin);
|
|
195
|
+
if (onStatus) onStatus("extension connected — the code is in its popup");
|
|
196
|
+
askCode();
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (msg.type === "ping") {
|
|
202
|
+
// Keep-alive — just reset the idle timer (already done above); do NOT regenerate.
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (msg.type === "pair_code_request") {
|
|
207
|
+
pairHost.handleCodeRequest(ws, origin);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Popup/options asking whether this extension is paired yet.
|
|
212
|
+
// Reply with uploads_not_paired so the UI can show the pair code.
|
|
213
|
+
if (msg.type === "uploads_get_config") {
|
|
214
|
+
try {
|
|
215
|
+
if (ws.readyState === 1 /* OPEN */) {
|
|
216
|
+
const reply = { type: "uploads_not_paired", pairCode: pairHost._socketCodes.get(ws) || null };
|
|
217
|
+
if (msg._replyId !== undefined) reply._replyId = msg._replyId;
|
|
218
|
+
ws.send(JSON.stringify(reply));
|
|
219
|
+
}
|
|
220
|
+
} catch (_) {}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Non-pairing message from a Secondary or AI tool dialing in during setup.
|
|
225
|
+
try {
|
|
226
|
+
if (ws.readyState === 1 /* OPEN */) {
|
|
227
|
+
ws.send(JSON.stringify({
|
|
228
|
+
type: "error",
|
|
229
|
+
error: "pairing mode — restart your AI tool for browser control",
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
} catch (_) {}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
ws.on("close", () => pairHost.onSocketClose(ws));
|
|
236
|
+
ws.on("error", () => pairHost.onSocketClose(ws));
|
|
237
|
+
|
|
238
|
+
function askCode() {
|
|
239
|
+
rl.question(" Code (Enter to skip): ", (ans) => {
|
|
240
|
+
const code = (ans || "").trim().toUpperCase().replace(/\s+/g, "");
|
|
241
|
+
if (!code) { finish({ ok: false, reason: "skipped" }); return; }
|
|
242
|
+
|
|
243
|
+
const result = pairHost.submitCode(code);
|
|
244
|
+
if (result.ok) {
|
|
245
|
+
const bareId = result.extensionId
|
|
246
|
+
.replace(/^chrome-extension:\/\//, "")
|
|
247
|
+
.replace(/\/$/, "");
|
|
248
|
+
let writeErr = null;
|
|
249
|
+
try { addTrustedExtensionId(bareId); } catch (e) { writeErr = e; }
|
|
250
|
+
if (writeErr) {
|
|
251
|
+
// Re-push to the specific extension that matched, not the enclosing socket.
|
|
252
|
+
if (result.extWs && result.extWs.readyState === WebSocket.OPEN) {
|
|
253
|
+
pairHost.generateForSocket(result.extWs, result.extensionId);
|
|
254
|
+
}
|
|
255
|
+
process.stderr.write(
|
|
256
|
+
" Pairing failed — could not save the extension ID to config.json.\n" +
|
|
257
|
+
" Check that the file is writable, then try again.\n",
|
|
258
|
+
);
|
|
259
|
+
finish({ ok: false, reason: "config_write_failed" });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
if (result.extWs && result.extWs.readyState === WebSocket.OPEN) {
|
|
264
|
+
result.extWs.send(JSON.stringify({ type: "uploads_paired" }));
|
|
265
|
+
}
|
|
266
|
+
} catch (_) {}
|
|
267
|
+
finish({ ok: true, extensionId: bareId });
|
|
268
|
+
} else if (result.reason === "locked_out") {
|
|
269
|
+
const mins = result.secsLeft ? Math.ceil(result.secsLeft / 60) : 15;
|
|
270
|
+
process.stderr.write(
|
|
271
|
+
`\n Too many wrong codes. Pairing is locked for ${mins} minutes.\n` +
|
|
272
|
+
" (You can still pair during the lockout with: poltertab uploads pair --id <extension-id>)\n",
|
|
273
|
+
);
|
|
274
|
+
finish({ ok: false, reason: "locked" });
|
|
275
|
+
} else if (result.reason === "expired") {
|
|
276
|
+
process.stderr.write(
|
|
277
|
+
" That code expired — the popup is showing a fresh one. Try again.\n",
|
|
278
|
+
);
|
|
279
|
+
askCode();
|
|
280
|
+
} else {
|
|
281
|
+
const triesLeft =
|
|
282
|
+
result.triesLeft !== undefined ? ` ${result.triesLeft} tries left.` : "";
|
|
283
|
+
process.stderr.write(` That code didn’t match.${triesLeft}\n`);
|
|
284
|
+
askCode();
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── poltertab server probe ─────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
// Returns true when the server at port is a poltertab Primary.
|
|
295
|
+
// Prevents treating an unrelated WebSocket server as the pair host.
|
|
296
|
+
async function probeIsPoltertab(port) {
|
|
297
|
+
const http = require("http");
|
|
298
|
+
return new Promise((resolve) => {
|
|
299
|
+
const req = http.get({ host: "127.0.0.1", port, path: "/status", timeout: 2000 }, (res) => {
|
|
300
|
+
let data = "";
|
|
301
|
+
res.on("data", (c) => { data += c; });
|
|
302
|
+
res.on("end", () => {
|
|
303
|
+
try {
|
|
304
|
+
const body = JSON.parse(data);
|
|
305
|
+
resolve(body.role === "primary");
|
|
306
|
+
} catch (_) { resolve(false); }
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
req.on("error", () => resolve(false));
|
|
310
|
+
req.on("timeout", () => { req.destroy(); resolve(false); });
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
module.exports = {
|
|
315
|
+
resolveWsPort,
|
|
316
|
+
tryConnectToServer,
|
|
317
|
+
probeIsPoltertab,
|
|
318
|
+
pairViaRunningServer,
|
|
319
|
+
pairViaTempListener,
|
|
320
|
+
};
|
package/bin/setup.js
CHANGED
|
@@ -30,6 +30,8 @@ const SNIPPET_SRC = path.join(PKG, "assets", "claude-md-snippet.md");
|
|
|
30
30
|
// link would get the previous stable extension, because GitHub's
|
|
31
31
|
// /releases/latest skips prereleases — a fresh, correctly-followed setup that
|
|
32
32
|
// lands in version skew.
|
|
33
|
+
const { addAllowlistEntry } = require("../mcp-server/uploads-config.js");
|
|
34
|
+
const { validateAllowlistEntry } = require("../mcp-server/upload-paths.js");
|
|
33
35
|
const updates = require("../mcp-server/update-check.js");
|
|
34
36
|
const EXTENSION_URL = updates.extensionUrl(
|
|
35
37
|
require("../package.json").version,
|
|
@@ -39,6 +41,10 @@ const EXTENSION_URL = updates.extensionUrl(
|
|
|
39
41
|
// "already installed" — this is the whole idempotency check for step 2.
|
|
40
42
|
const MARKER = "<!-- poltertab -->";
|
|
41
43
|
|
|
44
|
+
// Default folder offered in the uploads prompt. Must never be inside
|
|
45
|
+
// buildAlwaysBlocked — a unit test (H7) pins this.
|
|
46
|
+
const DEFAULT_UPLOAD_FOLDER = "~/Downloads";
|
|
47
|
+
|
|
42
48
|
const c = {
|
|
43
49
|
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
44
50
|
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
@@ -150,13 +156,163 @@ async function chooseScope(rl) {
|
|
|
150
156
|
}
|
|
151
157
|
}
|
|
152
158
|
|
|
153
|
-
|
|
159
|
+
// --- Section 1: folder prompt -----------------------------------------------
|
|
160
|
+
|
|
161
|
+
// Shows the uploads-folder prompt and updates the allowlist.
|
|
162
|
+
// Uses the wizard's existing readline interface (rl); never opens a second one.
|
|
163
|
+
// Caller guarantees stdin is a TTY before calling.
|
|
164
|
+
//
|
|
165
|
+
// Exported for direct unit testing.
|
|
166
|
+
async function runFolderPrompt(rl, {
|
|
167
|
+
homeDir = os.homedir(),
|
|
168
|
+
addFn = addAllowlistEntry,
|
|
169
|
+
validateFn = validateAllowlistEntry,
|
|
170
|
+
} = {}) {
|
|
171
|
+
console.log(`\n Let agents upload files from a folder? [${DEFAULT_UPLOAD_FOLDER}]`);
|
|
172
|
+
console.log(" Enter = allow ~/Downloads · type another folder · n = skip");
|
|
173
|
+
|
|
174
|
+
const MAX_RETRIES = 2;
|
|
175
|
+
let retries = 0;
|
|
176
|
+
|
|
177
|
+
for (;;) {
|
|
178
|
+
const raw = (await ask(rl, " > ")).trim();
|
|
179
|
+
|
|
180
|
+
if (raw === "n" || raw === "N") {
|
|
181
|
+
console.log(" – uploads skipped enable later: poltertab uploads allow <dir>");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const folderPath = raw === "" ? DEFAULT_UPLOAD_FOLDER : raw;
|
|
186
|
+
|
|
187
|
+
const v = validateFn(folderPath, homeDir);
|
|
188
|
+
if (!v.ok) {
|
|
189
|
+
console.log(` ${c.yellow("!")} ${v.errorText}`);
|
|
190
|
+
retries++;
|
|
191
|
+
if (retries >= MAX_RETRIES) {
|
|
192
|
+
console.log(" – uploads skipped (poltertab uploads allow <dir> any time)");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// re-ask; the loop continues
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const added = addFn(folderPath);
|
|
200
|
+
if (added) {
|
|
201
|
+
console.log(` ${c.green("✓")} uploads folder ${c.dim(folderPath)}`);
|
|
202
|
+
} else {
|
|
203
|
+
console.log(` ${c.green("✓")} uploads folder ${c.dim(folderPath)} (already set)`);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- Section 3: setup pairing step ------------------------------------------
|
|
210
|
+
|
|
211
|
+
// Shows the extension instructions and runs the pairing code loop.
|
|
212
|
+
// Uses the wizard's existing readline interface (rl).
|
|
213
|
+
// Caller guarantees stdin is a TTY before calling.
|
|
214
|
+
//
|
|
215
|
+
// opts.pairFlow — injectable pair function for unit tests. Signature:
|
|
216
|
+
// async (rl) => { ok, extensionId? } | { ok: false, reason }
|
|
217
|
+
// When not injected, the real server/temp-listener flow is used.
|
|
218
|
+
//
|
|
219
|
+
// Exported for direct unit testing.
|
|
220
|
+
async function runSetupPairingStep(rl, { pairFlow } = {}) {
|
|
221
|
+
const {
|
|
222
|
+
resolveWsPort,
|
|
223
|
+
tryConnectToServer,
|
|
224
|
+
probeIsPoltertab,
|
|
225
|
+
pairViaRunningServer,
|
|
226
|
+
pairViaTempListener,
|
|
227
|
+
} = require("./pair-flow.js");
|
|
228
|
+
|
|
229
|
+
console.log(`\n ${c.bold("Last step — connect the extension:")}`);
|
|
230
|
+
console.log(` 1. Download and unzip: ${c.cyan(EXTENSION_URL)}`);
|
|
231
|
+
console.log(" 2. chrome://extensions → turn on Developer mode → Load unpacked → pick the folder");
|
|
232
|
+
console.log(" 3. Click the PolterTab icon in Chrome and type the 4-letter code it shows.");
|
|
233
|
+
console.log("");
|
|
234
|
+
|
|
235
|
+
// Use the injected flow (tests) or the real connection flow.
|
|
236
|
+
if (!pairFlow) {
|
|
237
|
+
const port = resolveWsPort();
|
|
238
|
+
let serverWs = null;
|
|
239
|
+
try {
|
|
240
|
+
serverWs = await tryConnectToServer(port);
|
|
241
|
+
// Verify the server is actually poltertab before treating it as the pair host.
|
|
242
|
+
if (!(await probeIsPoltertab(port))) {
|
|
243
|
+
serverWs.terminate();
|
|
244
|
+
serverWs = null;
|
|
245
|
+
console.log(
|
|
246
|
+
` Port ${port} is in use by something that isn’t poltertab.` +
|
|
247
|
+
" Close it or set a different port in ~/.poltertab/config.json, then retry.",
|
|
248
|
+
);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
if (err.code !== "ECONNREFUSED" && err.code !== "ENOENT" && err.code !== "ETIMEDOUT") {
|
|
253
|
+
console.log(` Could not reach port ${port}: ${err.message}`);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// Nothing listening — use temp listener below.
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (serverWs) {
|
|
260
|
+
process.stdout.write(" waiting for the extension to connect…\n");
|
|
261
|
+
pairFlow = (r) => pairViaRunningServer(serverWs, r);
|
|
262
|
+
} else {
|
|
263
|
+
process.stdout.write(" waiting for the extension to connect…\n");
|
|
264
|
+
pairFlow = async (r) => {
|
|
265
|
+
try {
|
|
266
|
+
return await pairViaTempListener(port, r, {
|
|
267
|
+
onStatus: (msg) => process.stdout.write(` ${msg}\n`),
|
|
268
|
+
});
|
|
269
|
+
} catch (e) {
|
|
270
|
+
if (e.code === "EADDRINUSE") {
|
|
271
|
+
console.log(
|
|
272
|
+
` Port ${port} is in use by something that isn’t poltertab.` +
|
|
273
|
+
" Close it or set a different port in ~/.poltertab/config.json, then retry.",
|
|
274
|
+
);
|
|
275
|
+
return { ok: false, reason: "skipped" };
|
|
276
|
+
}
|
|
277
|
+
throw e;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const result = await pairFlow(rl);
|
|
284
|
+
|
|
285
|
+
if (result.ok) {
|
|
286
|
+
const shortId = result.extensionId.slice(0, 8);
|
|
287
|
+
console.log(
|
|
288
|
+
` ${c.green("✓")} paired extension ${shortId}…` +
|
|
289
|
+
" · uploads panel is live in the extension",
|
|
290
|
+
);
|
|
291
|
+
console.log(c.dim(` undo: poltertab uploads unpair ${result.extensionId}`));
|
|
292
|
+
} else if (result.reason === "skipped") {
|
|
293
|
+
console.log(
|
|
294
|
+
" – pairing skipped run poltertab uploads pair once the extension is loaded",
|
|
295
|
+
);
|
|
296
|
+
} else if (result.reason === "locked") {
|
|
297
|
+
console.log(" Too many wrong codes. Pairing is locked for 15 minutes.");
|
|
298
|
+
}
|
|
299
|
+
// Other reasons (error, server_disconnected): silent skip, don't abort setup.
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// --- wizard ----------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
async function run(argv = [], {
|
|
305
|
+
home = os.homedir(),
|
|
306
|
+
cwd = process.cwd(),
|
|
307
|
+
isTTY = process.stdin.isTTY,
|
|
308
|
+
rl: injectedRl = null,
|
|
309
|
+
} = {}) {
|
|
154
310
|
console.log(`\n ${c.bold("PolterTab")} — browser control for AI agents\n`);
|
|
155
311
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
output: process.stdout
|
|
159
|
-
|
|
312
|
+
// Open one readline interface for all prompts. If tests inject one, use it.
|
|
313
|
+
const rl = injectedRl || (isTTY
|
|
314
|
+
? readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
315
|
+
: null);
|
|
160
316
|
|
|
161
317
|
let scope;
|
|
162
318
|
try {
|
|
@@ -164,12 +320,15 @@ async function run(argv = []) {
|
|
|
164
320
|
? "global"
|
|
165
321
|
: argv.includes("--project")
|
|
166
322
|
? "project"
|
|
167
|
-
:
|
|
168
|
-
|
|
169
|
-
|
|
323
|
+
: rl
|
|
324
|
+
? await chooseScope(rl)
|
|
325
|
+
: (() => { throw new Error("Choose --global or --project when stdin is not a TTY"); })();
|
|
326
|
+
} catch (err) {
|
|
327
|
+
if (rl && !injectedRl) rl.close();
|
|
328
|
+
throw err;
|
|
170
329
|
}
|
|
171
330
|
|
|
172
|
-
const paths = resolvePaths(scope);
|
|
331
|
+
const paths = resolvePaths(scope, { home, cwd });
|
|
173
332
|
console.log("");
|
|
174
333
|
|
|
175
334
|
// Each step reports what it actually did. A step that fails is reported with
|
|
@@ -199,10 +358,31 @@ async function run(argv = []) {
|
|
|
199
358
|
console.log(c.dim(` run: claude mcp add --scope ${paths.mcpScope} poltertab poltertab`));
|
|
200
359
|
}
|
|
201
360
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
361
|
+
// Section 1: optional uploads folder prompt (TTY only).
|
|
362
|
+
if (isTTY && rl) {
|
|
363
|
+
try {
|
|
364
|
+
await runFolderPrompt(rl, { homeDir: home });
|
|
365
|
+
} catch (err) {
|
|
366
|
+
console.log(` ${c.yellow("!")} uploads folder skipped — ${err.message}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Section 3: optional pairing step (TTY only).
|
|
371
|
+
if (isTTY && rl) {
|
|
372
|
+
try {
|
|
373
|
+
await runSetupPairingStep(rl);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
console.log(` ${c.yellow("!")} pairing step failed — ${err.message}`);
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
// Non-TTY: show extension instructions so the output is still useful.
|
|
379
|
+
console.log(`\n ${c.bold("One step left — install the Chrome extension:")}`);
|
|
380
|
+
console.log(` ${c.cyan(EXTENSION_URL)}`);
|
|
381
|
+
console.log(c.dim(" unzip it, then chrome://extensions → Developer mode → Load unpacked\n"));
|
|
382
|
+
console.log(` ${c.dim("Restart Claude Code to pick up the new tools.")}\n`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (rl && !injectedRl) rl.close();
|
|
206
386
|
}
|
|
207
387
|
|
|
208
388
|
module.exports = {
|
|
@@ -211,8 +391,11 @@ module.exports = {
|
|
|
211
391
|
installSkill,
|
|
212
392
|
updateClaudeMd,
|
|
213
393
|
registerServer,
|
|
394
|
+
runFolderPrompt,
|
|
395
|
+
runSetupPairingStep,
|
|
214
396
|
MARKER,
|
|
215
397
|
EXTENSION_URL,
|
|
398
|
+
DEFAULT_UPLOAD_FOLDER,
|
|
216
399
|
};
|
|
217
400
|
|
|
218
401
|
// Guard so the test suite can require this file without triggering a wizard
|
package/bin/uploads.js
CHANGED
|
@@ -9,9 +9,18 @@ const {
|
|
|
9
9
|
addAllowlistEntry,
|
|
10
10
|
removeAllowlistEntry,
|
|
11
11
|
addTrustedExtensionId,
|
|
12
|
+
getTrustedExtensionIds,
|
|
13
|
+
removeTrustedExtensionId,
|
|
12
14
|
} = require("../mcp-server/uploads-config.js");
|
|
13
15
|
const { getRecentLog } = require("../mcp-server/uploads-log.js");
|
|
14
16
|
const { validateAllowlistEntry, buildAlwaysBlocked } = require("../mcp-server/upload-paths.js");
|
|
17
|
+
const {
|
|
18
|
+
resolveWsPort,
|
|
19
|
+
tryConnectToServer,
|
|
20
|
+
probeIsPoltertab,
|
|
21
|
+
pairViaRunningServer,
|
|
22
|
+
pairViaTempListener,
|
|
23
|
+
} = require("./pair-flow.js");
|
|
15
24
|
|
|
16
25
|
async function run(argv) {
|
|
17
26
|
const sub = argv[0];
|
|
@@ -103,134 +112,181 @@ async function run(argv) {
|
|
|
103
112
|
return;
|
|
104
113
|
}
|
|
105
114
|
|
|
115
|
+
if (sub === "unpair") {
|
|
116
|
+
await runUnpair(argv.slice(1));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
106
120
|
console.error(`\n Unknown subcommand: ${sub}`);
|
|
107
|
-
console.error(" Usage: poltertab uploads list|allow <dir>|revoke <dir>|log [-n N]|pair [--id <id>]\n");
|
|
121
|
+
console.error(" Usage: poltertab uploads list|allow <dir>|revoke <dir>|log [-n N]|pair [--id <id>]|unpair [--id <id>]\n");
|
|
108
122
|
process.exit(1);
|
|
109
123
|
}
|
|
110
124
|
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const { resolvePort } = require("../mcp-server/config.js");
|
|
115
|
-
return resolvePort();
|
|
116
|
-
} catch (_) {
|
|
117
|
-
return 7822;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
125
|
+
// (pair primitives live in pair-flow.js and are imported above)
|
|
126
|
+
|
|
127
|
+
// ── poltertab uploads pair ─────────────────────────────────────────────────
|
|
120
128
|
|
|
121
|
-
// poltertab uploads pair — link a Chrome extension by its ID so it may send
|
|
122
|
-
// upload config/log WS messages. Interactive by default (requires TTY);
|
|
123
|
-
// --id <id> skips the prompt for scripted human use.
|
|
124
129
|
async function runPair(argv) {
|
|
130
|
+
// --id is kept for backward compatibility: pair by bare extension ID directly.
|
|
125
131
|
const idFlagIdx = argv.indexOf("--id");
|
|
126
132
|
const flagId = idFlagIdx !== -1 ? argv[idFlagIdx + 1] : null;
|
|
127
133
|
|
|
128
|
-
if (
|
|
134
|
+
if (flagId) {
|
|
135
|
+
if (!/^[a-p]{32}$/.test(flagId)) {
|
|
136
|
+
console.error(`\nInvalid extension ID: ${flagId}`);
|
|
137
|
+
console.error("Chrome extension IDs are 32 lowercase letters (a-p).\n");
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
const added = addTrustedExtensionId(flagId);
|
|
141
|
+
if (added) {
|
|
142
|
+
console.log(`\n Paired: ${flagId}`);
|
|
143
|
+
console.log(" Upload config access is now enabled for this extension.");
|
|
144
|
+
console.log(` (undo: poltertab uploads unpair ${flagId})\n`);
|
|
145
|
+
} else {
|
|
146
|
+
console.log(`\n Already paired: ${flagId}\n`);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!process.stdin.isTTY) {
|
|
129
152
|
console.error("\nError: poltertab uploads pair requires an interactive terminal.\n" +
|
|
130
153
|
"Use --id <extension-id> to pair non-interactively.\n");
|
|
131
154
|
process.exit(1);
|
|
132
155
|
}
|
|
133
156
|
|
|
134
|
-
|
|
157
|
+
const readline = require("readline");
|
|
158
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
135
159
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
const port = resolveWsPort();
|
|
161
|
+
|
|
162
|
+
let serverWs = null;
|
|
163
|
+
try {
|
|
164
|
+
serverWs = await tryConnectToServer(port);
|
|
165
|
+
// Verify the server is actually poltertab, not an unrelated WS server.
|
|
166
|
+
if (!(await probeIsPoltertab(port))) {
|
|
167
|
+
serverWs.terminate();
|
|
168
|
+
serverWs = null;
|
|
169
|
+
rl.close();
|
|
170
|
+
console.error(
|
|
171
|
+
`\n Port ${port} is in use by something that isn't poltertab.` +
|
|
172
|
+
" Close it or set a different port in ~/.poltertab/config.json, then retry.\n",
|
|
173
|
+
);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
} catch (err) {
|
|
177
|
+
if (err.code === "ETIMEDOUT") {
|
|
178
|
+
rl.close();
|
|
179
|
+
console.error(
|
|
180
|
+
`\n Port ${port} is in use by something that isn't poltertab.` +
|
|
181
|
+
" Close it or set a different port in ~/.poltertab/config.json, then retry.\n",
|
|
182
|
+
);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
if (err.code !== "ECONNREFUSED" && err.code !== "ENOENT") {
|
|
186
|
+
rl.close();
|
|
187
|
+
console.error(`\nCould not connect to port ${port}: ${err.message}\n`);
|
|
145
188
|
process.exit(1);
|
|
146
189
|
}
|
|
190
|
+
// Nothing listening — start temp listener.
|
|
191
|
+
}
|
|
147
192
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
193
|
+
let result;
|
|
194
|
+
if (serverWs) {
|
|
195
|
+
result = await pairViaRunningServer(serverWs, rl);
|
|
196
|
+
} else {
|
|
197
|
+
console.log(
|
|
198
|
+
`\n No poltertab server found. Starting a temporary pairing listener on port ${port}.`,
|
|
199
|
+
);
|
|
200
|
+
console.log(" Open the extension popup — it will connect automatically and show a 4-letter code.\n");
|
|
201
|
+
process.stdout.write(" waiting for the extension to connect…\n");
|
|
151
202
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
203
|
+
try {
|
|
204
|
+
result = await pairViaTempListener(port, rl, {
|
|
205
|
+
onStatus: (msg) => process.stdout.write(` ${msg}\n`),
|
|
206
|
+
});
|
|
207
|
+
} catch (err) {
|
|
208
|
+
rl.close();
|
|
209
|
+
if (err.code === "EADDRINUSE") {
|
|
210
|
+
console.error(
|
|
211
|
+
`\n Port ${port} is in use by something that isn't poltertab.` +
|
|
212
|
+
" Close it or set a different port in ~/.poltertab/config.json, then retry.\n",
|
|
213
|
+
);
|
|
214
|
+
} else {
|
|
215
|
+
console.error(`\n Failed to start pairing listener: ${err.message}\n`);
|
|
216
|
+
}
|
|
155
217
|
process.exit(1);
|
|
156
218
|
}
|
|
219
|
+
}
|
|
157
220
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
221
|
+
rl.close();
|
|
222
|
+
|
|
223
|
+
if (result.ok) {
|
|
224
|
+
const shortId = result.extensionId.slice(0, 8);
|
|
225
|
+
console.log(
|
|
226
|
+
`\n ✓ paired extension ${shortId}… · uploads panel is live in the extension`,
|
|
227
|
+
);
|
|
228
|
+
console.log(` (undo: poltertab uploads unpair ${result.extensionId})\n`);
|
|
229
|
+
} else if (result.reason === "skipped") {
|
|
230
|
+
console.log("\n – pairing skipped run poltertab uploads pair once the extension is loaded\n");
|
|
231
|
+
} else if (result.reason === "locked") {
|
|
232
|
+
console.error("\n Too many wrong codes. Pairing is locked for 15 minutes.\n");
|
|
233
|
+
process.exit(1);
|
|
234
|
+
} else if (result.reason === "config_write_failed") {
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── poltertab uploads unpair ───────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
async function runUnpair(argv) {
|
|
242
|
+
// Accept positional id: `poltertab uploads unpair <id>` (the form success hints print).
|
|
243
|
+
// Also accept --id <id> for backward compatibility.
|
|
244
|
+
const idFlagIdx = argv.indexOf("--id");
|
|
245
|
+
const flagId = idFlagIdx !== -1 ? argv[idFlagIdx + 1] : null;
|
|
246
|
+
const positionalId = !flagId && argv.length > 0 && !argv[0].startsWith("-") ? argv[0] : null;
|
|
247
|
+
const targetId = flagId || positionalId;
|
|
248
|
+
|
|
249
|
+
if (targetId) {
|
|
250
|
+
const removed = removeTrustedExtensionId(targetId);
|
|
251
|
+
if (removed) {
|
|
252
|
+
console.log(`\n Unpaired: ${targetId}\n`);
|
|
179
253
|
} else {
|
|
180
|
-
console.
|
|
181
|
-
process.exit(1);
|
|
254
|
+
console.log(`\n Not found in paired extensions: ${targetId}\n`);
|
|
182
255
|
}
|
|
256
|
+
return;
|
|
183
257
|
}
|
|
184
258
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
259
|
+
// No-id form: list all paired ids, require TTY + y/n confirmation before removing.
|
|
260
|
+
const ids = getTrustedExtensionIds();
|
|
261
|
+
if (ids.length === 0) {
|
|
262
|
+
console.log("\n No extensions are currently paired.\n");
|
|
263
|
+
return;
|
|
189
264
|
}
|
|
190
265
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
266
|
+
if (!process.stdin.isTTY) {
|
|
267
|
+
console.error(
|
|
268
|
+
"\n 'poltertab uploads unpair' with no id requires an interactive terminal.\n" +
|
|
269
|
+
" Pass the extension id to remove a specific one: poltertab uploads unpair <id>\n",
|
|
270
|
+
);
|
|
271
|
+
process.exit(1);
|
|
197
272
|
}
|
|
198
|
-
}
|
|
199
273
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}, 5000);
|
|
215
|
-
ws.on("open", () => {
|
|
216
|
-
ws.send(JSON.stringify({ type: "uploads_get_origins" }));
|
|
217
|
-
});
|
|
218
|
-
ws.on("message", (raw) => {
|
|
219
|
-
clearTimeout(timer);
|
|
220
|
-
try {
|
|
221
|
-
const msg = JSON.parse(raw);
|
|
222
|
-
if (msg.type === "uploads_origins") {
|
|
223
|
-
ws.close();
|
|
224
|
-
resolve(Array.isArray(msg.origins) ? msg.origins : []);
|
|
225
|
-
}
|
|
226
|
-
} catch (_) {
|
|
227
|
-
ws.close();
|
|
228
|
-
reject(new Error("Invalid response from server"));
|
|
274
|
+
console.log("\n Currently paired extensions:");
|
|
275
|
+
for (const id of ids) console.log(` ${id}`);
|
|
276
|
+
console.log();
|
|
277
|
+
|
|
278
|
+
const readline = require("readline");
|
|
279
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
280
|
+
await new Promise((resolve) => {
|
|
281
|
+
rl.question(" Remove ALL paired extensions? [y/N] ", (ans) => {
|
|
282
|
+
rl.close();
|
|
283
|
+
if ((ans || "").trim().toLowerCase() === "y") {
|
|
284
|
+
for (const id of ids) removeTrustedExtensionId(id);
|
|
285
|
+
console.log(`\n Unpaired ${ids.length} extension${ids.length === 1 ? "" : "s"}.\n`);
|
|
286
|
+
} else {
|
|
287
|
+
console.log("\n Cancelled.\n");
|
|
229
288
|
}
|
|
230
|
-
|
|
231
|
-
ws.on("error", (err) => {
|
|
232
|
-
clearTimeout(timer);
|
|
233
|
-
reject(err);
|
|
289
|
+
resolve();
|
|
234
290
|
});
|
|
235
291
|
});
|
|
236
292
|
}
|
package/mcp-server/bridge.js
CHANGED
|
@@ -35,7 +35,8 @@ const {
|
|
|
35
35
|
} = require("./config.js");
|
|
36
36
|
const updates = require("./update-check.js");
|
|
37
37
|
const OWN_VERSION = require("./../package.json").version;
|
|
38
|
-
const { getAllowlist, addAllowlistEntry, removeAllowlistEntry, getTrustedExtensionIds } = require("./uploads-config.js");
|
|
38
|
+
const { getAllowlist, addAllowlistEntry, removeAllowlistEntry, getTrustedExtensionIds, addTrustedExtensionId } = require("./uploads-config.js");
|
|
39
|
+
const { PairHost } = require("./pair-host.js");
|
|
39
40
|
const { logUpload, getRecentLog } = require("./uploads-log.js");
|
|
40
41
|
const { buildAlwaysBlocked, validateAllowlistEntry } = require("./upload-paths.js");
|
|
41
42
|
const fs = require("fs");
|
|
@@ -69,6 +70,10 @@ function isPaired() {
|
|
|
69
70
|
|
|
70
71
|
let WS_PORT = resolvePort();
|
|
71
72
|
|
|
73
|
+
// Manages in-memory pair codes for the current Primary session. Null when this
|
|
74
|
+
// node is a Secondary (Secondary never generates codes; the Primary does).
|
|
75
|
+
let pairHost = null;
|
|
76
|
+
|
|
72
77
|
// Populated when the extension connects; stays null until then, which is itself
|
|
73
78
|
// worth reporting — a skew warning must not fire on "no extension yet".
|
|
74
79
|
let extensionVersion = null;
|
|
@@ -301,6 +306,7 @@ function allowOrigin(info, cb) {
|
|
|
301
306
|
}
|
|
302
307
|
|
|
303
308
|
function setupPrimaryServer() {
|
|
309
|
+
pairHost = new PairHost();
|
|
304
310
|
const http = require("http");
|
|
305
311
|
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
306
312
|
// The source address alone does not stop DNS rebinding: a page at
|
|
@@ -1151,12 +1157,67 @@ function setupPrimaryWss(wss) {
|
|
|
1151
1157
|
// A socket that died mid-handshake reconnects on its own.
|
|
1152
1158
|
}
|
|
1153
1159
|
}
|
|
1160
|
+
// Push a pair code to unpaired extensions so the UI can display it
|
|
1161
|
+
// without the user running any CLI command first.
|
|
1162
|
+
// Per-origin check: an already-trusted extension must not receive a code.
|
|
1163
|
+
if (pairHost && ws.handshakeOrigin && !pairHost._socketCodes.has(ws) && !isUploadsAuthorized(ws.handshakeOrigin)) {
|
|
1164
|
+
pairHost.generateForSocket(ws, ws.handshakeOrigin);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// pair_code_request — extension asks for a (re)fresh of its pair code.
|
|
1171
|
+
// Only accepted from unpaired extension sockets; throttled per socket (30 s).
|
|
1172
|
+
if (msg.type === "pair_code_request") {
|
|
1173
|
+
if (!ws.handshakeOrigin || !ws.handshakeOrigin.startsWith("chrome-extension://")) return;
|
|
1174
|
+
if (isUploadsAuthorized(ws.handshakeOrigin) || !pairHost) return;
|
|
1175
|
+
pairHost.handleCodeRequest(ws, ws.handshakeOrigin);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// uploads_pair_submit — CLI submits a 4-char code to pair an extension.
|
|
1180
|
+
// Originless (CLI) callers only; extension sockets must not self-pair.
|
|
1181
|
+
if (msg.type === "uploads_pair_submit") {
|
|
1182
|
+
if (ws.handshakeOrigin) return; // extension socket — ignore
|
|
1183
|
+
if (!pairHost) {
|
|
1184
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1185
|
+
ws.send(JSON.stringify({ type: "uploads_pair_result", ok: false, reason: "no_active_code" }));
|
|
1186
|
+
}
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const result = pairHost.submitCode(msg.code);
|
|
1190
|
+
if (result.ok) {
|
|
1191
|
+
// Store the bare ID (without chrome-extension:// prefix).
|
|
1192
|
+
const bareId = result.extensionId.slice("chrome-extension://".length).replace(/\/$/, "");
|
|
1193
|
+
let trustErr = null;
|
|
1194
|
+
try { addTrustedExtensionId(bareId); } catch (e) { trustErr = e; }
|
|
1195
|
+
if (trustErr) {
|
|
1196
|
+
// Config write failed — do NOT claim success to avoid a paired-but-not-stored split.
|
|
1197
|
+
// Re-push a fresh code so the extension popup remains usable for a retry.
|
|
1198
|
+
if (result.extWs && result.extWs.readyState === WebSocket.OPEN) {
|
|
1199
|
+
pairHost.generateForSocket(result.extWs, result.extensionId);
|
|
1200
|
+
}
|
|
1201
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1202
|
+
ws.send(JSON.stringify({ type: "uploads_pair_result", ok: false, reason: "config_write_failed" }));
|
|
1203
|
+
}
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (result.extWs && result.extWs.readyState === WebSocket.OPEN) {
|
|
1207
|
+
result.extWs.send(JSON.stringify({ type: "uploads_paired" }));
|
|
1208
|
+
}
|
|
1209
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1210
|
+
ws.send(JSON.stringify({ type: "uploads_pair_result", ok: true, extensionId: bareId }));
|
|
1211
|
+
}
|
|
1212
|
+
} else {
|
|
1213
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1214
|
+
ws.send(JSON.stringify({ type: "uploads_pair_result", ...result }));
|
|
1215
|
+
}
|
|
1154
1216
|
}
|
|
1155
1217
|
return;
|
|
1156
1218
|
}
|
|
1157
1219
|
|
|
1158
1220
|
// uploads_get_origins — return list of connected extension origins.
|
|
1159
|
-
// Used by `poltertab uploads pair` to discover which extension to trust.
|
|
1160
1221
|
// Only originless (CLI/local) sockets may request this; chrome-extension://
|
|
1161
1222
|
// origins must not be able to enumerate other connected extensions.
|
|
1162
1223
|
if (msg.type === "uploads_get_origins") {
|
|
@@ -1229,12 +1290,14 @@ function setupPrimaryWss(wss) {
|
|
|
1229
1290
|
if (msg.type === "uploads_get_config") {
|
|
1230
1291
|
const allowlist = getAllowlist();
|
|
1231
1292
|
const alwaysBlocked = buildAlwaysBlocked(homeDir);
|
|
1293
|
+
const trustedExtensionIds = getTrustedExtensionIds();
|
|
1232
1294
|
if (ws.readyState === WebSocket.OPEN) {
|
|
1233
1295
|
ws.send(JSON.stringify({
|
|
1234
1296
|
type: "uploads_config",
|
|
1235
1297
|
_replyId: msg._replyId || null,
|
|
1236
1298
|
allowlist,
|
|
1237
1299
|
alwaysBlocked,
|
|
1300
|
+
trustedExtensionIds,
|
|
1238
1301
|
}));
|
|
1239
1302
|
}
|
|
1240
1303
|
return;
|
|
@@ -1353,6 +1416,7 @@ function setupPrimaryWss(wss) {
|
|
|
1353
1416
|
});
|
|
1354
1417
|
|
|
1355
1418
|
ws.on("close", () => {
|
|
1419
|
+
if (pairHost) pairHost.onSocketClose(ws);
|
|
1356
1420
|
if (ws.nodeId) {
|
|
1357
1421
|
console.error(
|
|
1358
1422
|
`[PolterTab MCP] Secondary node disconnected: ${ws.nodeId}`,
|
package/mcp-server/config.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// mirrored at the top of background.js with a pointer back to this comment. If
|
|
23
23
|
// you change one, change both.
|
|
24
24
|
|
|
25
|
+
const fs = require("fs");
|
|
25
26
|
const os = require("os");
|
|
26
27
|
const path = require("path");
|
|
27
28
|
|
|
@@ -37,12 +38,17 @@ const path = require("path");
|
|
|
37
38
|
const POLTERTAB_HOME =
|
|
38
39
|
process.env.POLTERTAB_HOME || path.join(os.homedir(), ".poltertab");
|
|
39
40
|
|
|
40
|
-
// Port: MCP_BROWSER_WS_PORT env
|
|
41
|
+
// Port: --port arg → MCP_BROWSER_WS_PORT env → ws_port in config.json → 7822.
|
|
41
42
|
const DEFAULT_WS_PORT = 7822;
|
|
42
43
|
function resolvePort(argv = process.argv, env = process.env) {
|
|
43
44
|
const i = argv.indexOf("--port");
|
|
44
45
|
if (i !== -1 && argv[i + 1]) return parseInt(argv[i + 1], 10);
|
|
45
46
|
if (env.MCP_BROWSER_WS_PORT) return parseInt(env.MCP_BROWSER_WS_PORT, 10);
|
|
47
|
+
try {
|
|
48
|
+
const home = env.POLTERTAB_HOME || path.join(os.homedir(), ".poltertab");
|
|
49
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(home, "config.json"), "utf8"));
|
|
50
|
+
if (Number.isInteger(cfg.ws_port) && cfg.ws_port > 0) return cfg.ws_port;
|
|
51
|
+
} catch (_) {}
|
|
46
52
|
return DEFAULT_WS_PORT;
|
|
47
53
|
}
|
|
48
54
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// In-memory pairing state. The pair code NEVER leaves this module except
|
|
4
|
+
// via the pair_code push to one socket. It is never written to config.json,
|
|
5
|
+
// to uploads.log.jsonl, to the console, or returned in get_status / state_update.
|
|
6
|
+
//
|
|
7
|
+
// generateForSocket — push a fresh code to an extension socket.
|
|
8
|
+
// handleCodeRequest — throttled re-push (30 s / socket).
|
|
9
|
+
// submitCode — validate a code from the CLI; returns { ok, extensionId, extWs }
|
|
10
|
+
// or { ok: false, reason, triesLeft? / secsLeft? }.
|
|
11
|
+
// onSocketClose — void the code for a socket when it disconnects.
|
|
12
|
+
|
|
13
|
+
const crypto = require("crypto");
|
|
14
|
+
|
|
15
|
+
const ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // 31 chars; no I L O 0 1
|
|
16
|
+
const CODE_LENGTH = 4;
|
|
17
|
+
const CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
18
|
+
const REQUEST_THROTTLE_MS = 30 * 1000; // 30 s per socket
|
|
19
|
+
const LOCKOUT_MAX_WRONG = 5;
|
|
20
|
+
const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes
|
|
21
|
+
|
|
22
|
+
function _randomCode() {
|
|
23
|
+
let s = "";
|
|
24
|
+
for (let i = 0; i < CODE_LENGTH; i++) {
|
|
25
|
+
s += ALPHABET[crypto.randomInt(ALPHABET.length)];
|
|
26
|
+
}
|
|
27
|
+
return s;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class PairHost {
|
|
31
|
+
// opts.codeTtlMs — injectable TTL for unit tests (defaults to 5 minutes).
|
|
32
|
+
constructor({ codeTtlMs = CODE_TTL_MS } = {}) {
|
|
33
|
+
this._codeTtlMs = codeTtlMs;
|
|
34
|
+
// code → { ws, extensionId, expiresAt, timer }
|
|
35
|
+
this._codes = new Map();
|
|
36
|
+
// ws → code
|
|
37
|
+
this._socketCodes = new Map();
|
|
38
|
+
// ws → timestamp of last pair_code_request
|
|
39
|
+
this._socketLastRequest = new Map();
|
|
40
|
+
// global wrong-guess counter
|
|
41
|
+
this._lockout = { wrongCount: 0, lockedUntil: 0 };
|
|
42
|
+
// codes that recently expired — kept for one TTL window so submitCode can
|
|
43
|
+
// tell the user "expired, try the new one" without counting it as wrong.
|
|
44
|
+
this._recentlyExpired = new Set();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_isLockedOut() {
|
|
48
|
+
return this._lockout.lockedUntil > Date.now();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_uniqueCode() {
|
|
52
|
+
let code;
|
|
53
|
+
let attempts = 0;
|
|
54
|
+
do {
|
|
55
|
+
code = _randomCode();
|
|
56
|
+
} while (this._codes.has(code) && ++attempts < 200);
|
|
57
|
+
return code;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Push a pair_code to this extension socket.
|
|
61
|
+
// Voids any previous code for the same socket first.
|
|
62
|
+
generateForSocket(ws, extensionId) {
|
|
63
|
+
// Void old code for this socket without removing throttle tracking.
|
|
64
|
+
const old = this._socketCodes.get(ws);
|
|
65
|
+
if (old) {
|
|
66
|
+
const entry = this._codes.get(old);
|
|
67
|
+
if (entry) clearTimeout(entry.timer);
|
|
68
|
+
this._codes.delete(old);
|
|
69
|
+
this._socketCodes.delete(ws);
|
|
70
|
+
// Track as recently-expired so a late submitCode returns "expired" not "wrong".
|
|
71
|
+
this._recentlyExpired.add(old);
|
|
72
|
+
setTimeout(() => this._recentlyExpired.delete(old), this._codeTtlMs).unref();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const code = this._uniqueCode();
|
|
76
|
+
const expiresAt = Date.now() + this._codeTtlMs;
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
if (this._codes.get(code)) {
|
|
79
|
+
this._codes.delete(code);
|
|
80
|
+
this._socketCodes.delete(ws);
|
|
81
|
+
// Track as recently-expired so a late submitCode returns "expired" (not
|
|
82
|
+
// "wrong") and does not burn a lockout try.
|
|
83
|
+
this._recentlyExpired.add(code);
|
|
84
|
+
setTimeout(() => this._recentlyExpired.delete(code), this._codeTtlMs).unref();
|
|
85
|
+
// Spec: push a fresh code down the same socket so the popup never shows
|
|
86
|
+
// a dead code. If the socket is already closed, nothing to do.
|
|
87
|
+
if (ws.readyState === 1 /* OPEN */) {
|
|
88
|
+
this.generateForSocket(ws, extensionId);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}, this._codeTtlMs);
|
|
92
|
+
|
|
93
|
+
this._codes.set(code, { ws, extensionId, expiresAt, timer });
|
|
94
|
+
this._socketCodes.set(ws, code);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
if (ws.readyState === 1 /* OPEN */) {
|
|
98
|
+
ws.send(JSON.stringify({ type: "pair_code", code, expiresAt }));
|
|
99
|
+
}
|
|
100
|
+
} catch (_) {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Throttled re-request from an extension socket.
|
|
104
|
+
handleCodeRequest(ws, extensionId) {
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const last = this._socketLastRequest.get(ws) || 0;
|
|
107
|
+
if (now - last < REQUEST_THROTTLE_MS) {
|
|
108
|
+
// Re-send the existing code if one exists; otherwise ignore.
|
|
109
|
+
const existing = this._socketCodes.get(ws);
|
|
110
|
+
if (existing && this._codes.has(existing)) {
|
|
111
|
+
try {
|
|
112
|
+
if (ws.readyState === 1) {
|
|
113
|
+
ws.send(JSON.stringify({ type: "pair_code", code: existing }));
|
|
114
|
+
}
|
|
115
|
+
} catch (_) {}
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this._socketLastRequest.set(ws, now);
|
|
120
|
+
this.generateForSocket(ws, extensionId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Validate a submitted code. Called by bridge.js (from CLI) or by the temp
|
|
124
|
+
// listener (directly from the local readline prompt).
|
|
125
|
+
// Returns { ok: true, extensionId, extWs }
|
|
126
|
+
// or { ok: false, reason: "locked_out"|"wrong"|"expired", secsLeft?, triesLeft? }
|
|
127
|
+
submitCode(rawCode) {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
if (this._isLockedOut()) {
|
|
130
|
+
const secsLeft = Math.ceil((this._lockout.lockedUntil - now) / 1000);
|
|
131
|
+
return { ok: false, reason: "locked_out", secsLeft };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const code = String(rawCode || "").toUpperCase().trim();
|
|
135
|
+
const entry = this._codes.get(code);
|
|
136
|
+
|
|
137
|
+
// Expired code: tell the user to wait for the new one; do NOT count as a
|
|
138
|
+
// wrong guess — the expiry timer already regenerated a fresh code.
|
|
139
|
+
// This handles both the sub-second race (entry still present but past
|
|
140
|
+
// expiresAt) and the post-timer case (entry deleted, code in _recentlyExpired).
|
|
141
|
+
if ((entry && entry.expiresAt < now) || this._recentlyExpired.has(code)) {
|
|
142
|
+
return { ok: false, reason: "expired" };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!entry) {
|
|
146
|
+
this._lockout.wrongCount += 1;
|
|
147
|
+
if (this._lockout.wrongCount >= LOCKOUT_MAX_WRONG) {
|
|
148
|
+
this._lockout = { wrongCount: 0, lockedUntil: now + LOCKOUT_DURATION_MS };
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
reason: "locked_out",
|
|
152
|
+
secsLeft: Math.ceil(LOCKOUT_DURATION_MS / 1000),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
reason: "wrong",
|
|
158
|
+
triesLeft: LOCKOUT_MAX_WRONG - this._lockout.wrongCount,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Consume — single-use.
|
|
163
|
+
clearTimeout(entry.timer);
|
|
164
|
+
this._codes.delete(code);
|
|
165
|
+
this._socketCodes.delete(entry.ws);
|
|
166
|
+
this._lockout.wrongCount = 0;
|
|
167
|
+
|
|
168
|
+
return { ok: true, extensionId: entry.extensionId, extWs: entry.ws };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Called on every socket close in the Primary's connection handler.
|
|
172
|
+
onSocketClose(ws) {
|
|
173
|
+
const code = this._socketCodes.get(ws);
|
|
174
|
+
if (code) {
|
|
175
|
+
const entry = this._codes.get(code);
|
|
176
|
+
if (entry) clearTimeout(entry.timer);
|
|
177
|
+
this._codes.delete(code);
|
|
178
|
+
this._socketCodes.delete(ws);
|
|
179
|
+
}
|
|
180
|
+
this._socketLastRequest.delete(ws);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { PairHost };
|
|
@@ -70,11 +70,22 @@ function getTrustedExtensionIds() {
|
|
|
70
70
|
return Array.isArray(v) ? v : [];
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// Removes id from trusted_extension_ids. Returns true if removed, false if absent.
|
|
74
|
+
function removeTrustedExtensionId(id) {
|
|
75
|
+
const cfg = _readConfig();
|
|
76
|
+
const list = Array.isArray(cfg.trusted_extension_ids) ? cfg.trusted_extension_ids : [];
|
|
77
|
+
const next = list.filter((e) => e !== id);
|
|
78
|
+
if (next.length === list.length) return false;
|
|
79
|
+
_writeConfig({ ...cfg, trusted_extension_ids: next });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
73
83
|
module.exports = {
|
|
74
84
|
getAllowlist,
|
|
75
85
|
addAllowlistEntry,
|
|
76
86
|
removeAllowlistEntry,
|
|
77
87
|
addTrustedExtensionId,
|
|
78
88
|
getTrustedExtensionIds,
|
|
89
|
+
removeTrustedExtensionId,
|
|
79
90
|
CONFIG_PATH,
|
|
80
91
|
};
|
package/package.json
CHANGED