poltertab 1.9.0 → 1.10.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/pair-flow.js +320 -0
- package/bin/poltertab.js +11 -0
- package/bin/setup.js +196 -13
- package/bin/uploads.js +294 -0
- package/mcp-server/bridge.js +262 -1
- package/mcp-server/config.js +7 -1
- package/mcp-server/index.js +10 -1
- package/mcp-server/pair-host.js +184 -0
- package/mcp-server/upload-paths.js +74 -4
- package/mcp-server/uploads-config.js +91 -0
- package/mcp-server/uploads-log.js +59 -0
- package/package.json +1 -1
package/bin/uploads.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// poltertab uploads — manage the upload allowlist and view the activity log.
|
|
4
|
+
// Works with no server or extension running; edits config.json directly.
|
|
5
|
+
|
|
6
|
+
const os = require("os");
|
|
7
|
+
const {
|
|
8
|
+
getAllowlist,
|
|
9
|
+
addAllowlistEntry,
|
|
10
|
+
removeAllowlistEntry,
|
|
11
|
+
addTrustedExtensionId,
|
|
12
|
+
getTrustedExtensionIds,
|
|
13
|
+
removeTrustedExtensionId,
|
|
14
|
+
} = require("../mcp-server/uploads-config.js");
|
|
15
|
+
const { getRecentLog } = require("../mcp-server/uploads-log.js");
|
|
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");
|
|
24
|
+
|
|
25
|
+
async function run(argv) {
|
|
26
|
+
const sub = argv[0];
|
|
27
|
+
|
|
28
|
+
if (!sub || sub === "list") {
|
|
29
|
+
const homeDir = os.homedir();
|
|
30
|
+
const allowlist = getAllowlist();
|
|
31
|
+
const alwaysBlocked = buildAlwaysBlocked(homeDir);
|
|
32
|
+
|
|
33
|
+
console.log("\nUpload configuration (~/.poltertab/config.json)");
|
|
34
|
+
console.log("─".repeat(50));
|
|
35
|
+
|
|
36
|
+
if (allowlist.length === 0) {
|
|
37
|
+
console.log("\n Allowed folders: (none — uploads disabled)");
|
|
38
|
+
} else {
|
|
39
|
+
console.log("\n Allowed folders:");
|
|
40
|
+
for (const dir of allowlist) {
|
|
41
|
+
console.log(` + ${dir}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log("\n Always blocked (cannot be changed):");
|
|
46
|
+
for (const dir of alwaysBlocked) {
|
|
47
|
+
console.log(` ✗ ${dir}`);
|
|
48
|
+
}
|
|
49
|
+
console.log(` ✗ ~/.* (home dotfiles)`);
|
|
50
|
+
console.log();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (sub === "allow") {
|
|
55
|
+
const dir = argv[1];
|
|
56
|
+
if (!dir) {
|
|
57
|
+
console.error("Usage: poltertab uploads allow <dir>");
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
const v = validateAllowlistEntry(dir);
|
|
61
|
+
if (!v.ok) {
|
|
62
|
+
console.error(`\n Error: ${v.errorText}\n`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
const added = addAllowlistEntry(dir);
|
|
66
|
+
if (added) {
|
|
67
|
+
console.log(`\n Added: ${dir}\n`);
|
|
68
|
+
} else {
|
|
69
|
+
console.log(`\n Already present: ${dir}\n`);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (sub === "revoke") {
|
|
75
|
+
const dir = argv[1];
|
|
76
|
+
if (!dir) {
|
|
77
|
+
console.error("Usage: poltertab uploads revoke <dir>");
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
const removed = removeAllowlistEntry(dir);
|
|
81
|
+
if (removed) {
|
|
82
|
+
console.log(`\n Removed: ${dir}\n`);
|
|
83
|
+
} else {
|
|
84
|
+
console.log(`\n Not found in allowlist: ${dir}\n`);
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (sub === "log") {
|
|
90
|
+
let n = 20;
|
|
91
|
+
const nIdx = argv.indexOf("-n");
|
|
92
|
+
if (nIdx !== -1 && argv[nIdx + 1]) {
|
|
93
|
+
n = parseInt(argv[nIdx + 1], 10) || 20;
|
|
94
|
+
}
|
|
95
|
+
const entries = getRecentLog(n);
|
|
96
|
+
if (entries.length === 0) {
|
|
97
|
+
console.log("\n No upload activity recorded.\n");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
console.log(`\nUpload activity log (last ${entries.length} entries)`);
|
|
101
|
+
console.log("─".repeat(50));
|
|
102
|
+
for (const e of entries) {
|
|
103
|
+
const paths = Array.isArray(e.paths) ? e.paths.join(", ") : String(e.paths);
|
|
104
|
+
console.log(` ${e.ts} [${e.outcome}] method=${e.method} ${paths}`);
|
|
105
|
+
}
|
|
106
|
+
console.log();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (sub === "pair") {
|
|
111
|
+
await runPair(argv.slice(1));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (sub === "unpair") {
|
|
116
|
+
await runUnpair(argv.slice(1));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.error(`\n Unknown subcommand: ${sub}`);
|
|
121
|
+
console.error(" Usage: poltertab uploads list|allow <dir>|revoke <dir>|log [-n N]|pair [--id <id>]|unpair [--id <id>]\n");
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// (pair primitives live in pair-flow.js and are imported above)
|
|
126
|
+
|
|
127
|
+
// ── poltertab uploads pair ─────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
async function runPair(argv) {
|
|
130
|
+
// --id is kept for backward compatibility: pair by bare extension ID directly.
|
|
131
|
+
const idFlagIdx = argv.indexOf("--id");
|
|
132
|
+
const flagId = idFlagIdx !== -1 ? argv[idFlagIdx + 1] : null;
|
|
133
|
+
|
|
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) {
|
|
152
|
+
console.error("\nError: poltertab uploads pair requires an interactive terminal.\n" +
|
|
153
|
+
"Use --id <extension-id> to pair non-interactively.\n");
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const readline = require("readline");
|
|
158
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
159
|
+
|
|
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`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
// Nothing listening — start temp listener.
|
|
191
|
+
}
|
|
192
|
+
|
|
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");
|
|
202
|
+
|
|
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
|
+
}
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
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`);
|
|
253
|
+
} else {
|
|
254
|
+
console.log(`\n Not found in paired extensions: ${targetId}\n`);
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
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;
|
|
264
|
+
}
|
|
265
|
+
|
|
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);
|
|
272
|
+
}
|
|
273
|
+
|
|
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");
|
|
288
|
+
}
|
|
289
|
+
resolve();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
module.exports = { run };
|
package/mcp-server/bridge.js
CHANGED
|
@@ -35,9 +35,45 @@ 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, addTrustedExtensionId } = require("./uploads-config.js");
|
|
39
|
+
const { PairHost } = require("./pair-host.js");
|
|
40
|
+
const { logUpload, getRecentLog } = require("./uploads-log.js");
|
|
41
|
+
const { buildAlwaysBlocked, validateAllowlistEntry } = require("./upload-paths.js");
|
|
42
|
+
const fs = require("fs");
|
|
43
|
+
const path = require("path");
|
|
44
|
+
|
|
45
|
+
// ── Trusted extension IDs ─────────────────────────────────────────────────
|
|
46
|
+
// Upload config/log WS messages are restricted to origins paired via
|
|
47
|
+
// `poltertab uploads pair`. The CWS-assigned ID will go here once the Store
|
|
48
|
+
// publishes the extension; until then it is an empty string (unused).
|
|
49
|
+
//
|
|
50
|
+
// Caveat (design §4): a non-browser local process can forge any Origin header;
|
|
51
|
+
// this gate stops other Chrome extensions, not a hostile process with raw socket
|
|
52
|
+
// access. The always-blocked list and allowlist are the server-side guards.
|
|
53
|
+
const CWS_EXTENSION_ID = ""; // filled when the Chrome Web Store assigns it
|
|
54
|
+
|
|
55
|
+
// isUploadsAuthorized and isPaired may throw on malformed config.json —
|
|
56
|
+
// callers must catch and send uploads_error.
|
|
57
|
+
function isUploadsAuthorized(origin) {
|
|
58
|
+
if (!origin || !origin.startsWith("chrome-extension://")) return false;
|
|
59
|
+
const id = origin.slice("chrome-extension://".length).replace(/\/$/, "");
|
|
60
|
+
const ids = new Set(getTrustedExtensionIds());
|
|
61
|
+
if (CWS_EXTENSION_ID) ids.add(CWS_EXTENSION_ID);
|
|
62
|
+
return ids.has(id);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isPaired() {
|
|
66
|
+
const ids = new Set(getTrustedExtensionIds());
|
|
67
|
+
if (CWS_EXTENSION_ID) ids.add(CWS_EXTENSION_ID);
|
|
68
|
+
return ids.size > 0;
|
|
69
|
+
}
|
|
38
70
|
|
|
39
71
|
let WS_PORT = resolvePort();
|
|
40
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
|
+
|
|
41
77
|
// Populated when the extension connects; stays null until then, which is itself
|
|
42
78
|
// worth reporting — a skew warning must not fire on "no extension yet".
|
|
43
79
|
let extensionVersion = null;
|
|
@@ -270,6 +306,7 @@ function allowOrigin(info, cb) {
|
|
|
270
306
|
}
|
|
271
307
|
|
|
272
308
|
function setupPrimaryServer() {
|
|
309
|
+
pairHost = new PairHost();
|
|
273
310
|
const http = require("http");
|
|
274
311
|
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
275
312
|
// The source address alone does not stop DNS rebinding: a page at
|
|
@@ -841,8 +878,9 @@ function _dispatchProxyNow(callerWs, id, action, params) {
|
|
|
841
878
|
}
|
|
842
879
|
|
|
843
880
|
function setupPrimaryWss(wss) {
|
|
844
|
-
wss.on("connection", (ws) => {
|
|
881
|
+
wss.on("connection", (ws, req) => {
|
|
845
882
|
ws.isAlive = true;
|
|
883
|
+
ws.handshakeOrigin = (req && req.headers && req.headers.origin) || null;
|
|
846
884
|
ws.supportsNewCloseCodes = false;
|
|
847
885
|
ws.on("pong", () => {
|
|
848
886
|
ws.isAlive = true;
|
|
@@ -954,6 +992,28 @@ function setupPrimaryWss(wss) {
|
|
|
954
992
|
broadcastSessionState();
|
|
955
993
|
}
|
|
956
994
|
|
|
995
|
+
// Self-navigation guard: refuse commands targeting extension pages.
|
|
996
|
+
const EXTENSION_SENSITIVE_ACTIONS = new Set([
|
|
997
|
+
"navigate", "tab_new", "click", "fill", "snapshot", "scrape", "extract",
|
|
998
|
+
]);
|
|
999
|
+
if (EXTENSION_SENSITIVE_ACTIONS.has(action)) {
|
|
1000
|
+
const urlParam = params.url || params.target || "";
|
|
1001
|
+
let isSelfNav = false;
|
|
1002
|
+
if (typeof urlParam === "string" && urlParam) {
|
|
1003
|
+
try { isSelfNav = new URL(urlParam).protocol.toLowerCase() === "chrome-extension:"; } catch (_) {}
|
|
1004
|
+
}
|
|
1005
|
+
if (isSelfNav) {
|
|
1006
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1007
|
+
ws.send(JSON.stringify({
|
|
1008
|
+
id,
|
|
1009
|
+
success: false,
|
|
1010
|
+
error: "[self_navigation_refused] Agent commands cannot target extension pages.",
|
|
1011
|
+
}));
|
|
1012
|
+
}
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
957
1017
|
if (action === "get_network_state") {
|
|
958
1018
|
(async () => {
|
|
959
1019
|
try {
|
|
@@ -1097,10 +1157,193 @@ function setupPrimaryWss(wss) {
|
|
|
1097
1157
|
// A socket that died mid-handshake reconnects on its own.
|
|
1098
1158
|
}
|
|
1099
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
|
+
}
|
|
1100
1216
|
}
|
|
1101
1217
|
return;
|
|
1102
1218
|
}
|
|
1103
1219
|
|
|
1220
|
+
// uploads_get_origins — return list of connected extension origins.
|
|
1221
|
+
// Only originless (CLI/local) sockets may request this; chrome-extension://
|
|
1222
|
+
// origins must not be able to enumerate other connected extensions.
|
|
1223
|
+
if (msg.type === "uploads_get_origins") {
|
|
1224
|
+
if (ws.handshakeOrigin) {
|
|
1225
|
+
// Extension socket — refuse; only CLI callers (no origin) may enumerate.
|
|
1226
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1227
|
+
ws.send(JSON.stringify({ type: "uploads_error", error: "uploads_get_origins is restricted to local CLI callers" }));
|
|
1228
|
+
}
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
const origins = [];
|
|
1232
|
+
if (wss) {
|
|
1233
|
+
for (const client of wss.clients) {
|
|
1234
|
+
if (client.handshakeOrigin && client.handshakeOrigin.startsWith("chrome-extension://")) {
|
|
1235
|
+
origins.push(client.handshakeOrigin);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1240
|
+
ws.send(JSON.stringify({ type: "uploads_origins", origins }));
|
|
1241
|
+
}
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// Upload config/log messages — paired extension origin only.
|
|
1246
|
+
// Requires the origin to be in trusted_extension_ids (via `poltertab uploads pair`).
|
|
1247
|
+
// Until an ID is paired, returns uploads_not_paired.
|
|
1248
|
+
if (
|
|
1249
|
+
msg.type === "uploads_get_config" ||
|
|
1250
|
+
msg.type === "uploads_set_config" ||
|
|
1251
|
+
msg.type === "uploads_get_log"
|
|
1252
|
+
) {
|
|
1253
|
+
let pairedOk, authorizedOk;
|
|
1254
|
+
try {
|
|
1255
|
+
pairedOk = isPaired();
|
|
1256
|
+
authorizedOk = isUploadsAuthorized(ws.handshakeOrigin);
|
|
1257
|
+
} catch (cfgErr) {
|
|
1258
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1259
|
+
ws.send(JSON.stringify({
|
|
1260
|
+
type: "uploads_error",
|
|
1261
|
+
_replyId: msg._replyId || null,
|
|
1262
|
+
error: "config read error: " + cfgErr.message,
|
|
1263
|
+
}));
|
|
1264
|
+
}
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
if (!pairedOk) {
|
|
1268
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1269
|
+
ws.send(JSON.stringify({
|
|
1270
|
+
type: "uploads_not_paired",
|
|
1271
|
+
_replyId: msg._replyId || null,
|
|
1272
|
+
error: "Run poltertab uploads pair to link your extension",
|
|
1273
|
+
}));
|
|
1274
|
+
}
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
if (!authorizedOk) {
|
|
1278
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1279
|
+
ws.send(JSON.stringify({
|
|
1280
|
+
type: "uploads_not_paired",
|
|
1281
|
+
_replyId: msg._replyId || null,
|
|
1282
|
+
error: "Run poltertab uploads pair to link your extension",
|
|
1283
|
+
}));
|
|
1284
|
+
}
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
const os = require("os");
|
|
1288
|
+
const homeDir = os.homedir();
|
|
1289
|
+
|
|
1290
|
+
if (msg.type === "uploads_get_config") {
|
|
1291
|
+
const allowlist = getAllowlist();
|
|
1292
|
+
const alwaysBlocked = buildAlwaysBlocked(homeDir);
|
|
1293
|
+
const trustedExtensionIds = getTrustedExtensionIds();
|
|
1294
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1295
|
+
ws.send(JSON.stringify({
|
|
1296
|
+
type: "uploads_config",
|
|
1297
|
+
_replyId: msg._replyId || null,
|
|
1298
|
+
allowlist,
|
|
1299
|
+
alwaysBlocked,
|
|
1300
|
+
trustedExtensionIds,
|
|
1301
|
+
}));
|
|
1302
|
+
}
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
if (msg.type === "uploads_set_config") {
|
|
1307
|
+
const { action: cfgAction, dir } = msg;
|
|
1308
|
+
let result;
|
|
1309
|
+
if (cfgAction === "add") {
|
|
1310
|
+
const v = validateAllowlistEntry(dir, homeDir);
|
|
1311
|
+
if (!v.ok) {
|
|
1312
|
+
result = { ok: false, error: v.errorText };
|
|
1313
|
+
} else {
|
|
1314
|
+
addAllowlistEntry(dir);
|
|
1315
|
+
result = { ok: true };
|
|
1316
|
+
}
|
|
1317
|
+
} else if (cfgAction === "remove") {
|
|
1318
|
+
removeAllowlistEntry(dir);
|
|
1319
|
+
result = { ok: true };
|
|
1320
|
+
} else {
|
|
1321
|
+
result = { ok: false, error: `Unknown action: ${cfgAction}` };
|
|
1322
|
+
}
|
|
1323
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1324
|
+
ws.send(JSON.stringify({
|
|
1325
|
+
type: "uploads_config_result",
|
|
1326
|
+
_replyId: msg._replyId || null,
|
|
1327
|
+
...result,
|
|
1328
|
+
}));
|
|
1329
|
+
}
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if (msg.type === "uploads_get_log") {
|
|
1334
|
+
const n = Math.min(typeof msg.n === "number" ? msg.n : 20, 500);
|
|
1335
|
+
const entries = getRecentLog(n);
|
|
1336
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1337
|
+
ws.send(JSON.stringify({
|
|
1338
|
+
type: "uploads_log",
|
|
1339
|
+
_replyId: msg._replyId || null,
|
|
1340
|
+
entries,
|
|
1341
|
+
}));
|
|
1342
|
+
}
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1104
1347
|
// Handle captured network data
|
|
1105
1348
|
if (msg.type === "network_data") {
|
|
1106
1349
|
const { tabId, url, body } = msg;
|
|
@@ -1173,6 +1416,7 @@ function setupPrimaryWss(wss) {
|
|
|
1173
1416
|
});
|
|
1174
1417
|
|
|
1175
1418
|
ws.on("close", () => {
|
|
1419
|
+
if (pairHost) pairHost.onSocketClose(ws);
|
|
1176
1420
|
if (ws.nodeId) {
|
|
1177
1421
|
console.error(
|
|
1178
1422
|
`[PolterTab MCP] Secondary node disconnected: ${ws.nodeId}`,
|
|
@@ -1271,6 +1515,23 @@ function _buildEnvelope(action, params, activeNodeId) {
|
|
|
1271
1515
|
// Helper to send command to the extension
|
|
1272
1516
|
async function sendCommand(action, params) {
|
|
1273
1517
|
if (incompatible) throw new Error(incompatibleError());
|
|
1518
|
+
|
|
1519
|
+
// Self-navigation guard: refuse commands targeting extension pages.
|
|
1520
|
+
// Mirrors the proxy_command guard; covers the Primary's own tool calls.
|
|
1521
|
+
const SENDCMD_SENSITIVE = new Set([
|
|
1522
|
+
"navigate", "tab_new", "click", "fill", "snapshot", "scrape", "extract",
|
|
1523
|
+
]);
|
|
1524
|
+
if (SENDCMD_SENSITIVE.has(action) && params) {
|
|
1525
|
+
const urlParam = params.url || params.target || "";
|
|
1526
|
+
if (typeof urlParam === "string" && urlParam) {
|
|
1527
|
+
let isSelfNav = false;
|
|
1528
|
+
try { isSelfNav = new URL(urlParam).protocol.toLowerCase() === "chrome-extension:"; } catch (_) {}
|
|
1529
|
+
if (isSelfNav) {
|
|
1530
|
+
throw new Error("[self_navigation_refused] Agent commands cannot target extension pages.");
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1274
1535
|
// Evicted, or a caller ahead of this one is already reconnecting: wait for
|
|
1275
1536
|
// that one dial rather than racing it.
|
|
1276
1537
|
if (isEvicted || reconnect) await lazyReconnect();
|
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
|
|
package/mcp-server/index.js
CHANGED
|
@@ -36,6 +36,7 @@ const {
|
|
|
36
36
|
const { extractAll } = require("./extract-all.js");
|
|
37
37
|
const bridge = require("./bridge.js");
|
|
38
38
|
const { validateUploadPaths, MAX_BYTES } = require("./upload-paths.js");
|
|
39
|
+
const { logUpload } = require("./uploads-log.js");
|
|
39
40
|
const OWN_VERSION = require("./../package.json").version;
|
|
40
41
|
const fs = require("fs");
|
|
41
42
|
const path = require("path");
|
|
@@ -222,6 +223,7 @@ const handleToolCall = async (request) => {
|
|
|
222
223
|
checkSize: method === "drop",
|
|
223
224
|
});
|
|
224
225
|
if (!validation.ok) {
|
|
226
|
+
try { logUpload({ paths: filePaths, method, outcome: `denied:${validation.errorText}`, origin: null, session: opts.session || null }); } catch (_) {}
|
|
225
227
|
return {
|
|
226
228
|
content: [{ type: "text", text: validation.errorText }],
|
|
227
229
|
isError: true,
|
|
@@ -261,7 +263,14 @@ const handleToolCall = async (request) => {
|
|
|
261
263
|
`[poltertab] upload_file method=${method} files=${filePaths.join(", ")}\n`,
|
|
262
264
|
);
|
|
263
265
|
|
|
264
|
-
|
|
266
|
+
let result;
|
|
267
|
+
try {
|
|
268
|
+
result = await bridge.sendCommand("upload_file", bridgeParams);
|
|
269
|
+
} catch (uploadErr) {
|
|
270
|
+
try { logUpload({ paths: filePaths, method, outcome: "error", origin: null, session: opts.session || null }); } catch (_) {}
|
|
271
|
+
throw uploadErr;
|
|
272
|
+
}
|
|
273
|
+
try { logUpload({ paths: filePaths, method, outcome: "ok", origin: null, session: opts.session || null }); } catch (_) {}
|
|
265
274
|
bridge.noteActiveTab(result && result.tabId);
|
|
266
275
|
return {
|
|
267
276
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|