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

208 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.