refactor: give the AI assistant's host APIs one set of files per area

The assistant's new host calls come in four areas (context, actions,
Files, the panel in every view), built side by side. Each area gets its
own files under lib/plugin-sandbox/assistant for its permissions, method
table, dispatcher, host feature flags and sandbox methods; the shared
files only read the merged result. No area adds anything yet. The host
features test now compares against the core flags plus the areas' flags.
This commit is contained in:
Linus Rath
2026-11-02 21:21:06 +01:00
parent d5a362b43e
commit f7ff8212a3
23 changed files with 322 additions and 5 deletions
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest';
// The AI assistant's host APIs live in one set of files per area
// (lib/plugin-sandbox/assistant). These tests check that whatever an area
// adds reaches the permission table, the dispatcher, api.host.features and
// the plugin's api, without the shared files naming it.
vi.mock('@/stores/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
}));
const area = vi.hoisted(() => ({
calls: [] as unknown[][],
}));
vi.mock('@/lib/plugin-sandbox/assistant/b', () => ({
PERMS: { 'email.probeArea': 'email:read' },
dispatch: async (_plugin: unknown, method: string, args: unknown[]) => { area.calls.push([method, ...args]); return 'from area b'; },
features: () => ({ probeArea: true }),
}));
vi.mock('@/lib/plugin-sandbox/assistant/b.runtime', () => ({
runtime: (callApi: (m: string, a: unknown[]) => Promise<unknown>) => ({ email: { probeArea: (x: string) => callApi('email.probeArea', [x]) } }),
}));
const { PERM_PER_METHOD, dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api');
const { snapshotHostFeatures } = await import('@/lib/plugin-sandbox/host-features');
const { withAssistantRuntime } = await import('@/lib/plugin-sandbox/assistant/runtime');
const { NOT_MINE, dispatchAssistant } = await import('@/lib/plugin-sandbox/assistant');
const plugin = { id: 'p', name: 'P', permissions: ['email:read'], managed: true } as never;
describe('the assistant areas', () => {
it('put their methods behind their permissions and run them', async () => {
expect(PERM_PER_METHOD['email.probeArea']).toBe('email:read');
expect(await dispatchApiCall(plugin, 'email.probeArea', ['x'])).toBe('from area b');
expect(area.calls).toEqual([['email.probeArea', 'x']]);
// Without the permission the area never sees the call.
const without = { ...(plugin as object), permissions: [] } as never;
await expect(dispatchApiCall(without, 'email.probeArea', [])).rejects.toThrow(/lacks permission "email:read"/);
expect(area.calls).toHaveLength(1);
});
it('leave other methods alone', async () => {
expect(await dispatchAssistant(plugin, 'email.get', [])).toBe(NOT_MINE);
});
it('add their flags to api.host.features', () => {
expect(snapshotHostFeatures()).toMatchObject({ probeArea: true, externalSenderLabel: false });
});
it('add their methods to the plugin api next to the existing ones', () => {
const calls: unknown[][] = [];
const api = withAssistantRuntime({ email: { get: () => 'core' } }, async (method, args) => { calls.push([method, args]); return null; });
expect(api.email.get()).toBe('core');
(api.email as unknown as { probeArea: (x: string) => unknown }).probeArea('y');
expect(calls).toEqual([['email.probeArea', ['y']]]);
});
});
+7 -3
View File
@@ -17,6 +17,10 @@ import {
isSlotHandedOver,
snapshotHostFeatures,
} 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() });
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
@@ -31,11 +35,11 @@ beforeEach(() => setExternalMail({}));
describe('snapshotHostFeatures', () => {
it('has one flag per switch', () => {
expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false });
expect(snapshotHostFeatures()).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false }));
setExternalMail({ senderLabel: true });
expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false });
expect(snapshotHostFeatures()).toEqual(withAssistant({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false }));
setExternalMail({ recipientWarning: true });
expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false });
expect(snapshotHostFeatures()).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false }));
});
// A link check switches Google Safe Browsing off in the mode instead of
+27
View File
@@ -0,0 +1,27 @@
# Host APIs for the AI assistant (2.2)
Host calls the assistant plugin needs, one set of files per area, so the
areas can be built side by side without editing the same lines:
| Area | What | Files |
| --- | --- | --- |
| b | More context: attachment text and images, threads, the list selection | `b.types.ts`, `b.ts`, `b.runtime.ts` |
| c | More actions: invitations, filters, batches, out-of-office, settings | `c.types.ts`, `c.ts`, `c.runtime.ts` |
| d | Files: find, read, attach, save, create | `d.types.ts`, `d.ts`, `d.runtime.ts` |
| f | The panel in every view | `f.types.ts`, `f.ts`, `f.runtime.ts` |
Each area fills its own three files:
- `<area>.types.ts` (pure, no imports from the app): `PERMISSIONS`, the
permissions the area adds (they join `ALL_PERMISSIONS`), and
`<Area>HostFeatures`, the flags it adds to `api.host.features`.
- `<area>.ts` (host side): `PERMS` (method → permission, joins
`PERM_PER_METHOD`), `dispatch(plugin, method, args)` (returns `NOT_MINE`
for methods it doesn't own) and `features()` (its flags right now).
- `<area>.runtime.ts` (inside the sandbox): `runtime(callApi)`, the methods
it adds to the plugin's `api`, by namespace (`{ email: { getThread } }`).
`index.ts`, `runtime.ts` and `types.ts` merge the areas; host-api.ts,
host-features.ts, runtime.tsx and plugin-types.ts read only those. A new
permission needs a check (a method in `PERMS`, a slot or a hook), its texts
in every locale under `plugins.permissions.<key>`, and docs.
@@ -0,0 +1,8 @@
// Area b of the assistant's host APIs, inside the plugin sandbox (see README.md).
import type { CallApi, RuntimeAdditions } from './runtime';
/** The methods this area adds to the plugin's api, by namespace. */
export function runtime(_callApi: CallApi): RuntimeAdditions {
return {};
}
+18
View File
@@ -0,0 +1,18 @@
// Area b of the assistant's host APIs, host side (see README.md).
import type { InstalledPlugin, Permission } from '../../plugin-types';
import { NOT_MINE } from './not-mine';
import type { AreaHostFeaturesB } from './b.types';
/** Method → permission, joined into PERM_PER_METHOD. */
export const PERMS: Readonly<Record<string, Permission | null>> = {};
/** Runs a method of this area; NOT_MINE for any other. */
export async function dispatch(_plugin: InstalledPlugin, _method: string, _args: unknown[]): Promise<unknown> {
return NOT_MINE;
}
/** This area's flags in api.host.features, right now. */
export function features(): AreaHostFeaturesB {
return {};
}
+9
View File
@@ -0,0 +1,9 @@
// Area b of the assistant's host APIs: its permissions and its flags in
// api.host.features (see README.md). Pure: no imports from the app.
/** Permissions this area adds to ALL_PERMISSIONS. */
export const PERMISSIONS = [] as const;
/** Flags this area adds to api.host.features. */
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface AreaHostFeaturesB {}
@@ -0,0 +1,8 @@
// Area c of the assistant's host APIs, inside the plugin sandbox (see README.md).
import type { CallApi, RuntimeAdditions } from './runtime';
/** The methods this area adds to the plugin's api, by namespace. */
export function runtime(_callApi: CallApi): RuntimeAdditions {
return {};
}
+18
View File
@@ -0,0 +1,18 @@
// Area c of the assistant's host APIs, host side (see README.md).
import type { InstalledPlugin, Permission } from '../../plugin-types';
import { NOT_MINE } from './not-mine';
import type { AreaHostFeaturesC } from './c.types';
/** Method → permission, joined into PERM_PER_METHOD. */
export const PERMS: Readonly<Record<string, Permission | null>> = {};
/** Runs a method of this area; NOT_MINE for any other. */
export async function dispatch(_plugin: InstalledPlugin, _method: string, _args: unknown[]): Promise<unknown> {
return NOT_MINE;
}
/** This area's flags in api.host.features, right now. */
export function features(): AreaHostFeaturesC {
return {};
}
+9
View File
@@ -0,0 +1,9 @@
// Area c of the assistant's host APIs: its permissions and its flags in
// api.host.features (see README.md). Pure: no imports from the app.
/** Permissions this area adds to ALL_PERMISSIONS. */
export const PERMISSIONS = [] as const;
/** Flags this area adds to api.host.features. */
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface AreaHostFeaturesC {}
@@ -0,0 +1,8 @@
// Area d of the assistant's host APIs, inside the plugin sandbox (see README.md).
import type { CallApi, RuntimeAdditions } from './runtime';
/** The methods this area adds to the plugin's api, by namespace. */
export function runtime(_callApi: CallApi): RuntimeAdditions {
return {};
}
+18
View File
@@ -0,0 +1,18 @@
// Area d of the assistant's host APIs, host side (see README.md).
import type { InstalledPlugin, Permission } from '../../plugin-types';
import { NOT_MINE } from './not-mine';
import type { AreaHostFeaturesD } from './d.types';
/** Method → permission, joined into PERM_PER_METHOD. */
export const PERMS: Readonly<Record<string, Permission | null>> = {};
/** Runs a method of this area; NOT_MINE for any other. */
export async function dispatch(_plugin: InstalledPlugin, _method: string, _args: unknown[]): Promise<unknown> {
return NOT_MINE;
}
/** This area's flags in api.host.features, right now. */
export function features(): AreaHostFeaturesD {
return {};
}
+9
View File
@@ -0,0 +1,9 @@
// Area d of the assistant's host APIs: its permissions and its flags in
// api.host.features (see README.md). Pure: no imports from the app.
/** Permissions this area adds to ALL_PERMISSIONS. */
export const PERMISSIONS = [] as const;
/** Flags this area adds to api.host.features. */
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface AreaHostFeaturesD {}
@@ -0,0 +1,8 @@
// Area f of the assistant's host APIs, inside the plugin sandbox (see README.md).
import type { CallApi, RuntimeAdditions } from './runtime';
/** The methods this area adds to the plugin's api, by namespace. */
export function runtime(_callApi: CallApi): RuntimeAdditions {
return {};
}
+18
View File
@@ -0,0 +1,18 @@
// Area f of the assistant's host APIs, host side (see README.md).
import type { InstalledPlugin, Permission } from '../../plugin-types';
import { NOT_MINE } from './not-mine';
import type { AreaHostFeaturesF } from './f.types';
/** Method → permission, joined into PERM_PER_METHOD. */
export const PERMS: Readonly<Record<string, Permission | null>> = {};
/** Runs a method of this area; NOT_MINE for any other. */
export async function dispatch(_plugin: InstalledPlugin, _method: string, _args: unknown[]): Promise<unknown> {
return NOT_MINE;
}
/** This area's flags in api.host.features, right now. */
export function features(): AreaHostFeaturesF {
return {};
}
+9
View File
@@ -0,0 +1,9 @@
// Area f of the assistant's host APIs: its permissions and its flags in
// api.host.features (see README.md). Pure: no imports from the app.
/** Permissions this area adds to ALL_PERMISSIONS. */
export const PERMISSIONS = [] as const;
/** Flags this area adds to api.host.features. */
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface AreaHostFeaturesF {}
+29
View File
@@ -0,0 +1,29 @@
// The assistant's host API areas, merged, host side (see README.md).
import type { InstalledPlugin, Permission } from '../../plugin-types';
import { NOT_MINE } from './not-mine';
import * as b from './b';
import * as c from './c';
import * as d from './d';
import * as f from './f';
import type { AssistantHostFeatures } from './types';
export { NOT_MINE };
const AREAS = [b, c, d, f];
/** Method → permission of every area, joined into PERM_PER_METHOD. */
export const ASSISTANT_PERMS: Readonly<Record<string, Permission | null>> = Object.assign({}, ...AREAS.map((a) => a.PERMS));
/** Runs an area's method; NOT_MINE when no area owns it. The permission is checked before. */
export async function dispatchAssistant(plugin: InstalledPlugin, method: string, args: unknown[]): Promise<unknown> {
for (const area of AREAS) {
if (!(method in area.PERMS)) continue;
return area.dispatch(plugin, method, args);
}
return NOT_MINE;
}
/** Every area's api.host.features flags, right now. */
export function assistantFeatures(): AssistantHostFeatures {
return Object.assign({}, ...AREAS.map((a) => a.features()));
}
+2
View File
@@ -0,0 +1,2 @@
/** What an area's dispatch returns for a method it doesn't own. */
export const NOT_MINE: unique symbol = Symbol('not mine');
+21
View File
@@ -0,0 +1,21 @@
// The assistant's host API areas, merged, inside the plugin sandbox (see README.md).
import { runtime as b } from './b.runtime';
import { runtime as c } from './c.runtime';
import { runtime as d } from './d.runtime';
import { runtime as f } from './f.runtime';
export type CallApi = (method: string, args: unknown[], timeoutMs?: number) => Promise<unknown>;
/** Methods by namespace: { email: { getThread: (...) => ... } }. */
export type RuntimeAdditions = Record<string, Record<string, (...args: never[]) => unknown>>;
/** Adds every area's methods to the plugin's api, namespace by namespace. */
export function withAssistantRuntime<T extends object>(api: T, callApi: CallApi): T {
const target = api as unknown as Record<string, Record<string, unknown>>;
for (const area of [b, c, d, f]) {
for (const [ns, methods] of Object.entries(area(callApi))) {
target[ns] = Object.assign(target[ns] ?? {}, methods);
}
}
return api;
}
+15
View File
@@ -0,0 +1,15 @@
// The assistant's host API areas, merged: permissions and api.host.features
// flags (see README.md). Pure: plugin-types.ts imports this.
import { PERMISSIONS as B } from './b.types';
import { PERMISSIONS as C } from './c.types';
import { PERMISSIONS as D } from './d.types';
import { PERMISSIONS as F } from './f.types';
import type { AreaHostFeaturesB } from './b.types';
import type { AreaHostFeaturesC } from './c.types';
import type { AreaHostFeaturesD } from './d.types';
import type { AreaHostFeaturesF } from './f.types';
export const ASSISTANT_PERMISSIONS = [...B, ...C, ...D, ...F] as const;
export interface AssistantHostFeatures extends AreaHostFeaturesB, AreaHostFeaturesC, AreaHostFeaturesD, AreaHostFeaturesF {}
+6
View File
@@ -74,6 +74,7 @@ import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposer
import { wkdAllowed } from '../outgoing-hosts';
import { noThirdPartyTraffic } from '../third-party-mode';
import { openExternalLink } from '../plugin-hook-result';
import { ASSISTANT_PERMS, NOT_MINE, dispatchAssistant } from './assistant';
/**
* Methods only callable from the privileged (same-origin) tier. These expose
@@ -112,6 +113,8 @@ const PRIVILEGED_ONLY_METHODS = new Set<string>([
]);
export const PERM_PER_METHOD: Readonly<Record<string, Permission | null>> = {
// The AI assistant's host APIs, one table per area (lib/plugin-sandbox/assistant).
...ASSISTANT_PERMS,
// storage is unscoped by the manifest - implicit.
'storage.get': null,
'storage.set': null,
@@ -2016,6 +2019,9 @@ export async function dispatchApiCall(
throw new Error(`Unknown API method "${method}"`);
}
const assistant = await dispatchAssistant(plugin, method, args);
if (assistant !== NOT_MINE) return assistant;
switch (method) {
case 'proxy.fetch': return proxyFetch(plugin, args, opts?.sink ?? null);
case 'proxy.cancel': proxyCancel(plugin, args[0]); return undefined;
+6 -1
View File
@@ -12,6 +12,7 @@ import { usePolicyStore } from '@/stores/policy-store';
import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-mode';
import type { HostFeaturesView } from '../plugin-types';
import type { OrganisationPolicy } from '@/lib/admin/organisation-policy';
import { assistantFeatures } from './assistant';
/** The external-mail-warning plugin, retired in favour of organisation.externalMail. */
export const EXTERNAL_MAIL_WARNING_PLUGIN_ID = 'external-mail-warning';
@@ -26,7 +27,11 @@ export function hostFeaturesFor(externalMail: OrganisationPolicy['externalMail']
}
export function snapshotHostFeatures(): HostFeaturesView {
return hostFeaturesFor(usePolicyStore.getState().getOrganisation().externalMail, noThirdPartyTraffic());
return {
...hostFeaturesFor(usePolicyStore.getState().getOrganisation().externalMail, noThirdPartyTraffic()),
// The AI assistant's host APIs this webmail offers (lib/plugin-sandbox/assistant).
...assistantFeatures(),
};
}
/**
+6
View File
@@ -38,6 +38,7 @@ import type {
TaskView, TemplateInput, TemplateView, VacationInput, VacationView, ComposerSnapshot, ComposerAttachmentData,
} from '../plugin-types';
import { AddressBook, ContactCard } from '../jmap/types';
import { withAssistantRuntime } from './assistant/runtime';
import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-security-store';
// ─── Module-scope state ──────────────────────────────────────
@@ -424,7 +425,12 @@ interface PluginKeywordCounts {
unread: number;
}
/** The plugin's api, with the AI assistant's host APIs (lib/plugin-sandbox/assistant) added. */
function buildPluginApi(manifest: PluginManifest) {
return withAssistantRuntime(buildCoreApi(manifest), callApi);
}
function buildCoreApi(manifest: PluginManifest) {
applySettings({ ...manifest.settings });
localeTables = JSON.parse(JSON.stringify(manifest.locales ?? {}));
return {
+4 -1
View File
@@ -1,5 +1,6 @@
import type { CredentialSpec } from './plugin-secrets/credentials';
import type { CatalogSlotName } from './plugin-slot-catalog';
import { ASSISTANT_PERMISSIONS, type AssistantHostFeatures } from './plugin-sandbox/assistant/types';
// Plugin & Theme system types
@@ -956,7 +957,7 @@ export interface AppInfoView {
* that does the same job steps back while one is true. Missing on hosts
* older than 1.19.0, so read it as `api.host?.features?.x === true`.
*/
export interface HostFeaturesView {
export interface HostFeaturesView extends AssistantHostFeatures {
/** The admin turned on the "External" label on mail from outside the organisation (organisation.externalMail.senderLabel). */
externalSenderLabel: boolean;
/** The admin turned on the confirm before sending outside the organisation (organisation.externalMail.recipientWarning). */
@@ -1591,6 +1592,8 @@ export const ALL_PERMISSIONS = [
'http:proxy',
'admin:config',
'app:lifecycle',
// The AI assistant's host APIs, one list per area (lib/plugin-sandbox/assistant).
...ASSISTANT_PERMISSIONS,
] as const;
export type Permission = (typeof ALL_PERMISSIONS)[number];