diff --git a/dev/claude-connector/DESIGN-claude-connector.md b/dev/claude-connector/DESIGN-claude-connector.md new file mode 100644 index 0000000..e962646 --- /dev/null +++ b/dev/claude-connector/DESIGN-claude-connector.md @@ -0,0 +1,208 @@ +# 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:/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:/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 `, compared in constant time. +- DNS-rebinding and browser guard: the `Host` header must be + `127.0.0.1:` or `localhost:`, 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 `` | +| `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 ``. +- **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. diff --git a/dev/index.html b/dev/index.html index 5645571..4c6711c 100644 --- a/dev/index.html +++ b/dev/index.html @@ -282,8 +282,11 @@ GET /health (evaluate) is a separate switch, off by default. Every action is logged with its time. The toolbar chip's ✕ stops the connector at once; Revoke token cuts off every client you set up. -

The connector ships with the next Theseus release after 0.3.106. Design notes: - DESIGN-claude-connector.md ↗.

+

Design notes: + DESIGN-claude-connector.md + (hosted here · + forge mirror ↗). + The connector shipped with Theseus 0.3.107.