theseus/DESIGN-claude-connector.md
Dev c06557e957 Theseus: Claude connector, an opt-in MCP server for the signed-in profile
Claude sessions could not work inside the user's own profile: the desktop
app's browser lost synthetic pastes and typing in Medium's editor, and a
browser-wide --remote-debugging-port would hand every session to any local
process. The connector is a built-in extension, off until turned on in
Settings > Extensions, that serves MCP on 127.0.0.1 behind a per-install
token and drives one tab at a time through that tab's webContents.debugger.

- Host: a "tab-control" capability (built-in add-ons only) for listing,
  opening, navigating, closing and focusing tabs, handing out web tabs'
  webContents (never Settings, extension or home tabs), and a toolbar chip
  plus tab outline while a client is connected; the chip's X stops it.
  Built-in add-ons can ship "defaultEnabled": false.
- Tools: list_tabs, open_tab, navigate, activate_tab, close_tab, screenshot,
  read_page_text, snapshot (accessibility tree with refs), click, type
  (insertText + real Enter), press_key, paste_html / paste_text (system
  clipboard + webContents.paste, clipboard restored), upload_file
  (DOM.setFileInputFiles), wait_for, and evaluate behind its own switch.
- Safety: first use of each site asks the user; password and one-time-code
  fields are refused; uploads only from folders the user added; requests
  from web pages or foreign Host headers are refused; every action is
  logged with a timestamp in the panel and actions.log.
- Claude Desktop runs stdio-shim.cjs through the Theseus executable in Node
  mode.
- Tests: unit (protocol, auth, policy, shim) and an end-to-end run on a
  throwaway profile that types, pastes HTML with a heading and a link, and
  uploads a PNG on a local page.
2026-10-23 17:45:02 +03:00

12 KiB
Raw Blame History

Claude connector — design and plan

An opt-in MCP server built into Theseus, so a Claude client on the same computer (Claude Code, Claude Desktop) can work inside the user's real, signed-in profile: open a tab, read it, click, type, paste rich text into an editor, upload a file.

Why

On 2026-10-22 a Claude Code session had to paste a prepared story into a Medium draft as the user's author account. The Claude desktop app's built-in browser could not do it: its synthetic paste arrived empty and its typed text never reached Medium's editor. The two other routes are worse:

  • --remote-debugging-port on the main profile opens a browser-wide DevTools port. Any local process can attach and read every tab, cookie and session.
  • Theseus is a desktop app, and these sessions have no computer-use access to drive it from outside.

So the browser itself has to offer a narrow, user-controlled door.

Shape

A built-in add-on, bundled-addons/claude-connector/, following the Pithos pattern: activate(api) starts a small HTTP server on 127.0.0.1 from the main process and registers a sidebar panel. What it needs from the browser that no add-on API offered yet comes through one new host capability, tab-control, which only built-in add-ons can hold.

Claude Code ──HTTP (Bearer token)──► 127.0.0.1:<port>/mcp ─┐
Claude Desktop ─stdio─► stdio-shim.cjs ──HTTP──────────────┤
                                                           ▼
                                    claude-connector add-on (main process)
                                      policy: allowlist · password guard · upload folders · log
                                                           │ api.tabControl (tab-control capability)
                                                           ▼
                                    main.js: tabs, open/close/navigate, indicator
                                                           │ webContents.debugger (CDP, per tab)
                                                           ▼
                                                    one tab's page

Off by default

  • The add-on ships with "defaultEnabled": false. main.js puts any built-in add-on with that flag into settings.disabledAddons the first time it sees it (remembered in settings.addonsDefaultOffSeen, so the user's later choice is never overridden). The user turns it on in Settings › Extensions › Claude connector.
  • While it is off, nothing listens. When it is on, the panel has a second switch, Accept connections, on by default once the extension is turned on, so the stop button can close the port without uninstalling anything.

Transport and authentication

  • MCP Streamable HTTP at http://127.0.0.1:<port>/mcp. POST carries JSON-RPC; GET opens an SSE stream (kept alive with comments, used as the "connected" signal); DELETE ends a session.
  • Binds 127.0.0.1 only. The port is picked once per install (first free in 47800–47899, else any) and kept, so the config the user pasted stays valid.
  • A random 256-bit token per install, base64url. Every request needs Authorization: Bearer <token>, compared in constant time.
  • DNS-rebinding and browser guard: the Host header must be 127.0.0.1:<port> or localhost:<port>, and any request carrying an Origin header is refused. A web page cannot reach the server even if it guessed the token.
  • Revoke in the panel issues a new token, which drops every client at once. Stop (toolbar chip or panel) closes the port.
  • Claude Desktop speaks stdio, so the add-on ships stdio-shim.cjs, run by the Theseus executable itself in Node mode (ELECTRON_RUN_AS_NODE=1, the same mode the extension sandbox already uses). It reads the port from extensions-data/claude-connector/endpoint.json and the token from THESEUS_MCP_TOKEN, and relays line-delimited JSON-RPC to the HTTP endpoint.

Per-tab control, no browser-wide port

  • Each controlled tab gets webContents.debugger (CDP 1.3) attached on its own webContents. No --remote-debugging-port, no DevTools target list.
  • main.js already attaches that debugger to freeze background tabs (setTabFrozen). The connector shares the attachment: it attaches only if nothing is attached, and detaches only what it attached itself.
  • Only ordinary web tabs (http:, https:) are controllable. Settings, add-on pages, the home page, file: and internal schemes are refused: a Settings tab would let a client switch the connector's own safety settings.
  • A dormant restored tab is loaded and a frozen one thawed before it is used. While a tab is controlled it gets focus emulation (Emulation. setFocusEmulationEnabled), so editors that check document.hasFocus() accept input while the Theseus window is in the background.

Host capability tab-control (addons-host.js + main.js)

api.tabControl, built-in add-ons only, manifest must declare tab-control:

call does
list() every tab: id, title, url, kind (web/settings/addon/home), active, pending
webContents(id) the tab's webContents, after loading a dormant tab and thawing a frozen one; throws for non-web tabs
open(url, {background}) new tab through main's own createTab (so BCNR names resolve as typed)
navigate(id, url) main's navigateTab
close(id), activate(id) as the tab strip does
setStatus({connected, clients, busy, tabIds}) drives the toolbar indicator and the per-tab marker
onStop(cb) the indicator's stop button

Indicator

  • A chip in the toolbar, next to Downloads: Claude with a dot that pulses while a tool call runs. Visible whenever a client is connected (an SSE stream is open or a request arrived in the last 2 minutes). Clicking it opens the connector panel; its ✕ stops the connector at once (closes the port, ends sessions, detaches every debugger).
  • Each tab the client is controlling gets an outline marker in the tab strip.

Tools

tool how
list_tabs tabControl.list(); tabs on sites not yet approved show only their host
open_tab(url, background?) approval for the target origin first
navigate(tab_id, url | action) url, or back/forward/reload
close_tab(tab_id), activate_tab(tab_id)
screenshot(tab_id, full_page?) Page.captureScreenshot, scaled to CSS pixels so coordinates match click(x, y); a hidden tab is brought to the front first (hidden views render no frames)
read_page_text(tab_id, max_chars?) innerText read in an isolated world
snapshot(tab_id, interactive_only?) Accessibility.getFullAXTree, compacted to role "name" [ref=eN] lines; refs map to backendDOMNodeId
click(tab_id, ref | x,y) DOM.scrollIntoViewIfNeeded + DOM.getContentQuads, then Input.dispatchMouseEvent press/release
type(tab_id, text, ref?) DOM.focus, then Input.insertText per line and a real Enter (Input.dispatchKeyEvent) between lines; mode: "keys" sends every character as keyDown/keyUp
press_key(tab_id, key, modifiers?) Enter, Tab, Backspace, arrows, ctrl+a …
paste_html(tab_id, html, ref?), paste_text(tab_id, text, ref?) write the real clipboard (clipboard.write({html, text})), webContents.paste(), then put the user's clipboard back
upload_file(tab_id, ref, path) DOM.setFileInputFiles on an <input type=file>
wait_for(tab_id, selector | text, timeout_ms?) polls in an isolated world
evaluate(tab_id, expression) Runtime.evaluate in the page; listed only while Allow evaluate is on (off by default)

Page reads run in an isolated world (Page.createIsolatedWorld), so page scripts can neither see them nor tamper with the helpers.

Safety

  • Per-site allowlist. The first time a client acts on an origin (opens it, navigates to it, reads or drives a tab on it), Theseus asks: Allow Claude to control medium.com? — Allow (remembered), This session only, or Don't allow. A refusal is remembered for 10 minutes, so a client cannot spam the prompt. The panel lists approved sites with a remove button.
  • Never types into password fields. Before type, press_key, paste_* and evaluate-free input, the focused element (followed into shadow roots and same-origin frames) is checked in an isolated world: input[type=password], autocomplete=current-password|new-password| one-time-code, or a cross-origin frame that cannot be inspected → refused. click on a ref that is a password field is refused too, so a client cannot focus one by ref and then type.
  • Upload folders. upload_file resolves the real path (symlinks and junctions followed) and refuses anything outside the folders the user added in the panel (none by default), anything that is not a regular file, and files over 100 MB. The ref must be an <input type=file>.
  • evaluate is a separate switch, off by default, with a warning in the panel: it runs arbitrary script as the page.
  • Action log. Every call is logged with an ISO timestamp, the tool, the tab's origin, a short summary of the arguments (typed and pasted text is logged by length and first 60 characters) and the outcome. The panel shows the newest entries live; the log is kept in extensions-data/claude-connector/actions.log (JSON lines, rotated at 1 MB).

What it does not defend against

  • A program already running as the user. The token sits in the add-on store (extensions-data/claude-connector.json) next to the profile's cookies, which such a program can read anyway. The connector keeps the door shut to web pages and to other machines, and keeps the user in charge of which sites a connected client may touch.
  • A site that changes a field's type after the focus check. The check runs right before each input; what the page does in between is up to the page.

Files

  • bundled-addons/claude-connector/ — addon.json, index.js (activate, settings, panel messages), lib/mcp-server.cjs (HTTP + JSON-RPC + sessions), lib/tab-driver.cjs (CDP per tab), lib/tools.cjs (tool definitions and handlers), lib/policy.cjs (allowlist, upload paths, password guard script), lib/action-log.cjs, panel.html, stdio-shim.cjs, README.md.
  • addons-host.js — tab-control capability and api.tabControl.
  • main.js — the tabControl hook, indicator state, defaultEnabled:false handling, claude-connector kept as a reserved first-party id.
  • preload.js, chrome.html — the indicator chip and the tab marker.
  • tests/claude-connector.test.mjs — launches Theseus on a scratch profile (THESEUS_USER_DATA, THESEUS_NO_UPDATE_CHECK), with the connector pre-enabled in that profile, and drives a local test page over MCP: type into a rich editor, paste HTML with headings and links, upload a PNG, refuse a password field and an upload outside the folder.
  • tests/claude-connector-unit.test.mjs — protocol, auth and policy checks that run under plain Node.
  • README section with the claude mcp add command.

Steps

  1. Host: tab-control capability in addons-host.js; hook, indicator state, default-off handling in main.js; chip in chrome.html/preload.js.
  2. Add-on: server, driver, tools, policy, log, panel, shim.
  3. Tests: unit (plain Node), then the Electron end-to-end test.
  4. Docs: README section, add-on README.

Not in this round

  • Cross-origin iframes (OOPIFs) are not driven; typing into one is refused because its focused element cannot be checked.
  • No per-tool approval prompts beyond the per-site one.
  • No remote (non-loopback) clients, by design.