Claude connector panel: split site approvals from add-on-panel approvals

Site approvals and panel approvals share one allowedOrigins list on
disk because the policy is one SitePolicy either way, but on screen
they are two different things: a URL you signed in on versus an
Aegis sidebar panel you unlocked. The panel showed them in one mono
list under "Sites Claude may control", so an Aegis approval read as
"theseus-panel://aegis" with no label and no clue it was a panel at
all.

publicState() now splits allowedOrigins into allowedSites (web
origins) and allowedPanels (theseus-panel://<addon>, enriched with
the add-on's own title via api.tabControl.list()). The panel.html
renders them in their own sections: "Sites Claude may control" and
"Add-on panels Claude may control", the latter hidden when nothing
is approved. Each panel row shows the add-on title in bold with the
mono origin as a subtitle. The ✕ button goes through the existing
remove-origin IPC — removing either kind takes one call.

The renderer falls back to deriving the split from allowedOrigins so
a UI loaded against an older host still renders both sections.
This commit is contained in:
Hermes Dev 2026-10-29 00:32:51 +02:00
parent baf86fc5a6
commit a04775ad0d
3 changed files with 45 additions and 3 deletions

View file

@ -50,7 +50,10 @@ code change.
closed panels too so a client can see what exists; `webContents(-N)`
throws "<addon> is not open; ask the user to open the panel from the
sidebar first" until the user docks it. Password-field refusal and the
action log still apply.
action log still apply. The connector panel.html now splits site
approvals from panel approvals into their own sections, with the add-on's
own title ("Aegis panel") above the mono origin string and a ✕ Remove
button that goes through the existing `remove-origin` IPC.
## session: `parallel:collision-policy`

View file

@ -116,6 +116,26 @@ module.exports = {
function publicState() {
const st = server ? server.status() : { listening: false, port: null, connected: false, clients: [], sessions: [] };
const url = server && server.url;
// Panel approvals (theseus-panel://<addonId>) and site approvals share
// the allowedOrigins list on disk. Split them for the UI so each gets
// its own section, and enrich panel rows with the add-on's own title.
const PANEL_PREFIX = "theseus-panel://";
const panelTitles = new Map();
try {
for (const t of api.tabControl.list()) {
if (t && t.kind === "panel" && t.addon) panelTitles.set(String(t.addon).toLowerCase(), t.title || t.addon);
}
} catch {}
const allowedPanels = [];
const allowedSites = [];
for (const o of S.allowedOrigins) {
if (typeof o === "string" && o.startsWith(PANEL_PREFIX)) {
const addon = o.slice(PANEL_PREFIX.length);
allowedPanels.push({ origin: o, addon, title: panelTitles.get(addon.toLowerCase()) || addon.replace(/^\w/, (c) => c.toUpperCase()) });
} else {
allowedSites.push(o);
}
}
return {
enabled: true,
accept: S.accept,
@ -128,6 +148,8 @@ module.exports = {
controlledTabs: controller.controlledTabIds(),
allowEvaluate: S.allowEvaluate,
allowedOrigins: S.allowedOrigins,
allowedSites,
allowedPanels,
uploadDirs: S.uploadDirs,
sessionLimit: S.sessionLimit,
token: S.token,

View file

@ -116,6 +116,12 @@
<ul class="list" id="origins"></ul>
</section>
<section id="panelsSection" hidden>
<h2>Add-on panels Claude may control</h2>
<p>Each add-on is approved once. The approval covers every panel owned by that add-on, and the panel must be open in the sidebar for Claude to read or act on it.</p>
<ul class="list" id="panels"></ul>
</section>
<section>
<h2>Upload folders</h2>
<p>Claude can attach files only from these folders.</p>
@ -156,9 +162,20 @@
$("setup").hidden = !st.commands;
renderApps(st.apps);
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("")
// Site approvals (http(s)) and panel approvals (theseus-panel://) are
// kept apart on screen even though they share one list on disk, so the
// user sees them for what they are. publicState() does the split and
// enriches each panel with the add-on's own title; the fallback keeps
// older hosts rendering the mixed list the way they used to.
const sites = Array.isArray(st.allowedSites) ? st.allowedSites : (st.allowedOrigins || []).filter((o) => !String(o).startsWith("theseus-panel://"));
const panels = Array.isArray(st.allowedPanels) ? st.allowedPanels : (st.allowedOrigins || []).filter((o) => String(o).startsWith("theseus-panel://")).map((o) => ({ origin: o, addon: o.slice("theseus-panel://".length), title: o.slice("theseus-panel://".length).replace(/^\w/, (c) => c.toUpperCase()) }));
$("origins").innerHTML = sites.length
? sites.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>';
$("panelsSection").hidden = panels.length === 0;
$("panels").innerHTML = panels.length
? panels.map((p) => `<li><span class="grow"><b>${esc(p.title)} panel</b><div class="note mono">${esc(p.origin)}</div></span><button class="x" data-origin="${esc(p.origin)}" title="Remove">✕</button></li>`).join("")
: "";
$("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>';