RFC-006 — Agent-driven file upload in logged-in Chrome
- Status: Draft
- Authors: @yiidtw
- Created: 2026-05-22
- Related: RFC-001 (function-based v0.1), RFC-002 (Clipper skills catalog), amem-librarian#3 (window-only capture)
TL;DR
Today no MCP-driven path lets a Claude/Cursor/Cline agent upload a file in the user’s real, logged-in Chrome. Three reasons:
- JS-initiated
<input type="file">click → Chrome blocks the native picker (or opens it but JS can’t see/select) - crossmem bridge has no
execute_script/evalaction (verified 2026-05-22), so we can’t even inject the DataTransfer workaround through it - Playwright / CDP routes are banned by
CLAUDE.md(debug-port issues)
This RFC adds file upload as a first-class amem capability by giving
amem-clipper its own bridge to amem-librarian (separate from crossmem) and
landing one new MCP tool: chrome_upload_file(selector, path).
Differentiation framing: when this ships, amem-clipper is the first non-debug-port stack that lets an agent complete a file upload on any logged-in site (CWS dashboard, GitHub PR attachments, Notion image upload, Slack file drops). Closing this hole is one of the largest practical agent gaps in 2026; TARS / Connector / chrome-devtools MCP all hit the same wall.
What “file upload” actually needs
Standard web upload flow:
1. user clicks <button> / <label for=fileinput>
2. browser opens native file picker
3. user selects file(s)
4. <input type=file>.files is set
5. page reads .files, kicks off XHR/fetch
Steps 1, 5 are normal DOM operations. Step 2–4 happen inside a sandbox JS can’t reach. The workaround the web has used for ~a decade:
// in page context
const dt = new DataTransfer();
dt.items.add(file); // file is a File object
input.files = dt.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
This BYPASSES the picker entirely. The page’s own change-handler runs as if
the user picked the file. Works on >95% of normal <input type=file> sites.
Sites with custom drag-drop-only zones may need a drop event variant.
Why this can’t run through crossmem today
crossmem bridge actions (verified 2026-05-22 via curl /command):
✅ navigate, click, type, wait, extract, screenshot, summarize,
tab_info, ping
❌ execute_script, eval, inject_script, capture_page,
chrome_runtime_send, fetch_resource
Without an execute_script verb, the agent can’t push the DataTransfer
snippet into the page. To stay within CLAUDE.md rules (no Playwright, no
debug port), the only options are:
A. Fork crossmem to add execute_script — out of scope (third party)
B. Grow amem-clipper into its own bridge endpoint — chosen
C. Use Native Messaging — feasible but adds OS-specific plist/registry
plumbing; left as v0.2 hardening (see §Hardening)
Architecture (chosen path: B)
Add a second bridge daemon, embedded inside amem mcp serve, scoped to
amem-specific commands. crossmem stays in charge of general agent
computer-use; amem owns this new lane.
┌────────────────────────────────────────────┐
agent (claude)──►│ amem-librarian │
│ stdio MCP server │
│ ┌─────────────────────────────────────┐ │
│ │ clipper_bridge (NEW) │ │
│ │ tokio HTTP server on │ │
│ │ 127.0.0.1:7601 │ │
│ │ ├─ POST /command (agent ↔ daemon) │ │
│ │ └─ GET /poll (ext ↔ daemon) │ │
│ └────────────────┬────────────────────┘ │
└────────────────────┼───────────────────────┘
│ long-poll JSON
┌────────────────────▼───────────────────────┐
│ amem-clipper (Chrome MV3 extension) │
│ background.js — poll loop │
│ └─ on "upload_file": │
│ chrome.scripting.executeScript({ │
│ target:{tabId:active}, │
│ func: dataTransferInject, │
│ args:[selector, base64, mime, name] │
│ }) │
└────────────────────────────────────────────┘
Why a separate port from crossmem (7600 → 7601):
- amem can be installed without crossmem and still work
- crossmem can be uninstalled without breaking amem
- Daemons stay single-purpose: cross-extension fan-out vs amem-specific verbs
- Avoid editing third-party code we don’t own
Wire protocol
POST /command (agent → daemon):
{
"id": "<uuid>",
"action": "upload_file",
"params": { "selector": "input[type=file]",
"path": "/Users/me/file.mp4" }
}
Daemon reads the file, base64-encodes, enqueues:
{
"id": "<uuid>",
"action": "upload_file",
"params": {
"selector": "input[type=file]",
"fileName": "file.mp4",
"mimeType": "video/mp4",
"base64": "AAAAFGZ0eXBpc..."
}
}
GET /poll?since=<lastId> (extension → daemon, long-poll up to 30s):
returns next pending command or 204 on timeout.
POST /ack (extension → daemon):
{ "id":"<uuid>", "success":true, "error":null, "data":{...} }
Daemon returns the ack back to the original POST /command caller.
DataTransfer injection snippet (runs in page context)
(selector, base64, mime, name) => {
const bin = atob(base64);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
const file = new File([buf], name, { type: mime });
const input = document.querySelector(selector);
if (!input) return { ok: false, error: 'selector not found' };
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
return { ok: true };
};
Failure modes & mitigations
| Mode | Cause | Mitigation |
|---|---|---|
Selector matches <button> not <input> | Common — many sites hide the real input | Resolver tries selector → if not input[type=file], walks up to <label for> / <form> / aria-controls to find the real input. Documented in tool description. |
| Site uses drag-drop only zone | No <input type=file> to set | v0.1 fails fast with “no compatible input”. v0.2 may dispatch a synthetic drop event with the file in DataTransfer. |
| Site validates with a custom event listener | Most use change; some only input | Snippet dispatches both. |
| File >100MB | base64-over-localhost is slow/memory hungry | Cap at 50MB in v0.1; bigger files return {error: "file too large; use v0.2 chunked path"}. |
| Multiple file inputs on page | Wrong one selected | User must provide a specific selector. Document :nth-of-type patterns. |
File path outside $HOME | Surprising | Resolve path; refuse if outside $HOME unless --allow-system-paths is set. |
| Extension not connected | amem-clipper not installed / not polling | Daemon returns 502 after 10s with “amem-clipper not connected — load the extension”. |
What’s NOT in this RFC
- Native Messaging variant — cleaner long-term, postponed to v0.2 once cross-platform installer (mac/win/linux) is built. See §Hardening.
- Drag-drop only sites — niche, defer to v0.2
- Chunked uploads — same; v0.1 caps at 50MB total
- Folder uploads —
webkitdirectoryinputs — defer - Cross-frame uploads — iframes that own the input — defer
MCP tool surface
One new tool registered with the existing amem mcp serve:
chrome_upload_file(selector: string, path: string) -> string
description:
Upload a local file to a logged-in Chrome page via amem-clipper.
The selector should target a standard <input type="file"> or a
parent <label for=...>/<button> that maps to one. Path must be
inside $HOME unless --allow-system-paths is set in amem config.
Returns:
success: <fileName> uploaded into <selector>on okError: <reason>on failure (selector miss, file too large, extension not connected, etc.)
Hardening (post-v0.1, separate RFCs)
- Native Messaging variant — replace the loopback HTTP poll with a NM port. Faster, no port choice/conflict, survives reboots. Requires per-platform manifest install.
- Drop-zone fallback — synthesize
dropevent with DataTransfer for sites that don’t expose an<input type=file>. - CWS-submission skill — built on top of
chrome_upload_file, automates the 4 file-pickers in the CWS dashboard flow (issue #amem-hq/11). - File chunking — for >50MB uploads, slice base64 across multiple poll messages reassembled in the extension before injection.
Roll-out
| Day | Work | Owner |
|---|---|---|
| 1 | This RFC merged | amem-hq |
| 1 | amem-librarian clipper_bridge module + MCP tool stub | amem-librarian |
| 2 | amem-clipper background poll + DataTransfer injection | amem-clipper |
| 2 | End-to-end smoke test against a public <input type=file> page | both |
| 3 | Document in docs/guide/file-upload.md + bake into CWS-submission skill | amem-hq |
Nothing in this RFC blocks the current CWS amem-clipper submission (that’s manual on the 4 file pickers); but shipping this RFC means the next extension submission could be one MCP call end-to-end.
Rejected alternatives
- Add
execute_scriptto crossmem — out of scope; crossmem is a third-party project we shouldn’t fork unilaterally - Use chrome-devtools MCP (CDP) — banned by CLAUDE.md, debug-port issues
- Browser-use / TARS visual route — they hit the same native-picker wall
- Server-side preview + manual user step — not agent automation, defeats the point
- Ask the user to drag-drop into a sidepanel — friction; sidepanel-only inputs don’t help when the upload form is on the target site
Open questions
- Should the daemon also handle download mirrors (
amem_download(url, path))? Likely yes — symmetric verb, same protocol shape. Out of scope for this RFC. - Long-term: does amem-clipper replace crossmem for our users, given the new bridge channel? Initial answer: no, parallel — crossmem keeps its generic bridge role.
- Multi-window Chrome: if user has two Chrome windows, which gets the upload? v0.1 picks the active tab of the focused window. Documented.