An extension was require()d into the browser's main process. Its declared capabilities bound only an honest one: there it could load electron, hook the unlock prompt for the master password, read the vault files and the wallet's store, call the OS keystore, and reach every tab. Extensions that do not ship with Theseus now run in a separate process, in Node mode under Node's permission model: read access to their own folder, write access to a scratch folder, no child processes, no workers, no native add-ons, no Electron, and none of the browser's memory. They reach the browser only through an allow-listed message API that calls the same functions, with the same capability checks, as before. Built-in add-ons are unchanged and stay in-process. For extension authors: host calls return promises (tabs.active included); api.require / api.import are gone, so dependencies must be bundled; registerRequestFilter and registerSiteRoute are not offered; the store is handed over at start and written through, so storage.get stays synchronous. An approval raised while handling a page message is still tied to that page's tab. If the sandbox cannot start, the extension does not run. The channel is JSON with tagged bytes: the binary IPC format depends on the exact V8 build on both ends.
181 lines
9.3 KiB
JavaScript
181 lines
9.3 KiB
JavaScript
// Runs ONE community extension outside the browser process.
|
|
//
|
|
// An extension used to be require()d into Electron's main process, where it
|
|
// could load `electron`, hook the unlock prompt, read the vault files and
|
|
// the wallet's store, or shell out to the OS keystore — its declared
|
|
// capabilities only bound an honest extension. Extensions that do not ship
|
|
// with Theseus now run here instead: a separate process started in Node mode
|
|
// under Node's permission model, allowed to read nothing but its own folder
|
|
// (and write nothing but its own scratch folder), with no child processes,
|
|
// no workers, no native add-ons and no Electron. Everything it can do to the
|
|
// browser goes through the message channel below, and the host answers only
|
|
// the calls on its allow-list, with the same capability checks as before.
|
|
//
|
|
// The `api` object keeps the in-process shape where it can. Differences an
|
|
// extension author will meet:
|
|
// - host calls return promises (tabs.active() included);
|
|
// - api.require / api.import are gone — bundle your dependencies;
|
|
// - registerRequestFilter and registerSiteRoute are not offered (both hand
|
|
// the host a function it would have to call synchronously);
|
|
// - vault.imports, vault.pin and vault.lifecycle.unlock/setup/lock are
|
|
// built-in only, as they already were.
|
|
"use strict";
|
|
const { AsyncLocalStorage } = require("node:async_hooks");
|
|
const callScope = new AsyncLocalStorage(); // which page call a host request belongs to
|
|
|
|
let seq = 0;
|
|
const pending = new Map(); // id -> { resolve, reject }
|
|
const handlers = new Map(); // message name -> fn(payload, ctx)
|
|
const tabListeners = new Set();
|
|
let mod = null;
|
|
|
|
// 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.
|
|
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 send = (m) => { try { process.send(wireEnc(m)); } catch { /* host is gone */ } };
|
|
const errOut = (e) => ({ message: String((e && e.message) || e), code: e && e.code != null ? e.code : undefined });
|
|
const plain = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
|
|
|
|
function hostCall(path, args) {
|
|
return new Promise((resolve, reject) => {
|
|
const id = ++seq;
|
|
pending.set(id, { resolve, reject });
|
|
const scope = callScope.getStore();
|
|
send({ t: "api", id, path, args, callId: scope ? scope.callId : null });
|
|
});
|
|
}
|
|
const builtInOnly = (what) => () => Promise.reject(new Error(`${what} is reserved for built-in add-ons`));
|
|
const notOffered = (what, why) => () => { throw new Error(`${what} is not available to sandboxed extensions — ${why}`); };
|
|
|
|
function makeApi(init) {
|
|
const store = init.store && typeof init.store === "object" ? init.store : {};
|
|
const call = (path) => (...args) => hostCall(path, args);
|
|
return {
|
|
id: init.manifest.id,
|
|
folder: init.folder,
|
|
dataDir: init.scratchDir,
|
|
features: Object.freeze({ ...(init.features || {}), sandboxed: true }),
|
|
log: (...a) => send({ t: "log", args: a.map((x) => (typeof x === "string" ? x : (() => { try { return JSON.stringify(x); } catch { return String(x); } })())) }),
|
|
// The store is this extension's own key/value file. It is handed over
|
|
// whole at start and written through, so get() stays synchronous.
|
|
storage: {
|
|
get: (key, fallback = null) => (Object.prototype.hasOwnProperty.call(store, key) && store[key] != null ? plain(store[key]) : fallback),
|
|
set: (key, value) => {
|
|
const k = String(key);
|
|
if (value === null || value === undefined) delete store[k]; else store[k] = plain(value);
|
|
send({ t: "storage.set", key: k, value: value === undefined ? null : plain(value) });
|
|
},
|
|
all: () => plain(store),
|
|
},
|
|
onMessage: (name, fn) => {
|
|
if (typeof name !== "string" || !name || typeof fn !== "function") throw new Error("onMessage needs (name, fn)");
|
|
handlers.set(name, fn);
|
|
send({ t: "handler", name });
|
|
},
|
|
emit: (msg, payload) => { hostCall("emit", [String(msg), payload]).catch(() => {}); },
|
|
registerSidebarPanel: (spec) => { hostCall("registerSidebarPanel", [spec]).catch((e) => send({ t: "log", args: ["registerSidebarPanel: " + e.message] })); },
|
|
revealSidebar: (panelId) => { hostCall("revealSidebar", [panelId]).catch(() => {}); },
|
|
openTab: call("openTab"),
|
|
openSettings: call("openSettings"),
|
|
setSessionProxy: call("setSessionProxy"),
|
|
captureTab: call("captureTab"),
|
|
saveCapture: call("saveCapture"),
|
|
scanActiveTabForUris: call("scanActiveTabForUris"),
|
|
approvalModal: call("approvalModal"),
|
|
checkAndStageSelfUpdate: call("checkAndStageSelfUpdate"),
|
|
applySelfUpdate: call("applySelfUpdate"),
|
|
restartApp: call("restartApp"),
|
|
startAtLaunch: (on) => { hostCall("startAtLaunch", [!!on]).catch(() => {}); },
|
|
whenUiReady: call("whenUiReady"),
|
|
tabs: {
|
|
active: call("tabs.active"),
|
|
onChange: (cb) => {
|
|
if (typeof cb !== "function") return () => {};
|
|
tabListeners.add(cb);
|
|
send({ t: "tabs.watch" });
|
|
return () => tabListeners.delete(cb);
|
|
},
|
|
},
|
|
vault: {
|
|
derive: call("vault.derive"),
|
|
requestUnlock: call("vault.requestUnlock"),
|
|
lifecycle: {
|
|
status: call("vault.lifecycle.status"),
|
|
unlock: builtInOnly("vault.lifecycle.unlock"),
|
|
setup: builtInOnly("vault.lifecycle.setup"),
|
|
lock: builtInOnly("vault.lifecycle.lock"),
|
|
},
|
|
imports: { list: builtInOnly("vault.imports"), add: builtInOnly("vault.imports"), remove: builtInOnly("vault.imports"), signer: builtInOnly("vault.imports") },
|
|
pin: { status: builtInOnly("vault.pin"), unlock: builtInOnly("vault.pin"), set: builtInOnly("vault.pin"), clear: builtInOnly("vault.pin") },
|
|
},
|
|
registerRequestFilter: notOffered("registerRequestFilter", "the host would have to call into the extension synchronously for every request"),
|
|
registerSiteRoute: notOffered("registerSiteRoute", "site routes are for built-in add-ons"),
|
|
require: notOffered("api.require", "bundle the modules you need inside the extension folder"),
|
|
import: notOffered("api.import", "bundle the modules you need inside the extension folder"),
|
|
};
|
|
}
|
|
|
|
process.on("message", async (raw) => {
|
|
if (!raw || typeof raw !== "object") return;
|
|
const m = wireDec(raw);
|
|
if (m.t === "res") {
|
|
const p = pending.get(m.id);
|
|
if (!p) return;
|
|
pending.delete(m.id);
|
|
if (m.ok) p.resolve(m.value);
|
|
else { const e = new Error(m.error && m.error.message || "host call failed"); if (m.error && m.error.code != null) e.code = m.error.code; p.reject(e); }
|
|
return;
|
|
}
|
|
if (m.t === "activate") {
|
|
try {
|
|
mod = require(m.mainPath);
|
|
if (!mod || typeof mod.activate !== "function") throw new Error("main file must export an activate(api) function");
|
|
await mod.activate(makeApi(m));
|
|
send({ t: "activated", ok: true });
|
|
} catch (e) { send({ t: "activated", ok: false, error: errOut(e) }); }
|
|
return;
|
|
}
|
|
if (m.t === "call") {
|
|
const fn = handlers.get(m.name);
|
|
if (!fn) { send({ t: "res", id: m.id, ok: false, error: { message: `no handler for "${m.name}"` } }); return; }
|
|
try {
|
|
const value = await callScope.run({ callId: m.id }, () => fn(m.payload, m.ctx || {}));
|
|
send({ t: "res", id: m.id, ok: true, value: value === undefined ? null : value });
|
|
} catch (e) { send({ t: "res", id: m.id, ok: false, error: errOut(e) }); }
|
|
return;
|
|
}
|
|
if (m.t === "tabs") { for (const cb of tabListeners) { try { cb(m.data); } catch {} } return; }
|
|
if (m.t === "deactivate") {
|
|
try { if (mod && typeof mod.deactivate === "function") await mod.deactivate(); } catch {}
|
|
process.exit(0);
|
|
}
|
|
});
|
|
// An extension's stray rejection or exception must not take its process down
|
|
// silently — say so in the host log and keep serving.
|
|
process.on("uncaughtException", (e) => send({ t: "log", args: ["uncaught exception: " + ((e && e.stack) || e)] }));
|
|
process.on("unhandledRejection", (e) => send({ t: "log", args: ["unhandled rejection: " + ((e && e.message) || e)] }));
|
|
process.on("disconnect", () => process.exit(0));
|
|
send({ t: "ready" });
|