theseus/bundled-addons/claude-connector/lib/tools.cjs
Hermes Dev baf86fc5a6 Claude connector: reach add-on sidebar panels (Aegis, Hermes, Pithos, …)
The connector only drove web pages. Add-on sidebar panels — Aegis's
wallet, Hermes's messenger, Pithos's storage panel, anything with
a sidebar-panel capability — were walled off: list_tabs skipped them
and webContents() refused anything that was not kind:"web".

Panels are now first-class tabs with a negative id, kind:"panel", and
an addon/panelId pair. Each open panel is listed next to the real
tabs; a closed one is still listed so a client can see what exists.
screenshot / snapshot / read_page_text / click / type / press_key /
paste_html / paste_text / wait_for / upload_file / evaluate all work
on them with the same TabDriver a web page uses. navigate /
activate_tab / close_tab refuse panels — the user owns panel
visibility, not the connector.

Approval is per add-on under a synthetic theseus-panel://<addon>
origin, so Aegis is approved once and every Aegis panel is reachable
from then on. The approval modal titles itself "Let Claude control
the Aegis panel?" when the origin is a panel origin, and the body
explains the panel is read, not the whole browser profile. Origin
pinning is skipped (the panel is served from the add-on scheme and
never navigates under us); password-field refusal and the action log
still apply, so a PIN input in a wallet panel is never typed into
and every call is in the connector's log.
2026-10-29 00:06:51 +02:00

364 lines
22 KiB
JavaScript
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.

// 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 and add-on panels. Each row has id, title and which one is active. Web tabs on sites the user has not approved show only their host. Add-on panels (Aegis, Hermes, Pithos, …) have kind \"panel\", carry addon and panelId, and are driven as a tab once the user opens them from the sidebar and approves the panel in the connector.", 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(/&nbsp;/g, " ").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&amp;/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;
}
// Approval key for an add-on panel. Each add-on's panels share one key
// (`theseus-panel://aegis`) — approve Aegis once, drive every Aegis panel.
_panelOrigin(tab) {
return `theseus-panel://${String(tab.addon || tab.panelId || "panel").toLowerCase()}`;
}
// The tab, approved and attached. Returns { tab, wc, driver, origin }.
async use(tabId, session) {
const tab = this._tabRecord(tabId);
if (tab.kind === "panel") return this._usePanel(tab, session);
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();
}
// 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);
await driver.ready();
return { tab, wc, driver, origin };
}
// Add-on panels (Aegis, Hermes, Pithos, …) share two WebContentsViews on
// the host side, one for currently-open left panels and one for the right
// sidebar. The connector drives them with the same CDP toolbox a web tab
// uses, under a synthetic origin so the policy approval prompt names the
// add-on. navigation / origin pinning do not apply — the page is served
// from the add-on scheme and never changes under us.
async _usePanel(tab, session) {
if (!tab.open) throw new Error(`${tab.title || tab.panelId} is not open; ask the user to open the panel from the sidebar first`);
const wc = this.tabControl.webContents(tab.id);
const origin = this._panelOrigin(tab);
await this.policy.ensure(origin, { client: session && session.client, action: "control-panel", addon: tab.addon, panelId: tab.panelId, title: tab.title });
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();
}
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 base = { id: t.id, active: t.active, window: t.window, kind: t.kind, controlled: this.drivers.has(t.id), ...(t.sidebar ? { sidebar: t.sidebar } : {}) };
if (t.kind === "panel") {
const approved = this.policy.isAllowed(this._panelOrigin(t));
return { ...base, title: t.title, addon: t.addon, panelId: t.panelId, side: t.side, open: !!t.open, ...(approved ? {} : { approved: false }) };
}
if (t.kind !== "web") return { ...base, title: t.kind === "settings" ? "Settings" : t.title };
const o = originOf(t.url);
const approved = o && this.policy.isAllowed(o);
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 peek = this._tabRecord(a.tab_id);
if (peek.kind === "panel") throw new Error("navigate is not supported on add-on panels; drive the panel's own controls instead");
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 peek = this._tabRecord(a.tab_id);
if (peek.kind === "panel") throw new Error("add-on panels are not activated by the connector; the user controls what is open in the sidebar");
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 peek = this._tabRecord(a.tab_id);
if (peek.kind === "panel") throw new Error("add-on panels are closed by the user, not by the connector");
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}\nTitle: ${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 || ""}\nTitle: ${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");
else await driver.assertNotSecretAt(Number(x), Number(y));
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 };