poltertab 1.8.1-beta.1 → 1.9.0-beta.1

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 CHANGED
@@ -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 27 tools to the LLM (all prefixed with `browser_`). Every
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, so a selector that only
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 `chrome-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 folder.
360
+
340
361
  **Extension not connecting:**
341
362
 
342
363
  - Ensure the MCP Server is running and pointing to the correct port.
@@ -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 || {};
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poltertab",
3
- "version": "1.8.1-beta.1",
3
+ "version": "1.9.0-beta.1",
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"