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.
@@ -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
- async function run(argv = []) {
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
- const rl = readline.createInterface({
157
- input: process.stdin,
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
- : await chooseScope(rl);
168
- } finally {
169
- rl.close();
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
- console.log(`\n ${c.bold("One step left — install the Chrome extension:")}`);
203
- console.log(` ${c.cyan(EXTENSION_URL)}`);
204
- console.log(c.dim(" unzip it, then chrome://extensions → Developer mode → Load unpacked\n"));
205
- console.log(` ${c.dim("Restart Claude Code to pick up the new tools.")}\n`);
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
- // Reads the port poltertab is listening on from the default config or falls back.
112
- function resolveWsPort() {
113
- try {
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 (!flagId && !process.stdin.isTTY) {
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
- let extensionId = flagId;
157
+ const readline = require("readline");
158
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
135
159
 
136
- if (!extensionId) {
137
- // Ask the running server which extension origins are connected.
138
- const port = resolveWsPort();
139
- let origins = [];
140
- try {
141
- origins = await fetchConnectedOrigins(port);
142
- } catch (err) {
143
- console.error(`\nCould not connect to poltertab server on port ${port}: ${err.message}`);
144
- console.error("Make sure poltertab is running, then try again.\n");
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
- const extIds = origins
149
- .filter((o) => o.startsWith("chrome-extension://"))
150
- .map((o) => o.slice("chrome-extension://".length).replace(/\/$/, ""));
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
- if (extIds.length === 0) {
153
- console.error("\nNo extensions are currently connected to poltertab.");
154
- console.error("Make sure the poltertab Chrome extension is installed and active, then try again.\n");
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
- console.log("\nConnected extension IDs:");
159
- extIds.forEach((id, i) => console.log(` ${i + 1}. ${id}`));
160
- console.log("\nOpen chrome://extensions to find which ID matches your poltertab extension.");
161
-
162
- const readline = require("readline");
163
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
164
- const choice = await new Promise((resolve) => {
165
- rl.question(
166
- `\nCompare the ID(s) above with chrome://extensions to find your poltertab extension.\n` +
167
- `Enter number (1-${extIds.length}) to pair, or paste the ID directly: `,
168
- (ans) => {
169
- rl.close();
170
- resolve(ans.trim());
171
- },
172
- );
173
- });
174
- const num = parseInt(choice, 10);
175
- if (!isNaN(num) && num >= 1 && num <= extIds.length) {
176
- extensionId = extIds[num - 1];
177
- } else if (/^[a-p]{32}$/.test(choice)) {
178
- extensionId = choice;
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.error("\nInvalid selection.\n");
181
- process.exit(1);
254
+ console.log(`\n Not found in paired extensions: ${targetId}\n`);
182
255
  }
256
+ return;
183
257
  }
184
258
 
185
- if (!/^[a-p]{32}$/.test(extensionId)) {
186
- console.error(`\nInvalid extension ID: ${extensionId}`);
187
- console.error("Chrome extension IDs are 32 lowercase letters.\n");
188
- process.exit(1);
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
- const added = addTrustedExtensionId(extensionId);
192
- if (added) {
193
- console.log(`\n Paired: ${extensionId}`);
194
- console.log(" Upload config access is now enabled for this extension.\n");
195
- } else {
196
- console.log(`\n Already paired: ${extensionId}\n`);
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
- // Ask the WS server for connected extension origins via uploads_get_origins.
201
- function fetchConnectedOrigins(port) {
202
- return new Promise((resolve, reject) => {
203
- let WebSocket;
204
- try {
205
- WebSocket = require("ws");
206
- } catch (_) {
207
- reject(new Error("ws module not available"));
208
- return;
209
- }
210
- const ws = new WebSocket(`ws://localhost:${port}`);
211
- const timer = setTimeout(() => {
212
- ws.terminate();
213
- reject(new Error("timeout waiting for server response"));
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
  }
@@ -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}`,
@@ -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, then --port CLI arg, then the default.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poltertab",
3
- "version": "1.10.0-beta.1",
3
+ "version": "1.10.0",
4
4
  "description": "Let any AI assistant drive and scrape your existing Chrome profile — no headless browser, no complex setup.",
5
5
  "bin": {
6
6
  "poltertab": "./bin/poltertab.js"