From f7ff8212a3b76fc05046e2520ff081aa32247c78 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:21:06 +0100 Subject: [PATCH] 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. --- lib/__tests__/plugin-assistant-areas.test.ts | 59 ++++++++++++++++++++ lib/__tests__/plugin-host-features.test.ts | 10 +++- lib/plugin-sandbox/assistant/README.md | 27 +++++++++ lib/plugin-sandbox/assistant/b.runtime.ts | 8 +++ lib/plugin-sandbox/assistant/b.ts | 18 ++++++ lib/plugin-sandbox/assistant/b.types.ts | 9 +++ lib/plugin-sandbox/assistant/c.runtime.ts | 8 +++ lib/plugin-sandbox/assistant/c.ts | 18 ++++++ lib/plugin-sandbox/assistant/c.types.ts | 9 +++ lib/plugin-sandbox/assistant/d.runtime.ts | 8 +++ lib/plugin-sandbox/assistant/d.ts | 18 ++++++ lib/plugin-sandbox/assistant/d.types.ts | 9 +++ lib/plugin-sandbox/assistant/f.runtime.ts | 8 +++ lib/plugin-sandbox/assistant/f.ts | 18 ++++++ lib/plugin-sandbox/assistant/f.types.ts | 9 +++ lib/plugin-sandbox/assistant/index.ts | 29 ++++++++++ lib/plugin-sandbox/assistant/not-mine.ts | 2 + lib/plugin-sandbox/assistant/runtime.ts | 21 +++++++ lib/plugin-sandbox/assistant/types.ts | 15 +++++ lib/plugin-sandbox/host-api.ts | 6 ++ lib/plugin-sandbox/host-features.ts | 7 ++- lib/plugin-sandbox/runtime.tsx | 6 ++ lib/plugin-types.ts | 5 +- 23 files changed, 322 insertions(+), 5 deletions(-) create mode 100644 lib/__tests__/plugin-assistant-areas.test.ts create mode 100644 lib/plugin-sandbox/assistant/README.md create mode 100644 lib/plugin-sandbox/assistant/b.runtime.ts create mode 100644 lib/plugin-sandbox/assistant/b.ts create mode 100644 lib/plugin-sandbox/assistant/b.types.ts create mode 100644 lib/plugin-sandbox/assistant/c.runtime.ts create mode 100644 lib/plugin-sandbox/assistant/c.ts create mode 100644 lib/plugin-sandbox/assistant/c.types.ts create mode 100644 lib/plugin-sandbox/assistant/d.runtime.ts create mode 100644 lib/plugin-sandbox/assistant/d.ts create mode 100644 lib/plugin-sandbox/assistant/d.types.ts create mode 100644 lib/plugin-sandbox/assistant/f.runtime.ts create mode 100644 lib/plugin-sandbox/assistant/f.ts create mode 100644 lib/plugin-sandbox/assistant/f.types.ts create mode 100644 lib/plugin-sandbox/assistant/index.ts create mode 100644 lib/plugin-sandbox/assistant/not-mine.ts create mode 100644 lib/plugin-sandbox/assistant/runtime.ts create mode 100644 lib/plugin-sandbox/assistant/types.ts diff --git a/lib/__tests__/plugin-assistant-areas.test.ts b/lib/__tests__/plugin-assistant-areas.test.ts new file mode 100644 index 000000000..2574cd39c --- /dev/null +++ b/lib/__tests__/plugin-assistant-areas.test.ts @@ -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) => ({ 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']]]); + }); +}); diff --git a/lib/__tests__/plugin-host-features.test.ts b/lib/__tests__/plugin-host-features.test.ts index 5c76a65d5..9e0dc0abf 100644 --- a/lib/__tests__/plugin-host-features.test.ts +++ b/lib/__tests__/plugin-host-features.test.ts @@ -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) => ({ ...core, ...assistantFeatures() }); function setExternalMail(patch: Partial) { 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 diff --git a/lib/plugin-sandbox/assistant/README.md b/lib/plugin-sandbox/assistant/README.md new file mode 100644 index 000000000..cf0d413aa --- /dev/null +++ b/lib/plugin-sandbox/assistant/README.md @@ -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: + +- `.types.ts` (pure, no imports from the app): `PERMISSIONS`, the + permissions the area adds (they join `ALL_PERMISSIONS`), and + `HostFeatures`, the flags it adds to `api.host.features`. +- `.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). +- `.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.`, and docs. diff --git a/lib/plugin-sandbox/assistant/b.runtime.ts b/lib/plugin-sandbox/assistant/b.runtime.ts new file mode 100644 index 000000000..b9e5f1850 --- /dev/null +++ b/lib/plugin-sandbox/assistant/b.runtime.ts @@ -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 {}; +} diff --git a/lib/plugin-sandbox/assistant/b.ts b/lib/plugin-sandbox/assistant/b.ts new file mode 100644 index 000000000..ea6ac7e71 --- /dev/null +++ b/lib/plugin-sandbox/assistant/b.ts @@ -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> = {}; + +/** Runs a method of this area; NOT_MINE for any other. */ +export async function dispatch(_plugin: InstalledPlugin, _method: string, _args: unknown[]): Promise { + return NOT_MINE; +} + +/** This area's flags in api.host.features, right now. */ +export function features(): AreaHostFeaturesB { + return {}; +} diff --git a/lib/plugin-sandbox/assistant/b.types.ts b/lib/plugin-sandbox/assistant/b.types.ts new file mode 100644 index 000000000..41e079eb8 --- /dev/null +++ b/lib/plugin-sandbox/assistant/b.types.ts @@ -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 {} diff --git a/lib/plugin-sandbox/assistant/c.runtime.ts b/lib/plugin-sandbox/assistant/c.runtime.ts new file mode 100644 index 000000000..5e63b9136 --- /dev/null +++ b/lib/plugin-sandbox/assistant/c.runtime.ts @@ -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 {}; +} diff --git a/lib/plugin-sandbox/assistant/c.ts b/lib/plugin-sandbox/assistant/c.ts new file mode 100644 index 000000000..f0fc6c41e --- /dev/null +++ b/lib/plugin-sandbox/assistant/c.ts @@ -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> = {}; + +/** Runs a method of this area; NOT_MINE for any other. */ +export async function dispatch(_plugin: InstalledPlugin, _method: string, _args: unknown[]): Promise { + return NOT_MINE; +} + +/** This area's flags in api.host.features, right now. */ +export function features(): AreaHostFeaturesC { + return {}; +} diff --git a/lib/plugin-sandbox/assistant/c.types.ts b/lib/plugin-sandbox/assistant/c.types.ts new file mode 100644 index 000000000..cebdb0f88 --- /dev/null +++ b/lib/plugin-sandbox/assistant/c.types.ts @@ -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 {} diff --git a/lib/plugin-sandbox/assistant/d.runtime.ts b/lib/plugin-sandbox/assistant/d.runtime.ts new file mode 100644 index 000000000..0ce8066a4 --- /dev/null +++ b/lib/plugin-sandbox/assistant/d.runtime.ts @@ -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 {}; +} diff --git a/lib/plugin-sandbox/assistant/d.ts b/lib/plugin-sandbox/assistant/d.ts new file mode 100644 index 000000000..d6e69eba4 --- /dev/null +++ b/lib/plugin-sandbox/assistant/d.ts @@ -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> = {}; + +/** Runs a method of this area; NOT_MINE for any other. */ +export async function dispatch(_plugin: InstalledPlugin, _method: string, _args: unknown[]): Promise { + return NOT_MINE; +} + +/** This area's flags in api.host.features, right now. */ +export function features(): AreaHostFeaturesD { + return {}; +} diff --git a/lib/plugin-sandbox/assistant/d.types.ts b/lib/plugin-sandbox/assistant/d.types.ts new file mode 100644 index 000000000..2433c44fc --- /dev/null +++ b/lib/plugin-sandbox/assistant/d.types.ts @@ -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 {} diff --git a/lib/plugin-sandbox/assistant/f.runtime.ts b/lib/plugin-sandbox/assistant/f.runtime.ts new file mode 100644 index 000000000..f682649eb --- /dev/null +++ b/lib/plugin-sandbox/assistant/f.runtime.ts @@ -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 {}; +} diff --git a/lib/plugin-sandbox/assistant/f.ts b/lib/plugin-sandbox/assistant/f.ts new file mode 100644 index 000000000..6565ac62b --- /dev/null +++ b/lib/plugin-sandbox/assistant/f.ts @@ -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> = {}; + +/** Runs a method of this area; NOT_MINE for any other. */ +export async function dispatch(_plugin: InstalledPlugin, _method: string, _args: unknown[]): Promise { + return NOT_MINE; +} + +/** This area's flags in api.host.features, right now. */ +export function features(): AreaHostFeaturesF { + return {}; +} diff --git a/lib/plugin-sandbox/assistant/f.types.ts b/lib/plugin-sandbox/assistant/f.types.ts new file mode 100644 index 000000000..82361b441 --- /dev/null +++ b/lib/plugin-sandbox/assistant/f.types.ts @@ -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 {} diff --git a/lib/plugin-sandbox/assistant/index.ts b/lib/plugin-sandbox/assistant/index.ts new file mode 100644 index 000000000..9d73e4a8d --- /dev/null +++ b/lib/plugin-sandbox/assistant/index.ts @@ -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> = 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 { + 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())); +} diff --git a/lib/plugin-sandbox/assistant/not-mine.ts b/lib/plugin-sandbox/assistant/not-mine.ts new file mode 100644 index 000000000..3dd29ae6c --- /dev/null +++ b/lib/plugin-sandbox/assistant/not-mine.ts @@ -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'); diff --git a/lib/plugin-sandbox/assistant/runtime.ts b/lib/plugin-sandbox/assistant/runtime.ts new file mode 100644 index 000000000..8d0c42800 --- /dev/null +++ b/lib/plugin-sandbox/assistant/runtime.ts @@ -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; +/** Methods by namespace: { email: { getThread: (...) => ... } }. */ +export type RuntimeAdditions = Record unknown>>; + +/** Adds every area's methods to the plugin's api, namespace by namespace. */ +export function withAssistantRuntime(api: T, callApi: CallApi): T { + const target = api as unknown as Record>; + 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; +} diff --git a/lib/plugin-sandbox/assistant/types.ts b/lib/plugin-sandbox/assistant/types.ts new file mode 100644 index 000000000..a334c8131 --- /dev/null +++ b/lib/plugin-sandbox/assistant/types.ts @@ -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 {} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 76e9a50e7..d12b847f9 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -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([ ]); export const PERM_PER_METHOD: Readonly> = { + // 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; diff --git a/lib/plugin-sandbox/host-features.ts b/lib/plugin-sandbox/host-features.ts index 63edc8cb3..0d82ac618 100644 --- a/lib/plugin-sandbox/host-features.ts +++ b/lib/plugin-sandbox/host-features.ts @@ -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(), + }; } /** diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index daf0ee0db..63b0b361a 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -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 { diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index fa1a74b5d..a122af6c6 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -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];