theseus/bundled-addons/claude-connector/panel.html
Dev c06557e957 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:45:02 +03:00

189 lines
12 KiB
HTML
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.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Claude connector</title>
<style>
:root { color-scheme: light dark;
--bg:#0e131c; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --clay:#d97757; --bad:#ff9081;
--btn:#1c2432; --btn-h:#242e40; }
@media (prefers-color-scheme: light) {
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#f0f3fa; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --btn:#f0f3fa; --btn-h:#e3e8f2; --bad:#c43c2c; }
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body { background: var(--bg); color: var(--ink); font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
header { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 2; }
header .t { font-weight: 600; flex: 1; }
.pill { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--mut); white-space: nowrap; }
.pill.on { color: var(--clay); border-color: rgb(217 119 87 / .6); background: rgb(217 119 87 / .12); }
.pill.idle { color: var(--acid); border-color: rgb(from var(--acid) r g b / .35); }
section { padding: 12px 14px; border-bottom: 1px solid var(--line); }
h2 { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); margin: 0 0 8px; font-weight: 600; }
p { margin: 0 0 8px; color: var(--mut); }
.row { display: flex; align-items: center; gap: 10px; margin: 6px 0; }
.row .grow { flex: 1; min-width: 0; }
.sw { position: relative; width: 34px; height: 20px; flex: none; }
.sw input { opacity: 0; width: 0; height: 0; position: absolute; }
.sw span { position: absolute; inset: 0; background: var(--btn); border: 1px solid var(--line); border-radius: 999px; cursor: pointer; transition: .15s; }
.sw span::after { content: ""; position: absolute; width: 14px; height: 14px; left: 2px; top: 2px; background: var(--mut); border-radius: 50%; transition: .15s; }
.sw input:checked + span { background: rgb(217 119 87 / .35); border-color: var(--clay); }
.sw input:checked + span::after { transform: translateX(14px); background: var(--clay); }
button.b { font: inherit; font-size: 12px; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); background: var(--btn); color: var(--ink); cursor: pointer; white-space: nowrap; }
button.b:hover { background: var(--btn-h); }
button.b.warn { color: var(--bad); }
button.x { border: none; background: none; color: var(--dim); cursor: pointer; font-size: 13px; padding: 2px 6px; }
button.x:hover { color: var(--bad); }
pre { margin: 6px 0; padding: 8px; background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; font: 11.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 180px; overflow: auto; }
.mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
.list { margin: 4px 0; padding: 0; list-style: none; }
.list li { display: flex; align-items: center; gap: 6px; padding: 3px 0; border-bottom: 1px dashed var(--line); }
.list li:last-child { border-bottom: none; }
.list li .grow { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.empty { color: var(--dim); font-style: italic; }
.note { font-size: 12px; color: var(--mut); }
.warnbox { font-size: 12px; color: var(--bad); margin-top: 4px; }
.log { font: 11.5px/1.4 ui-monospace, Consolas, monospace; }
.log .e { padding: 4px 0; border-bottom: 1px solid var(--line); }
.log .e .h { display: flex; gap: 6px; flex-wrap: wrap; }
.log .ts { color: var(--dim); }
.log .tool { color: var(--ink); font-weight: 600; }
.log .org { color: var(--mut); }
.log .ok { color: var(--acid); }
.log .bad { color: var(--bad); }
.log .a { color: var(--mut); overflow-wrap: anywhere; }
.tabs { display: flex; gap: 4px; margin-bottom: 6px; }
.tabs button { font: inherit; font-size: 12px; padding: 3px 9px; border: 1px solid var(--line); background: var(--btn); color: var(--mut); border-radius: 6px; cursor: pointer; }
.tabs button.sel { color: var(--ink); border-color: var(--clay); }
</style>
</head>
<body>
<header><span class="t">Claude connector</span><span class="pill" id="pill">…</span></header>
<section>
<div class="row"><div class="grow"><b>Accept connections</b><div class="note" id="where">Off</div></div><label class="sw"><input type="checkbox" id="accept"><span></span></label></div>
<div id="clients" class="note"></div>
<div class="row" style="margin-top:8px"><button class="b" id="disconnect">Disconnect clients</button><button class="b warn" id="revoke" title="Issue a new token. Every client must be set up again with the new command.">Revoke token</button></div>
</section>
<section id="setup">
<h2>Connect Claude</h2>
<div class="tabs"><button id="tCode" class="sel">Claude Code</button><button id="tDesk">Claude Desktop</button></div>
<div id="pCode">
<p>Run once in a terminal. <span class="mono">--scope user</span> makes it available in every project.</p>
<pre id="cmdCode"></pre>
<div class="row"><button class="b" id="copyCode">Copy command</button></div>
</div>
<div id="pDesk" hidden>
<p>Add to <span class="mono">claude_desktop_config.json</span> (Settings › Developer › Edit config), then restart Claude Desktop. It runs through Theseus itself, nothing else to install.</p>
<pre id="cmdDesk"></pre>
<div class="row"><button class="b" id="copyDesk">Copy config</button></div>
</div>
<p class="note">The token in this command is the key to this browser. Keep it out of shared files; Revoke token makes a new one.</p>
</section>
<section>
<h2>Sites Claude may control</h2>
<p>Theseus asks the first time Claude acts on a site.</p>
<ul class="list" id="origins"></ul>
</section>
<section>
<h2>Upload folders</h2>
<p>Claude can attach files only from these folders.</p>
<ul class="list" id="dirs"></ul>
<div class="row"><button class="b" id="addDir">Add folder…</button></div>
</section>
<section>
<h2>Advanced</h2>
<div class="row"><div class="grow"><b>Allow evaluate</b><div class="note">Lets Claude run its own JavaScript in approved sites' pages.</div></div><label class="sw"><input type="checkbox" id="evaluate"><span></span></label></div>
<div class="warnbox" id="evalWarn" hidden>Evaluate runs any script as the page itself, including reading what you see there. Turn it off when you do not need it.</div>
</section>
<section>
<h2>Activity</h2>
<div class="row"><span class="note grow">Every action, newest first.</span><button class="b" id="openLog">Log file</button><button class="b" id="clearLog">Clear</button></div>
<div class="log" id="log"></div>
</section>
<script>
const $ = (id) => document.getElementById(id);
const S = window.silentmode;
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
let state = null;
function render(st) {
state = st;
$("accept").checked = !!st.accept;
const pill = $("pill");
if (st.connected) { pill.textContent = "Connected"; pill.className = "pill on"; }
else if (st.listening) { pill.textContent = "Waiting for Claude"; pill.className = "pill idle"; }
else { pill.textContent = "Off"; pill.className = "pill"; }
$("where").textContent = st.listening ? `Listening on ${st.url} (this computer only)` : st.accept ? "Starting…" : "Not listening";
const sess = st.sessions || [];
$("clients").innerHTML = sess.length
? sess.map((s) => `${esc(s.client)} — connected since ${esc(new Date(s.since).toLocaleTimeString())}${s.streaming ? "" : ", idle"}`).join("<br>") + (st.controlledTabs.length ? `<br>Controlling tab${st.controlledTabs.length === 1 ? "" : "s"} ${st.controlledTabs.join(", ")}` : "")
: (st.listening ? "No client connected." : "");
$("setup").hidden = !st.commands;
if (st.commands) { $("cmdCode").textContent = st.commands.claudeCode; $("cmdDesk").textContent = st.commands.claudeDesktop; }
$("origins").innerHTML = st.allowedOrigins.length
? st.allowedOrigins.map((o) => `<li><span class="grow mono">${esc(o)}</span><button class="x" data-origin="${esc(o)}" title="Remove">✕</button></li>`).join("")
: '<li class="empty">None yet.</li>';
$("dirs").innerHTML = st.uploadDirs.length
? st.uploadDirs.map((d) => `<li><span class="grow mono">${esc(d)}</span><button class="x" data-dir="${esc(d)}" title="Remove">✕</button></li>`).join("")
: '<li class="empty">None. Uploads are refused.</li>';
$("evaluate").checked = !!st.allowEvaluate;
$("evalWarn").hidden = !st.allowEvaluate;
}
function entryHtml(e) {
const t = new Date(e.ts);
const ts = `${t.toLocaleDateString()} ${t.toLocaleTimeString()}`;
return `<div class="e"><div class="h"><span class="ts">${esc(ts)}</span><span class="tool">${esc(e.tool)}</span>${e.tab != null ? `<span class="org">tab ${esc(e.tab)}</span>` : ""}${e.origin ? `<span class="org">${esc(e.origin)}</span>` : ""}<span class="${e.ok ? "ok" : "bad"}">${e.ok ? "ok" : "failed"}</span>${e.ms != null ? `<span class="ts">${esc(e.ms)} ms</span>` : ""}</div>${e.args ? `<div class="a">${esc(e.args)}</div>` : ""}${e.error ? `<div class="bad">${esc(e.error)}</div>` : ""}</div>`;
}
async function loadLog() {
const list = await S.invoke("log", { n: 200 });
$("log").innerHTML = list.slice().reverse().map(entryHtml).join("") || '<div class="empty">Nothing yet.</div>';
}
$("accept").onchange = async (e) => render(await S.invoke("set-accept", { on: e.target.checked }));
$("evaluate").onchange = async (e) => render(await S.invoke("set-evaluate", { on: e.target.checked }));
$("disconnect").onclick = async () => render(await S.invoke("disconnect"));
// Two clicks: every client has to be set up again afterwards.
let revokeArmed = null;
$("revoke").onclick = async () => {
const b = $("revoke");
if (!revokeArmed) { b.textContent = "Click again to revoke"; revokeArmed = setTimeout(() => { revokeArmed = null; b.textContent = "Revoke token"; }, 4000); return; }
clearTimeout(revokeArmed); revokeArmed = null; b.textContent = "Revoke token";
render(await S.invoke("revoke-token"));
};
$("addDir").onclick = async () => render(await S.invoke("add-upload-dir"));
$("copyCode").onclick = () => state && S.invoke("copy", { text: state.commands.claudeCode }).then(() => flash("copyCode"));
$("copyDesk").onclick = () => state && S.invoke("copy", { text: state.commands.claudeDesktop }).then(() => flash("copyDesk"));
$("clearLog").onclick = async () => { await S.invoke("clear-log"); loadLog(); };
$("openLog").onclick = () => S.invoke("open-log-folder");
$("tCode").onclick = () => { $("pCode").hidden = false; $("pDesk").hidden = true; $("tCode").classList.add("sel"); $("tDesk").classList.remove("sel"); };
$("tDesk").onclick = () => { $("pCode").hidden = true; $("pDesk").hidden = false; $("tDesk").classList.add("sel"); $("tCode").classList.remove("sel"); };
document.body.addEventListener("click", async (e) => {
const o = e.target.closest("[data-origin]");
if (o) render(await S.invoke("remove-origin", { origin: o.dataset.origin }));
const d = e.target.closest("[data-dir]");
if (d) render(await S.invoke("remove-upload-dir", { dir: d.dataset.dir }));
});
function flash(id) { const b = $(id), t = b.textContent; b.textContent = "Copied"; setTimeout(() => { b.textContent = t; }, 1200); }
S.on("status", render);
S.on("log-entry", (e) => {
const box = $("log");
if (box.querySelector(".empty")) box.innerHTML = "";
box.insertAdjacentHTML("afterbegin", entryHtml(e));
while (box.children.length > 300) box.lastChild.remove();
});
S.invoke("state").then(render);
loadLog();
</script>
</body>
</html>