Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. JS-initiated <input type="file"> click → Chrome blocks the native picker (or opens it but JS can’t see/select)
  2. crossmem bridge has no execute_script / eval action (verified 2026-05-22), so we can’t even inject the DataTransfer workaround through it
  3. 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

ModeCauseMitigation
Selector matches <button> not <input>Common — many sites hide the real inputResolver 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 zoneNo <input type=file> to setv0.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 listenerMost use change; some only inputSnippet dispatches both.
File >100MBbase64-over-localhost is slow/memory hungryCap at 50MB in v0.1; bigger files return {error: "file too large; use v0.2 chunked path"}.
Multiple file inputs on pageWrong one selectedUser must provide a specific selector. Document :nth-of-type patterns.
File path outside $HOMESurprisingResolve path; refuse if outside $HOME unless --allow-system-paths is set.
Extension not connectedamem-clipper not installed / not pollingDaemon 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 — webkitdirectory inputs — 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 ok
  • Error: <reason> on failure (selector miss, file too large, extension not connected, etc.)

Hardening (post-v0.1, separate RFCs)

  1. Native Messaging variant — replace the loopback HTTP poll with a NM port. Faster, no port choice/conflict, survives reboots. Requires per-platform manifest install.
  2. Drop-zone fallback — synthesize drop event with DataTransfer for sites that don’t expose an <input type=file>.
  3. CWS-submission skill — built on top of chrome_upload_file, automates the 4 file-pickers in the CWS dashboard flow (issue #amem-hq/11).
  4. File chunking — for >50MB uploads, slice base64 across multiple poll messages reassembled in the extension before injection.

Roll-out

DayWorkOwner
1This RFC mergedamem-hq
1amem-librarian clipper_bridge module + MCP tool stubamem-librarian
2amem-clipper background poll + DataTransfer injectionamem-clipper
2End-to-end smoke test against a public <input type=file> pageboth
3Document in docs/guide/file-upload.md + bake into CWS-submission skillamem-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_script to 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.