poltertab 1.8.1-beta.1 → 1.9.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -5
- package/mcp-server/index.js +89 -0
- package/mcp-server/tools.js +29 -0
- package/mcp-server/upload-paths.js +142 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,10 +57,10 @@ Prefer not to install globally? `npx poltertab setup` does the same thing.
|
|
|
57
57
|
|
|
58
58
|
The installer prints this link when it finishes:
|
|
59
59
|
|
|
60
|
-
1. Download the latest [release zip](https://github.com/abhinav162/poltertab/releases/latest) and unzip it
|
|
60
|
+
1. Download the latest [release zip](https://github.com/abhinav162/poltertab/releases/latest) and unzip it into its own folder (e.g. `unzip poltertab-extension-vX.zip -d poltertab-extension`, or double-click on macOS — which creates a folder automatically)
|
|
61
61
|
2. Open Chrome → `chrome://extensions/`
|
|
62
62
|
3. Enable **Developer mode** (top right toggle)
|
|
63
|
-
4. Click **Load unpacked** and select the
|
|
63
|
+
4. Click **Load unpacked** and select that folder (the one containing `manifest.json`)
|
|
64
64
|
|
|
65
65
|
Then restart your AI tool so it picks up the new tools.
|
|
66
66
|
|
|
@@ -84,7 +84,7 @@ Running from a clone instead of npm? Point the client at the file directly:
|
|
|
84
84
|
|
|
85
85
|
## Supported MCP Tools
|
|
86
86
|
|
|
87
|
-
The server exposes
|
|
87
|
+
The server exposes 28 tools to the LLM (all prefixed with `browser_`). Every
|
|
88
88
|
page-facing tool also takes optional `tabId` and `session` to target a specific
|
|
89
89
|
tab — omit both and PolterTab uses the agent's current tab: the last one it
|
|
90
90
|
navigated, opened or switched to. The first `browser_navigate` (and the first
|
|
@@ -215,6 +215,22 @@ tab instead of quietly acting on another one.
|
|
|
215
215
|
| `browser_tab_switch` | `tabId` or `session`, `focus` | Make another tab current. A tab that does not exist in Chrome returns `tab_not_found`. |
|
|
216
216
|
| `browser_tab_close` | `tabId` or `session`, `force` | Close a tab (default: current). Tabs not opened by PolterTab (including ones adopted via `browser_session_create({tabId})`) return `needs_user_permission` — ask the user, then retry with `force: true`. A tab that does not exist in Chrome returns `tab_not_found`. |
|
|
217
217
|
|
|
218
|
+
### File upload
|
|
219
|
+
|
|
220
|
+
| Action | Parameters | Description |
|
|
221
|
+
| -------- | ----------- | ------------- |
|
|
222
|
+
| `browser_upload_file` | `selector` (required), `paths` (required), `method` (`"picker"` or `"drop"`), `tabId`, `session` | Upload local files to a page. `method: "picker"` (default) clicks `selector` and intercepts the OS file chooser via CDP — works for any `<input type="file">` or button that opens one. `method: "drop"` dispatches a DataTransfer drag-and-drop event — use for drop zones. Paths must be within directories listed in `upload_allowed_paths` in `~/.poltertab/config.json`. A brief debugging banner appears during picker uploads (CDP requirement). |
|
|
223
|
+
|
|
224
|
+
**Configuration** — add `upload_allowed_paths` to `~/.poltertab/config.json` before using `browser_upload_file`:
|
|
225
|
+
|
|
226
|
+
```json
|
|
227
|
+
{
|
|
228
|
+
"upload_allowed_paths": ["/Users/you/Documents", "/Users/you/Downloads"]
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Always-blocked regardless of the allowlist: `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.config`, `/etc`, `/private/etc`, `/System`, `/private/System`, `/Library`, `/private/Library`, `/proc`, home dotfiles. Note: `/tmp` (and its macOS realpath `/private/tmp`) is **not** blocked and can be added to `upload_allowed_paths` normally.
|
|
233
|
+
|
|
218
234
|
### Site memory
|
|
219
235
|
|
|
220
236
|
| Action | Parameters | Description |
|
|
@@ -232,8 +248,7 @@ Selectors are resolved in order:
|
|
|
232
248
|
3. **XPath** — `//div[@class="foo"]`
|
|
233
249
|
4. **Text match** — exact text content of an element
|
|
234
250
|
|
|
235
|
-
A miss on all four is retried with shadow-root piercing,
|
|
236
|
-
exists inside a web component still resolves.
|
|
251
|
+
A miss on all four is retried with shadow-root piercing (open roots, one level deep) and top-level same-origin iframe search. Nested iframes (iframe inside iframe) and cross-origin iframes are not supported; the tool returns a clear error in those cases.
|
|
237
252
|
|
|
238
253
|
## Troubleshooting & Options
|
|
239
254
|
|
|
@@ -337,6 +352,12 @@ PolterTab servers share that port by design (see above). Both sides must move:
|
|
|
337
352
|
"args": ["/absolute/path/to/poltertab/mcp-server/index.js", "--port", "7824"]
|
|
338
353
|
```
|
|
339
354
|
|
|
355
|
+
**Picker uploads fail while DevTools is open:**
|
|
356
|
+
Chrome allows only one debugger at a time per tab. When DevTools is open on the target tab, `browser_upload_file` (method `"picker"`) fails with "Another debugger is already attached". Close DevTools on that tab before running a picker upload.
|
|
357
|
+
|
|
358
|
+
**Adding `debugger` permission requires re-approving the extension:**
|
|
359
|
+
Loading or reloading the extension folder after the `debugger` permission was added prompts Chrome to ask for re-approval — the extension is disabled until you click **Keep** or **Allow** on the warning dialog in `chrome://extensions/`. Unpack-loaded extensions do not auto-update, so this only happens when you explicitly reload or update the extension folder.
|
|
360
|
+
|
|
340
361
|
**Extension not connecting:**
|
|
341
362
|
|
|
342
363
|
- Ensure the MCP Server is running and pointing to the correct port.
|
package/mcp-server/index.js
CHANGED
|
@@ -35,7 +35,11 @@ const {
|
|
|
35
35
|
} = require("./recipes.js");
|
|
36
36
|
const { extractAll } = require("./extract-all.js");
|
|
37
37
|
const bridge = require("./bridge.js");
|
|
38
|
+
const { validateUploadPaths, MAX_BYTES } = require("./upload-paths.js");
|
|
38
39
|
const OWN_VERSION = require("./../package.json").version;
|
|
40
|
+
const fs = require("fs");
|
|
41
|
+
const path = require("path");
|
|
42
|
+
const os = require("os");
|
|
39
43
|
|
|
40
44
|
let updateState = { latest: null, updateAvailable: false };
|
|
41
45
|
let noticeDelivered = false;
|
|
@@ -179,6 +183,91 @@ const handleToolCall = async (request) => {
|
|
|
179
183
|
const action = name.replace("browser_", "");
|
|
180
184
|
|
|
181
185
|
try {
|
|
186
|
+
// Custom handling for file upload: path allowlist validation + byte reading for drop method
|
|
187
|
+
if (action === "upload_file") {
|
|
188
|
+
const opts = args || {};
|
|
189
|
+
const filePaths = opts.paths || [];
|
|
190
|
+
const method = opts.method || "picker";
|
|
191
|
+
|
|
192
|
+
// Reject unknown methods explicitly (schema enums aren't enforced by every MCP client)
|
|
193
|
+
if (method !== "drop" && method !== "picker") {
|
|
194
|
+
return {
|
|
195
|
+
content: [
|
|
196
|
+
{
|
|
197
|
+
type: "text",
|
|
198
|
+
text: `Unknown upload method: "${method}". Use "picker" or "drop".`,
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
isError: true,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Load allowlist from config.json
|
|
206
|
+
const configFile = path.join(POLTERTAB_HOME, "config.json");
|
|
207
|
+
let allowedPaths;
|
|
208
|
+
try {
|
|
209
|
+
const raw = fs.readFileSync(configFile, "utf8");
|
|
210
|
+
const cfg = JSON.parse(raw);
|
|
211
|
+
allowedPaths = Array.isArray(cfg.upload_allowed_paths)
|
|
212
|
+
? cfg.upload_allowed_paths
|
|
213
|
+
: null;
|
|
214
|
+
} catch (_) {
|
|
215
|
+
allowedPaths = null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Validate all paths through the shared module (handles disabled check,
|
|
219
|
+
// symlink resolution, always-blocked, dotfile, allowlist, and size cap).
|
|
220
|
+
const validation = validateUploadPaths(filePaths, {
|
|
221
|
+
allowedPaths,
|
|
222
|
+
checkSize: method === "drop",
|
|
223
|
+
});
|
|
224
|
+
if (!validation.ok) {
|
|
225
|
+
return {
|
|
226
|
+
content: [{ type: "text", text: validation.errorText }],
|
|
227
|
+
isError: true,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Use the realpathSync'd paths that validation already resolved — what was
|
|
232
|
+
// checked is what is sent (avoids a TOCTOU window between validate and read).
|
|
233
|
+
const { resolvedPaths } = validation;
|
|
234
|
+
|
|
235
|
+
let bridgeParams;
|
|
236
|
+
if (method === "drop") {
|
|
237
|
+
// Read file bytes and base64-encode for DataTransfer injection.
|
|
238
|
+
const files = [];
|
|
239
|
+
for (let i = 0; i < resolvedPaths.length; i++) {
|
|
240
|
+
const resolved = resolvedPaths[i];
|
|
241
|
+
const buf = fs.readFileSync(resolved);
|
|
242
|
+
const ext = path.extname(resolved).slice(1).toLowerCase();
|
|
243
|
+
const mimeMap = {
|
|
244
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
|
|
245
|
+
gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
|
|
246
|
+
pdf: "application/pdf", txt: "text/plain", csv: "text/csv",
|
|
247
|
+
json: "application/json", zip: "application/zip",
|
|
248
|
+
};
|
|
249
|
+
const type = mimeMap[ext] || "application/octet-stream";
|
|
250
|
+
// Use the caller's original path for the file name so a symlink keeps its link name,
|
|
251
|
+
// not the target's name; bytes are still read from the realpathSync'd resolved path.
|
|
252
|
+
files.push({ name: path.basename(filePaths[i]), type, data: buf.toString("base64") });
|
|
253
|
+
}
|
|
254
|
+
bridgeParams = { selector: opts.selector, files, method: "drop", ...((opts.tabId != null) ? { tabId: opts.tabId } : {}), ...((opts.session != null) ? { session: opts.session } : {}) };
|
|
255
|
+
} else {
|
|
256
|
+
// picker method: send the realpathSync'd paths so CDP receives absolute real paths
|
|
257
|
+
bridgeParams = { selector: opts.selector, paths: resolvedPaths, method: "picker", ...((opts.tabId != null) ? { tabId: opts.tabId } : {}), ...((opts.session != null) ? { session: opts.session } : {}) };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
process.stderr.write(
|
|
261
|
+
`[poltertab] upload_file method=${method} files=${filePaths.join(", ")}\n`,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const result = await bridge.sendCommand("upload_file", bridgeParams);
|
|
265
|
+
bridge.noteActiveTab(result && result.tabId);
|
|
266
|
+
return {
|
|
267
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
182
271
|
// Custom handling for network state tool
|
|
183
272
|
if (action === "get_network_state") {
|
|
184
273
|
const opts = args || {};
|
package/mcp-server/tools.js
CHANGED
|
@@ -563,6 +563,35 @@ const BROWSER_TOOLS = [
|
|
|
563
563
|
required: ["obstacle", "solution"],
|
|
564
564
|
},
|
|
565
565
|
},
|
|
566
|
+
{
|
|
567
|
+
name: "browser_upload_file",
|
|
568
|
+
description:
|
|
569
|
+
"Upload one or more local files to the current page. Works with plain file inputs (visible or hidden), custom upload buttons, shadow-DOM inputs (open roots only, one level deep), top-level same-origin iframes, and drag-and-drop zones. For file inputs and upload buttons, uses CDP (Chrome DevTools Protocol) to intercept the OS file picker — a brief debugging banner appears during the upload. For drop zones, dispatches a DataTransfer drag-and-drop event. Paths must be within directories configured in upload_allowed_paths in ~/.poltertab/config.json. Nested iframes (iframe inside iframe) and cross-origin (out-of-process) iframes are not supported and return a clear error.",
|
|
570
|
+
inputSchema: {
|
|
571
|
+
type: "object",
|
|
572
|
+
properties: {
|
|
573
|
+
selector: {
|
|
574
|
+
type: "string",
|
|
575
|
+
description:
|
|
576
|
+
"CSS selector for the upload trigger — the visible button, label, or file input itself. For drop zones (method: 'drop'), the selector of the drop target element.",
|
|
577
|
+
},
|
|
578
|
+
paths: {
|
|
579
|
+
type: "array",
|
|
580
|
+
items: { type: "string" },
|
|
581
|
+
description:
|
|
582
|
+
"Absolute paths to local files to upload. Each must be within a directory listed in upload_allowed_paths in ~/.poltertab/config.json.",
|
|
583
|
+
},
|
|
584
|
+
method: {
|
|
585
|
+
type: "string",
|
|
586
|
+
enum: ["picker", "drop"],
|
|
587
|
+
description:
|
|
588
|
+
"'picker' (default): click the selector and intercept the OS file chooser via CDP — works for any file input or button that opens one. 'drop': dispatch drag-and-drop events with the file bytes — use for drop zones that do not open an OS picker.",
|
|
589
|
+
},
|
|
590
|
+
...TARGET,
|
|
591
|
+
},
|
|
592
|
+
required: ["selector", "paths"],
|
|
593
|
+
},
|
|
594
|
+
},
|
|
566
595
|
];
|
|
567
596
|
|
|
568
597
|
module.exports = { BROWSER_TOOLS, TARGET, SINK };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Shared path-validation logic for browser_upload_file.
|
|
4
|
+
// Used by both index.js (the MCP server) and group-x.js (tests).
|
|
5
|
+
// Validates an array of file paths against the configured allowlist,
|
|
6
|
+
// the always-blocked list, and the home-dotfile rule.
|
|
7
|
+
//
|
|
8
|
+
// Returns { ok: true } on success, or { ok: false, errorText: string } on
|
|
9
|
+
// the first rejected path. Callers wrap errorText in an MCP error response.
|
|
10
|
+
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const os = require("os");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
|
|
15
|
+
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB per file
|
|
16
|
+
|
|
17
|
+
// Paths that are never allowed regardless of the allowlist.
|
|
18
|
+
// /private is NOT listed here because /private/tmp and /private/var/folders
|
|
19
|
+
// are legitimate writable locations on macOS (they are the realpaths of /tmp
|
|
20
|
+
// and the system temp dir). Sensitive subdirectories ARE listed instead.
|
|
21
|
+
function buildAlwaysBlocked(homeDir) {
|
|
22
|
+
return [
|
|
23
|
+
path.join(homeDir, ".ssh"),
|
|
24
|
+
path.join(homeDir, ".aws"),
|
|
25
|
+
path.join(homeDir, ".gnupg"),
|
|
26
|
+
path.join(homeDir, ".config"),
|
|
27
|
+
"/etc",
|
|
28
|
+
"/private/etc",
|
|
29
|
+
"/System",
|
|
30
|
+
"/private/System",
|
|
31
|
+
"/Library",
|
|
32
|
+
"/private/Library",
|
|
33
|
+
"/proc",
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Expand ~ and resolve symlinks in a single allowlist entry.
|
|
39
|
+
* Returns the real absolute path, or the resolved-but-not-realpathd path if
|
|
40
|
+
* the directory does not exist yet (so future-created dirs still match).
|
|
41
|
+
*/
|
|
42
|
+
function expandAllowlistEntry(p) {
|
|
43
|
+
const expanded = p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
|
|
44
|
+
try {
|
|
45
|
+
return fs.realpathSync(path.resolve(expanded));
|
|
46
|
+
} catch (_) {
|
|
47
|
+
return path.resolve(expanded);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validate file paths for upload.
|
|
53
|
+
*
|
|
54
|
+
* @param {string[]} filePaths - Raw paths as supplied by the caller.
|
|
55
|
+
* @param {object} config
|
|
56
|
+
* @param {string[]|null} config.allowedPaths - Raw allowlist entries from config.json;
|
|
57
|
+
* null or empty array means uploads are disabled.
|
|
58
|
+
* @param {boolean} [config.checkSize] - When true, also enforce MAX_BYTES.
|
|
59
|
+
* @param {string} [config.homeDir] - Override os.homedir() (tests only).
|
|
60
|
+
* @returns {{ ok: true } | { ok: false, errorText: string }}
|
|
61
|
+
*/
|
|
62
|
+
function validateUploadPaths(filePaths, config) {
|
|
63
|
+
const {
|
|
64
|
+
allowedPaths = null,
|
|
65
|
+
checkSize = false,
|
|
66
|
+
homeDir = os.homedir(),
|
|
67
|
+
} = config;
|
|
68
|
+
|
|
69
|
+
if (!allowedPaths || allowedPaths.length === 0) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
errorText:
|
|
73
|
+
"File uploads are disabled. To enable them, add an upload_allowed_paths array " +
|
|
74
|
+
"to ~/.poltertab/config.json listing the directories Claude may read files from.\n\n" +
|
|
75
|
+
"Example:\n{\n \"upload_allowed_paths\": [\"/Users/you/Documents\", \"/Users/you/Downloads\"]\n}",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const expandedAllowed = allowedPaths.map(expandAllowlistEntry);
|
|
80
|
+
const alwaysBlocked = buildAlwaysBlocked(homeDir);
|
|
81
|
+
const resolvedPaths = [];
|
|
82
|
+
|
|
83
|
+
for (const p of filePaths) {
|
|
84
|
+
// Resolve symlinks so a symlink pointing outside the allowlist cannot
|
|
85
|
+
// bypass the check (e.g. ~/Downloads/key.pem -> ~/.ssh/id_ed25519).
|
|
86
|
+
let resolved;
|
|
87
|
+
try {
|
|
88
|
+
resolved = fs.realpathSync(path.resolve(p));
|
|
89
|
+
} catch (_) {
|
|
90
|
+
return { ok: false, errorText: `File not found: "${p}"` };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Always-blocked check
|
|
94
|
+
for (const blocked of alwaysBlocked) {
|
|
95
|
+
if (resolved === blocked || resolved.startsWith(blocked + path.sep)) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
errorText: `Access denied: "${p}" is in a protected system directory.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Deny home dotfiles (direct children of homeDir starting with .)
|
|
104
|
+
const rel = path.relative(homeDir, resolved);
|
|
105
|
+
if (!rel.startsWith("..") && rel.startsWith(".") && !rel.includes(path.sep)) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
errorText: `Access denied: "${p}" is a home directory dotfile.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Allowlist check (both sides are realpathSync'd)
|
|
113
|
+
const allowed = expandedAllowed.some(
|
|
114
|
+
(a) => resolved === a || resolved.startsWith(a + path.sep),
|
|
115
|
+
);
|
|
116
|
+
if (!allowed) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
errorText: `Access denied: "${p}" is not within any configured upload_allowed_paths directory. Check ~/.poltertab/config.json.`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Optional size check (drop method only)
|
|
124
|
+
if (checkSize) {
|
|
125
|
+
const stat = fs.statSync(resolved);
|
|
126
|
+
if (stat.size > MAX_BYTES) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
errorText: `File too large: "${path.basename(p)}" is ${(stat.size / 1024 / 1024).toFixed(1)} MB. The drop method supports files up to 10 MB.`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
resolvedPaths.push(resolved);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// resolvedPaths contains the realpathSync'd path for each input — use these
|
|
138
|
+
// for subsequent reads and CDP calls so what was checked is what is sent.
|
|
139
|
+
return { ok: true, resolvedPaths };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { validateUploadPaths, MAX_BYTES };
|
package/package.json
CHANGED