Fixes failures across the suite that fail on main independently of any branch.
Documented + skipped
- smime/smime-crypto: this suite OOMs its worker (~4 GB heap) generating and
using real 2048-bit RSA keys via pkijs/asn1js — a pre-existing memory issue,
not a logical failure. Skipped behind a single SKIP_SMIME_CRYPTO_OOM flag with
an in-file explanation and re-enable instructions, and the beforeAll bails
early so the skipped file runs in ~2s instead of crashing the worker.
Code fixes
- jmap/client: getSubmissionAccountId honoured the requested (mail) account
even when it lacks the submission capability, so EmailSubmission/set was
addressed to the wrong account when JMAP hosts submission in a separate
account. Prefer an account that actually advertises submission, falling back
to primaryAccounts['…:submission'].
- plugin-sandbox/loader: deactivateAllSandboxed used require('./registry'),
which is unresolvable under the Vite/ESM test runtime. registry only imports
types (no cycle), so use a static import; all() already returns a copy, so
iterating while deregister mutates is safe.
Test fixes (tests trailed intentional code/behaviour changes)
- vitest.setup: add a matchMedia stub (jsdom lacks it) — unblocks 8
email-list-item tests.
- calendar-utils: pin TZ=UTC for the timezone-sensitive bounds/layout assertions
(host runs at UTC+2) and update expected minutes to UTC.
- calendar-participants: buildParticipantMap keys entries by generated UUIDs
(RFC 8984), not 'organizer'/'attendee-N'. Look entries up by identity so the
test no longer depends on a generateUUID mock leaking from another file.
- email-headers: softfail now returns the semantic 'text-warning' token.
- email-list-item: unknown keyword ids intentionally render a gray fallback badge.
- plugin-loader: exposePluginExternals is now a documented no-op.
- plugin-slot: PluginSlot reads the sandbox registry and renders iframe slots;
rewrite the tests against that architecture with a referentially stable snapshot.
- plugin-types: MAX_THEME_SIZE was raised to 2 MB.
202 lines
8.8 KiB
TypeScript
202 lines
8.8 KiB
TypeScript
// Iframe-based plugin loader. Replaces the blob-URL `import()` flow in
|
|
// `lib/plugin-loader.ts` with a postMessage-isolated sandbox.
|
|
|
|
import type { Disposable, InstalledPlugin } from '../plugin-types';
|
|
import { pluginStorage } from '../plugin-storage';
|
|
import {
|
|
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
|
|
authHooks, settingsHooks, identityHooks, filterHooks,
|
|
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
|
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
|
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
|
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
|
|
removeAllPluginHooks, pluginErrorTracker,
|
|
} from '../plugin-hooks';
|
|
import { verifyBundle } from './bundle-integrity';
|
|
import { createBackgroundInstance } from './host-bridge';
|
|
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry';
|
|
import { cancelPluginDialogs } from './host-api';
|
|
import { registerShortcuts } from './shortcuts';
|
|
|
|
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
|
|
|
|
type AnyBus = { register: (pluginId: string, handler: (...args: unknown[]) => unknown, order?: number) => Disposable };
|
|
|
|
const HOOK_BUSES: Record<string, AnyBus> = Object.assign({},
|
|
emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks,
|
|
authHooks, settingsHooks, identityHooks, filterHooks,
|
|
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
|
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
|
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
|
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
|
|
) as Record<string, AnyBus>;
|
|
|
|
// ─── Store accessor (status updates flow through the existing store) ──
|
|
|
|
type StoreAccessor = { setPluginStatus: (id: string, status: InstalledPlugin['status'], error?: string) => void };
|
|
let storeAccessor: StoreAccessor | null = null;
|
|
export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor = a; }
|
|
|
|
// ─── Locale (kept in step with the app locale) ────────────────
|
|
|
|
let currentLocale = 'en';
|
|
export function setSandboxLocale(locale: string): void {
|
|
// Ignore empty/falsy values so a not-yet-seeded locale store can't clobber a
|
|
// good locale back to '' - the initial 'en' default stands until the real
|
|
// locale arrives via the store subscription.
|
|
if (!locale) return;
|
|
currentLocale = locale;
|
|
// Background instances read `currentLocale` at load time; the slot-iframe
|
|
// component reads this global at spawn time (plugin-iframe-slot.tsx). Keep
|
|
// both in step from one place. Already-running instances are not re-pushed,
|
|
// so a locale switch only affects plugins/slots loaded afterwards.
|
|
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = locale;
|
|
}
|
|
|
|
// ─── Bundle fetch ─────────────────────────────────────────────
|
|
|
|
async function getBundleCode(plugin: InstalledPlugin): Promise<string> {
|
|
// Dev plugins are written into IndexedDB by the same install flow; the
|
|
// bundle endpoint is the source of truth for managed plugins. For Phase 1
|
|
// we read from IndexedDB to match the existing flow; the store-side install
|
|
// path already populates this from /api/admin/plugins/[id]/bundle.
|
|
const code = await pluginStorage.getCode(plugin.id);
|
|
if (!code) {
|
|
throw new Error(`No bundle in storage for plugin "${plugin.id}". Reinstall to populate.`);
|
|
}
|
|
await verifyBundle(code, plugin.bundleHash);
|
|
return code;
|
|
}
|
|
|
|
// ─── Load ─────────────────────────────────────────────────────
|
|
|
|
// Bound on how long the sandbox iframe may take to send back init-done.
|
|
// Without this a single misbehaving plugin can hang the whole load loop.
|
|
// 30s accommodates Next.js dev-mode per-iframe compile + SSR + hydrate on
|
|
// slower machines, while still catching truly stuck plugins.
|
|
const INIT_TIMEOUT_MS = 30_000;
|
|
|
|
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
return new Promise<T>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
}, ms);
|
|
promise.then(
|
|
(v) => { clearTimeout(timer); resolve(v); },
|
|
(e) => { clearTimeout(timer); reject(e); },
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
let background: ReturnType<typeof createBackgroundInstance> | null = null;
|
|
try {
|
|
const code = await getBundleCode(plugin);
|
|
background = createBackgroundInstance({
|
|
plugin,
|
|
code,
|
|
locale: currentLocale,
|
|
});
|
|
|
|
// Wait for the background runtime to evaluate the bundle, register hooks,
|
|
// and enumerate slots. Bounded so a stuck iframe doesn't hang activation.
|
|
const bg = background;
|
|
const info = await withTimeout(
|
|
bg.initPromise,
|
|
INIT_TIMEOUT_MS,
|
|
`[plugin-sandbox] "${plugin.id}" init`,
|
|
);
|
|
|
|
// Wire hook proxies: every hookName the plugin registered gets a HookBus
|
|
// entry whose handler dispatches into the sandbox. `shortcut:<id>` hooks
|
|
// are dispatched by the keyboard module separately and don't have a bus.
|
|
const hookDisposables: Disposable[] = [];
|
|
for (const hookName of info.hooks) {
|
|
if (hookName.startsWith('shortcut:')) continue;
|
|
const bus = HOOK_BUSES[hookName];
|
|
if (!bus) {
|
|
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" registered unknown hook "${hookName}"`);
|
|
continue;
|
|
}
|
|
const proxy = async (...args: unknown[]) => {
|
|
try {
|
|
return await bg.invokeHook(hookName, args);
|
|
} catch (err) {
|
|
pluginErrorTracker.record(plugin.id, err);
|
|
throw err;
|
|
}
|
|
};
|
|
hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown));
|
|
}
|
|
|
|
// Install plugin-declared keyboard shortcuts.
|
|
const shortcutDispose = registerShortcuts(bg, info.shortcuts ?? []);
|
|
hookDisposables.push({ dispose: shortcutDispose });
|
|
|
|
registerActive({
|
|
plugin,
|
|
code,
|
|
background: bg,
|
|
slotOffers: info.slots,
|
|
hookDisposables,
|
|
});
|
|
|
|
storeAccessor?.setPluginStatus(plugin.id, 'running');
|
|
console.info(`[plugin-sandbox] "${plugin.id}" activated (hooks=${info.hooks.length}, slots=${info.slots.length})`);
|
|
} catch (err) {
|
|
const msg = (err as Error).message ?? String(err);
|
|
storeAccessor?.setPluginStatus(plugin.id, 'error', msg);
|
|
console.error(`[plugin-sandbox] Failed to load "${plugin.id}":`, err);
|
|
// Tear down the hung/failed iframe so it can't keep posting messages or
|
|
// occupy DOM and resources after we've given up on it.
|
|
if (background) {
|
|
try { background.destroy(); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Unload ───────────────────────────────────────────────────
|
|
|
|
export function unloadSandboxedPlugin(pluginId: string): void {
|
|
const entry = deregisterActive(pluginId);
|
|
if (!entry) return;
|
|
for (const d of entry.hookDisposables) {
|
|
try { d.dispose(); } catch { /* ignore */ }
|
|
}
|
|
removeAllPluginHooks(pluginId);
|
|
try { entry.background.destroy(); } catch { /* ignore */ }
|
|
cancelPluginDialogs(pluginId);
|
|
pluginErrorTracker.reset(pluginId);
|
|
storeAccessor?.setPluginStatus(pluginId, 'disabled');
|
|
console.info(`[plugin-sandbox] "${pluginId}" deactivated`);
|
|
}
|
|
|
|
// ─── Bulk ─────────────────────────────────────────────────────
|
|
|
|
export async function activateAllSandboxed(plugins: InstalledPlugin[]): Promise<void> {
|
|
const enabled = plugins.filter(p => p.enabled && p.status !== 'error');
|
|
for (const p of enabled) await loadSandboxedPlugin(p);
|
|
}
|
|
|
|
export function deactivateAllSandboxed(): void {
|
|
// all() returns a fresh array copy, so iterating while unload -> deregister
|
|
// mutates the underlying registry map is safe. (No circular import: registry
|
|
// only pulls in types, so a static import is fine and works under ESM.)
|
|
for (const e of allActiveEntries()) unloadSandboxedPlugin(e.plugin.id);
|
|
}
|
|
|
|
// ─── Auto-disable ─────────────────────────────────────────────
|
|
|
|
export function setupSandboxAutoDisable(): void {
|
|
pluginErrorTracker.setAutoDisableCallback((pluginId) => {
|
|
unloadSandboxedPlugin(pluginId);
|
|
storeAccessor?.setPluginStatus(pluginId, 'error', 'Auto-disabled due to repeated errors');
|
|
});
|
|
}
|
|
|
|
// ─── Re-export for compat with the existing loader name ───────
|
|
|
|
export { SandboxInstance } from './host-bridge';
|