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.
144 lines
6.6 KiB
JavaScript
144 lines
6.6 KiB
JavaScript
// What the connector lets a client do: which sites (asked once per origin),
|
|
// which files it may upload, and what counts as a password field.
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
// "https://medium.com" for any page on it. BCNR pages load as bns://name.bch
|
|
// inside Theseus and show as https:// in the address bar; they are the same
|
|
// site, so both forms map to the https origin. Anything else has no origin
|
|
// the connector will act on.
|
|
function originOf(url) {
|
|
let u;
|
|
try { u = new URL(String(url || "")); } catch { return null; }
|
|
if (u.protocol === "bns:") return `https://${u.host.toLowerCase()}`;
|
|
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
|
return `${u.protocol}//${u.host.toLowerCase()}`;
|
|
}
|
|
|
|
function hostOf(url) {
|
|
try { return new URL(String(url || "")).host.toLowerCase(); } catch { return ""; }
|
|
}
|
|
|
|
const DENY_MS = 10 * 60 * 1000;
|
|
const ASK_TIMEOUT_MS = 3 * 60 * 1000;
|
|
|
|
// Approved origins live in the add-on store (remembered) or in memory (this
|
|
// session only). A refusal is remembered for ten minutes, so a client cannot
|
|
// keep the prompt on screen. Concurrent calls for one origin share a prompt.
|
|
class SitePolicy {
|
|
constructor({ getAllowed, setAllowed, ask }) {
|
|
this.getAllowed = getAllowed;
|
|
this.setAllowed = setAllowed;
|
|
this.ask = ask;
|
|
this.session = new Set();
|
|
this.denied = new Map(); // origin -> until (ms)
|
|
this.pending = new Map(); // origin -> Promise<boolean>
|
|
}
|
|
|
|
isAllowed(origin) {
|
|
return !!origin && (this.session.has(origin) || this.getAllowed().includes(origin));
|
|
}
|
|
|
|
async ensure(origin, context = {}) {
|
|
if (!origin) throw new Error("this page has no web origin the connector can act on");
|
|
if (this.isAllowed(origin)) return true;
|
|
const until = this.denied.get(origin);
|
|
if (until && until > Date.now()) throw new Error(`the user did not allow control of ${origin}; ask them to approve it in the Claude connector panel`);
|
|
if (!this.pending.has(origin)) {
|
|
const p = (async () => {
|
|
let answer = "cancel";
|
|
let timer;
|
|
try {
|
|
answer = await Promise.race([
|
|
this.ask(origin, context),
|
|
new Promise((r) => { timer = setTimeout(() => r("timeout"), ASK_TIMEOUT_MS); }),
|
|
]);
|
|
} finally { clearTimeout(timer); }
|
|
const pick = String(answer || "").split("+")[0];
|
|
if (pick === "allow") {
|
|
const list = this.getAllowed();
|
|
if (!list.includes(origin)) this.setAllowed([...list, origin]);
|
|
return true;
|
|
}
|
|
if (pick === "session") { this.session.add(origin); return true; }
|
|
if (pick === "timeout") throw new Error(`no answer from the user about ${origin}; try again when they are at the computer`);
|
|
this.denied.set(origin, Date.now() + DENY_MS);
|
|
throw new Error(`the user did not allow control of ${origin}`);
|
|
})().finally(() => this.pending.delete(origin));
|
|
this.pending.set(origin, p);
|
|
}
|
|
return this.pending.get(origin);
|
|
}
|
|
|
|
forget(origin) {
|
|
this.session.delete(origin);
|
|
this.denied.delete(origin);
|
|
this.setAllowed(this.getAllowed().filter((o) => o !== origin));
|
|
}
|
|
|
|
reset() { this.session.clear(); this.denied.clear(); }
|
|
}
|
|
|
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
|
|
// The real file behind `p` if it sits inside one of the allowed folders.
|
|
// Both sides are resolved with realpath, so a symlink or junction inside an
|
|
// allowed folder that points elsewhere is refused.
|
|
function resolveUpload(p, allowedDirs) {
|
|
if (!p || typeof p !== "string") throw new Error("upload needs a file path");
|
|
if (!path.isAbsolute(p)) throw new Error("upload needs an absolute file path");
|
|
if (!Array.isArray(allowedDirs) || !allowedDirs.length) throw new Error("no upload folder is set; the user can add one in the Claude connector panel");
|
|
let real;
|
|
try { real = fs.realpathSync.native(p); } catch { throw new Error(`file not found: ${p}`); }
|
|
const st = fs.statSync(real);
|
|
if (!st.isFile()) throw new Error(`not a regular file: ${p}`);
|
|
if (st.size > MAX_UPLOAD_BYTES) throw new Error(`file is larger than ${MAX_UPLOAD_BYTES / 1024 / 1024} MB`);
|
|
const norm = (x) => (process.platform === "win32" ? x.toLowerCase() : x);
|
|
for (const d of allowedDirs) {
|
|
let rd;
|
|
try { rd = fs.realpathSync.native(d); } catch { continue; }
|
|
const base = norm(rd.endsWith(path.sep) ? rd : rd + path.sep);
|
|
if (norm(real).startsWith(base)) return real;
|
|
}
|
|
throw new Error(`${p} is outside the upload folders the user allowed`);
|
|
}
|
|
|
|
const SECRET_AUTOCOMPLETE = /\b(?:current-password|new-password|one-time-code)\b/i;
|
|
|
|
// From DOM.describeNode: is this element a password (or one-time code) field?
|
|
function isSecretField(node) {
|
|
if (!node) return false;
|
|
const attrs = {};
|
|
const a = node.attributes || [];
|
|
for (let i = 0; i + 1 < a.length; i += 2) attrs[String(a[i]).toLowerCase()] = String(a[i + 1]);
|
|
if (String(node.localName || node.nodeName || "").toLowerCase() === "input" && String(attrs.type || "").toLowerCase() === "password") return true;
|
|
return SECRET_AUTOCOMPLETE.test(attrs.autocomplete || "");
|
|
}
|
|
|
|
// Runs in the connector's isolated world (page scripts cannot reach or
|
|
// patch it): follows focus through open shadow roots and same-origin frames
|
|
// and says whether typing there is allowed.
|
|
const FOCUS_GUARD = `(() => {
|
|
let el = document.activeElement;
|
|
for (let i = 0; el && i < 25; i++) {
|
|
if (el.shadowRoot && el.shadowRoot.activeElement) { el = el.shadowRoot.activeElement; continue; }
|
|
if (el.tagName === "IFRAME" || el.tagName === "FRAME") {
|
|
let doc = null;
|
|
try { doc = el.contentDocument; } catch {}
|
|
if (!doc) return { ok: false, reason: "the focused element is inside a frame from another site, which the connector cannot check" };
|
|
el = doc.activeElement;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
if (!el || el.tagName === "BODY" || el.tagName === "HTML") return { ok: true, editable: false, tag: el ? el.tagName.toLowerCase() : null };
|
|
const tag = el.tagName.toLowerCase();
|
|
const ac = String((el.getAttribute && el.getAttribute("autocomplete")) || "");
|
|
if (tag === "input" && String(el.type).toLowerCase() === "password") return { ok: false, reason: "the focused field is a password field" };
|
|
if (${SECRET_AUTOCOMPLETE}.test(ac)) return { ok: false, reason: "the focused field is marked for a password or one-time code" };
|
|
const editable = !!el.isContentEditable || tag === "textarea" || tag === "select" || (tag === "input" && !/^(?:button|submit|reset|checkbox|radio|file|image|range|color|hidden)$/i.test(el.type));
|
|
return { ok: true, editable, tag };
|
|
})()`;
|
|
|
|
module.exports = { originOf, hostOf, SitePolicy, resolveUpload, isSecretField, FOCUS_GUARD, MAX_UPLOAD_BYTES };
|