theseus/lib/addon-sandbox-runner.cjs

182 lines
9.3 KiB
JavaScript
Raw Normal View History

// 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" });