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:04:03 +03:00
// The connector's MCP tools. Each tab tool resolves its tab through the
// host (web tabs only), asks the site policy for the tab's origin, then acts
// through that tab's TabDriver. Every call is logged.
const { TabDriver } = require ( "./tab-driver.cjs" ) ;
const { originOf , hostOf , resolveUpload } = require ( "./policy.cjs" ) ;
const { summarizeArgs } = require ( "./action-log.cjs" ) ;
const tabIdProp = { tab _id : { type : "integer" , description : "Tab id from list_tabs. Omit for the active tab." } } ;
const refProp = { ref : { type : "string" , description : "Element ref from the latest snapshot, e.g. \"e12\"." } } ;
const DEFS = [
{ name : "list_tabs" , description : "List the open Theseus tabs (id, title, URL, which one is active). Tabs on sites the user has not approved show only their host." , inputSchema : { type : "object" , properties : { } } } ,
{ name : "open_tab" , description : "Open a URL in a new tab. The user approves each new site the first time." , inputSchema : { type : "object" , properties : { url : { type : "string" } , background : { type : "boolean" , description : "Open without switching to it (default false)." } } , required : [ "url" ] } } ,
{ name : "navigate" , description : "Load a URL in a tab, or go back / forward / reload." , inputSchema : { type : "object" , properties : { ... tabIdProp , url : { type : "string" } , action : { type : "string" , enum : [ "back" , "forward" , "reload" ] } } } } ,
{ name : "activate_tab" , description : "Bring a tab to the front." , inputSchema : { type : "object" , properties : tabIdProp , required : [ "tab_id" ] } } ,
{ name : "close_tab" , description : "Close a tab." , inputSchema : { type : "object" , properties : tabIdProp , required : [ "tab_id" ] } } ,
{ name : "screenshot" , description : "Screenshot of a tab, in CSS pixels: a point in the image is the same point for click(x, y). A tab in the background is brought to the front first." , inputSchema : { type : "object" , properties : { ... tabIdProp , full _page : { type : "boolean" } , format : { type : "string" , enum : [ "png" , "jpeg" ] } } } } ,
{ name : "read_page_text" , description : "The visible text of a tab (innerText), with its URL and title." , inputSchema : { type : "object" , properties : { ... tabIdProp , max _chars : { type : "integer" , description : "Default 40000." } } } } ,
{ name : "snapshot" , description : "Accessibility tree of a tab, one element per line with a ref (e.g. [ref=e12]) usable in click, type, paste_html, paste_text and upload_file. Refs reset on every snapshot and navigation." , inputSchema : { type : "object" , properties : { ... tabIdProp , interactive _only : { type : "boolean" , description : "Only controls, editable areas and headings." } } } } ,
{ name : "click" , description : "Click an element by ref, or a point (x, y) in CSS pixels as in the screenshot." , inputSchema : { type : "object" , properties : { ... tabIdProp , ... refProp , x : { type : "number" } , y : { type : "number" } , button : { type : "string" , enum : [ "left" , "right" , "middle" ] } , double : { type : "boolean" } } } } ,
{ name : "type" , description : "Type text as real input that rich editors register (lines are inserted as text, with a real Enter key between them). With ref, focuses that element first. Refuses password fields." , inputSchema : { type : "object" , properties : { ... tabIdProp , ... refProp , text : { type : "string" } , mode : { type : "string" , enum : [ "insert" , "keys" ] , description : "\"keys\" sends every character as its own key press (slower)." } } , required : [ "text" ] } } ,
{ name : "press_key" , description : "Press one key, optionally with modifiers: Enter, Tab, Backspace, Delete, Escape, ArrowUp/Down/Left/Right, Home, End, PageUp, PageDown, or a single character (e.g. key \"a\" with modifiers [\"ctrl\"])." , inputSchema : { type : "object" , properties : { ... tabIdProp , key : { type : "string" } , modifiers : { type : "array" , items : { type : "string" , enum : [ "ctrl" , "shift" , "alt" , "meta" ] } } , repeat : { type : "integer" } } , required : [ "key" ] } } ,
{ name : "paste_html" , description : "Paste rich HTML (headings, links, lists, bold…) into the focused editor, or into ref, through the real clipboard and the browser's own paste. The user's clipboard is put back afterwards." , inputSchema : { type : "object" , properties : { ... tabIdProp , ... refProp , html : { type : "string" } , text : { type : "string" , description : "Plain-text alternative (default: derived from the HTML)." } } , required : [ "html" ] } } ,
{ name : "paste_text" , description : "Paste plain text through the real clipboard and the browser's own paste. The user's clipboard is put back afterwards." , inputSchema : { type : "object" , properties : { ... tabIdProp , ... refProp , text : { type : "string" } } , required : [ "text" ] } } ,
{ name : "upload_file" , description : "Set a local file on an <input type=file> (by ref). Only files inside the upload folders the user allowed in the connector panel." , inputSchema : { type : "object" , properties : { ... tabIdProp , ... refProp , path : { type : "string" , description : "Absolute path." } , paths : { type : "array" , items : { type : "string" } , description : "Several files, for inputs that accept multiple." } } , required : [ "ref" ] } } ,
{ name : "wait_for" , description : "Wait until a CSS selector matches, or a text appears, in a tab." , inputSchema : { type : "object" , properties : { ... tabIdProp , selector : { type : "string" } , text : { type : "string" } , timeout _ms : { type : "integer" , description : "Default 10000, at most 60000." } } } } ,
] ;
const EVALUATE _DEF = { name : "evaluate" , description : "Run JavaScript in the page and return the JSON result. Available only while the user has turned on \"Allow evaluate\" in the connector panel." , inputSchema : { type : "object" , properties : { ... tabIdProp , expression : { type : "string" } } , required : [ "expression" ] } } ;
const text = ( s ) => ( { content : [ { type : "text" , text : String ( s ) } ] } ) ;
const sleep = ( ms ) => new Promise ( ( r ) => setTimeout ( r , ms ) ) ;
const htmlToText = ( html ) => String ( html )
. replace ( /<(script|style)[\s\S]*?<\/\1>/gi , "" )
. replace ( /<br\s*\/?>/gi , "\n" )
. replace ( /<\/(p|div|h[1-6]|li|blockquote|pre|tr)>/gi , "\n" )
. replace ( /<[^>]+>/g , "" )
. replace ( / /g , " " ) . replace ( /</g , "<" ) . replace ( />/g , ">" ) . replace ( /"/g , "\"" ) . replace ( /'/g , "'" ) . replace ( /&/g , "&" )
. replace ( /\n{3,}/g , "\n\n" ) . trim ( ) ;
const normalizeUrl = ( u ) => {
const s = String ( u || "" ) . trim ( ) ;
if ( ! s ) throw new Error ( "url is required" ) ;
return /^[a-z][a-z0-9+.-]*:/i . test ( s ) ? s : ` https:// ${ s } ` ;
} ;
class Controller {
constructor ( { tabControl , policy , log , getSettings , clipboard , onTabsChanged , onBusy } ) {
this . tabControl = tabControl ;
this . policy = policy ;
this . log = log ;
this . getSettings = getSettings ;
this . clipboard = clipboard ;
this . onTabsChanged = onTabsChanged || ( ( ) => { } ) ;
this . onBusy = onBusy || ( ( ) => { } ) ;
this . drivers = new Map ( ) ; // tabId -> TabDriver
this . inflight = 0 ;
this . _pasteChain = Promise . resolve ( ) ;
}
definitions ( ) {
return this . getSettings ( ) . allowEvaluate ? [ ... DEFS , EVALUATE _DEF ] : DEFS ;
}
controlledTabIds ( ) { return [ ... this . drivers . keys ( ) ] ; }
async releaseAll ( ) {
const all = [ ... this . drivers . values ( ) ] ;
this . drivers . clear ( ) ;
await Promise . all ( all . map ( ( d ) => d . release ( ) . catch ( ( ) => { } ) ) ) ;
this . onTabsChanged ( ) ;
}
async release ( tabId ) {
const d = this . drivers . get ( tabId ) ;
if ( ! d ) return ;
this . drivers . delete ( tabId ) ;
await d . release ( ) . catch ( ( ) => { } ) ;
this . onTabsChanged ( ) ;
}
_tabRecord ( tabId ) {
const list = this . tabControl . list ( ) ;
if ( tabId == null ) {
const t = list . find ( ( x ) => x . active && x . kind === "web" && x . window === 0 ) || list . find ( ( x ) => x . active && x . kind === "web" ) ;
if ( ! t ) throw new Error ( "no active web tab; pass tab_id (see list_tabs)" ) ;
return t ;
}
const t = list . find ( ( x ) => x . id === Number ( tabId ) ) ;
if ( ! t ) throw new Error ( ` no tab ${ tabId } ; see list_tabs ` ) ;
return t ;
}
// The tab, approved and attached. Returns { tab, wc, driver, origin }.
async use ( tabId , session ) {
const tab = this . _tabRecord ( tabId ) ;
if ( tab . kind !== "web" ) throw new Error ( ` tab ${ tab . id } is not a web page ( ${ tab . kind } ); the connector only controls web pages ` ) ;
const wc = this . tabControl . webContents ( tab . id ) ;
// A dormant tab just started loading: let it reach a URL first.
for ( let i = 0 ; i < 40 && ! /^(?:https?|bns):/i . test ( wc . getURL ( ) ) ; i ++ ) await sleep ( 100 ) ;
const origin = originOf ( wc . getURL ( ) || tab . url ) ;
await this . policy . ensure ( origin , { client : session && session . client , action : "control" } ) ;
let driver = this . drivers . get ( tab . id ) ;
if ( ! driver || driver . wc !== wc ) {
if ( driver ) await driver . release ( ) . catch ( ( ) => { } ) ;
driver = new TabDriver ( tab . id , wc , { onGone : ( id ) => { this . drivers . delete ( id ) ; this . onTabsChanged ( ) ; } } ) ;
this . drivers . set ( tab . id , driver ) ;
this . onTabsChanged ( ) ;
}
Theseus 0.3.107: two-section home + Claude connector hardening
Home page splits into two self-contained panels so the left-column
links no longer scale with the Silent Mode cards. The slot grid is
narrower, the icons default closer to Windows desktop-shortcut size,
and each section carries its own subtle panel background and heading.
Claude connector hardening (the opt-in MCP add-on from 0.3.106):
- Pin the approved origin to the TabDriver on every Controller.use()
and re-check the tab's current URL before each CDP read / input
(isolated, snapshot, screenshot, clickAt, insertText, press,
setFiles, evaluate). A meta-refresh or soft-nav to an unapproved
origin between approval and the call now throws instead of leaking
content or steering input to the new site.
- Rotate the bearer token on every stop() so toggling the connector
off (toolbar chip, panel switch, deactivate) invalidates existing
credentials. Re-enabling issues a fresh token; the panel's install
strings update on the next start. Matches the mental model where
"off" means "the keys change" instead of "the server stops".
- click without a ref now resolves the element at (x, y) through
DOM.getNodeForLocation and refuses if it's a password / one-time-
code field. Previously only ref-based click ran isSecretField; raw
coordinates let focus land on a password field before FOCUS_GUARD
kicked in to block typing.
2026-10-23 19:12:52 +03:00
// Pin the just-approved origin to the driver so every subsequent CDP
// call re-checks the tab's current URL. A soft-nav / meta-refresh to a
// different origin between here and the call throws instead of leaking
// content or landing input on the new site.
driver . setExpectedOrigin ( origin , originOf ) ;
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:04:03 +03:00
await driver . ready ( ) ;
return { tab , wc , driver , origin } ;
}
async waitLoad ( wc , timeoutMs = 20000 ) {
const until = Date . now ( ) + timeoutMs ;
await sleep ( 150 ) ;
while ( Date . now ( ) < until ) {
if ( wc . isDestroyed ( ) ) return ;
if ( ! wc . isLoading ( ) ) return ;
await sleep ( 150 ) ;
}
}
async call ( name , args , session ) {
const a = args && typeof args === "object" ? args : { } ;
const started = Date . now ( ) ;
this . inflight ++ ;
this . onBusy ( true ) ;
let origin = null ;
let tabId = a . tab _id ? ? null ;
try {
const out = await this . _run ( name , a , session , ( o , id ) => { origin = o ; if ( id != null ) tabId = id ; } ) ;
this . log . add ( { tool : name , client : session && session . client , tab : tabId , origin , args : summarizeArgs ( a ) , ok : true , ms : Date . now ( ) - started } ) ;
return out ;
} catch ( e ) {
this . log . add ( { tool : name , client : session && session . client , tab : tabId , origin , args : summarizeArgs ( a ) , ok : false , error : String ( e ? . message || e ) . slice ( 0 , 300 ) , ms : Date . now ( ) - started } ) ;
throw e ;
} finally {
this . inflight -- ;
if ( ! this . inflight ) this . onBusy ( false ) ;
}
}
async _run ( name , a , session , note ) {
switch ( name ) {
case "list_tabs" : {
const rows = this . tabControl . list ( ) . map ( ( t ) => {
const o = originOf ( t . url ) ;
const approved = o && this . policy . isAllowed ( o ) ;
const base = { id : t . id , active : t . active , window : t . window , kind : t . kind , controlled : this . drivers . has ( t . id ) } ;
if ( t . kind !== "web" ) return { ... base , title : t . kind === "settings" ? "Settings" : t . title } ;
return approved ? { ... base , title : t . title , url : t . url } : { ... base , host : hostOf ( t . url ) , approved : false } ;
} ) ;
return text ( JSON . stringify ( rows , null , 1 ) ) ;
}
case "open_tab" : {
const url = normalizeUrl ( a . url ) ;
const origin = originOf ( url ) ;
note ( origin ) ;
await this . policy . ensure ( origin , { client : session && session . client , action : "open" } ) ;
const id = this . tabControl . open ( url , { background : ! ! a . background } ) ;
note ( origin , id ) ;
const wc = this . tabControl . webContents ( id ) ;
await this . waitLoad ( wc ) ;
return text ( JSON . stringify ( { tab _id : id , url : wc . getURL ( ) , title : wc . getTitle ( ) } ) ) ;
}
case "navigate" : {
const { tab , wc , driver , origin } = await this . use ( a . tab _id , session ) ;
note ( origin , tab . id ) ;
if ( a . action ) {
const h = wc . navigationHistory ;
if ( a . action === "back" ) { if ( ! h . canGoBack ( ) ) throw new Error ( "nothing to go back to" ) ; h . goBack ( ) ; }
else if ( a . action === "forward" ) { if ( ! h . canGoForward ( ) ) throw new Error ( "nothing to go forward to" ) ; h . goForward ( ) ; }
else if ( a . action === "reload" ) wc . reload ( ) ;
else throw new Error ( ` unknown action ${ a . action } ` ) ;
} else {
const url = normalizeUrl ( a . url ) ;
const target = originOf ( url ) ;
if ( target !== origin ) await this . policy . ensure ( target , { client : session && session . client , action : "open" } ) ;
await this . tabControl . navigate ( tab . id , url ) ;
}
await this . waitLoad ( wc ) ;
driver . refs . clear ( ) ;
return text ( JSON . stringify ( { tab _id : tab . id , url : wc . getURL ( ) , title : wc . getTitle ( ) } ) ) ;
}
case "activate_tab" : {
const { tab , origin } = await this . use ( a . tab _id , session ) ;
note ( origin , tab . id ) ;
this . tabControl . activate ( tab . id ) ;
return text ( ` tab ${ tab . id } is in front ` ) ;
}
case "close_tab" : {
const { tab , origin } = await this . use ( a . tab _id , session ) ;
note ( origin , tab . id ) ;
await this . release ( tab . id ) ;
this . tabControl . close ( tab . id ) ;
return text ( ` closed tab ${ tab . id } ` ) ;
}
case "screenshot" : {
const { tab , driver , origin } = await this . use ( a . tab _id , session ) ;
note ( origin , tab . id ) ;
let fronted = false ;
// A hidden view renders no frames, so a screenshot would never come.
if ( ! tab . active ) { this . tabControl . activate ( tab . id ) ; fronted = true ; await sleep ( 300 ) ; }
const s = await driver . screenshot ( { fullPage : ! ! a . full _page , format : a . format } ) ;
return { content : [
{ type : "image" , data : s . data , mimeType : s . mimeType } ,
{ type : "text" , text : ` ${ s . width } × ${ s . height } CSS px ${ s . truncated ? " (cut at 12000 px)" : "" } ${ fronted ? ` ; tab ${ tab . id } was brought to the front ` : "" } ` } ,
] } ;
}
case "read_page_text" : {
const { tab , driver , origin } = await this . use ( a . tab _id , session ) ;
note ( origin , tab . id ) ;
const r = await driver . text ( a . max _chars ) ;
return text ( ` URL: ${ r . url } \n Title: ${ r . title } \n ${ r . truncated ? ` (first ${ r . text . length } of ${ r . length } characters) \n ` : "" } \n ${ r . text } ` ) ;
}
case "snapshot" : {
const { tab , driver , origin } = await this . use ( a . tab _id , session ) ;
note ( origin , tab . id ) ;
const info = await driver . pageInfo ( ) . catch ( ( ) => ( { } ) ) ;
const s = await driver . snapshot ( { interactiveOnly : ! ! a . interactive _only } ) ;
return text ( ` URL: ${ info . url || "" } \n Title: ${ info . title || "" } \n \n ${ s . lines . join ( "\n" ) } ${ s . truncated ? "\n… (cut; use interactive_only or read_page_text)" : "" } ` ) ;
}
case "click" : {
const { tab , driver , origin } = await this . use ( a . tab _id , session ) ;
note ( origin , tab . id ) ;
let x = a . x , y = a . y ;
if ( a . ref ) {
const id = driver . backendNode ( a . ref ) ;
await driver . assertNotSecret ( id ) ;
( { x , y } = await driver . centerOf ( id ) ) ;
} else if ( ! ( Number . isFinite ( Number ( x ) ) && Number . isFinite ( Number ( y ) ) ) ) throw new Error ( "click needs a ref or x and y" ) ;
Theseus 0.3.107: two-section home + Claude connector hardening
Home page splits into two self-contained panels so the left-column
links no longer scale with the Silent Mode cards. The slot grid is
narrower, the icons default closer to Windows desktop-shortcut size,
and each section carries its own subtle panel background and heading.
Claude connector hardening (the opt-in MCP add-on from 0.3.106):
- Pin the approved origin to the TabDriver on every Controller.use()
and re-check the tab's current URL before each CDP read / input
(isolated, snapshot, screenshot, clickAt, insertText, press,
setFiles, evaluate). A meta-refresh or soft-nav to an unapproved
origin between approval and the call now throws instead of leaking
content or steering input to the new site.
- Rotate the bearer token on every stop() so toggling the connector
off (toolbar chip, panel switch, deactivate) invalidates existing
credentials. Re-enabling issues a fresh token; the panel's install
strings update on the next start. Matches the mental model where
"off" means "the keys change" instead of "the server stops".
- click without a ref now resolves the element at (x, y) through
DOM.getNodeForLocation and refuses if it's a password / one-time-
code field. Previously only ref-based click ran isSecretField; raw
coordinates let focus land on a password field before FOCUS_GUARD
kicked in to block typing.
2026-10-23 19:12:52 +03:00
else await driver . assertNotSecretAt ( Number ( x ) , Number ( y ) ) ;
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:04:03 +03:00
await driver . clickAt ( Number ( x ) , Number ( y ) , { button : a . button || "left" , clickCount : a . double ? 2 : 1 } ) ;
return text ( ` clicked at ${ Math . round ( x ) } , ${ Math . round ( y ) } ${ a . ref ? ` ( ${ a . ref } ) ` : "" } ` ) ;
}
case "type" : {
const { tab , driver , origin } = await this . use ( a . tab _id , session ) ;
note ( origin , tab . id ) ;
if ( typeof a . text !== "string" ) throw new Error ( "text is required" ) ;
if ( a . ref ) { const id = driver . backendNode ( a . ref ) ; await driver . assertNotSecret ( id ) ; await driver . focus ( id ) ; }
const g = await driver . focusGuard ( ) ;
await driver . type ( a . text , { mode : a . mode === "keys" ? "keys" : "insert" } ) ;
return text ( ` typed ${ a . text . length } characters ${ g . editable ? "" : " (note: no editable element had focus)" } ` ) ;
}
case "press_key" : {
const { tab , driver , origin } = await this . use ( a . tab _id , session ) ;
note ( origin , tab . id ) ;
await driver . focusGuard ( ) ;
const n = Math . max ( 1 , Math . min ( Number ( a . repeat ) || 1 , 100 ) ) ;
for ( let i = 0 ; i < n ; i ++ ) await driver . press ( a . key , a . modifiers || [ ] ) ;
return text ( ` pressed ${ ( a . modifiers || [ ] ) . concat ( a . key ) . join ( "+" ) } ${ n > 1 ? ` × ${ n } ` : "" } ` ) ;
}
case "paste_html" :
case "paste_text" : {
const { tab , wc , driver , origin } = await this . use ( a . tab _id , session ) ;
note ( origin , tab . id ) ;
const isHtml = name === "paste_html" ;
const content = isHtml ? a . html : a . text ;
if ( typeof content !== "string" || ! content ) throw new Error ( ` ${ isHtml ? "html" : "text" } is required ` ) ;
if ( a . ref ) { const id = driver . backendNode ( a . ref ) ; await driver . assertNotSecret ( id ) ; await driver . focus ( id ) ; }
await driver . focusGuard ( ) ;
const data = isHtml ? { html : content , text : typeof a . text === "string" ? a . text : htmlToText ( content ) } : { text : content } ;
// One paste at a time: the clipboard is shared.
const run = this . _pasteChain . then ( ( ) => this . _paste ( wc , driver , data ) ) ;
this . _pasteChain = run . catch ( ( ) => { } ) ;
await run ;
return text ( ` pasted ${ content . length } characters of ${ isHtml ? "HTML" : "text" } ` ) ;
}
case "upload_file" : {
const { tab , driver , origin } = await this . use ( a . tab _id , session ) ;
note ( origin , tab . id ) ;
const list = Array . isArray ( a . paths ) && a . paths . length ? a . paths : [ a . path ] ;
const dirs = this . getSettings ( ) . uploadDirs || [ ] ;
const real = list . map ( ( p ) => resolveUpload ( p , dirs ) ) ;
await driver . setFiles ( driver . backendNode ( a . ref ) , real ) ;
return text ( ` set ${ real . length } file ${ real . length === 1 ? "" : "s" } on ${ a . ref } ` ) ;
}
case "wait_for" : {
const { tab , driver , origin } = await this . use ( a . tab _id , session ) ;
note ( origin , tab . id ) ;
if ( ! a . selector && ! a . text ) throw new Error ( "wait_for needs selector or text" ) ;
const r = await driver . waitFor ( { selector : a . selector , text : a . text , timeoutMs : a . timeout _ms } ) ;
if ( ! r . found ) throw new Error ( ` timed out waiting for ${ a . selector ? ` selector ${ a . selector } ` : ` text " ${ a . text } " ` } ` ) ;
return text ( ` found ${ r . visible === false ? " (not visible)" : "" } ` ) ;
}
case "evaluate" : {
if ( ! this . getSettings ( ) . allowEvaluate ) throw new Error ( "evaluate is turned off; the user can allow it in the Claude connector panel" ) ;
const { tab , driver , origin } = await this . use ( a . tab _id , session ) ;
note ( origin , tab . id ) ;
const r = await driver . evaluate ( a . expression ) ;
let out ;
try { out = r . value === undefined ? ( r . description || r . type ) : JSON . stringify ( r . value , null , 1 ) ; } catch { out = String ( r . description || r . type ) ; }
return text ( String ( out ) . slice ( 0 , 100000 ) ) ;
}
default :
throw new Error ( ` unknown tool ${ name } ` ) ;
}
}
// The real clipboard, the browser's own paste, then the user's clipboard
// back. The renderer reads the clipboard when it runs the paste, so the
// old contents return only after a round trip to the page.
async _paste ( wc , driver , data ) {
const cb = this . clipboard ; // lib/clipboard.cjs
let saved = null ;
try { saved = await cb . save ( ) ; } catch { }
await cb . put ( data ) ;
try {
wc . paste ( ) ;
await sleep ( 250 ) ;
await driver . isolated ( "1" ) . catch ( ( ) => { } ) ;
await sleep ( 150 ) ;
} finally {
// Nothing was read (the clipboard was busy): leave it rather than wipe it.
if ( saved ) await cb . restore ( saved ) . catch ( ( ) => { } ) ;
}
}
}
module . exports = { Controller , DEFS , EVALUATE _DEF , htmlToText , normalizeUrl } ;