fix: tell each plugin whether it may show the side panel

api.host.features.sidePanel now says whether this plugin may offer the
app-side-panel (ui:side-panel granted), not only that the webmail has
it. A plugin enabled before an update added the permission can then say
so instead of offering a switch that does nothing. The host computes the
assistant's flags per plugin for a frame's init and for each push.

plugin-host-features.test.ts: the host bridge assertions take the
assistant's flags as the test plugin sees them (it lacks ui:side-panel,
so sidePanel is false there).
This commit is contained in:
Linus Rath
2026-11-02 23:54:52 +01:00
parent ecaad2b3d9
commit a256beb918
6 changed files with 57 additions and 21 deletions
+22 -3
View File
@@ -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', () => {
+6 -6
View File
@@ -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<string, boolean>) => ({ ...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<string, boolean>, plugin?: never) => ({ ...core, ...assistantFeatures(plugin) });
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
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);
+10 -3
View File
@@ -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 };
}
+6 -3
View File
@@ -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)));
}
+10 -3
View File
@@ -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<HostFeaturesView> {
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);
}
+3 -3
View File
@@ -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),
};
}