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.
325 lines
19 KiB
JavaScript
325 lines
19 KiB
JavaScript
// 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();
|
||
}
|
||
// 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 };
|
||
}
|
||
|
||
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}\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 };
|