diff --git a/lib/__tests__/plugin-app-side-panel.test.ts b/lib/__tests__/plugin-app-side-panel.test.ts index ce8fafbda..9f53bd284 100644 --- a/lib/__tests__/plugin-app-side-panel.test.ts +++ b/lib/__tests__/plugin-app-side-panel.test.ts @@ -5,6 +5,8 @@ import { isFillableSlot } from '@/lib/plugin-sandbox/slot-layout'; import { ALL_PERMISSIONS, type InstalledPlugin, type Permission } from '@/lib/plugin-types'; import { slotSpec } from '@/lib/plugin-slot-catalog'; import { assistantFeatures } from '@/lib/plugin-sandbox/assistant'; +import { runPluginAction } from '@/lib/plugin-sandbox/actions'; +import { deregister, register, type ActivePlugin } from '@/lib/plugin-sandbox/registry'; import { APP_SIDE_PANEL_CONTEXT_FIELDS } from '@/lib/plugin-sandbox/assistant/f.types'; // The app-side-panel slot (area f of the assistant's host APIs): a side @@ -62,11 +64,28 @@ describe('app-side-panel', () => { expect(Object.keys(all.context as object).sort()).toEqual(Object.keys(APP_SIDE_PANEL_CONTEXT_FIELDS).sort()); }); - it('cuts the context the same way in an action click (the dialog on a phone)', () => { - const visible = slotPropsFor(plugin(['ui:side-panel', 'calendar:read']), 'app-side-panel', { view: 'contacts', context: { contact: context.contact, page: context.page } }); - expect(visible).toEqual({ view: 'contacts', context: { page: context.page } }); + it("cuts the context the same way in the click that asks the plugin for its dialog (the loader's dispatcher)", async () => { + // Importing the loader installs its action dispatcher, as in the app. + await import('@/lib/plugin-sandbox/loader'); + const invokeHook = vi.fn(async () => undefined); + register({ plugin: plugin(['ui:side-panel', 'calendar:read']), code: '', tier: 'untrusted', background: { invokeHook }, slotOffers: [], hookDisposables: [] } as unknown as ActivePlugin); + try { + await runPluginAction('p', 'app-side-panel', 'open', { view: 'contacts', context: { contact: context.contact, page: context.page, secret: 'x' } }); + expect(invokeHook).toHaveBeenCalledWith('onAction', [{ surface: 'app-side-panel', actionId: 'open', context: { view: 'contacts', context: { page: context.page } } }]); + } finally { + deregister('p'); + } // Garbage in the context is dropped, not passed on. expect(slotPropsFor(plugin(['ui:side-panel']), 'app-side-panel', { context: 'x' }).context).toEqual({}); + // The loader's import is slow under load. + }, 30_000); + + it('tells each plugin whether it may show the panel (sidePanel follows its grant)', () => { + expect(assistantFeatures(plugin(['ui:side-panel'])).sidePanel).toBe(true); + // Enabled before an update added the permission: not granted yet. + const notGranted = { ...plugin(['ui:side-panel']), grantedPermissions: [] }; + expect(assistantFeatures(notGranted).sidePanel).toBe(false); + expect(assistantFeatures(plugin([])).sidePanel).toBe(false); }); it('routes api.ui.openPanel for it to the view that shows it, and nowhere else', () => { diff --git a/lib/__tests__/plugin-host-features.test.ts b/lib/__tests__/plugin-host-features.test.ts index 9db8a0e83..d85e4443c 100644 --- a/lib/__tests__/plugin-host-features.test.ts +++ b/lib/__tests__/plugin-host-features.test.ts @@ -19,8 +19,8 @@ import { } from '@/lib/plugin-sandbox/host-features'; import { assistantFeatures } from '@/lib/plugin-sandbox/assistant'; -/** The AI assistant's flags (lib/plugin-sandbox/assistant) come on top; their own tests check them. */ -const withAssistant = (core: Record) => ({ ...core, ...assistantFeatures() }); +/** The AI assistant's flags (lib/plugin-sandbox/assistant) come on top, as `plugin` sees them; their own tests check them. */ +const withAssistant = (core: Record, plugin?: never) => ({ ...core, ...assistantFeatures(plugin) }); function setExternalMail(patch: Partial) { usePolicyStore.setState({ @@ -85,21 +85,21 @@ describe('host bridge', () => { const plugin = { id: 'p', name: 'P', version: '1.0.0', permissions: [], settings: {}, settingsSchema: {} } as never; const instance = createBackgroundInstance({ plugin, tier: 'privileged', code: '', locale: 'en' }); const pending = (instance as unknown as { pendingInit: { hostFeatures?: unknown } | null }).pendingInit; - expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false })); + expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false }, plugin)); // Before init has gone out a change lands in the payload ... setExternalMail({}); - expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false })); + expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false }, plugin)); // ... after it, as a host-change message. const sent: unknown[] = []; (instance as unknown as { pendingInit: unknown }).pendingInit = null; (instance as unknown as { send: (m: unknown) => void }).send = (m) => sent.push(m); setExternalMail({ recipientWarning: true }); - expect(sent).toEqual([{ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false }) }]); + expect(sent).toEqual([{ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false }, plugin) }]); // The admin turns third-party traffic off: running plugins hear it. setNoThirdPartyTraffic(true); - expect(sent[1]).toEqual({ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: true }) }); + expect(sent[1]).toEqual({ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: true }, plugin) }); setNoThirdPartyTraffic(false); instance.destroy(); }, 30_000); diff --git a/lib/plugin-sandbox/assistant/f.ts b/lib/plugin-sandbox/assistant/f.ts index c4cccac8f..e125c0cc6 100644 --- a/lib/plugin-sandbox/assistant/f.ts +++ b/lib/plugin-sandbox/assistant/f.ts @@ -5,6 +5,7 @@ // the whole host side, mounted by components/plugins/app-side-panel.tsx. import type { InstalledPlugin, Permission } from '../../plugin-types'; +import { mayOfferSlot } from '../permissions'; import { NOT_MINE } from './not-mine'; import type { AreaHostFeaturesF } from './f.types'; @@ -16,7 +17,13 @@ export async function dispatch(_plugin: InstalledPlugin, _method: string, _args: return NOT_MINE; } -/** This area's flags in api.host.features, right now. */ -export function features(): AreaHostFeaturesF { - return { sidePanel: true }; +/** + * This area's flags in api.host.features, right now. For one plugin, + * sidePanel says whether it may show the panel (ui:side-panel granted; a + * plugin enabled before an update that added it lacks it until turned off + * and on), so it can tell the user instead of offering a switch that does + * nothing. + */ +export function features(plugin?: InstalledPlugin): AreaHostFeaturesF { + return { sidePanel: plugin ? mayOfferSlot(plugin, 'app-side-panel') : true }; } diff --git a/lib/plugin-sandbox/assistant/index.ts b/lib/plugin-sandbox/assistant/index.ts index 9d73e4a8d..f98ac68a4 100644 --- a/lib/plugin-sandbox/assistant/index.ts +++ b/lib/plugin-sandbox/assistant/index.ts @@ -23,7 +23,10 @@ export async function dispatchAssistant(plugin: InstalledPlugin, method: string, return NOT_MINE; } -/** Every area's api.host.features flags, right now. */ -export function assistantFeatures(): AssistantHostFeatures { - return Object.assign({}, ...AREAS.map((a) => a.features())); +/** + * Every area's api.host.features flags, right now; with `plugin`, as that + * plugin's frames see them (a flag may depend on what it was granted). + */ +export function assistantFeatures(plugin?: InstalledPlugin): AssistantHostFeatures { + return Object.assign({}, ...AREAS.map((a) => (a.features as (p?: InstalledPlugin) => object)(plugin))); } diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index a780c3e7b..617dffabe 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -14,6 +14,7 @@ export type { RegionalSnapshot }; import { withBasePath } from '../browser-navigation'; import { snapshotHostTheme, type ThemeSnapshot } from './host-theme'; import { snapshotHostFeatures, watchHostFeatures } from './host-features'; +import { assistantFeatures } from './assistant'; import { effectiveSettings } from '../plugin-settings'; import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout'; import { resolveDateLocale } from '../utils'; @@ -257,7 +258,8 @@ function pushThemeToFrames(): void { /** Every frame hears about a Bulwark feature that replaces plugins turning on or off. */ function pushHostFeaturesToFrames(features: HostFeaturesView): void { for (const instance of liveInstances) { - try { instance.setHostFeatures(features); } catch { /* one broken iframe must not stop the rest */ } + // Some of the assistant's flags depend on the plugin's grants (host-features.ts). + try { instance.setHostFeatures({ ...features, ...instance.ownHostFeatures() }); } catch { /* one broken iframe must not stop the rest */ } } } @@ -672,6 +674,11 @@ export class SandboxInstance { } /** Push what api.host.features says now; before init it goes into the init payload. */ + /** The flags of api.host.features that depend on this plugin. */ + ownHostFeatures(): Partial { + return assistantFeatures(this.plugin); + } + setHostFeatures(features: HostFeaturesView): void { if (this.destroyed) return; if (this.pendingInit) { @@ -782,7 +789,7 @@ export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstan code: opts.code, locale: opts.locale, regional: opts.regional ?? snapshotRegionalSettings(opts.locale), - hostFeatures: snapshotHostFeatures(), + hostFeatures: snapshotHostFeatures(opts.plugin), }; return new SandboxInstance( opts.plugin, @@ -812,7 +819,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance { locale: opts.locale, regional: opts.regional ?? snapshotRegionalSettings(opts.locale), theme: snapshotHostTheme(), - hostFeatures: snapshotHostFeatures(), + hostFeatures: snapshotHostFeatures(opts.plugin), }; return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize, opts.onFloatingDropdown); } diff --git a/lib/plugin-sandbox/host-features.ts b/lib/plugin-sandbox/host-features.ts index 0d82ac618..0b679d28c 100644 --- a/lib/plugin-sandbox/host-features.ts +++ b/lib/plugin-sandbox/host-features.ts @@ -10,7 +10,7 @@ import { usePolicyStore } from '@/stores/policy-store'; import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-mode'; -import type { HostFeaturesView } from '../plugin-types'; +import type { HostFeaturesView, InstalledPlugin } from '../plugin-types'; import type { OrganisationPolicy } from '@/lib/admin/organisation-policy'; import { assistantFeatures } from './assistant'; @@ -26,11 +26,11 @@ export function hostFeaturesFor(externalMail: OrganisationPolicy['externalMail'] }; } -export function snapshotHostFeatures(): HostFeaturesView { +export function snapshotHostFeatures(plugin?: InstalledPlugin): HostFeaturesView { return { ...hostFeaturesFor(usePolicyStore.getState().getOrganisation().externalMail, noThirdPartyTraffic()), // The AI assistant's host APIs this webmail offers (lib/plugin-sandbox/assistant). - ...assistantFeatures(), + ...assistantFeatures(plugin), }; }