Theseus 0.3.109: Save page as PDF or PNG; connector design doc hosted on theseus.x
Right-click on a tab now shows a Save page as submenu with PDF and Image (PNG) items. PDF uses webContents.printToPDF (A4, prefers CSS page size, prints backgrounds); PNG uses webContents.capturePage of the visible viewport. Both open a save dialog with a filename derived from the tab title or hostname, write to the chosen path, and register the result in the downloads list. The Claude connector's design doc now ships at theseus.x/dev/claude-connector/DESIGN-claude-connector.md so readers on the site do not have to leave for the forge; the Developers page links the local copy first and the forge mirror alongside it.
This commit is contained in:
parent
721613c117
commit
ef82ec8411
2 changed files with 213 additions and 2 deletions
208
dev/claude-connector/DESIGN-claude-connector.md
Normal file
208
dev/claude-connector/DESIGN-claude-connector.md
Normal file
|
|
@ -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:<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.
|
||||||
|
|
@ -282,8 +282,11 @@ GET /health</code></pre>
|
||||||
(<code>evaluate</code>) is a separate switch, off by default. Every action is logged with its time.
|
(<code>evaluate</code>) is a separate switch, off by default. Every action is logged with its time.
|
||||||
The toolbar chip's ✕ stops the connector at once; <i>Revoke token</i> cuts off every client you set
|
The toolbar chip's ✕ stops the connector at once; <i>Revoke token</i> cuts off every client you set
|
||||||
up.</div>
|
up.</div>
|
||||||
<p>The connector ships with the next Theseus release after 0.3.106. Design notes:
|
<p>Design notes:
|
||||||
<a href="https://code.silentmode.st/silentmode/theseus/src/branch/master/DESIGN-claude-connector.md">DESIGN-claude-connector.md ↗</a>.</p>
|
<a href="claude-connector/DESIGN-claude-connector.md">DESIGN-claude-connector.md</a>
|
||||||
|
(hosted here ·
|
||||||
|
<a href="https://code.silentmode.st/silentmode/theseus/src/branch/master/DESIGN-claude-connector.md">forge mirror ↗</a>).
|
||||||
|
The connector shipped with Theseus 0.3.107.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="plug-ins">
|
<section id="plug-ins">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue