// Theseus add-on framework — loader + API surface. // // Add-ons live in /extensions// as ordinary folders on disk. Each // carries an `addon.json` manifest and (per the manifest's `main` field) a // CommonJS entry that exports `activate(api)` and optionally `deactivate()`. // Nothing about an add-on ships in the Theseus repo or installer — drop a // folder, restart Theseus, it's live. This is the same trust model as // dev-mode browser extensions: the user is choosing to run local code with // the app's full privileges. // // Discovery is synchronous at app-ready time. An add-on whose manifest says // "activation": "startup" (the default) is activated right then; one that // says "on-demand" is only listed — its declared panels, toolbar menu, // context-menu items and page-inject bridge are live, and activate() runs // the first time one of them is actually used (see ensureActive). Failed // activations are logged and skipped without breaking the app. // // Persistence: // settings.disabledAddons — ids the user has toggled off // /extensions-data/.json — per-add-on kv store (api.storage) const fs = require("node:fs"); const { storeFor } = require("./lib/addon-store.cjs"); // Which tab a page message came from, carried through the handler's awaits, // so an approval it raises is tied to that tab (see approvalModal). const { AsyncLocalStorage } = require("node:async_hooks"); const pageCallCtx = new AsyncLocalStorage(); const path = require("node:path"); // How long a call waits for an async activate() to settle before it is // delivered anyway. const READY_WAIT_MS = 5000; // What a sandboxed (community) extension may ask of the host. Each name is a // path on the api object _makeApi builds, so its capability check still runs. // Absent on purpose: require/import (host modules), registerRequestFilter and // registerSiteRoute (take a function), vault.imports / vault.pin and the // lifecycle calls that handle the master password, and storage (the store is // handed over at start and written through its own message). const SANDBOX_API = new Set([ "emit", "registerSidebarPanel", "revealSidebar", "openTab", "openSettings", "setSessionProxy", "captureTab", "saveCapture", "scanActiveTabForUris", "approvalModal", "checkAndStageSelfUpdate", "applySelfUpdate", "restartApp", "startAtLaunch", "whenUiReady", "tabs.active", "vault.derive", "vault.requestUnlock", "vault.lifecycle.status", ]); // Messages cross the process boundary as JSON (the binary 'advanced' channel // format is tied to the exact V8 build on both ends). Bytes and BigInts are // tagged so they arrive as what they were. Same pair as in the runner. function wireEnc(v, depth = 0) { if (depth > 40) throw new Error("value too deeply nested"); if (v === null || v === undefined) return v === undefined ? undefined : null; if (typeof v === "bigint") return { __wire: "big", v: v.toString() }; if (typeof v === "function" || typeof v === "symbol") return undefined; if (typeof v !== "object") return v; if (v instanceof Uint8Array) return { __wire: "u8", v: Buffer.from(v.buffer, v.byteOffset, v.byteLength).toString("base64") }; if (v instanceof ArrayBuffer) return { __wire: "u8", v: Buffer.from(v).toString("base64") }; if (Array.isArray(v)) return v.map((x) => { const e = wireEnc(x, depth + 1); return e === undefined ? null : e; }); if (v instanceof Date) return v.toISOString(); const out = {}; for (const k of Object.keys(v)) { const e = wireEnc(v[k], depth + 1); if (e !== undefined) out[k] = e; } return out; } function wireDec(v) { if (v === null || typeof v !== "object") return v; if (Array.isArray(v)) return v.map(wireDec); if (v.__wire === "u8" && typeof v.v === "string") return new Uint8Array(Buffer.from(v.v, "base64")); if (v.__wire === "big" && typeof v.v === "string") return BigInt(v.v); const out = {}; for (const k of Object.keys(v)) out[k] = wireDec(v[k]); return out; } const SANDBOX_CALL_MS = 120000; const SANDBOX_STORE_VALUE_MAX = 4 * 1024 * 1024; // Extension points the framework understands. Extending this list means also // teaching main.js and (typically) the chrome renderer about the new point. // Right now only sidebar panels are wired — future rev adds toolbar-chip, // proxy, page-inject, etc. const KNOWN_CAPABILITIES = new Set([ "sidebar-panel", "session-proxy", // request-filter: api.registerRequestFilter(fn) — fn sees every subresource // request of the session ({url, resourceType, method, // frameUrl, initiator, webContentsId}) and returns true to // block it. Main never lets a filter cancel a top-level // navigation. Also unlocks api.tabs (active tab's url/host // and a change event) so a blocker can show per-site // numbers. Sees every URL the browser loads: same trust // bar as page-inject on "*://*/*". "request-filter", // vault-derive: api.vault.derive(purposePath) — HKDF child of the password // vault's root, namespaced under the add-on id. // page-inject: manifest["page-inject"] = { preload, origins } — the add-on's // preload source runs in the isolated world of every tab whose // URL matches one of the origin patterns. // approval-modal: api.approvalModal({...}) — user-facing consent dialog over // the active tab, resolved by main. // capture-tab: api.captureTab({mode, ...}) + api.saveCapture({dataUrl, filename}) // — snapshot the active tab (visible viewport / full page / // user-drawn rectangle) and save the result through the app's // downloads pipeline. The add-on sees pixels of whatever the // current tab is showing, so this is the same trust bar as a // page-inject add-on that matches "*://*/*". // toolbar-menu: manifest["toolbar-menu"] = { title?, icon?, items:[{id,label,icon?}] } // — chrome renders a dropdown under the add-on's dock icon; // picking an item dispatches "menu-select" with {id} to the // add-on's onMessage("menu-select", …) handler. // open-tab: api.openTab(pathOrUrl, {query?}) — for a bare http(s) URL // this stays available without the capability (legacy). // Declaring "open-tab" additionally lets the add-on open // one of its OWN HTML files as a full Theseus tab, with a // lean preload so the page can keep talking to the add-on // via window.silentmode.invoke(). // scan-page: api.scanActiveTabForUris({scheme, limit}) — the HOST // searches the active tab for URIs of one scheme and // returns only those. The add-on never receives page text, // markup or form values, so this sits well below // page-inject or capture-tab on the trust ladder: it can // learn that a page is offering e.g. a wiz:// pairing // code, and nothing else about the page. "vault-derive", "page-inject", "approval-modal", "capture-tab", "toolbar-menu", "open-tab", "scan-page", // context-menu-item: manifest["context-menu-items"] = [{id, label, when, icon?}] // — chrome merges these into every tab's right-click // menu, filtered by `when` (selectionText | linkURL | // editable | image | always). Picking one dispatches // "context-menu" with the item id + the surrounding // context (selection text, link URL, media info, host) // to the add-on's onMessage handler. Add-ons that also // declare "sidebar-panel" typically follow up with // api.revealSidebar(panelId) to surface the result. "context-menu-item", // site-route: manifest.siteRoutes = ["pithos.sia"] + api.registerSiteRoute // ({ host, handle }) — the add-on answers requests for paths // under a BCNR name it ships with (Pithos serves its app at // pithos.sia///…). handle(request) gets the // Fetch Request and returns a Response, or null for "not // mine" (the name's own site then answers). The root page // always stays the site's. Built-in add-ons only: answering // for a name is impersonating it. "site-route", ]); // Chrome-style match pattern → predicate. ":///" where // scheme may be "*", host may start with "*." (matches the bare host and any // subdomain) or be "*", and path is a glob where "*" matches anything. // bns:// (how Theseus fetches BCNR sites internally) is folded into https:// // so a pattern written the way the address bar shows it keeps working. function compileOriginPattern(pattern) { const m = /^(\*|[a-z][a-z0-9+.-]*):\/\/(\*|\*\.[^/*]+|[^/*]+)(\/.*)?$/i.exec(String(pattern).trim()); if (!m) throw new Error(`bad origin pattern: ${pattern}`); const [, scheme, host, pathGlob = "/*"] = m; const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const schemeRe = scheme === "*" ? "https?" : esc(scheme.toLowerCase()); let hostRe; if (host === "*") hostRe = "[^/]+"; else if (host.startsWith("*.")) hostRe = `(?:[^/]+\\.)?${esc(host.slice(2).toLowerCase())}`; else hostRe = esc(host.toLowerCase()); const pathRe = pathGlob.split("*").map(esc).join(".*"); const re = new RegExp(`^${schemeRe}://${hostRe}(?::\\d+)?${pathRe}$`, "i"); return (url) => re.test(String(url).replace(/^bns:\/\//i, "https://")); } function urlMatchesAny(url, matchers) { for (const fn of matchers) { try { if (fn(url)) return true; } catch {} } return false; } // Manifest field guardrails. Reject anything shape-suspicious so a bad // addon.json can't get past the loader gate. function validateManifest(raw, folderName) { const m = raw && typeof raw === "object" ? raw : {}; const id = String(m.id || "").trim(); if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(id)) { throw new Error(`invalid or missing "id" (allowed: [a-z0-9._-], up to 64 chars) — folder ${folderName}`); } const name = String(m.name || id); const version = String(m.version || "0.0.0"); const description = String(m.description || ""); const author = String(m.author || ""); const icon = String(m.icon || "🧩"); const main = String(m.main || "index.js"); if (main.includes("..") || path.isAbsolute(main)) { throw new Error(`addon "${id}": main must be a relative path inside the addon folder`); } const capabilities = Array.isArray(m.capabilities) ? m.capabilities.map(String) : []; for (const cap of capabilities) { if (!KNOWN_CAPABILITIES.has(cap)) { // Not fatal — log later. Unknown caps are silently dropped. } } let pageInject = null; if (capabilities.includes("page-inject")) { const pi = m["page-inject"]; if (!pi || typeof pi !== "object") throw new Error(`addon "${id}": "page-inject" capability needs a "page-inject" manifest block`); const preload = String(pi.preload || ""); if (!preload || preload.includes("..") || path.isAbsolute(preload)) { throw new Error(`addon "${id}": page-inject.preload must be a relative path inside the addon folder`); } const origins = Array.isArray(pi.origins) ? pi.origins.map(String) : []; if (!origins.length) throw new Error(`addon "${id}": page-inject.origins must list at least one pattern`); pageInject = { preload, origins, matchers: origins.map(compileOriginPattern) }; } let toolbarMenu = null; if (capabilities.includes("toolbar-menu")) { const tm = m["toolbar-menu"]; if (!tm || typeof tm !== "object") { throw new Error(`addon "${id}": "toolbar-menu" capability needs a "toolbar-menu" manifest block`); } const items = Array.isArray(tm.items) ? tm.items : []; if (!items.length) throw new Error(`addon "${id}": toolbar-menu.items must list at least one entry`); const seen = new Set(); const cleanItems = items.map((it, idx) => { const iid = String(it && it.id || "").trim(); if (!iid || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(iid)) { throw new Error(`addon "${id}": toolbar-menu.items[${idx}].id is required and must match [a-z0-9._-]`); } if (seen.has(iid)) throw new Error(`addon "${id}": toolbar-menu.items[${idx}].id "${iid}" duplicates an earlier entry`); seen.add(iid); const label = String(it.label || iid); const itemIcon = it.icon == null ? "" : String(it.icon); return { id: iid, label, icon: itemIcon }; }); toolbarMenu = { title: tm.title == null ? name : String(tm.title), icon: tm.icon == null ? icon : String(tm.icon), items: cleanItems, }; } const contextMenuItems = []; if (capabilities.includes("context-menu-item")) { const rawItems = m["context-menu-items"]; if (!Array.isArray(rawItems) || !rawItems.length) { throw new Error(`addon "${id}": "context-menu-item" capability needs a "context-menu-items" array with at least one entry`); } const seen = new Set(); const ALLOWED_WHEN = new Set(["selectionText", "linkURL", "editable", "image", "always"]); for (let idx = 0; idx < rawItems.length; idx++) { const it = rawItems[idx]; const iid = String(it && it.id || "").trim(); if (!iid || !/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(iid)) { throw new Error(`addon "${id}": context-menu-items[${idx}].id is required and must match [a-z0-9._-]`); } if (seen.has(iid)) throw new Error(`addon "${id}": context-menu-items[${idx}].id "${iid}" duplicates an earlier entry`); seen.add(iid); const when = String(it.when || "always"); if (!ALLOWED_WHEN.has(when)) { throw new Error(`addon "${id}": context-menu-items[${idx}].when must be one of ${[...ALLOWED_WHEN].join("|")}`); } contextMenuItems.push({ id: iid, label: String(it.label || iid), when, icon: it.icon == null ? "" : String(it.icon), }); } } // `absorbs`: legacy add-on ids whose vault-derive namespace this add-on // inherits. Set on a superseding add-on (e.g. aegis absorbs siawallet) so // funds derived under the old id's paths stay reachable through the new // one. Each entry is validated as an id itself and gates vault.derive by // (own id OR one of these) in makeApi below. const absorbs = Array.isArray(m.absorbs) ? m.absorbs.map(String).filter(Boolean) : []; // siteRoutes: BCNR names this add-on answers paths under (see site-route). const siteRoutes = []; if (capabilities.includes("site-route")) { const list = Array.isArray(m.siteRoutes) ? m.siteRoutes : []; for (const h of list) { const host = String(h || "").trim().toLowerCase(); if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(host)) throw new Error(`addon "${id}": siteRoutes entry "${h}" is not a host name`); siteRoutes.push(host); } if (!siteRoutes.length) throw new Error(`addon "${id}": capability "site-route" needs a non-empty "siteRoutes" list`); } for (const a of absorbs) { if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(a)) { throw new Error(`addon "${id}": absorbs entry "${a}" is not a valid add-on id`); } if (a === id) throw new Error(`addon "${id}": absorbs cannot list its own id`); } // Category: "plugin" for first-class Silent Mode components (Aegis and // future Ariadne-as-addon) that are surfaced in Settings › Plug-ins with // their own copy instead of the raw Extensions list. Anything else falls // back to plain-extension rendering. const category = m.category && ["plugin"].includes(String(m.category)) ? String(m.category) : null; // dock: "hidden" — the add-on starts without a toolbar button (its settings // live in Settings); main honours it once, the user can show it later. const dock = m.dock === "hidden" ? "hidden" : undefined; // panels: sidebar panels declared up front — [{id, title, icon?, page}]. // The dock shows them before the add-on runs, and they are registered // for it at activation, so activate() does not have to call // registerSidebarPanel for them (calling it with the same id replaces // the declared entry). const panels = []; if (m.panels != null) { if (!Array.isArray(m.panels)) throw new Error(`addon "${id}": "panels" must be an array`); const seen = new Set(); m.panels.forEach((p, idx) => { const pid = String(p && p.id || "").trim(); if (!/^[a-z0-9][a-z0-9._-]{0,63}$/i.test(pid)) throw new Error(`addon "${id}": panels[${idx}].id is required and must match [a-z0-9._-]`); if (seen.has(pid)) throw new Error(`addon "${id}": panels[${idx}].id "${pid}" duplicates an earlier entry`); seen.add(pid); const page = String(p.page || "").replace(/^[\\/]+/, ""); if (!page || page.includes("..") || path.isAbsolute(page)) throw new Error(`addon "${id}": panels[${idx}].page must be a relative path inside the addon folder`); panels.push({ id: pid, title: String(p.title || name), icon: p.icon == null ? icon : String(p.icon), page, side: p.side === "left" ? "left" : "right" }); }); } // activation: "startup" runs activate() at launch; "on-demand" waits for // the first use of something the manifest declares. Startup is the // default because the host cannot know what an add-on that predates this // field does in activate() — most register their panels there, and some // start work that must not wait (timers, a proxy, a filter). Two cases // are forced back to startup: a request filter has to see the first // request, and an add-on that declares nothing could never be woken. let activation = m.activation === "on-demand" ? "on-demand" : "startup"; let activationNote = ""; if (activation === "on-demand") { if (capabilities.includes("request-filter")) { activation = "startup"; activationNote = "request-filter add-ons start at launch"; } else if (!panels.length && !toolbarMenu && !contextMenuItems.length && !pageInject && !siteRoutes.length) { activation = "startup"; activationNote = "declares nothing that could start it"; } } return { id, name, version, description, author, icon, main, capabilities, pageInject, toolbarMenu, contextMenuItems, absorbs, category, dock, panels, siteRoutes, activation, activationNote }; } // Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest // of the app queries via `getActive()` / `getInstalled()`. class AddonHost { constructor({ addonsDir, dataDir, isDisabled, activationFor, onActivated, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture, scanTabForUris, checkAndStageUpdates, restartApp, applySelfUpdate, revealSidebar, requestFilter, tabs }) { this.addonsDir = addonsDir; this.dataDir = dataDir; this.isDisabled = isDisabled || (() => false); this.log = logger || ((...a) => console.log("[addons]", ...a)); this._installed = []; // [{ manifest, folder, error? }] this._active = new Map(); // id -> { manifest, folder, exports, sidebarPanels: [...], handlers: Map, inject, ready } // On-demand add-ons that are enabled but not started yet: // id -> { manifest, folder, panels, injectSource }. this._dormant = new Map(); this._activating = new Map(); // id -> Promise of a first-use activation // First-use activations run one at a time, in the order they were asked for. this._queue = Promise.resolve(); // activationFor(manifest) -> "startup" | "on-demand": main's settings may // override the manifest (Settings › Performance › Startup). this._activationFor = typeof activationFor === "function" ? activationFor : (m) => m.activation; this._onActivated = typeof onActivated === "function" ? onActivated : null; // setStartAtLaunch(id, on): backs api.startAtLaunch; main persists it and // folds it into activationFor. this._setStartAtLaunch = typeof arguments[0].setStartAtLaunch === "function" ? arguments[0].setStartAtLaunch : null; // Capability hooks injected by main. Each is (args..., addonId) so main // can log/gate per add-on. Missing hook = capability unavailable. this._vaultDerive = typeof vaultDerive === "function" ? vaultDerive : null; // vaultImports: main-process shim {list, add, remove, signer} that owns // wallet-imports.enc. Same trust tier as vaultDerive — an add-on that // holds vault-derive can also see imports (design §3.2 co-tenancy). this._vaultImports = vaultImports && typeof vaultImports.list === "function" ? vaultImports : null; // vaultLifecycle: main-process shim {status, setup, unlock, lock} so the // wallet add-on can drive vault setup/unlock without redirecting users // to Settings > Passwords. Same "vault-derive" capability gate. this._vaultLifecycle = arguments[0].vaultLifecycle && typeof arguments[0].vaultLifecycle.unlock === "function" ? arguments[0].vaultLifecycle : null; // vaultRequestUnlock: Theseus shows its own PIN / master-password prompt // and resolves { ok } — the add-on never sees what the user typed. this._vaultRequestUnlock = typeof arguments[0].vaultRequestUnlock === "function" ? arguments[0].vaultRequestUnlock : null; // vaultPin: the one PIN (lib/vault-pin.cjs), for built-in add-ons that // draw their own PIN pad. {status, unlock(pin), set(pin, pw), clear}. this._vaultPin = arguments[0].vaultPin && typeof arguments[0].vaultPin.unlock === "function" ? arguments[0].vaultPin : null; // isFirstPartyId(id) / isReservedId(id): which ids ship inside Theseus, // and which legacy ids those absorb. Gate `absorbs` in vault.derive. this._isFirstPartyId = typeof arguments[0].isFirstPartyId === "function" ? arguments[0].isFirstPartyId : null; // Extensions that are not built in run in a sandboxed process unless the // host explicitly opts out (sandboxExecPath overrides the binary; tests). this._sandboxCommunity = arguments[0].sandboxCommunity !== false; this._sandboxExecPath = typeof arguments[0].sandboxExecPath === "string" ? arguments[0].sandboxExecPath : null; this._isReservedId = typeof arguments[0].isReservedId === "function" ? arguments[0].isReservedId : null; this._approvalModal = typeof approvalModal === "function" ? approvalModal : null; this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null; this._scanTabForUris = typeof scanTabForUris === "function" ? scanTabForUris : null; // Add-ons live outside the app's node_modules tree, so a bare require() // from their folder can't see Theseus's deps (ws, @noble/*, …). Main // hands us its own require so add-ons can share the bundled tree. this._hostRequire = typeof hostRequire === "function" ? hostRequire : null; this._requestFilter = requestFilter && typeof requestFilter.set === "function" ? requestFilter : null; this._tabs = tabs && typeof tabs.active === "function" ? tabs : null; // ESM-only deps (@noble/*, @scure/*) can't be require()d by Electron's // Node; hostImport resolves them from the app tree and import()s them. this._hostImport = typeof hostImport === "function" ? hostImport : null; this._openTab = typeof openTab === "function" ? openTab : null; // open-tab: opens one of the add-on's own HTML files as a full Theseus tab. // Signature: (addonId, relPath, queryString) => Promise. this._openAddonTab = typeof openAddonTab === "function" ? openAddonTab : null; // openSettings: opens Theseus's Settings tab, optionally scrolled to a // named section (e.g. "passwords"). Uses the same IPC route the picker // uses for "Search settings…". Signature: (section?: string) => void. this._openSettings = typeof openSettings === "function" ? openSettings : null; // Panel-driven self-update: an add-on may ask the host to run the // OTA check + verify + stage flow for itself and, if a newer signed // build lands, restart Theseus so promoteStagedUpdates picks it up. // Owns the entire trust chain (sig, hash, manifest match) so no // add-on ever gets to hand-write into its own installed folder. this._checkAndStageUpdates = typeof checkAndStageUpdates === "function" ? checkAndStageUpdates : null; this._restartApp = typeof restartApp === "function" ? restartApp : null; this._applySelfUpdate = typeof applySelfUpdate === "function" ? applySelfUpdate : null; // revealSidebar: opens the sidebar and switches to the given panel id. // Signature: (addonId, panelId) => void. Add-ons use this from their // context-menu handler to surface a result in their sidebar UI. this._revealSidebar = typeof revealSidebar === "function" ? revealSidebar : null; // Session-proxy hook — injected by main so add-ons can swap the default // session's proxy rules (e.g. a "route everything through my VPS" add-on). // Signature: (rules: string | { proxyRules, proxyBypassRules }) => Promise // A `null` rule clears the proxy. Kept as a callback rather than requiring // the loader itself import electron. this._setSessionProxy = typeof setSessionProxy === "function" ? setSessionProxy : null; // capture-tab hooks — main captures/saves; the loader only enforces the // manifest gate. this._captureTab = typeof captureTab === "function" ? captureTab : null; this._saveCapture = typeof saveCapture === "function" ? saveCapture : null; // api.whenUiReady() plumbing — see signalUiReady(). this._uiReady = false; this._uiReadyWaiters = []; } // main calls this once the browser chrome has painted. Add-ons that pull // in heavy dependencies (Aegis: noble curve precompute, bitcoinjs, libauth, // WizardConnect) gate that work on api.whenUiReady() so module evaluation // doesn't land on the main thread while chrome.html is still trying to // paint. Sticky: a later discoverAndActivate() resolves immediately. signalUiReady() { this._uiReady = true; for (const resolve of this._uiReadyWaiters.splice(0)) { try { resolve(); } catch {} } } ensureDirs() { for (const d of [this.addonsDir, this.dataDir]) { try { fs.mkdirSync(d, { recursive: true }); } catch (e) { this.log("mkdir failed", d, e?.message); } } } // api.vault.lifecycle namespace: unlock/setup/status/lock the vault. Same // "vault-derive" cap. Purpose: let the wallet add-on drive vault setup from // its own gate instead of redirecting users to Settings > Passwords. _makeLifecycleApi(manifest) { const requireCap = () => { if (!manifest.capabilities.includes("vault-derive")) { throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); } if (!this._vaultLifecycle) throw new Error("vault.lifecycle unavailable (host not wired)"); }; // Taking the master password (unlock is also an unthrottled oracle for // it), creating the vault and locking it are for add-ons that ship with // Theseus. Anything else asks the user through requestUnlock, where // Theseus draws the prompt and the add-on never sees what was typed. const firstPartyOnly = (what) => { if (this._isFirstPartyId && !this._isFirstPartyId(manifest.id)) { throw new Error(`vault.lifecycle.${what} is reserved for built-in add-ons — use vault.requestUnlock()`); } }; return { status: async () => { requireCap(); return this._vaultLifecycle.status(); }, unlock: async (pw) => { requireCap(); firstPartyOnly("unlock"); return this._vaultLifecycle.unlock(String(pw || ""), manifest.id); }, setup: async (pw, seedSource) => { requireCap(); firstPartyOnly("setup"); return this._vaultLifecycle.setup(String(pw || ""), seedSource, manifest.id); }, lock: async () => { requireCap(); firstPartyOnly("lock"); return this._vaultLifecycle.lock(manifest.id); }, }; } // api.vault.pin namespace: the Theseus vault PIN, checked in main with one // strike counter, for built-in add-ons that show their own PIN pad. unlock // opens the vault and answers { ok } or why not; the master password the // PIN wraps stays in main. Built-in only: a PIN check is a guessing oracle // and set() takes the master password. _makePinApi(manifest) { const gate = (what) => { if (!manifest.capabilities.includes("vault-derive")) { throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); } if (this._isFirstPartyId && !this._isFirstPartyId(manifest.id)) { throw new Error(`vault.pin.${what} is reserved for built-in add-ons — use vault.requestUnlock()`); } if (!this._vaultPin) throw new Error("vault.pin unavailable (host not wired)"); }; return { status: async () => { gate("status"); return this._vaultPin.status(manifest.id); }, unlock: async (pin) => { gate("unlock"); return this._vaultPin.unlock(String(pin || ""), manifest.id); }, set: async (pin, pw) => { gate("set"); return this._vaultPin.set(String(pin || ""), String(pw || ""), manifest.id); }, clear: async () => { gate("clear"); return this._vaultPin.clear(manifest.id); }, }; } // api.vault.imports namespace factory. Gated by the "vault-derive" cap // because the two surfaces sit at the same trust tier (design §3.2). If // main didn't wire the vaultImports shim, calls throw a clear error. _makeImportsApi(manifest) { const requireCap = () => { if (!manifest.capabilities.includes("vault-derive")) { throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); } if (!this._vaultImports) throw new Error("vault.imports unavailable (host not wired)"); // The imports store holds raw seeds and private keys and is not // namespaced per add-on: list() and signer() hand back whatever any // add-on stored. Until it is, it belongs to built-in add-ons only — // a community extension with vault-derive could read the wallet's // imported keys. if (this._isFirstPartyId && !this._isFirstPartyId(manifest.id)) { throw new Error("vault.imports is reserved for built-in add-ons"); } }; return { list: async () => { requireCap(); return this._vaultImports.list(); }, add: async (spec) => { requireCap(); return this._vaultImports.add(spec, manifest.id); }, remove: async (id) => { requireCap(); return this._vaultImports.remove(String(id || ""), manifest.id); }, signer: async (id) => { requireCap(); return this._vaultImports.signer(String(id || ""), manifest.id); }, }; } discoverAndActivate() { this.ensureDirs(); // A re-discover (toggle, reload, hot-applied update) restarts whatever // was running, on-demand or not: its panel or tab may be open and // listening for api.emit, and nothing would wake it again until that // page next called in. const wasRunning = new Set(this._active.keys()); this._deactivateAll(); this._installed = []; this._dormant.clear(); let entries = []; try { entries = fs.readdirSync(this.addonsDir, { withFileTypes: true }); } catch { entries = []; } for (const dirent of entries) { if (!dirent.isDirectory()) continue; const folder = path.join(this.addonsDir, dirent.name); try { const manifest = this._readManifest(folder, dirent.name); this._installed.push({ manifest, folder }); if (this.isDisabled(manifest.id)) { this.log(`skipping disabled add-on ${manifest.id}`); continue; } if (manifest.activationNote) this.log(`${manifest.id}: starts at launch (${manifest.activationNote})`); if (!wasRunning.has(manifest.id) && this._activationFor(manifest) === "on-demand") { if (this._active.has(manifest.id) || this._dormant.has(manifest.id)) throw new Error(`duplicate add-on id "${manifest.id}"`); this._dormant.set(manifest.id, { manifest, folder, panels: this._resolvePanels(manifest, folder), injectSource: null }); this.log(`${manifest.id} v${manifest.version} starts on first use`); continue; } if (this._dormant.has(manifest.id)) throw new Error(`duplicate add-on id "${manifest.id}"`); this._activateOne(manifest, folder); } catch (e) { this.log(`failed to load ${dirent.name}: ${e?.message || e}`); // A manifest that parsed but whose activate() threw was already // pushed above — replace it rather than listing the add-on twice. const i = this._installed.findIndex((x) => x.folder === folder); const entry = { manifest: null, folder, error: String(e?.message || e) }; if (i >= 0) this._installed[i] = entry; else this._installed.push(entry); } } return this.snapshot(); } _readManifest(folder, folderName) { const p = path.join(folder, "addon.json"); const raw = JSON.parse(fs.readFileSync(p, "utf8")); return validateManifest(raw, folderName); } // Declared panels → the records getSidebarPanels hands out. A declared // page that is missing fails the add-on's load, same as a bad manifest. _resolvePanels(manifest, folder) { return (manifest.panels || []).map((p) => { const abs = path.join(folder, p.page); if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`); return { panelId: `${manifest.id}:${p.id}`, title: p.title, icon: p.icon, pageFile: abs, addonId: manifest.id, side: p.side || "right" }; }); } // Start an on-demand add-on now, if it isn't running yet. Resolves once // activate() has run and, when it returned a promise, that promise has // settled (bounded by READY_WAIT_MS) — so a call that woke the add-on // finds the handlers it registers. Callers arriving while an activation // is under way share its promise. Activations run one at a time. ensureActive(id, reason = "") { const running = this._active.get(id); if (running) return running.ready || Promise.resolve(); const pending = this._activating.get(id); if (pending) return pending; const d = this._dormant.get(id); if (!d) return Promise.reject(new Error(`add-on "${id}" is not active`)); const p = this._queue.then(() => { const now = this._active.get(id); if (now) return now.ready; // A re-discover while this waited in the queue replaced the entry. if (this._dormant.get(id) !== d) throw new Error(`add-on "${id}" is no longer installed`); this._dormant.delete(id); const t0 = Date.now(); try { this._activateOne(d.manifest, d.folder); } catch (e) { const msg = String(e?.message || e); this.log(`failed to start ${id}: ${msg}`); const i = this._installed.findIndex((x) => x.folder === d.folder); if (i >= 0) this._installed[i] = { manifest: null, folder: d.folder, error: msg }; try { if (this._onActivated) this._onActivated(id, false); } catch {} throw e; } this.log(`started ${id} on first use${reason ? ` (${reason})` : ""} in ${Date.now() - t0} ms`); try { if (this._onActivated) this._onActivated(id, true); } catch {} return this._active.get(id).ready; }).finally(() => { if (this._activating.get(id) === p) this._activating.delete(id); }); this._activating.set(id, p); this._queue = p.catch(() => {}); return p; } // Start every add-on still waiting for first use (the setting that defers // them was switched off). activateAllDormant(reason = "") { return Promise.all([...this._dormant.keys()].map((id) => this.ensureActive(id, reason).catch(() => {}))); } isDormant(id) { return this._dormant.has(id); } // The site route answering paths under `host`, starting its add-on if it // waits for first use; null when no built-in add-on claims the name. async siteRouteFor(host) { const h = String(host || "").toLowerCase(); for (const { manifest, dormant } of this._enabledEntries()) { if (!(manifest.siteRoutes || []).includes(h)) continue; if (this._isFirstPartyId && !this._isFirstPartyId(manifest.id)) continue; if (dormant) { try { await this.ensureActive(manifest.id, `${h} opened`); } catch { continue; } } const route = this._active.get(manifest.id)?.siteRoutes.get(h); if (route) return route; } return null; } // Enabled = running or waiting for first use. isEnabled(id) { return this._active.has(id) || this._dormant.has(id); } _activateOne(manifest, folder) { const mainPath = path.join(folder, manifest.main); if (this._active.has(manifest.id)) { throw new Error(`duplicate add-on id "${manifest.id}" (already loaded from ${this._active.get(manifest.id).folder})`); } // require() from a folder outside asar is fine — Electron just uses Node's // resolver. This is where the trust decision lives: we're loading arbitrary // JS into the main process with full API access. let mod; const holder = { active: null }; const sandboxed = this._shouldSandbox(manifest); if (sandboxed) mod = this._sandboxModule(manifest, folder, mainPath, holder); else try { // Bust the require cache so a manual reload picks up edits — cheap since // add-ons are small. When the version changed (a hot-applied update) the // whole folder goes, or the new index.js would run against the previous // version's lib/*.js; a plain re-activation keeps its submodules. this._loadedVersions = this._loadedVersions || new Map(); if (this._loadedVersions.get(folder) !== manifest.version) { const root = path.resolve(folder).toLowerCase() + path.sep; for (const k of Object.keys(require.cache)) if (k.toLowerCase().startsWith(root)) delete require.cache[k]; } else delete require.cache[require.resolve(mainPath)]; this._loadedVersions.set(folder, manifest.version); mod = require(mainPath); } catch (e) { throw new Error(`require() failed: ${e?.message || e}`); } if (!mod || typeof mod.activate !== "function") { throw new Error(`main file must export an activate(api) function`); } // Declared panels are registered up front; activate() may still add more. const active = { manifest, folder, exports: mod, sandboxed, sidebarPanels: this._resolvePanels(manifest, folder), handlers: new Map(), inject: null, tabListeners: [], siteRoutes: new Map(), ready: Promise.resolve() }; holder.active = active; if (manifest.pageInject) { // Read the inject source once at activation. It's shipped to every // matching tab's preload verbatim, so a syntax error surfaces in the // tab's console, not here — but a missing file is fatal for the add-on. const abs = path.join(folder, manifest.pageInject.preload); let source; try { source = fs.readFileSync(abs, "utf8"); } catch (e) { throw new Error(`page-inject preload not readable: ${abs} (${e?.message || e})`); } active.inject = { source, matchers: manifest.pageInject.matchers, origins: manifest.pageInject.origins }; } const api = this._makeApi(active); // Request filters and tab listeners register as activate() runs; if it // fails the add-on never reaches _active, so _deactivateAll can't undo them. const cleanup = () => { try { if (this._requestFilter) this._requestFilter.clear(manifest.id); } catch {} for (const off of active.tabListeners) { try { off(); } catch {} } }; try { const r = mod.activate(api); if (r && typeof r.then === "function") { r.catch((e) => this.log(`[${manifest.id}] activate() rejected: ${e?.message || e}`)); // Calls wait for this (see dispatch) — bounded, because some // activations await things that can take forever (a vault the user // never unlocks). active.ready = new Promise((resolve) => { const t = setTimeout(resolve, READY_WAIT_MS); if (t.unref) t.unref(); Promise.resolve(r).catch(() => {}).then(() => { clearTimeout(t); resolve(); }); }); } } catch (e) { cleanup(); throw new Error(`activate() threw: ${e?.message || e}`); } this._active.set(manifest.id, active); this.log(`activated ${manifest.id} v${manifest.version}`); } // ---- sandbox for extensions that do not ship with Theseus ----------------- // A built-in add-on is code we publish and it runs in this process. Anything // else — a community extension from the catalog — runs in its own process // under Node's permission model (see lib/addon-sandbox-runner.cjs) and // reaches the browser only through the allow-listed calls below. In this // process an extension could load `electron`, watch the unlock prompt, and // read the vault, the wallet's store and every tab, whatever its manifest // said. If the sandbox cannot be started the extension does not run. _shouldSandbox(manifest) { return this._sandboxCommunity && !!this._isFirstPartyId && !this._isFirstPartyId(manifest.id); } _sandboxRunnerFile() { if (this._runnerFile) return this._runnerFile; // Copied out to a real file each launch: the packaged app keeps lib/ // inside app.asar, which a process in Node mode under the permission // model cannot be pointed at. const src = fs.readFileSync(path.join(__dirname, "lib", "addon-sandbox-runner.cjs"), "utf8"); const dir = path.join(this.dataDir, ".sandbox"); fs.mkdirSync(dir, { recursive: true }); const file = path.join(dir, "runner.cjs"); fs.writeFileSync(file, src); return (this._runnerFile = file); } _sandboxModule(manifest, folder, mainPath, holder) { const { fork } = require("node:child_process"); const id = manifest.id; const log = (...a) => this.log(`[${id}]`, ...a); let child = null, nextId = 0, activated = null; const waiting = new Map(); // call id -> { resolve, reject, timer } const callScopes = new Map(); // call id -> pageCallCtx store while a page call is in flight const failAll = (why) => { for (const w of waiting.values()) { clearTimeout(w.timer); w.reject(new Error(why)); } waiting.clear(); callScopes.clear(); }; const send = (m) => { try { if (child && child.connected) child.send(wireEnc(m)); } catch {} }; const errOut = (e) => ({ message: String((e && e.message) || e), code: e && e.code != null ? e.code : undefined }); const activate = (api) => new Promise((resolve, reject) => { const runner = this._sandboxRunnerFile(); const scratch = path.join(this.dataDir, ".sandbox", "scratch", id); fs.mkdirSync(scratch, { recursive: true }); const env = { ELECTRON_RUN_AS_NODE: "1" }; for (const k of ["SystemRoot", "windir", "TEMP", "TMP", "LANG", "TZ"]) if (process.env[k]) env[k] = process.env[k]; try { child = fork(runner, [], { execPath: this._sandboxExecPath || process.execPath, execArgv: ["--permission", `--allow-fs-read=${runner}`, `--allow-fs-read=${folder}`, `--allow-fs-read=${scratch}`, `--allow-fs-write=${scratch}`], cwd: folder, env, serialization: "json", windowsHide: true, stdio: ["ignore", "ignore", "ignore", "ipc"], }); } catch (e) { reject(new Error(`sandbox could not start: ${e?.message || e}`)); return; } activated = { resolve, reject }; const startTimer = setTimeout(() => { if (activated) { activated = null; try { child.kill(); } catch {} reject(new Error("sandbox did not answer")); } }, 20000); if (startTimer.unref) startTimer.unref(); let tabsOff = null; child.on("exit", (code) => { clearTimeout(startTimer); try { if (tabsOff) tabsOff(); } catch {} failAll("the extension's process ended"); if (activated) { activated.reject(new Error(`sandbox exited before activation (code ${code})`)); activated = null; } else if (holder.active && this._active.get(id) === holder.active) log(`sandboxed process exited (code ${code})`); }); child.on("error", (e) => log("sandbox error:", e?.message || e)); child.on("message", async (raw) => { if (!raw || typeof raw !== "object") return; const m = wireDec(raw); const active = holder.active; if (m.t === "ready") { let store = {}; try { store = api.storage.all() || {}; } catch {} send({ t: "activate", manifest: { id, name: manifest.name, version: manifest.version, capabilities: manifest.capabilities }, folder, mainPath, scratchDir: scratch, store, features: api.features }); } else if (m.t === "activated") { clearTimeout(startTimer); const a = activated; activated = null; if (!a) return; if (m.ok) a.resolve(); else a.reject(new Error(m.error?.message || "activate() failed")); } else if (m.t === "handler") { if (!active || typeof m.name !== "string" || !m.name) return; // A stub in this process forwards each call to the handler that // lives in the extension's process. active.handlers.set(m.name, (payload, ctx) => new Promise((res, rej) => { const cid = ++nextId; const scope = pageCallCtx.getStore(); if (scope) callScopes.set(cid, scope); const timer = setTimeout(() => { waiting.delete(cid); callScopes.delete(cid); rej(new Error(`"${m.name}" timed out`)); }, SANDBOX_CALL_MS); if (timer.unref) timer.unref(); waiting.set(cid, { resolve: res, reject: rej, timer }); send({ t: "call", id: cid, name: m.name, payload, ctx: ctx ? { from: ctx.from, origin: ctx.origin, tabId: ctx.tabId } : {} }); })); } else if (m.t === "res") { const w = waiting.get(m.id); if (!w) return; waiting.delete(m.id); callScopes.delete(m.id); clearTimeout(w.timer); if (m.ok) w.resolve(m.value); else { const e = new Error(m.error?.message || "extension call failed"); if (m.error?.code != null) e.code = m.error.code; w.reject(e); } } else if (m.t === "api") { // The only way the extension reaches the browser. Not on the list: // not callable. On the list: the same function, with the same // capability checks, an in-process add-on would have called. let out; try { if (typeof m.path !== "string" || !SANDBOX_API.has(m.path)) throw new Error(`"${m.path}" is not available to sandboxed extensions`); const fn = m.path.split(".").reduce((o, k) => (o ? o[k] : undefined), api); if (typeof fn !== "function") throw new Error(`"${m.path}" is not available`); const args = Array.isArray(m.args) ? m.args : []; const scope = m.callId != null ? callScopes.get(m.callId) : null; const value = await (scope ? pageCallCtx.run(scope, () => fn(...args)) : fn(...args)); out = { t: "res", id: m.id, ok: true, value: value === undefined ? null : value }; } catch (e) { out = { t: "res", id: m.id, ok: false, error: errOut(e) }; } try { send(out); } catch { send({ t: "res", id: m.id, ok: false, error: { message: "result could not be passed to the extension" } }); } } else if (m.t === "storage.set") { try { const size = m.value == null ? 0 : JSON.stringify(m.value).length; if (size > SANDBOX_STORE_VALUE_MAX) throw new Error("value too large"); if (String(m.key).startsWith("__") && m.key !== "__pageInjectPolicy") throw new Error("reserved key"); api.storage.set(String(m.key), m.value == null ? null : m.value); } catch (e) { log(`storage.set("${m.key}") refused: ${e?.message || e}`); } } else if (m.t === "log") { log(...(Array.isArray(m.args) ? m.args.map((x) => String(x).slice(0, 2000)) : [])); } else if (m.t === "tabs.watch") { if (tabsOff) return; try { tabsOff = api.tabs.onChange((t) => send({ t: "tabs", data: t })); } catch (e) { log("tabs.onChange refused:", e?.message || e); } } }); }); const deactivate = () => { failAll("the extension was stopped"); const c = child; child = null; if (!c) return; try { if (c.connected) c.send(wireEnc({ t: "deactivate" })); } catch {} const t = setTimeout(() => { try { c.kill(); } catch {} }, 1500); if (t.unref) t.unref(); }; return { activate, deactivate }; } // Tear down every active add-on before a re-discover so long-lived state // (sockets, timers) from a previous activation doesn't pile up. _deactivateAll() { for (const [id, active] of this._active) { try { if (this._requestFilter) this._requestFilter.clear(active.manifest.id); } catch {} for (const off of active.tabListeners || []) { try { off(); } catch {} } try { if (typeof active.exports.deactivate === "function") active.exports.deactivate(); } catch (e) { this.log(`[${id}] deactivate() threw: ${e?.message || e}`); } } this._active.clear(); } _makeApi(active) { const { manifest, folder } = active; const storageFile = path.join(this.dataDir, `${manifest.id}.json`); return { // What this host can do beyond the documented surface, so an add-on // can tell "the user switched it off" from "this Theseus is too old". features: Object.freeze({ pageInjectPolicy: true, vaultPin: !!this._vaultPin }), // Metadata the add-on may want to reflect on id: manifest.id, folder, // Per-extension data folder (/extensions-data): where the kv // store lives and where an add-on should keep scratch files rather // than guessing the path from its own folder. dataDir: this.dataDir, log: (...a) => this.log(`[${manifest.id}]`, ...a), // Persistent per-add-on storage: kv JSON on disk, held in memory by // lib/addon-store.cjs (shared with the add-on's pages via main.js) — // re-reading the whole file on every get froze the browser once a // store grew to megabytes. storage: (() => { const store = storeFor(storageFile, (...a) => this.log(`[${manifest.id}]`, ...a)); return { get: (key, fallback = null) => store.get(key, fallback), set: (key, value) => store.set(key, value), all: () => store.all(), }; })(), // Register a sidebar panel — a right-side WebContentsView that hosts // one of the add-on's HTML pages. `page` is a path RELATIVE to the // add-on folder. `title` shows in the sidebar tab strip. `icon` is // a short emoji/glyph. // `side: "left"` puts the panel in the quick-links strip on the left // edge instead of the right sidebar (hosts without left panels ignore it). // See "site-route" above. Registered per activation, so a reload or a // disabled add-on drops it with the rest of its state. registerSiteRoute: ({ host, handle } = {}) => { if (!(manifest.capabilities || []).includes("site-route")) throw new Error(`registerSiteRoute requires the "site-route" capability`); if (this._isFirstPartyId && !this._isFirstPartyId(manifest.id)) throw new Error("registerSiteRoute is reserved for built-in add-ons"); const h = String(host || "").toLowerCase(); if (!(manifest.siteRoutes || []).includes(h)) throw new Error(`registerSiteRoute: "${h}" is not in the manifest's siteRoutes`); if (typeof handle !== "function") throw new Error("registerSiteRoute needs a handle(request) function"); active.siteRoutes.set(h, { addonId: manifest.id, handle }); }, registerSidebarPanel: ({ id, title, icon = manifest.icon, page, side }) => { if (!id || !title || !page) throw new Error(`registerSidebarPanel needs {id, title, page}`); const abs = path.join(folder, String(page).replace(/^[\\/]/, "")); if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`); // Namespaced id so two add-ons can't collide. const panelId = `${manifest.id}:${id}`; const rec = { panelId, title, icon, pageFile: abs, addonId: manifest.id, side: side === "left" ? "left" : "right" }; const at = active.sidebarPanels.findIndex((p) => p.panelId === panelId); if (at >= 0) active.sidebarPanels[at] = rec; else active.sidebarPanels.push(rec); this.log(`[${manifest.id}] registered sidebar panel: ${panelId}`); }, // Programmatically open the sidebar and switch to one of THIS add-on's // panels. `panelId` is the un-namespaced id passed to registerSidebarPanel // (e.g. "main"); host prefixes with the add-on id under the hood. Used // by context-menu handlers to surface a result in the sidebar. No-op if // the add-on doesn't own that panel or the host isn't wired. revealSidebar: (panelId) => { if (!this._revealSidebar) { this.log(`[${manifest.id}] revealSidebar unavailable (host not wired)`); return; } const pid = String(panelId || "").trim(); const full = pid.includes(":") ? pid : `${manifest.id}:${pid || (active.sidebarPanels[0] && active.sidebarPanels[0].panelId.split(":")[1])}`; const owned = active.sidebarPanels.some((p) => p.panelId === full); if (!owned) { this.log(`[${manifest.id}] revealSidebar: no such panel ${full}`); return; } this._revealSidebar(manifest.id, full); }, // Swap the default session's proxy. Rules follow Chromium's proxy // format ("socks5://1.2.3.4:1080" for a single SOCKS server, // "http=1.2.3.4:8080;https=5.6.7.8:8080" for scheme-split HTTP, etc.). // Pass null to clear. Add-ons that opt into this capability are // fully replacing the browser's outgoing network path — they're // the trust boundary while active. Same-signature as the built-in // Tor toggle uses under the hood. // Request filter: one function per add-on, consulted by main for every // subresource request. Returns an unregister function. registerRequestFilter: (fn) => { if (typeof fn !== "function") throw new Error(`registerRequestFilter needs a function`); if (!manifest.capabilities.includes("request-filter")) throw new Error(`add-on "${manifest.id}" must declare the "request-filter" capability in addon.json`); if (!this._requestFilter) { this.log(`[${manifest.id}] request filter unavailable (host not wired)`); return () => {}; } this._requestFilter.set(manifest.id, fn); return () => { try { this._requestFilter.clear(manifest.id); } catch {} }; }, // The active tab (id, url, host) and a change event — enough for a // per-site view, nothing about page content. tabs: { active: () => { if (!manifest.capabilities.includes("request-filter") && !manifest.capabilities.includes("page-inject")) throw new Error(`add-on "${manifest.id}" must declare "request-filter" or "page-inject" to read tabs`); return this._tabs ? this._tabs.active() : null; }, onChange: (cb) => { if (!manifest.capabilities.includes("request-filter") && !manifest.capabilities.includes("page-inject")) throw new Error(`add-on "${manifest.id}" must declare "request-filter" or "page-inject" to read tabs`); if (!this._tabs || typeof cb !== "function") return () => {}; const off = this._tabs.onChange(cb); active.tabListeners.push(off); return off; }, }, setSessionProxy: async (rules) => { if (!this._setSessionProxy) { this.log(`[${manifest.id}] setSessionProxy unavailable (host not wired)`); return; } if (!manifest.capabilities.includes("session-proxy")) { throw new Error(`add-on "${manifest.id}" must declare the "session-proxy" capability in addon.json`); } await this._setSessionProxy(rules, manifest.id); }, // Resolve a module from Theseus's own dependency tree. Add-ons run with // the app's full privileges anyway; this only saves them from shipping // a second copy of ws / @noble / etc. require: (name) => { if (!this._hostRequire) throw new Error(`api.require unavailable (host not wired)`); return this._hostRequire(name); }, // Same, for ES-module-only packages: resolves to a Promise of the // module namespace. import: async (name) => { if (!this._hostImport) throw new Error(`api.import unavailable (host not wired)`); return this._hostImport(name); }, // Open a new Theseus tab. Two shapes: // - api.openTab("https://…") — no capability needed // - api.openTab("editor.html", { query: {...} }) — opens one of the // add-on's OWN files as a full tab; requires the "open-tab" cap. // Path is resolved inside the add-on folder and rejected if it // escapes it (path traversal). Query is URL-encoded. The page // loads under addon-tab-preload.js so window.silentmode.invoke() // reaches the same handlers as a sidebar panel — main gates by // sender URL so a page hosted anywhere else gets nothing back. openTab: (pathOrUrl, opts) => { const s = String(pathOrUrl || ""); // Bare http(s) URL with no opts — legacy behaviour, unchanged. if (/^https?:\/\//i.test(s) && !opts) { if (!this._openTab) throw new Error("openTab unavailable (host not wired)"); this._openTab(s, manifest.id); return; } if (!manifest.capabilities.includes("open-tab")) { throw new Error(`add-on "${manifest.id}" must declare the "open-tab" capability in addon.json to open its own files in a tab`); } if (!this._openAddonTab) throw new Error("openAddonTab unavailable (host not wired)"); if (!s || path.isAbsolute(s) || s.includes("..")) { throw new Error(`openTab: path must be a relative file inside the add-on folder (got "${s}")`); } let qs = ""; if (opts && opts.query && typeof opts.query === "object") { const usp = new URLSearchParams(); for (const [k, v] of Object.entries(opts.query)) usp.append(String(k), String(v)); qs = usp.toString(); } return this._openAddonTab(manifest.id, s, qs); }, // Open Theseus's Settings tab, optionally scrolled to a named section // (validated against a known list in main). No capability needed — // it's the same thing the user could do from the ⋮ menu, just a // one-click shortcut so add-ons can point users at the right place // (e.g. Aegis's "Set up vault" gate → Passwords). openSettings: (section) => { if (!this._openSettings) throw new Error("openSettings unavailable (host not wired)"); this._openSettings(typeof section === "string" ? section : ""); }, // Check the OTA channel for a newer signed build of THIS add-on and // stage it if one is found. Returns { status, staged, current, next } // — status matches the shared addon-updater report vocabulary // ("up-to-date" | "staged" | "already-staged" | "fetch-failed" | …). // The staged copy activates on the next Theseus launch, so pair with // restartApp() when the caller wants an immediate apply. Scoped to // the calling add-on so a plug-in can't stage updates for its // neighbours. checkAndStageSelfUpdate: async () => { if (!this._checkAndStageUpdates) throw new Error("checkAndStageSelfUpdate unavailable (host not wired)"); const full = await this._checkAndStageUpdates(); const own = (full?.report || []).find((r) => r.id === manifest.id) || { status: "no-update-url" }; return { status: own.status || "unknown", detail: own.detail || null, current: own.currentVer || manifest.version, next: own.newVer || null, staged: (full?.staged || []).find((s) => s.id === manifest.id) || null, }; }, // Apply this add-on's staged update now, without a restart: Theseus // swaps it in, restarts the add-on and reloads its open panels and // tabs. Plug-ins (wallets) still update on the next launch. applySelfUpdate: async () => { if (!this._applySelfUpdate) throw new Error("applySelfUpdate unavailable (host not wired)"); return this._applySelfUpdate(manifest.id); }, // Ask Theseus to relaunch (the plug-in card's "apply update" chip). // The host asks the user first and resolves { restarted, deferred } // — an add-on cannot restart the browser on its own. restartApp: () => { if (!this._restartApp) throw new Error("restartApp unavailable (host not wired)"); return Promise.resolve(this._restartApp(manifest.name || manifest.id)); }, // An on-demand add-on that has started something the user expects to // keep working with Theseus closed to it — a live relay session, say — // asks to be started at launch from the next start on (false undoes // it). Saved by the host; no effect on a startup add-on. startAtLaunch: (on) => { if (this._setStartAtLaunch) this._setStartAtLaunch(manifest.id, !!on); }, // Resolves once the browser chrome has painted (immediately if it // already has). Put expensive dependency loading behind this so it // never competes with the first frame at launch. whenUiReady: () => (this._uiReady ? Promise.resolve() : new Promise((resolve) => this._uiReadyWaiters.push(resolve))), // Panel ↔ activate() messaging. Panels (and, for page-inject add-ons, // injected page bridges) call into the add-on with a message name + // one JSON payload; the handler's return value goes back as the // response. `ctx.from` is "panel" or "page"; pages also carry // `ctx.origin` ("https://host") so the add-on can scope permissions. onMessage: (msg, handler) => { if (typeof msg !== "string" || !msg || typeof handler !== "function") throw new Error(`onMessage needs (name, fn)`); active.handlers.set(msg, handler); }, // Push an event to the add-on's own sidebar panel if it's currently // loaded. Fire-and-forget; silently dropped when the panel is closed. emit: (msg, payload) => { if (this._emitToPanel) this._emitToPanel(manifest.id, String(msg), payload); }, // vault-derive: a 32-byte HKDF child of the password vault's root, // keyed by a path that MUST start with this add-on's id — or one of // the ids it declared under `absorbs` in addon.json, so a superseding // add-on can keep deriving the same keys as the add-on it replaced // (funds stay reachable across the transition). Resolves only once // the user has unlocked the vault (main polls; the await can be long). vault: { derive: async (purposePath) => { if (!manifest.capabilities.includes("vault-derive")) { throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); } if (!this._vaultDerive) throw new Error(`vault.derive unavailable (host not wired)`); const p = String(purposePath || ""); if (/[^a-z0-9/._-]/i.test(p) || p.includes("..")) { throw new Error(`vault.derive: purposePath must look like "${manifest.id}/"`); } // `absorbs` hands one add-on another add-on's key namespace, so it // is honoured only for first-party (bundled) ids. A community // install has it stripped, but an UPDATE of one is placed as // shipped — version 2 could declare absorbs:["aegis"] and derive // the wallet's keys. And an id a first-party add-on absorbs // ("bchwallet", "siawallet") is that add-on's namespace: nothing // else may derive under it, even if it got installed under the id. const firstParty = this._isFirstPartyId ? !!this._isFirstPartyId(manifest.id) : true; if (!firstParty && this._isReservedId && this._isReservedId(manifest.id)) { throw new Error(`vault.derive: the id "${manifest.id}" is reserved by a built-in add-on`); } const allowed = [manifest.id, ...(firstParty ? (manifest.absorbs || []) : [])]; if (!allowed.some((prefix) => p.startsWith(prefix + "/"))) { const list = allowed.length > 1 ? `one of "${allowed.join('", "')}"` : `"${manifest.id}"`; throw new Error(`vault.derive: purposePath must start with ${list} + "/"`); } return this._vaultDerive(p, manifest.id); }, imports: this._makeImportsApi(manifest), lifecycle: this._makeLifecycleApi(manifest), pin: this._makePinApi(manifest), // Ask Theseus to unlock the vault: it prompts for the PIN (or the // master password) in its own overlay. Resolves { ok: true } when // the vault is open, { ok: false, reason: "no-vault" | "cancelled" } // otherwise. Already unlocked resolves at once without a prompt. requestUnlock: async (opts = {}) => { if (!manifest.capabilities.includes("vault-derive")) { throw new Error(`add-on "${manifest.id}" must declare the "vault-derive" capability in addon.json`); } if (!this._vaultRequestUnlock) throw new Error("vault.requestUnlock unavailable (host not wired)"); return this._vaultRequestUnlock({ reason: String(opts.reason || "").slice(0, 200) }, manifest.id); }, }, // approval-modal: ask the user. Resolves to the chosen action id, or // "cancel" (Escape / mask click / window closed). With `checkbox` set // and ticked, the id comes back suffixed "+"; with // `select` {id, label, options:[{value,label}]} and a non-empty value // chosen, "+=". approvalModal: async (opts) => { if (!manifest.capabilities.includes("approval-modal")) { throw new Error(`add-on "${manifest.id}" must declare the "approval-modal" capability in addon.json`); } if (!this._approvalModal) throw new Error(`approvalModal unavailable (host not wired)`); const call = pageCallCtx.getStore(); return this._approvalModal(opts || {}, manifest.id, call ? call.tabId : null); }, // capture-tab: snapshot the currently-active tab. // opts.mode "visible" | "full" | "region" (required) // opts.format "png" | "jpeg" (default "png") // opts.quality 1-100 (jpeg only, default 90) // opts.overlaySource string (region only — DOM // code the add-on wants injected while the user // drags a selection. Must resolve to `{x,y,w,h}` // in CSS pixels; return null/undefined to cancel.) // Resolves to `{ dataUrl, width, height, host, format }`. captureTab: async (opts) => { if (!manifest.capabilities.includes("capture-tab")) { throw new Error(`add-on "${manifest.id}" must declare the "capture-tab" capability in addon.json`); } if (!this._captureTab) throw new Error(`captureTab unavailable (host not wired)`); return this._captureTab(opts || {}, manifest.id); }, // capture-tab: route an in-memory image into the app's downloads pipeline // so it lands in the user's Downloads folder AND shows up in the // download-chip list the same way any HTTP download would. // opts.dataUrl "data:image/png;base64,…" (required) // opts.filename filename shown in the chip (required) // Resolves to `{ savePath }`. saveCapture: async (opts) => { if (!manifest.capabilities.includes("capture-tab")) { throw new Error(`add-on "${manifest.id}" must declare the "capture-tab" capability in addon.json`); } if (!this._saveCapture) throw new Error(`saveCapture unavailable (host not wired)`); return this._saveCapture(opts || {}, manifest.id); }, // scan-page: pull URIs of ONE scheme out of the active tab. // // Deliberately not a "read the page" API. The host does the matching // and hands back only the URIs that matched, so an add-on with this // capability still cannot see page text, form values or anything else // it did not ask for. The scheme is fixed by the caller and validated // here, and every call is expected to be user-initiated — nothing in // the host polls a page on an add-on's behalf. // // opts.scheme e.g. "wiz" (required, [a-z][a-z0-9+.-]{0,19}) // opts.limit max URIs to return (default 20, hard cap 50) // Resolves to `{ origin, uris: [string] }`. scanActiveTabForUris: async (opts) => { if (!manifest.capabilities.includes("scan-page")) { throw new Error(`add-on "${manifest.id}" must declare the "scan-page" capability in addon.json`); } if (!this._scanTabForUris) throw new Error(`scanActiveTabForUris unavailable (host not wired)`); const scheme = String(opts?.scheme || "").toLowerCase(); if (!/^[a-z][a-z0-9+.-]{0,19}$/.test(scheme)) throw new Error("invalid scheme"); const limit = Math.min(Math.max(Number(opts?.limit) || 20, 1), 50); return this._scanTabForUris({ scheme, limit }, manifest.id); }, }; } // Route a message to an add-on's registered handler. Callers (main) have // already established WHO is asking; `ctx` carries that provenance. An // on-demand add-on that hasn't started yet is started first — this is the // path every first use goes through (panel and add-on-tab pages, page // bridges, toolbar and context menus, Settings), and the call waits for // the activation rather than being dropped. async dispatch(id, msg, payload, ctx) { if (!this._active.has(id)) { if (!this._dormant.has(id) && !this._activating.has(id)) throw new Error(`add-on "${id}" is not active`); await this.ensureActive(id, `${(ctx && ctx.from) || "call"} ${msg}`); } // An add-on whose activate() returned a promise gets calls only once it // has settled (bounded): handlers may be registered after an await, or // need state the activation is still loading. let active = this._active.get(id); if (!active) throw new Error(`add-on "${id}" is not active`); if (active.ready) { await active.ready; active = this._active.get(id); if (!active) throw new Error(`add-on "${id}" is not active`); } const handler = active.handlers.get(String(msg)); if (!handler) throw new Error(`add-on "${id}" has no handler for "${msg}"`); if (ctx && ctx.from === "page" && ctx.tabId != null) return pageCallCtx.run({ tabId: ctx.tabId }, () => handler(payload, ctx)); return handler(payload, ctx || {}); } hasHandler(id, msg) { const active = this._active.get(id); return !!(active && active.handlers.has(String(msg))); } // Running and waiting add-ons in discovery order — the order the dock, // menus and Settings list them in, which must not change when an add-on // starts. _enabledEntries() { const out = []; for (const { manifest } of this._installed) { if (!manifest) continue; const active = this._active.get(manifest.id); const dormant = active ? null : this._dormant.get(manifest.id); if (active || dormant) out.push({ manifest: (active || dormant).manifest, active, dormant }); } return out; } // Inject scripts that apply to a tab URL — [{ id, source }]. A waiting // add-on's bridge is injected too (its source is read on first match); the // bridge's first message is what starts the add-on. // Which pages an add-on's bridge is injected into, beyond its manifest // patterns. The add-on keeps `{ mode: "all" | "allowed", origins: [...] }` // under the reserved storage key "__pageInjectPolicy"; in "allowed" mode // only the listed origins get the bridge. It is read from the store, not // asked of the add-on, because the decision is made synchronously at // document start and has to hold while an on-demand add-on is still // dormant. A wallet that announces itself to every page tells every page // the user has one; this lets the user say which sites may know. _injectPolicyAllows(id, url) { let policy = null; try { policy = storeFor(path.join(this.dataDir, `${id}.json`), () => {}).get("__pageInjectPolicy", null); } catch { return true; } if (!policy || policy.mode !== "allowed") return true; let origin = ""; try { const u = new URL(String(url).replace(/^bns:\/\//i, "https://")); origin = `${u.protocol}//${u.host}`; } catch { return false; } return Array.isArray(policy.origins) && policy.origins.includes(origin); } injectionsFor(url) { const out = []; for (const { manifest, active, dormant } of this._enabledEntries()) { if (!this._injectPolicyAllows(manifest.id, url)) continue; if (active) { if (active.inject && urlMatchesAny(url, active.inject.matchers)) out.push({ id: manifest.id, source: active.inject.source }); continue; } const pi = manifest.pageInject; if (!pi || !urlMatchesAny(url, pi.matchers)) continue; if (dormant.injectSource == null) { try { dormant.injectSource = fs.readFileSync(path.join(dormant.folder, pi.preload), "utf8"); } catch (e) { this.log(`[${manifest.id}] page-inject preload not readable: ${e?.message || e}`); dormant.injectSource = ""; } } if (dormant.injectSource) out.push({ id: manifest.id, source: dormant.injectSource }); } return out; } // Does this add-on's page-inject declaration cover the URL? Used to gate // page → add-on IPC so a non-matching page can't spoof a matching one. pageAllowed(id, url) { if (!this._injectPolicyAllows(id, url)) return false; const active = this._active.get(id); if (active) return !!(active.inject && urlMatchesAny(url, active.inject.matchers)); const d = this._dormant.get(id); return !!(d && d.manifest.pageInject && urlMatchesAny(url, d.manifest.pageInject.matchers)); } // Read-only views for the rest of the app. snapshot() { return { installed: this._installed.map(({ manifest, folder, error }) => ({ id: manifest?.id ?? null, name: manifest?.name ?? null, version: manifest?.version ?? null, description: manifest?.description ?? "", author: manifest?.author ?? "", icon: manifest?.icon ?? "🧩", capabilities: manifest?.capabilities ?? [], // "plugin" — first-class Silent Mode component (Aegis, future // Ariadne-as-addon) surfaced in Settings › Plug-ins instead of // the raw Extensions list. Absent → plain extension. category: manifest?.category || null, folder, enabled: manifest?.id ? this.isEnabled(manifest.id) : false, // What the manifest asks for, and whether it is running right now // (false while an enabled on-demand add-on waits for first use). activation: manifest?.activation || null, running: manifest?.id ? this._active.has(manifest.id) : false, error: error || null, })), sidebarPanels: this.getSidebarPanels(), toolbarMenus: this.getToolbarMenus(), }; } // `plugin` marks surfaces of a first-class Silent Mode component (manifest // category "plugin", e.g. Aegis): the chrome pins those to their own dock // instead of the extensions row. getSidebarPanels() { const out = []; for (const { manifest, active, dormant } of this._enabledEntries()) { const plugin = manifest.category === "plugin"; for (const p of (active ? active.sidebarPanels : dormant.panels)) out.push({ ...p, plugin, addonName: manifest.name }); } return out; } // Menu declarations from every enabled add-on that carries a toolbar-menu // manifest block. Chrome renders one dock button per entry, opens the // dropdown, then dispatches "menu-select" with the picked item id. getToolbarMenus() { const out = []; for (const { manifest } of this._enabledEntries()) { const tm = manifest.toolbarMenu; if (!tm) continue; out.push({ addonId: manifest.id, title: tm.title, icon: tm.icon, plugin: manifest.category === "plugin", items: tm.items.map((it) => ({ id: it.id, label: it.label, icon: it.icon })), }); } return out; } // Right-click menu items declared by add-ons, filtered by the current // context. `ctx` is what Electron's context-menu event carries: // { selectionText, linkURL, mediaType, srcURL, isEditable, pageURL } // Returns [{ addonId, id, label, icon, when }]. `when` filters: // selectionText — non-empty text is selected // linkURL — right-clicked on a link // editable — right-clicked inside a form control / contenteditable // image — mediaType === "image" and srcURL is present // always — every menu getContextMenuItems(ctx = {}) { const has = { selectionText: !!(ctx.selectionText && String(ctx.selectionText).trim()), linkURL: !!ctx.linkURL, editable: !!ctx.isEditable, image: ctx.mediaType === "image" && !!ctx.srcURL, }; const out = []; for (const { manifest } of this._enabledEntries()) { for (const it of manifest.contextMenuItems || []) { if (it.when !== "always" && !has[it.when]) continue; out.push({ addonId: manifest.id, id: it.id, label: it.label, icon: it.icon, when: it.when }); } } return out; } getInstalled() { return this._installed.slice(); } isActive(id) { return this._active.has(id); } // Absolute folder of an enabled add-on, or null. Public so main can resolve // add-on-relative paths (openAddonTab) without reaching into internals. folderOf(id) { const a = this._active.get(id) || this._dormant.get(id); return a ? a.folder : null; } } module.exports = { AddonHost, KNOWN_CAPABILITIES, validateManifest, compileOriginPattern };