diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index aec3a2d5b45c0..16bd5cba4b542 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -99,7 +99,7 @@ describe("ElectronProtocol", () => { assert.equal(yield* Effect.promise(() => response.text()), "ok"); assert.include( response.headers.get("content-security-policy") ?? "", - "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://clerk.t3.codes https://challenges.cloudflare.com", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://clerk.t3.codes https://challenges.cloudflare.com", ); assert.include( response.headers.get("content-security-policy") ?? "", @@ -111,7 +111,7 @@ describe("ElectronProtocol", () => { ); assert.include( response.headers.get("content-security-policy") ?? "", - "font-src 'self' t3code-dev: data:", + "font-src 'self' t3code-dev: blob: data:", ); }), ); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 27af3f2cfd8ae..a0ef7ab71593e 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -74,6 +74,8 @@ function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInpu "'self'", "'unsafe-inline'", "'wasm-unsafe-eval'", + // Only the page holds each environment's credentials, so plugin bundles it fetches run from blob URLs. + "blob:", ...(clerkOrigin ? [clerkOrigin] : []), "https://challenges.cloudflare.com", ]; @@ -89,7 +91,8 @@ function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInpu `img-src 'self' ${input.scheme}: blob: data: http: https:`, `media-src 'self' ${input.scheme}: blob: http: https:`, "style-src 'self' 'unsafe-inline'", - `font-src 'self' ${input.scheme}: data:`, + // `blob:` for fonts a plugin ships, which reach the page through `api.assetUrl`. + `font-src 'self' ${input.scheme}: blob: data:`, "worker-src 'self' blob:", "frame-src 'self' blob: http: https:", "object-src 'self' blob: http: https:", diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 35333ac3d33a4..afb1806e0b415 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -49,6 +49,7 @@ import { pickProjectFavicon, pickThemeFiles, setTheme, + setWindowBackground, showContextMenu, } from "./methods/window.ts"; import { @@ -139,6 +140,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(pickProjectFavicon); yield* ipc.handle(pickThemeFiles); yield* ipc.handle(setTheme); + yield* ipc.handle(setWindowBackground); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); yield* ipc.handle(receiveProviderAuthCallback); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 538dd06b0b42a..fb78038046718 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -3,6 +3,7 @@ export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge"; export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; +export const SET_WINDOW_BACKGROUND_CHANNEL = "desktop:set-window-background"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index f0d661b58bc9a..cdb065bcbff56 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -3,6 +3,7 @@ import { DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, DesktopThemeSchema, + DesktopWindowBackgroundSchema, EDITORS, EditorId, PickedThemeFileSchema, @@ -38,6 +39,7 @@ import * as ElectronTheme from "../../electron/ElectronTheme.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as Electron from "electron"; import * as MacPermissions from "../../permissions/MacPermissions.ts"; +import * as DesktopWindow from "../../window/DesktopWindow.ts"; import { safariPermissionCheck } from "../../preview/BrowserImport/SafariPermission.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; @@ -264,6 +266,17 @@ export const setTheme = DesktopIpc.makeIpcMethod({ }), }); +export const setWindowBackground = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_WINDOW_BACKGROUND_CHANNEL, + payload: DesktopWindowBackgroundSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.setWindowBackground")(function* (background) { + const appSettings = yield* DesktopAppSettings.DesktopAppSettings; + const change = yield* appSettings.setWindowBackground(background); + if (change.changed) yield* (yield* DesktopWindow.DesktopWindow).syncAppearance; + }), +}); + export const showContextMenu = DesktopIpc.makeIpcMethod({ channel: IpcChannels.CONTEXT_MENU_CHANNEL, payload: ContextMenuInput, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 950ed972c6453..29c5976fa1515 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -192,6 +192,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath), pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), + setWindowBackground: (background) => + ipcRenderer.invoke(IpcChannels.SET_WINDOW_BACKGROUND_CHANNEL, background), showContextMenu: (items, position) => ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, { items, diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 0135225f90c48..4682457cbadb7 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { DesktopWindowBackgroundSchema } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -34,6 +35,7 @@ const DesktopSettingsPatch = Schema.Struct({ wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), wslOnly: Schema.optionalKey(Schema.Boolean), + windowCanvases: Schema.optionalKey(DesktopWindowBackgroundSchema), }); const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch)); @@ -186,6 +188,7 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + windowBackground: null, } satisfies DesktopAppSettings.DesktopSettings); }), ), @@ -218,6 +221,11 @@ describe("DesktopSettings", () => { yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); yield* settings.setServerExposureMode("network-accessible"); + const windowBackground = { + light: { appearance: "light", color: "#f4f4f4" }, + dark: { appearance: "dark", color: "#151515" }, + } as const; + yield* settings.setWindowBackground(windowBackground); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), @@ -226,7 +234,9 @@ describe("DesktopSettings", () => { mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", + windowCanvases: windowBackground, } satisfies typeof DesktopSettingsPatch.Type); + assert.deepEqual((yield* settings.load).windowBackground, windowBackground); }), ), ); @@ -281,6 +291,7 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + windowBackground: null, } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, @@ -310,6 +321,7 @@ describe("DesktopSettings", () => { wslBackendEnabled: false, wslOnly: false, wslDistro: null, + windowBackground: null, } satisfies DesktopAppSettings.DesktopSettings); }), { appVersion: "0.0.17-nightly.20260415.1" }, diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index a0624ced669ea..be2b823796986 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -1,8 +1,11 @@ import { DesktopServerExposureModeSchema, DesktopUpdateChannelSchema, + DesktopWindowBackgroundSchema, type DesktopServerExposureMode, type DesktopUpdateChannel, + type DesktopWindowBackground, + type DesktopWindowCanvas, } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import { resolveSymlinkTarget } from "@t3tools/shared/symlink"; @@ -39,6 +42,8 @@ export interface DesktopSettings { readonly wslDistro: string | null; // Requires restart because the pool chooses its primary spec at layer initialization. readonly wslOnly: boolean; + /** The renderer's theme canvases, painted before the page loads. */ + readonly windowBackground: DesktopWindowBackground | null; } export interface DesktopSettingsChange { @@ -76,6 +81,7 @@ export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { wslBackendEnabled: false, wslDistro: null, wslOnly: false, + windowBackground: null, }; const DesktopWindowBoundsDocument = Schema.Struct({ @@ -99,6 +105,7 @@ const DesktopSettingsDocument = Schema.Struct({ wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), wslOnly: Schema.optionalKey(Schema.Boolean), + windowCanvases: Schema.optionalKey(Schema.Unknown), }); type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; @@ -109,6 +116,8 @@ const decodeDesktopSettingsJson = Schema.decodeEffect(DesktopSettingsJson); const encodeDesktopSettingsJson = Schema.encodeEffect(DesktopSettingsJson); const decodeDesktopWindowBounds = Schema.decodeUnknownOption(DesktopWindowBoundsSchema); const desktopWindowBoundsEquivalence = Schema.toEquivalence(DesktopWindowBoundsSchema); +const decodeDesktopWindowBackground = Schema.decodeUnknownOption(DesktopWindowBackgroundSchema); +const desktopWindowBackgroundEquivalence = Schema.toEquivalence(DesktopWindowBackgroundSchema); const settingsChange = (settings: DesktopSettings, changed: boolean): DesktopSettingsChange => ({ settings, @@ -169,6 +178,9 @@ export class DesktopAppSettings extends Context.Service< readonly setWslOnly: ( enabled: boolean, ) => Effect.Effect; + readonly setWindowBackground: ( + background: DesktopWindowBackground, + ) => Effect.Effect; readonly applyWslWindowsFallback: Effect.Effect< DesktopSettingsChange, DesktopSettingsWriteError @@ -194,6 +206,21 @@ function normalizeWslDistro(value: unknown): string | null { return typeof value === "string" && isValidDistroName(value) ? value : null; } +function normalizeWindowColor(value: unknown): string | null { + return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : null; +} + +function normalizeWindowBackground(value: unknown): DesktopWindowBackground | null { + const canvas = ({ appearance, color }: DesktopWindowCanvas): DesktopWindowCanvas => ({ + appearance, + color: normalizeWindowColor(color), + }); + return Option.match(decodeDesktopWindowBackground(value), { + onNone: () => null, + onSome: (background) => ({ light: canvas(background.light), dark: canvas(background.dark) }), + }); +} + export function normalizeMainWindowBounds(value: unknown): DesktopWindowBounds | null { return Option.getOrNull(decodeDesktopWindowBounds(value)); } @@ -230,6 +257,7 @@ function normalizeDesktopSettingsDocument( wslBackendEnabled, wslDistro: normalizeWslDistro(parsed.wslDistro), wslOnly: parsed.wslOnly === true, + windowBackground: normalizeWindowBackground(parsed.windowCanvases), }; } @@ -276,6 +304,9 @@ function toDesktopSettingsDocument( if (settings.wslOnly !== defaults.wslOnly) { document.wslOnly = settings.wslOnly; } + if (settings.windowBackground !== null) { + document.windowCanvases = settings.windowBackground; + } return document; } @@ -366,6 +397,18 @@ function setWslOnly(settings: DesktopSettings, enabled: boolean): DesktopSetting }; } +function setWindowBackground( + settings: DesktopSettings, + background: DesktopWindowBackground, +): DesktopSettings { + const normalized = normalizeWindowBackground(background); + return settings.windowBackground !== null && + normalized !== null && + desktopWindowBackgroundEquivalence(settings.windowBackground, normalized) + ? settings + : { ...settings, windowBackground: normalized }; +} + function setLocalEnvironmentEnabled(settings: DesktopSettings, enabled: boolean): DesktopSettings { return settings.localEnvironmentEnabled === enabled ? settings @@ -564,6 +607,14 @@ export const make = Effect.gen(function* () { return yield* persist((settings) => setWslOnly(settings, enabled)); }), + setWindowBackground: Effect.fn("desktop.settings.setWindowBackground")(function* (background) { + yield* Effect.annotateCurrentSpan({ + light: background.light.color, + dark: background.dark.color, + }); + + return yield* persist((settings) => setWindowBackground(settings, background)); + }), setLocalEnvironmentEnabled: Effect.fn("desktop.settings.setLocalEnvironmentEnabled")( function* (enabled) { yield* Effect.annotateCurrentSpan({ enabled }); @@ -629,6 +680,11 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET setWslOnly: Effect.fn("DesktopAppSettings.setWslOnly")(function (enabled) { return update((settings) => setWslOnly(settings, enabled)); }), + setWindowBackground: Effect.fn("DesktopAppSettings.setWindowBackground")( + function (background) { + return update((settings) => setWindowBackground(settings, background)); + }, + ), setLocalEnvironmentEnabled: Effect.fn("DesktopAppSettings.setLocalEnvironmentEnabled")( function (enabled) { return update((settings) => setLocalEnvironmentEnabled(settings, enabled)); diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index d8d16b7dbfd62..1ad68c80a222d 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -228,6 +228,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { setWslDistro: () => Effect.die("unexpected WSL distro change"), setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setWindowBackground: () => Effect.die("unexpected window background update"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 0dcb2b85ac2c5..628d82b44e539 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -267,6 +267,7 @@ function layerTest(input: { setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), setWslDistro: () => Effect.die("unexpected WSL distro change"), setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + setWindowBackground: () => Effect.die("unexpected window background update"), setLocalEnvironmentEnabled: () => Effect.die("unexpected local environment toggle"), applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), @@ -479,6 +480,33 @@ describe("DesktopWindow", () => { ); }); + it("starts windows in the canvas the page paints in the current appearance", () => { + const surface = { + light: { appearance: "light", color: "#f4f4f4" }, + dark: { appearance: "dark", color: "#151515" }, + } as const; + const pinnedDark = { + light: { appearance: "dark", color: "#151515" }, + dark: { appearance: "dark", color: "#151515" }, + } as const; + const plain = { + light: { appearance: "light", color: null }, + dark: { appearance: "dark", color: null }, + } as const; + for (const [shouldUseDarkColors, background, expected] of [ + [false, surface, { color: "#f4f4f4", dark: false }], + [true, surface, { color: "#151515", dark: true }], + [false, pinnedDark, { color: "#151515", dark: true }], + [true, plain, { color: "#000000", dark: true }], + [false, null, { color: "#ffffff", dark: false }], + ] as const) { + assert.deepEqual( + DesktopWindow.resolveWindowCanvas(shouldUseDarkColors, background), + expected, + ); + } + }); + it("rejects renderer navigations whose origin only serializes as null", () => { for (const applicationUrl of ["t3code://app/", "t3code-dev://app/"]) { assert.isTrue( diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 10801089841d1..ac3d63e391a48 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -8,7 +8,11 @@ import * as Ref from "effect/Ref"; import * as Electron from "electron"; -import { type DesktopSnapShotEvent, DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; +import { + type DesktopSnapShotEvent, + type DesktopWindowBackground, + DEFAULT_CLIENT_SETTINGS, +} from "@t3tools/contracts"; import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -131,8 +135,14 @@ function getIconOption( }); } -function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { - return shouldUseDarkColors ? "#000000" : "#ffffff"; +/** The canvas the page paints in the current native theme, so a new window does not flash first. */ +export function resolveWindowCanvas( + shouldUseDarkColors: boolean, + background: DesktopWindowBackground | null, +): { readonly color: string; readonly dark: boolean } { + const canvas = background?.[shouldUseDarkColors ? "dark" : "light"]; + const dark = canvas === undefined ? shouldUseDarkColors : canvas.appearance === "dark"; + return { color: canvas?.color ?? (dark ? "#000000" : "#ffffff"), dark }; } type DisplayBounds = Pick; @@ -174,11 +184,13 @@ export function resolveInitialMainWindowBounds( return DesktopAppSettings.DEFAULT_MAIN_WINDOW_SIZE; } -function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string { - const background = getInitialWindowBackgroundColor(shouldUseDarkColors); - const label = shouldUseDarkColors ? "#9ca3af" : "#6b7280"; - const accent = shouldUseDarkColors ? "#f8fafc" : "#1f2937"; - const track = shouldUseDarkColors ? "rgba(248,250,252,0.18)" : "rgba(31,41,55,0.18)"; +function buildConnectingSplashDataUrl({ + color: background, + dark, +}: ReturnType): string { + const label = dark ? "#9ca3af" : "#6b7280"; + const accent = dark ? "#f8fafc" : "#1f2937"; + const track = dark ? "rgba(248,250,252,0.18)" : "rgba(31,41,55,0.18)"; const html = `
Connecting to WSL…
`; return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } @@ -258,6 +270,7 @@ function getWindowTitleBarOptions( function syncWindowAppearance( window: Electron.BrowserWindow, shouldUseDarkColors: boolean, + themeBackground: DesktopWindowBackground | null, platform: NodeJS.Platform, ): Effect.Effect { return Effect.sync(() => { @@ -265,7 +278,7 @@ function syncWindowAppearance( return; } - window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); + window.setBackgroundColor(resolveWindowCanvas(shouldUseDarkColors, themeBackground).color); const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); if (typeof titleBarOverlay === "object") { window.setTitleBarOverlay(titleBarOverlay); @@ -386,7 +399,8 @@ export const make = Effect.gen(function* () { show: false, autoHideMenuBar: true, ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), - backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), + backgroundColor: resolveWindowCanvas(shouldUseDarkColors, persistedSettings.windowBackground) + .color, ...iconOption, title: environment.displayName, ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), @@ -896,7 +910,10 @@ export const make = Effect.gen(function* () { const existingWindow = yield* electronWindow.currentMainOrFirst; if (Option.isSome(existingWindow)) return; - const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const canvas = resolveWindowCanvas( + yield* electronTheme.shouldUseDarkColors, + (yield* desktopSettings.get).windowBackground, + ); if (yield* Ref.get(desktopState.quitting)) return; const splash = yield* electronWindow.create({ width: 360, @@ -909,7 +926,7 @@ export const make = Effect.gen(function* () { center: true, show: false, skipTaskbar: false, - backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), + backgroundColor: canvas.color, title: environment.displayName, webPreferences: { contextIsolation: true, @@ -931,7 +948,7 @@ export const make = Effect.gen(function* () { splash.show(); } }); - void splash.loadURL(buildConnectingSplashDataUrl(shouldUseDarkColors)); + void splash.loadURL(buildConnectingSplashDataUrl(canvas)); yield* logWindowInfo("connecting splash shown"); }).pipe( Effect.catch((error) => @@ -1038,8 +1055,9 @@ export const make = Effect.gen(function* () { }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const { windowBackground } = yield* desktopSettings.get; yield* electronWindow.syncAllAppearance((window) => - syncWindowAppearance(window, shouldUseDarkColors, environment.platform), + syncWindowAppearance(window, shouldUseDarkColors, windowBackground, environment.platform), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), }); diff --git a/apps/server/package.json b/apps/server/package.json index 0dacbbe90720c..a06e2346d534c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -77,6 +77,7 @@ "@effect/vitest": "catalog:", "@opencode-ai/sdk": "^1.3.15", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk": "workspace:*", "@t3tools/provider-testing": "workspace:*", "@t3tools/secret-provider-core": "workspace:*", "@t3tools/secret-provider-definitions": "workspace:*", diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 8044740a7e2ee..d43b5a5c2f734 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -68,6 +68,16 @@ describe("RPC authorization scopes", () => { } }); + it("keeps enabling, reloading and configuring plugins, which runs their code, behind settings write", () => { + for (const method of [ + WS_METHODS.pluginsSetEnabled, + WS_METHODS.pluginsReload, + WS_METHODS.pluginsSettingsUpdate, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthSettingsWriteScope); + } + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6049121b41a5d..0fb91a610c9e1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -205,6 +205,13 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.deviceDetail]: AuthOrchestrationReadScope, [WS_METHODS.deviceAction]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope, + // `plugins.invoke` also needs the operate scope for methods a plugin registered as writes; + // PluginHost checks that against the plugin's own declaration. Channels are reads. + [WS_METHODS.pluginsList]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSubscribeStates]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSettingsGet]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsInvoke]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSubscribe]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope, [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 72d7f0c2de7ef..b2b2d6956398d 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -243,6 +243,7 @@ export const make = Effect.gen(function* () { threadSnooze: true, threadForceStop: true, environmentThemes: true, + plugins: true, usageLimitSources: true, providerWorkspaceSnapshotsOnDemand: true, usagePriceOverrides: true, diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts index e6b2e1bc67921..4ab2a66767c5c 100644 --- a/apps/server/src/environmentTheme.test.ts +++ b/apps/server/src/environmentTheme.test.ts @@ -90,6 +90,63 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ), ); + it.effect("adds plugin themes after the environment's own, which win on the same id", () => + withEnvironmentThemes( + { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, + Effect.gen(function* () { + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const pluginThemes = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-plugin-themes-" }); + yield* fs.writeFileString( + path.join(pluginThemes, "nightfall.json"), + encodeThemeFile({ ...NIGHTFALL_THEME, name: "Plugin Nightfall" }), + ); + yield* fs.writeFileString( + path.join(pluginThemes, "shared-light.json"), + encodeThemeFile(SHARED_THEME), + ); + + yield* environmentTheme.setPluginThemeDirectories([pluginThemes]); + // Marked, so clients show a plugin's themes only where they load its UI. + assert.deepEqual( + (yield* currentThemes).map((theme) => [theme.id, theme.name, theme.fromPlugin]), + [ + ["nightfall", "Nightfall", undefined], + ["shared-light", "Shared Light", true], + ], + ); + + // The same folders read again, so a plugin restarted with edited files reaches windows. + const seen = yield* Queue.unbounded>(); + yield* Stream.runForEach(environmentTheme.streamChanges, (themes) => + Queue.offer(seen, themes), + ).pipe(Effect.forkScoped); + yield* Queue.take(seen); + yield* fs.writeFileString( + path.join(pluginThemes, "shared-light.json"), + encodeThemeFile({ ...SHARED_THEME, name: "Shared Light Edited" }), + ); + // Only setting the folders reads them, so a client connecting doesn't read every plugin's. + assert.deepEqual( + (yield* currentThemes).map((theme) => theme.name), + ["Nightfall", "Shared Light"], + ); + yield* environmentTheme.setPluginThemeDirectories([pluginThemes]); + assert.deepEqual( + (yield* Queue.take(seen)).map((theme) => theme.name), + ["Nightfall", "Shared Light Edited"], + ); + + yield* environmentTheme.setPluginThemeDirectories([]); + assert.deepEqual( + (yield* currentThemes).map((theme) => theme.id), + ["nightfall"], + ); + }), + ), + ); + it.effect("never replays a set older than the snapshot it started from", () => withEnvironmentThemes( { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, @@ -158,6 +215,28 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ), ); + it.effect.skipIf(!symlinksSupported)("ignores a plugin's themes folder linked out of it", () => + withEnvironmentThemes( + {}, + Effect.gen(function* () { + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-plugin-themes-" }); + const outside = path.join(base, "outside"); + yield* fs.makeDirectory(outside); + yield* fs.writeFileString( + path.join(outside, "nightfall.json"), + encodeThemeFile(NIGHTFALL_THEME), + ); + yield* fs.makeDirectory(path.join(base, "plugin")); + yield* fs.symlink(outside, path.join(base, "plugin", "themes")); + yield* environmentTheme.setPluginThemeDirectories([path.join(base, "plugin", "themes")]); + assert.deepEqual(yield* currentThemes, []); + }), + ), + ); + it.effect("does not charge skipped files against the total size limit", () => withEnvironmentThemes( { diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index f0fb44b2bfda7..c47943bbe4671 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -17,6 +17,8 @@ import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -50,6 +52,14 @@ export class EnvironmentThemeService extends Context.Service< /** Subscribes before reading to retain changes during connection. */ readonly streamChanges: Stream.Stream>; + + /** + * Theme folders of active plugins, each `/themes`, read after the environment's + * own folder; one linked out of its plugin folder is skipped. The first theme with an id wins, + * so a plugin cannot replace a theme the user published. Each call reads the folders, so a + * plugin that restarts with edited files publishes them, and nothing else reads them again. + */ + readonly setPluginThemeDirectories: (directories: ReadonlyArray) => Effect.Effect; } >()("t3/environmentTheme/EnvironmentThemeService") {} @@ -155,32 +165,58 @@ export const readPublishedThemes = Effect.fn(function* (themesDir: string) { const make = Effect.gen(function* () { const { environmentThemesDir } = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Untraced: it runs for each plugin each time the folders are set. + const insideItsPlugin = Effect.fnUntraced(function* (directory: string) { + const real = yield* fs.realPath(directory).pipe(Effect.option); + const root = yield* fs.realPath(path.dirname(directory)).pipe(Effect.option); + return ( + Option.isSome(real) && + Option.isSome(root) && + real.value.startsWith(`${root.value}${path.sep}`) + ); + }); // Sequence numbers let subscribers drop queued events older than their initial snapshot. const changes = yield* PubSub.sliding(1); const published = yield* Ref.make({ seq: 0, themes: [] }); + const pluginThemes = yield* Ref.make>([]); // Serialize read/compare/publish so slow older reads cannot publish with newer sequence numbers. const refreshSemaphore = yield* Semaphore.make(1); const watcherScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); - const refresh = refreshSemaphore.withPermits(1)( - Effect.gen(function* () { - const themes = yield* readPublishedThemes(environmentThemesDir).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - ); - // Compare the whole value so new fields cannot silently miss republishes. - const [changed, next] = yield* Ref.modify( - published, - (previous): readonly [readonly [boolean, PublishedThemes], PublishedThemes] => { - if (Equal.equals(previous.themes, themes)) return [[false, previous], previous]; - const updated: PublishedThemes = { seq: previous.seq + 1, themes }; - return [[true, updated], updated]; - }, - ); - if (changed) yield* PubSub.publish(changes, next).pipe(Effect.asVoid); - return next; - }), - ); + const readThemes = (directory: string) => + readPublishedThemes(directory).pipe(Effect.provideService(FileSystem.FileSystem, fs)); + const readPluginThemes = Effect.fnUntraced(function* (directories: ReadonlyArray) { + const themes: Array = []; + for (const directory of directories) { + if (!(yield* insideItsPlugin(directory))) continue; + for (const theme of yield* readThemes(directory)) themes.push({ ...theme, fromPlugin: true }); + } + return themes; + }); + + const publishUnlocked = Effect.gen(function* () { + const themes = yield* readThemes(environmentThemesDir); + const ids = new Set(themes.map((theme) => theme.id)); + for (const theme of yield* Ref.get(pluginThemes)) { + if (ids.has(theme.id)) continue; + ids.add(theme.id); + themes.push(theme); + } + // Compare the whole value so new fields cannot silently miss republishes. + const [changed, next] = yield* Ref.modify( + published, + (previous): readonly [readonly [boolean, PublishedThemes], PublishedThemes] => { + if (Equal.equals(previous.themes, themes)) return [[false, previous], previous]; + const updated: PublishedThemes = { seq: previous.seq + 1, themes }; + return [[true, updated], updated]; + }, + ); + if (changed) yield* PubSub.publish(changes, next).pipe(Effect.asVoid); + return next; + }); + const refresh = refreshSemaphore.withPermits(1)(publishUnlocked); yield* fs .makeDirectory(environmentThemesDir, { recursive: true }) @@ -198,6 +234,16 @@ const make = Effect.gen(function* () { return { current: Effect.map(refresh, (state) => state.themes), + setPluginThemeDirectories: Effect.fn("EnvironmentThemeService.setPluginThemeDirectories")( + function* (directories) { + yield* refreshSemaphore.withPermits(1)( + readPluginThemes(directories).pipe( + Effect.flatMap((themes) => Ref.set(pluginThemes, themes)), + Effect.andThen(publishUnlocked), + ), + ); + }, + ), get streamChanges() { return Stream.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 5f7df20c738c8..8138850282bfb 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -18,6 +18,7 @@ import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "e import { ASSET_ROUTE_PREFIX } from "./assets/AssetAccess.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX } from "./assets/AttachmentUpload.ts"; import { openMediaFile } from "./assets/MediaFile.ts"; +import { PLUGIN_UPLOAD_ROUTE_PREFIX } from "./plugins/pluginUploads.ts"; import { injectMcpAppCsp, @@ -164,6 +165,7 @@ describe("untraced requests", () => { `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`, HttpServerResponse.empty(), ); + yield* router.add("POST", `${PLUGIN_UPLOAD_ROUTE_PREFIX}/*`, HttpServerResponse.empty()); yield* router.add("GET", "/api/environment", HttpServerResponse.empty()); }), ); @@ -182,6 +184,7 @@ describe("untraced requests", () => { HttpClientRequest.get(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png?v=1`), HttpClientRequest.post(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/payload.signature`), HttpClientRequest.post(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/payload.signature?v=1`), + HttpClientRequest.post(`${PLUGIN_UPLOAD_ROUTE_PREFIX}/payload.signature`), HttpClientRequest.head(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png`), ]) { expect((yield* client.execute(request)).status).toBe(204); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index e792a233c6f06..b1fe75f983754 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -48,6 +48,7 @@ import { failEnvironmentInternal, } from "./auth/http.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import { PLUGIN_UPLOAD_ROUTE_PREFIX } from "./plugins/pluginUploads.ts"; import { WEBHOOK_ROUTE_PREFIX } from "./scheduledTasks/ScheduledTaskService.ts"; import { browserApiCorsAllowedHeaders, @@ -288,7 +289,7 @@ function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { return redirectUrl.toString(); } -const authenticateRawRouteWithScope = +export const authenticateRawRouteWithScope = /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* ( scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, @@ -396,7 +397,8 @@ const layerUntracedRequests = Layer.succeed(HttpMiddleware.TracerDisabledWhen)(( UNTRACED_REQUEST_PATHS.has(path) || path.startsWith(`${WEBHOOK_ROUTE_PREFIX}/`) || path.startsWith(`${ASSET_ROUTE_PREFIX}/`) || - path.startsWith(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`) + path.startsWith(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`) || + path.startsWith(`${PLUGIN_UPLOAD_ROUTE_PREFIX}/`) ); }); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index e684316f48d9a..39a67768013cd 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -31,6 +31,7 @@ import * as HtmlRender from "../htmlRender/HtmlRender.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as PluginMcpTools from "../plugins/PluginMcpTools.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { type AgentThread, @@ -1140,6 +1141,59 @@ export const layerDeviceToolkit = Layer.mergeAll( layerDeviceScreenshotRegistration, ); +const isJsonObject = Schema.is(Schema.JsonObject); + +const pluginToolResult = (output: Schema.Json) => + new McpSchema.CallToolResult({ + isError: false, + ...(isJsonObject(output) ? { structuredContent: output } : {}), + content: [{ type: "text", text: JSON.stringify(output) }], + }); + +const pluginToolFailure = (error: { readonly message: string }) => + new McpSchema.CallToolResult({ isError: true, content: [{ type: "text", text: error.message }] }); + +/** + * Plugins add tools while the server runs, so they register here directly instead of through + * a toolkit. Kept out of `layerTools`: with no plugin installed there are none to measure. + */ +export const layerPluginTools = Layer.effectDiscard( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const plugins = yield* PluginMcpTools.PluginMcpTools; + const threads = yield* ThreadManagementService.ThreadManagementService; + yield* plugins.attach((entry) => { + const call = McpToolAccess.dynamic(entry.access, (input: Schema.JsonObject) => + McpInvocationContext.McpInvocationContext.pipe( + Effect.flatMap((scope) => + entry.call(input, { threadId: scope.thread?.threadId ?? null }), + ), + ), + ); + return server.addTool({ + tool: new McpSchema.Tool({ + name: entry.name, + description: entry.description, + inputSchema: entry.inputSchema, + annotations: { readOnlyHint: entry.access === "read" }, + }), + annotations: Context.make(McpSchema.EnabledWhen, () => plugins.isOffered(entry.name)), + handle: (payload) => + Effect.withFiber((fiber) => + call(isJsonObject(payload) ? payload : {}).pipe( + Effect.provideService( + McpInvocationContext.McpInvocationContext, + Context.getUnsafe(fiber.context, McpInvocationContext.McpInvocationContext), + ), + Effect.provideService(ThreadManagementService.ThreadManagementService, threads), + Effect.match({ onFailure: pluginToolFailure, onSuccess: pluginToolResult }), + ), + ), + }); + }); + }), +).pipe(Layer.provide(McpServer.McpServer.layer)); + export const layerMcpTransport = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, @@ -1148,7 +1202,7 @@ export const layerMcpTransport = McpServer.layerHttp({ allowSessionTermination: true, }).pipe(Layer.provide(layerMcpAuthMiddleware)); -/** Every tool the T3 MCP server advertises. AgentContextBudget.test.ts measures this set. */ +/** Every built-in tool the T3 MCP server advertises. AgentContextBudget.test.ts measures this set. */ export const layerTools = Layer.mergeAll( layerPreviewToolkit, layerOrchestratorToolkit, @@ -1163,4 +1217,6 @@ export const layerTools = Layer.mergeAll( layerHtmlToolkit, ); -export const layer = layerTools.pipe(Layer.provideMerge(layerMcpTransport)); +export const layer = Layer.merge(layerTools, layerPluginTools).pipe( + Layer.provideMerge(layerMcpTransport), +); diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index b82d9bb5ae59c..2837497e8a819 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -173,6 +173,41 @@ export const writesWithCapability = (handle: (params: P) => Effect.E () => writingCaller, ); +const pluginToolFullAccessRequired = + "Plugin tools that change things need this thread in Full access and out of Plan mode, or an outside agent approved with Full access. Ask the user to switch, then call again."; + +/** + * For tools whose access is data, registered while the server runs (plugin tools): `read` + * checks like `reads`; `write` like `writesEnvironment`, because a plugin tool can do anything + * the plugin can, and its handler never sees the caller's limits. Returns the checked handler. + */ +export const dynamic = ( + access: "read" | "write", + handle: (params: P) => Effect.Effect, +) => { + const fullAccess = (caller: Caller) => assertFullAccess(caller, pluginToolFullAccessRequired); + const declaration: Declaration< + (params: P) => Effect.Effect + > = + access === "read" + ? reads(handle) + : declare( + (params: P) => + orchestratingCaller.pipe( + Effect.tap((caller) => + assertNotLinked(caller, "use plugin tools that change things"), + ), + Effect.tap(fullAccess), + Effect.flatMap(() => handle(params)), + ), + // Only the handler leaves `dynamic`, and remote checks are read where toolkits route calls. + undefined, + ); + return checkedHandler(declaration); +}; + +type WritingServices = Effect.Services; + export const writesThreads = ( threads: (params: P) => ReadonlyArray, handle: (params: P) => Effect.Effect, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 1e3388e4136ad..ef4ee80cccad2 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -87,6 +87,17 @@ const RPC_AGGREGATES = { [WS_METHODS.serverRefreshUsageRates]: "server", [WS_METHODS.serverSignalProcess]: "server", [WS_METHODS.serverReportClientActivity]: "server", + [WS_METHODS.pluginsList]: "plugins", + [WS_METHODS.pluginsSubscribeStates]: "plugins", + [WS_METHODS.pluginsSetEnabled]: "plugins", + [WS_METHODS.pluginsReload]: "plugins", + [WS_METHODS.pluginsCreateUploadUrl]: "plugins", + [WS_METHODS.pluginsRescan]: "plugins", + [WS_METHODS.pluginsUninstall]: "plugins", + [WS_METHODS.pluginsSettingsGet]: "plugins", + [WS_METHODS.pluginsSettingsUpdate]: "plugins", + [WS_METHODS.pluginsInvoke]: "plugins", + [WS_METHODS.pluginsSubscribe]: "plugins", [WS_METHODS.scheduledTasksSubscribe]: "scheduledTasks", [WS_METHODS.scheduledTasksUpsert]: "scheduledTasks", [WS_METHODS.scheduledTasksSetEnabled]: "scheduledTasks", diff --git a/apps/server/src/plugins/PluginApi.ts b/apps/server/src/plugins/PluginApi.ts new file mode 100644 index 0000000000000..c44bf13f59f93 --- /dev/null +++ b/apps/server/src/plugins/PluginApi.ts @@ -0,0 +1,142 @@ +import { PluginRpcName, type PluginId } from "@t3tools/contracts"; +import type { PluginApi, PluginRpcAccess, PluginRpcHandler } from "@t3tools/plugin-sdk/server"; +import type * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import type * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; + +import { makeFacadeContext, pluginCodeScope, refused } from "./api/facade.ts"; +import { makeMcpApi } from "./api/mcp.ts"; +import type * as PluginMcpTools from "./PluginMcpTools.ts"; +import type * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import type * as PluginStorage from "./PluginStorage.ts"; + +const isJson = Schema.is(Schema.Json); +const isPluginRpcName = Schema.is(PluginRpcName); +const isPluginRpcAccess = Schema.is(Schema.Literals(["read", "write"])); + +/** Everything one activation registered. The host routes calls through it and tears it down. */ +export interface PluginActivation { + readonly methods: Map< + string, + { readonly handler: PluginRpcHandler; readonly access: PluginRpcAccess } + >; + readonly cleanups: Array<() => void | Promise>; + /** Aborted when deactivation starts; handler signals and iterators follow it. */ + readonly controller: AbortController; + /** Aborted once the cleanups have run; API calls are refused from then on. */ + readonly ended: AbortController; + /** + * Each subscriber's buffer, by channel, so a busy channel or a slow subscriber drops only its + * own values. The host ends them when `ended` aborts. + */ + readonly channels: Map>>; +} + +export const makePluginActivation = (): PluginActivation => ({ + methods: new Map(), + cleanups: [], + controller: new AbortController(), + ended: new AbortController(), + channels: new Map(), +}); + +const assertActive = (activation: PluginActivation) => { + if (activation.controller.signal.aborted) { + throw new Error("This plugin activation has ended."); + } +}; + +/** The host services the facade reaches. The host builds this once and shares it. */ +export interface PluginApiServices { + readonly settings: PluginSettingsStore.PluginSettingsStore["Service"]; + readonly storage: PluginStorage.PluginStorage["Service"]; + readonly mcpTools: PluginMcpTools.PluginMcpTools["Service"]; +} + +/** + * Builds the object a plugin's `activate` receives. Effect stays on this side: every call runs + * with the host's services and settles as a plain Promise that rejects with an `Error`. + */ +export const makePluginApi = (input: { + readonly id: PluginId; + readonly rootDir: string; + readonly settingsSchema: Schema.JsonObject | undefined; + readonly activation: PluginActivation; + readonly context: Context.Context; + readonly services: PluginApiServices; +}): PluginApi => { + const { id, activation, context, services } = input; + const facade = makeFacadeContext({ pluginId: id, activation, context }); + const { call } = facade; + const log = + (level: "Debug" | "Info" | "Warn" | "Error") => (message: string, fields?: Schema.JsonObject) => + facade.fork( + Effect.logWithLevel(level)(message, fields ?? {}).pipe( + Effect.annotateLogs({ pluginId: id }), + ), + ); + + const storage: PluginApi["storage"] = { + get: (key) => call(services.storage.get(id, key).pipe(Effect.map(Option.getOrUndefined))), + set: (key, value) => { + if (!isJson(value)) return refused(new TypeError("Storage values must be JSON.")); + return call(services.storage.set(id, key, value)); + }, + delete: (key) => call(services.storage.remove(id, key)), + list: (prefix) => call(services.storage.list(id, prefix)), + }; + + return { + version: 1, + id, + rootDir: input.rootDir, + log: { debug: log("Debug"), info: log("Info"), warn: log("Warn"), error: log("Error") }, + settings: { + get: () => call(services.settings.get(id, input.settingsSchema)), + changes: () => facade.iterate(services.settings.changes(id)), + }, + storage, + rpc: { + register: (method, handler, options) => { + assertActive(activation); + if (!isPluginRpcName(method)) + throw new TypeError(`"${method}" is not a valid method name.`); + // Plugins are untyped JavaScript, and a misspelled access level must not open a write. + if (!isPluginRpcAccess(options.access)) { + throw new TypeError(`Method "${method}" needs access "read" or "write".`); + } + if (activation.methods.has(method)) { + throw new Error(`Method "${method}" is already registered.`); + } + activation.methods.set(method, { handler, access: options.access }); + }, + publish: (channel, value) => { + assertActive(activation); + if (!isPluginRpcName(channel)) { + throw new TypeError(`"${channel}" is not a valid channel name.`); + } + if (!isJson(value)) throw new TypeError("Published values must be JSON."); + // Waking a subscriber schedules T3's work, which must not run in the plugin's scope. + pluginCodeScope.exit(() => { + for (const queue of activation.channels.get(channel) ?? []) { + Queue.offerUnsafe(queue, value); + } + }); + }, + }, + // Something created after deactivation has nothing left to wait for, so it cleans up now. + onDeactivate: (cleanup) => { + if (activation.controller.signal.aborted) { + void Promise.resolve() + .then(cleanup) + .catch(() => undefined); + return; + } + activation.cleanups.push(cleanup); + }, + mcp: makeMcpApi(facade, services), + }; +}; diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts new file mode 100644 index 0000000000000..489a7c9aec8d7 --- /dev/null +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -0,0 +1,1130 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + applyPluginStatesEvent, + type AuthEnvironmentScope, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + AuthSettingsWriteScope, + PluginId, + type PluginStatesEvent, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import * as EnvironmentTheme from "../environmentTheme.ts"; +import * as PluginHost from "./PluginHost.ts"; +import { + fixturesDir, + layerConfig, + layerFileSystemWith, + layerHost, + stateOf, + waitForStatuses, + writeFile, + writeInstalledPlugin, + writePluginsConfig, +} from "./pluginHostTestHarness.ts"; +import * as PluginInstaller from "./PluginInstaller.ts"; +import { makePluginRpcHandlers } from "./pluginRpcHandlers.ts"; +import * as PluginStorage from "./PluginStorage.ts"; +import * as PluginUploads from "./pluginUploads.ts"; + +const hello = PluginId.make("hello-plugin"); +const broken = PluginId.make("broken-plugin"); +const operate = [AuthOrchestrationReadScope, AuthOrchestrationOperateScope] as const; + +const manifestJson = ( + id: string, + options: { + readonly name?: string; + readonly version?: string; + readonly engines?: string; + readonly settingsSchema?: object; + } = {}, +) => + JSON.stringify({ + id, + name: options.name ?? id, + version: options.version ?? "0.1.0", + engines: { t3: options.engines ?? ">=0.1.0" }, + settingsSchema: options.settingsSchema, + }); + +const enableHello = writePluginsConfig({ + enabled: { [hello]: true }, + devPaths: [`${fixturesDir}hello-plugin`], +}); + +/** A `linked` plugin in a checkout folder, which the plugins folder holds a symlink to. */ +const linkPluginFolder = Effect.fn(function* (entries: { + readonly server?: string; + readonly web?: string; +}) { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const realDir = `${stateDir}/checkout/linked`; + yield* writeFile( + `${realDir}/t3-plugin.json`, + JSON.stringify({ + id: "linked", + name: "Linked", + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + ...entries, + }), + ); + yield* fs.makeDirectory(`${stateDir}/plugins`, { recursive: true }); + yield* fs.symlink(realDir, `${stateDir}/plugins/linked`); + return realDir; +}); + +const leaky = PluginId.make("leaky"); +// Fails outside a call: in a timer a method sets, or in a read a method never awaits. +const leakyServer = `module.exports = { activate(api) { + api.rpc.register("rejectLater", () => { setTimeout(() => { Promise.reject(new Error("rejected in a timer")); }, 0); }, { access: "read" }); + api.rpc.register("readMissing", () => { void require("node:fs/promises").readFile("/t3-plugin-test-missing-file"); }, { access: "read" }); +} };`; + +/** The next failure outside a call that the plugin's row shows. */ +const nextServerFailure = (host: PluginHost.PluginHost["Service"], pluginId: PluginId) => + host.streamStates.pipe( + Stream.map((states) => stateOf(states, pluginId)?.serverFailure), + Stream.filter((failure) => failure !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + +it.layer(NodeServices.layer)("PluginHost", (it) => { + it.effect("activates an enabled plugin and serves its methods with the caller's access", () => + Effect.gen(function* () { + yield* enableHello; + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { [hello]: "active" }); + assert.include(stateOf(states, hello), { source: "dev", hasServer: true, enabled: true }); + + const ping = (scopes: ReadonlyArray) => + host.invoke({ pluginId: hello, method: "ping", input: "a", scopes }); + assert.deepStrictEqual(yield* ping(operate), { + pong: "a", + greeting: "hello", + access: "write", + }); + // A read method tells a session that can only read from one that can also write. + assert.deepStrictEqual(yield* ping([AuthOrchestrationReadScope]), { + pong: "a", + greeting: "hello", + access: "read", + }); + + const count = (scopes: ReadonlyArray) => + host.invoke({ pluginId: hello, method: "count", input: undefined, scopes }); + const counted = yield* host + .subscribe({ pluginId: hello, channel: "counted" }) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + assert.strictEqual(yield* count(operate), 1); + assert.deepStrictEqual(yield* Fiber.join(counted), Option.some(1)); + const denied = yield* count([AuthOrchestrationReadScope]).pipe(Effect.flip); + assert.strictEqual(denied._tag, "PluginAccessDeniedError"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("hands an active plugin its saved settings and tells it when they change", () => + Effect.gen(function* () { + yield* enableHello; + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { [hello]: "active" }); + assert.include(stateOf(states, hello), { hasSettings: true }); + + const changed = yield* host + .subscribe({ pluginId: hello, channel: "settings" }) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + yield* host.updateSettings(hello, { greeting: "hey" }); + assert.deepStrictEqual(yield* Fiber.join(changed), Option.some({ greeting: "hey" })); + assert.deepInclude( + yield* host.invoke({ pluginId: hello, method: "ping", input: "b", scopes: operate }), + { greeting: "hey" }, + ); + // The settings form's schema comes with the settings, not on every row. + const { settings, settingsSchema } = yield* host.getSettings(hello); + assert.deepStrictEqual(settings, { greeting: "hey" }); + assert.include(settingsSchema, { additionalProperties: false }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("marks a plugin that throws in activate as failed and keeps the others running", () => + Effect.gen(function* () { + yield* writePluginsConfig({ + enabled: { [hello]: true, [broken]: true }, + devPaths: [`${fixturesDir}broken-plugin`, `${fixturesDir}hello-plugin`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { [broken]: "failed", [hello]: "active" }); + assert.deepStrictEqual(stateOf(states, broken)?.failure, { + stage: "activate", + message: "broken-plugin refuses to start", + }); + const missing = yield* host + .invoke({ pluginId: broken, method: "ping", input: undefined, scopes: operate }) + .pipe(Effect.flip); + assert.strictEqual(missing._tag, "PluginNotActiveError"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("fails a plugin with server code below Node 24 and runs one without", () => + Effect.gen(function* () { + const styles = PluginId.make("styles-plugin"); + yield* writePluginsConfig({ + enabled: { [hello]: true, [styles]: true }, + devPaths: [`${fixturesDir}hello-plugin`, `${fixturesDir}styles-plugin`], + }); + // As `npx t3` runs on Node 22. + yield* Effect.acquireRelease( + Effect.sync(() => { + const running = process.versions.node; + Object.defineProperty(process.versions, "node", { value: "22.16.0" }); + return running; + }), + (running) => + Effect.sync(() => Object.defineProperty(process.versions, "node", { value: running })), + ); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { [hello]: "failed", [styles]: "active" }); + assert.deepStrictEqual(stateOf(states, hello)?.failure, { + stage: "compatibility", + message: "Needs Node 24 or later for its server code; this server runs Node 22.16.0.", + }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.scoped, Effect.provide(layerConfig())), + ); + + it.effect("runs cleanup on disable and stops serving the plugin", () => + Effect.gen(function* () { + yield* writePluginsConfig({ + enabled: { [hello]: true }, + devPaths: [`${fixturesDir}hello-plugin`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const storage = yield* PluginStorage.PluginStorage; + yield* waitForStatuses(host, { [hello]: "active" }); + + const disabled = yield* host.setEnabled(hello, false); + assert.include(disabled, { status: "disabled", enabled: false }); + assert.deepStrictEqual(yield* storage.get(hello, "deactivated"), Option.some(true)); + const error = yield* host + .invoke({ pluginId: hello, method: "ping", input: undefined, scopes: operate }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PluginNotActiveError"); + + const { stateDir } = yield* ServerConfig.ServerConfig; + const saved = yield* FileSystem.FileSystem.use((fs) => + fs.readFileString(`${stateDir}/plugins.json`), + ); + assert.deepStrictEqual(JSON.parse(saved).enabled, { [hello]: false }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("reload picks up a changed manifest and changed code", () => + Effect.gen(function* () { + const rootDir = yield* writeInstalledPlugin( + "reloading", + `module.exports = { activate(api) { api.rpc.register("which", () => "v1", { access: "read" }); } };`, + ); + yield* writePluginsConfig({ enabled: { reloading: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("reloading"); + yield* waitForStatuses(host, { reloading: "active" }); + const which = host.invoke({ + pluginId: id, + method: "which", + input: undefined, + scopes: operate, + }); + assert.strictEqual(yield* which, "v1"); + + yield* writeFile( + `${rootDir}/t3-plugin.json`, + JSON.stringify({ + id: "reloading", + name: "Reloaded", + version: "0.2.0", + engines: { t3: ">=0.1.0" }, + server: "./server.cjs", + }), + ); + yield* writeFile( + `${rootDir}/server.cjs`, + `module.exports = { activate(api) { api.rpc.register("which", () => "v2", { access: "read" }); } };`, + ); + const reloaded = yield* host.reload(id); + assert.include(reloaded, { status: "active", version: "0.2.0", name: "Reloaded" }); + assert.strictEqual(yield* which, "v2"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("fails a plugin that registers a method with an unknown access level", () => + Effect.gen(function* () { + const rootDir = yield* writeInstalledPlugin( + "typo", + `module.exports = { activate(api) { api.rpc.register("go", () => 1, { access: "Write" }); } };`, + ); + yield* writeFile(`${rootDir}/styles/base.css`, "body {}"); + yield* writePluginsConfig({ enabled: { typo: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { typo: "failed" }); + assert.deepStrictEqual(stateOf(states, "typo")?.failure, { + stage: "activate", + message: 'Method "go" needs access "read" or "write".', + }); + // Nor does it serve its files. + const served = yield* host.readWebFile({ + pluginId: "typo", + path: "/styles/base.css", + revision: undefined, + }); + assert.isTrue(Option.isNone(served)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("reload stops a plugin whose folder was removed", () => + Effect.gen(function* () { + const stays = yield* writeInstalledPlugin("stays", `module.exports = { activate() {} };`); + const goes = yield* writeInstalledPlugin( + "goes", + `const marker = require("node:path").join(__dirname, "..", "..", "goes-cleaned"); +module.exports = { activate(api) { api.onDeactivate(() => require("node:fs").writeFileSync(marker, "")); } };`, + ); + yield* writePluginsConfig({ enabled: { stays: true, goes: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const fs = yield* FileSystem.FileSystem; + yield* waitForStatuses(host, { stays: "active", goes: "active" }); + yield* fs.remove(goes, { recursive: true }); + + yield* host.reload(PluginId.make("stays")); + + assert.isTrue(yield* fs.exists(`${stays}/../../goes-cleaned`)); + assert.isUndefined(stateOf(yield* host.list, "goes")); + + // A folder copied in later with its id is a plugin the user hasn't seen, so it starts off, + // and reloading the gone plugin still lists it. + yield* writeInstalledPlugin("goes", `module.exports = { activate() {} };`); + const missing = yield* host.reload(PluginId.make("arrives")).pipe(Effect.flip); + assert.strictEqual(missing._tag, "PluginNotFoundError"); + assert.include(stateOf(yield* host.list, "goes"), { status: "disabled", enabled: false }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("reload runs new code from a symlinked plugin folder", () => + Effect.gen(function* () { + const realDir = yield* linkPluginFolder({ server: "./server.cjs" }); + const plugin = (version: string) => + `module.exports = { activate(api) { api.rpc.register("which", () => "${version}", { access: "read" }); } };`; + yield* writeFile(`${realDir}/server.cjs`, plugin("v1")); + yield* writePluginsConfig({ enabled: { linked: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("linked"); + const which = host.invoke({ + pluginId: id, + method: "which", + input: undefined, + scopes: operate, + }); + yield* waitForStatuses(host, { linked: "active" }); + assert.strictEqual(yield* which, "v1"); + + yield* writeFile(`${realDir}/server.cjs`, plugin("v2")); + yield* host.reload(id); + assert.strictEqual(yield* which, "v2"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("serves no file of an assets folder linked out of a symlinked plugin folder", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const realDir = yield* linkPluginFolder({ web: "./web.js" }); + yield* writeFile(`${realDir}/web.js`, "export default {};"); + yield* writeFile(`${stateDir}/checkout/outside/secret.txt`, "Not the plugin's."); + yield* fs.symlink(`${stateDir}/checkout/outside`, `${realDir}/assets`); + yield* writePluginsConfig({ enabled: { linked: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { linked: "active" }); + const served = (path: string) => + host + .readWebFile({ pluginId: "linked", path, revision: undefined }) + .pipe(Effect.map(Option.isSome)); + assert.isTrue(yield* served("/web.js")); + assert.isFalse(yield* served("/assets/secret.txt")); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("unlinks a plugin loaded from devPaths and leaves its folder", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const folder = `${stateDir}/checkout/linked`; + yield* writeFile(`${folder}/t3-plugin.json`, manifestJson("linked")); + yield* writePluginsConfig({ enabled: { linked: true }, devPaths: [folder] }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, { linked: "active" }); + + yield* installer.uninstall(PluginId.make("linked")); + // Forgotten, not only stopped: a rescan does not find it again. + assert.isUndefined(stateOf((yield* host.rescan).plugins, "linked")); + const saved = yield* fs.readFileString(`${stateDir}/plugins.json`); + assert.deepStrictEqual(JSON.parse(saved), { enabled: {}, devPaths: [] }); + assert.isTrue(yield* fs.exists(`${folder}/t3-plugin.json`)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("refuses a dropped folder that would replace a linked plugin", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const folder = `${stateDir}/checkout/linked`; + yield* writeFile(`${folder}/t3-plugin.json`, manifestJson("linked")); + yield* writePluginsConfig({ devPaths: [folder] }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, { linked: "disabled" }); + const refused = yield* installer + .installFiles([ + { path: "t3-plugin.json", contents: new TextEncoder().encode(manifestJson("linked")) }, + ]) + .pipe(Effect.flip); + assert.strictEqual(refused.message, `linked is linked from ${folder}. Unlink it first.`); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("refuses a later folder with a plugin's id, and removes or unlinks it alone", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const id = PluginId.make("twice"); + const installed = `${stateDir}/plugins/twice-copy`; + const linked = `${stateDir}/checkout/twice`; + yield* writeFile(`${stateDir}/plugins/twice/t3-plugin.json`, manifestJson(id)); + yield* writeFile(`${installed}/t3-plugin.json`, manifestJson(id, { version: "0.2.0" })); + yield* writeFile(`${linked}/t3-plugin.json`, manifestJson(id, { version: "0.3.0" })); + yield* writePluginsConfig({ enabled: { [id]: true }, devPaths: [linked] }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + const storage = yield* PluginStorage.PluginStorage; + yield* waitForStatuses(host, { [id]: "active" }); + yield* storage.set(id, "kept", true); + const copies = host.list.pipe( + Effect.map((states) => + states + .filter((state) => state.id === id) + .map((state) => [state.version, state.status, state.failure?.message]), + ), + ); + const refused = + 'The installed folder "twice" already has the id twice. Remove or unlink one of them.'; + assert.deepStrictEqual(yield* copies, [ + ["0.1.0", "active", undefined], + ["0.2.0", "failed", refused], + ["0.3.0", "failed", refused], + ]); + + yield* installer.uninstall(id, linked); + yield* installer.uninstall(id, installed); + assert.deepStrictEqual(yield* copies, [["0.1.0", "active", undefined]]); + assert.isFalse(yield* fs.exists(installed)); + assert.isTrue(yield* fs.exists(linked)); + // The copy in use keeps the id's data. + assert.deepStrictEqual(yield* storage.get(id, "kept"), Option.some(true)); + + const elsewhere = yield* installer.uninstall(id, `${stateDir}/elsewhere`).pipe(Effect.flip); + assert.strictEqual(elsewhere.message, `No copy of twice is in ${stateDir}/elsewhere.`); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("lists a later folder with a removed plugin's id afresh, turned off", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const id = PluginId.make("twice"); + yield* writeFile(`${stateDir}/plugins/twice/t3-plugin.json`, manifestJson(id)); + yield* writeFile(`${stateDir}/plugins/twice-copy/t3-plugin.json`, manifestJson(id)); + yield* writePluginsConfig({ enabled: { [id]: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, { [id]: "active" }); + + yield* installer.uninstall(id); + const left = (yield* host.list).filter((state) => state.id === id); + assert.deepStrictEqual( + left.map((state) => [state.folder, state.status, state.enabled, state.failure]), + [[`${stateDir}/plugins/twice-copy`, "disabled", false, undefined]], + ); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("lists a plugin's rejection in a timer on its row instead of stopping the server", () => + Effect.gen(function* () { + yield* writeInstalledPlugin("leaky", leakyServer); + yield* writePluginsConfig({ enabled: { leaky: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { leaky: "active" }); + yield* host.invoke({ + pluginId: leaky, + method: "rejectLater", + input: undefined, + scopes: operate, + }); + assert.strictEqual(yield* nextServerFailure(host, leaky), "rejected in a timer"); + assert.include(stateOf(yield* host.list, leaky), { status: "active" }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("lists a failed read a plugin never awaited, though its stack is all Node's", () => + Effect.gen(function* () { + yield* writeInstalledPlugin("leaky", leakyServer); + yield* writePluginsConfig({ enabled: { leaky: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { leaky: "active" }); + yield* host.invoke({ + pluginId: leaky, + method: "readMissing", + input: undefined, + scopes: operate, + }); + assert.include(yield* nextServerFailure(host, leaky), "ENOENT"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("lists a failure of code a turned-off plugin left running on its row", () => + Effect.gen(function* () { + const id = PluginId.make("leftover"); + yield* writeInstalledPlugin( + id, + `module.exports = { activate(api) { + api.onDeactivate(() => { setTimeout(() => { Promise.reject(new Error("left running")); }, 0); }); +} };`, + ); + yield* writePluginsConfig({ enabled: { [id]: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { [id]: "active" }); + yield* host.setEnabled(id, false); + assert.strictEqual(yield* nextServerFailure(host, id), "left running"); + assert.include(stateOf(yield* host.list, id), { enabled: false, status: "disabled" }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("clears a failure from a start that failed once the plugin is turned off", () => + Effect.gen(function* () { + const id = PluginId.make("leaky-start"); + // A timer it sets while starting fails, then it fails to start. + yield* writeInstalledPlugin( + id, + `module.exports = { activate() { + setTimeout(() => { Promise.reject(new Error("rejected while starting")); }, 0); + return new Promise((_, reject) => setTimeout(() => reject(new Error("activate failed")), 0)); +} };`, + ); + yield* writePluginsConfig({ enabled: { [id]: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { [id]: "failed" }); + assert.strictEqual(yield* nextServerFailure(host, id), "rejected while starting"); + assert.isUndefined((yield* host.setEnabled(id, false)).serverFailure); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("does not start a plugin that stopped the server until it is turned on again", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const rootDir = yield* writeInstalledPlugin( + "crashing", + `require("node:fs").writeFileSync(__dirname + "/loaded", ""); +module.exports = { activate() {} };`, + ); + yield* writePluginsConfig({ enabled: { crashing: true } }); + const id = PluginId.make("crashing"); + const thrown = new Error("thrown in a timer"); + thrown.stack = `Error: thrown in a timer\n at ${yield* fs.realPath(rootDir)}/server.cjs:1:1`; + yield* Effect.gen(function* () { + yield* waitForStatuses(yield* PluginHost.PluginHost, { crashing: "active" }); + // As Node reports an uncaught exception, just before it exits. + process.emit("uncaughtExceptionMonitor", thrown); + }).pipe(Effect.provide(layerHost())); + yield* fs.remove(`${rootDir}/loaded`); + + // The next start lists it as failed and leaves its code unloaded. + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { crashing: "failed" }); + // Its switch shows it off, so turning it on is how the user retries it. + assert.deepInclude(stateOf(states, id), { + enabled: false, + failure: { stage: "crash", message: "thrown in a timer" }, + }); + assert.isFalse(yield* fs.exists(`${rootDir}/loaded`)); + assert.include(yield* host.setEnabled(id, true), { status: "active" }); + }).pipe(Effect.provide(layerHost())); + // Turned on again, it starts with the server again. + yield* Effect.gen(function* () { + yield* waitForStatuses(yield* PluginHost.PluginHost, { crashing: "active" }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("keeps a quiet channel's value while a busy channel overflows its buffer", () => + Effect.gen(function* () { + yield* writeInstalledPlugin( + "channels", + `module.exports = { activate(api) { + api.rpc.register("wake", () => api.rpc.publish("quiet", "wake"), { access: "read" }); + api.rpc.register("burst", () => { + for (let index = 0; index < 100; index++) api.rpc.publish("busy", index); + api.rpc.publish("quiet", "after"); + }, { access: "read" }); +} };`, + ); + yield* writePluginsConfig({ enabled: { channels: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("channels"); + const call = (method: string) => + host.invoke({ pluginId: id, method, input: undefined, scopes: operate }); + yield* waitForStatuses(host, { channels: "active" }); + // A subscriber still busy with its first value, as one whose socket is slow to send. + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + const quiet = yield* host.subscribe({ pluginId: id, channel: "quiet" }).pipe( + Stream.mapEffect((value) => + Deferred.succeed(reached, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(value), + ), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + yield* call("wake"); + yield* Deferred.await(reached); + yield* call("burst"); + yield* Deferred.succeed(release, undefined); + assert.deepStrictEqual(yield* Fiber.join(quiet), ["wake", "after"]); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("gives up on a method that does not answer within 30 seconds, and aborts it", () => + Effect.gen(function* () { + const started = Promise.withResolvers(); + const aborted = Promise.withResolvers(); + Reflect.set(globalThis, "__t3PluginHostInvokeStarted", started.resolve); + Reflect.set(globalThis, "__t3PluginHostInvokeAborted", aborted.resolve); + yield* writeInstalledPlugin( + "hangs", + `module.exports = { activate(api) { + api.rpc.register("wait", (_input, { signal }) => new Promise(() => { + signal.addEventListener("abort", () => globalThis.__t3PluginHostInvokeAborted()); + globalThis.__t3PluginHostInvokeStarted(); + }), { access: "read" }); +} };`, + ); + yield* writePluginsConfig({ enabled: { hangs: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("hangs"); + yield* waitForStatuses(host, { hangs: "active" }); + const call = yield* host + .invoke({ pluginId: id, method: "wait", input: undefined, scopes: operate }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => started.promise); + yield* TestClock.adjust("30 seconds"); + assert.strictEqual( + (yield* Fiber.join(call)).message, + "Plugin hangs did not answer wait within 30 seconds.", + ); + yield* Effect.promise(() => aborted.promise); + assert.include(stateOf(yield* host.list, id), { status: "active" }); + }).pipe(Effect.provide(layerHost())); + Reflect.deleteProperty(globalThis, "__t3PluginHostInvokeStarted"); + Reflect.deleteProperty(globalThis, "__t3PluginHostInvokeAborted"); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("finishes disabling a plugin when the caller gives up, or while it starts", () => + Effect.gen(function* () { + const started = Promise.withResolvers(); + const gate = Promise.withResolvers(); + Reflect.set(globalThis, "__t3PluginHostCleanupStarted", started.resolve); + Reflect.set(globalThis, "__t3PluginHostCleanupGate", gate.promise); + yield* writeInstalledPlugin( + "stubborn", + `module.exports = { activate(api) { + api.rpc.register("ping", () => "pong", { access: "read" }); + api.onDeactivate(() => { globalThis.__t3PluginHostCleanupStarted(); return globalThis.__t3PluginHostCleanupGate; }); +} };`, + ); + yield* writePluginsConfig({ enabled: { stubborn: true } }); + // Every publish that changes the theme folders hands them over, and holds there while `held` + // is set. + const themeFolders: Array> = []; + let held: + | { readonly reached: Deferred.Deferred; readonly release: Deferred.Deferred } + | undefined; + const layerHeldPublish = Layer.mock(EnvironmentTheme.EnvironmentThemeService)({ + setPluginThemeDirectories: (directories) => { + themeFolders.push(directories); + return held === undefined + ? Effect.void + : Deferred.succeed(held.reached, undefined).pipe( + Effect.andThen(Deferred.await(held.release)), + ); + }, + }); + // A disable's last wait before it stops the plugin is removing the temporary folder of its + // plugins.json write. A fork runs only once the disable waits again, which is at that stop. + let releaseOnWrite: Deferred.Deferred | undefined; + const layerReleaseOnWrite = layerFileSystemWith((fs) => ({ + remove: (target, options) => + fs + .remove(target, options) + .pipe( + Effect.andThen( + releaseOnWrite !== undefined && target.includes("plugins.json.") + ? Effect.forkChild(Deferred.succeed(releaseOnWrite, undefined)) + : Effect.void, + ), + ), + })); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("stubborn"); + yield* waitForStatuses(host, { stubborn: "active" }); + + const disabling = yield* host + .setEnabled(id, false) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => started.promise); + yield* Fiber.interrupt(disabling); + gate.resolve(); + + yield* waitForStatuses(host, { stubborn: "disabled" }); + const error = yield* host + .invoke({ pluginId: id, method: "ping", input: undefined, scopes: operate }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PluginNotActiveError"); + + // A stop that interrupts a start while it hands over its theme folder still lets that + // list go out, or the disabled list would match the last one sent and keep the folder. + const starting = { + reached: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + held = starting; + const enabling = yield* host + .setEnabled(id, true) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(starting.reached); + releaseOnWrite = starting.release; + assert.include(yield* host.setEnabled(id, false), { status: "disabled" }); + yield* Fiber.join(enabling); + assert.deepStrictEqual(themeFolders.at(-1), []); + }).pipe(Effect.provide(layerHost(layerHeldPublish).pipe(Layer.provide(layerReleaseOnWrite)))); + Reflect.deleteProperty(globalThis, "__t3PluginHostCleanupStarted"); + Reflect.deleteProperty(globalThis, "__t3PluginHostCleanupGate"); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("installs a dropped folder turned off, and one dropped again keeps its switch", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const helloFiles = yield* Effect.forEach(["t3-plugin.json", "server.cjs"], (name) => + fs + .readFile(`${fixturesDir}hello-plugin/${name}`) + .pipe(Effect.map((contents) => ({ path: name, contents }))), + ); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, {}); + // Nothing of a new plugin runs until its switch is turned on. + const installed = yield* installer.installFiles(helloFiles); + assert.include(installed, { id: hello, status: "disabled", enabled: false }); + const started = yield* host.setEnabled(hello, true); + const replaced = yield* installer.installFiles(helloFiles); + assert.include(replaced, { status: "active", enabled: true }); + assert.notStrictEqual(replaced.revision, started.revision); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect( + "lists a folder copied in by hand turned off, and a dropped one replaces it there", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const notesManifest = (version: string) => + JSON.stringify({ id: "notes", name: "Notes", version, engines: { t3: ">=0.1.0" } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, {}); + // The docs point people at the plugins folder, so a rescan makes sure it exists. + yield* host.rescan; + assert.isTrue(yield* fs.exists(`${stateDir}/plugins`)); + + // Copied in by hand under any name, a plugin is listed on the next rescan, turned off. + yield* writeFile(`${stateDir}/plugins/My Notes/t3-plugin.json`, notesManifest("1.0.0")); + const { plugins } = yield* host.rescan; + assert.include(stateOf(plugins, "notes"), { status: "disabled", enabled: false }); + // A new version copied over one that is off is what Settings lists and what starts. + yield* writeFile(`${stateDir}/plugins/My Notes/t3-plugin.json`, notesManifest("1.0.5")); + assert.include(stateOf((yield* host.rescan).plugins, "notes"), { version: "1.0.5" }); + + // A dropped folder replaces a plugin copied in by hand, in the folder it was copied to. + yield* installer.installFiles([ + { path: "t3-plugin.json", contents: new TextEncoder().encode(notesManifest("1.1.0")) }, + ]); + assert.include(stateOf(yield* host.list, "notes"), { version: "1.1.0", enabled: false }); + assert.deepStrictEqual(yield* fs.readDirectory(`${stateDir}/plugins`), ["My Notes"]); + yield* installer.uninstall(PluginId.make("notes")); + assert.isFalse(yield* fs.exists(`${stateDir}/plugins/My Notes`)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("puts the working version back when a new dropped version fails to start", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const encode = (text: string) => new TextEncoder().encode(text); + const upgrade = (version: string, activate = "") => [ + { + path: "t3-plugin.json", + contents: encode( + JSON.stringify({ + id: "upgrade", + name: "Upgrade", + version, + engines: { t3: ">=0.1.0" }, + server: "./server.cjs", + }), + ), + }, + { + path: "server.cjs", + contents: encode(`module.exports = { activate() { ${activate} } };`), + }, + ]; + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, {}); + yield* installer.installFiles(upgrade("1.0.0")); + yield* host.setEnabled(PluginId.make("upgrade"), true); + const failed = yield* installer + .installFiles(upgrade("2.0.0", `throw new Error("2.0.0 refuses to start");`)) + .pipe(Effect.flip); + assert.strictEqual( + failed.message, + "Upgrade 2.0.0 failed to start, so 1.0.0 is back. It failed with: 2.0.0 refuses to start", + ); + assert.include(stateOf(yield* host.list, "upgrade"), { + version: "1.0.0", + status: "active", + }); + assert.notInclude( + yield* fs.readFileString(`${stateDir}/plugins/upgrade/server.cjs`), + "refuses to start", + ); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("keeps server folder paths from clients that cannot manage plugins", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const folder = `${stateDir}/checkout/linked`; + yield* writeFile(`${folder}/t3-plugin.json`, manifestJson("linked")); + yield* writePluginsConfig({ devPaths: [folder] }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, { linked: "disabled" }); + const listed = (scopes: ReadonlyArray) => { + // Listing never signs an upload URL. + const uploads = PluginUploads.PluginUploads.of({ + issueUrl: Effect.die("unused"), + claim: () => Effect.die("unused"), + }); + const handlers = makePluginRpcHandlers(host, installer, uploads, scopes); + return Effect.all([ + handlers[WS_METHODS.pluginsList]({}), + handlers[WS_METHODS.pluginsSubscribeStates]({}).pipe( + Stream.runHead, + Effect.map((first) => + applyPluginStatesEvent({ plugins: [] }, Option.getOrThrow(first)), + ), + ), + ]).pipe(Effect.map((results) => results.flatMap((result) => result.plugins))); + }; + + for (const plugin of yield* listed([AuthOrchestrationReadScope])) { + assert.notProperty(plugin, "folder"); + } + const managed = yield* listed([AuthOrchestrationReadScope, AuthSettingsWriteScope]); + assert.include(stateOf(managed, "linked"), { folder }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("sends a client only the plugins that changed after the first list", () => + Effect.gen(function* () { + yield* writePluginsConfig({ + enabled: { [hello]: true }, + devPaths: [`${fixturesDir}broken-plugin`, `${fixturesDir}hello-plugin`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { [hello]: "active", [broken]: "disabled" }); + const disabled = (event: PluginStatesEvent) => + event._tag === "changed" && + event.rows.some(({ plugin }) => plugin.id === hello && plugin.status === "disabled"); + const events = yield* host + .streamForClient(operate) + .pipe( + Stream.takeUntil(disabled), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + yield* host.setEnabled(hello, false); + const [first, ...changes] = yield* Fiber.join(events); + assert.strictEqual(first?._tag, "snapshot"); + for (const change of changes) { + assert.deepStrictEqual( + change._tag === "changed" ? change.rows.map(({ plugin }) => plugin.id) : [], + [hello], + ); + } + const folded = [first!, ...changes].reduce(applyPluginStatesEvent, { plugins: [] }); + assert.deepStrictEqual(folded, yield* host.listForClient(operate)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("refuses a dropped folder this server can't run and keeps the old one", () => + Effect.gen(function* () { + const upgrade = ( + version: string, + options: { readonly engines?: string; readonly settingsSchema?: object } = {}, + ) => [ + { + path: "t3-plugin.json", + contents: new TextEncoder().encode( + manifestJson("upgrade", { name: "Upgrade", version, ...options }), + ), + }, + ]; + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, {}); + yield* installer.installFiles(upgrade("1.0.0")); + yield* host.setEnabled(PluginId.make("upgrade"), true); + const refused = yield* installer + .installFiles(upgrade("2.0.0", { engines: ">=999.0.0" })) + .pipe(Effect.flip); + assert.strictEqual( + refused.message, + `Upgrade 2.0.0 needs T3 Code >=999.0.0, and this server is ${packageJson.version}.`, + ); + const unchecked = yield* installer + .installFiles( + upgrade("2.0.0", { + settingsSchema: { type: "object", properties: { count: { $ref: "#/$defs/no" } } }, + }), + ) + .pipe(Effect.flip); + assert.include( + unchecked.message, + "Upgrade 2.0.0 has a settingsSchema T3 Code cannot validate", + ); + assert.include(unchecked.message, 'Missing definition "no"'); + assert.include(stateOf(yield* host.list, "upgrade"), { + version: "1.0.0", + status: "active", + }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("restores a version an interrupted install left in staging", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const previous = `${stateDir}/plugin-staging/crashed/previous`; + yield* writeFile( + `${previous}/t3-plugin.json`, + JSON.stringify({ + id: "stranded", + name: "Stranded", + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + }), + ); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { stranded: "disabled" }); + assert.isTrue(yield* fs.exists(`${stateDir}/plugins/stranded/t3-plugin.json`)); + assert.isFalse(yield* fs.exists(`${stateDir}/plugin-staging/crashed`)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("sends a client that connects during startup one empty list, not two", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + // Holds startup inside its lock, as it lists the plugins folder. + const layerHeldStartup = layerFileSystemWith((fs) => ({ + readDirectory: (target, options) => + target.endsWith("/plugins") + ? Deferred.succeed(reached, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(fs.readDirectory(target, options)), + ) + : fs.readDirectory(target, options), + })); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* Deferred.await(reached); + const lists = yield* host.streamStates.pipe( + Stream.map((states) => states.map((state) => state.id)), + Stream.take(2), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(release, undefined); + // Waits for startup's lock, so startup has published whatever it does by then. + yield* host.rescan; + yield* writeFile(`${stateDir}/plugins/late/t3-plugin.json`, manifestJson("late")); + yield* host.rescan; + assert.deepStrictEqual(yield* Fiber.join(lists), [[], ["late"]]); + }).pipe(Effect.provide(layerHost().pipe(Layer.provide(layerHeldStartup)))); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("does not load a disabled plugin at startup", () => + Effect.gen(function* () { + const rootDir = yield* writeInstalledPlugin( + "dormant", + `require("node:fs").writeFileSync(__dirname + "/loaded", ""); +module.exports = { activate() {} };`, + ); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { dormant: "disabled" }); + assert.include(stateOf(states, "dormant"), { enabled: false, hasServer: true }); + const fs = yield* FileSystem.FileSystem; + assert.isFalse(yield* fs.exists(`${rootDir}/loaded`)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect( + "finishes starting before a slow plugin finishes activating, and a rescan meanwhile keeps it", + () => + Effect.gen(function* () { + const gate = Promise.withResolvers(); + const cleanupStarted = Promise.withResolvers(); + const cleanupGate = Promise.withResolvers(); + Reflect.set(globalThis, "__t3PluginHostTestGate", gate.promise); + Reflect.set(globalThis, "__t3PluginHostCleanupStarted", cleanupStarted.resolve); + Reflect.set(globalThis, "__t3PluginHostCleanupGate", cleanupGate.promise); + yield* writeInstalledPlugin( + "slow", + `module.exports = { activate(api) { + api.rpc.register("ping", () => "pong", { access: "read" }); + return globalThis.__t3PluginHostTestGate; +} };`, + ); + const goes = yield* writeInstalledPlugin( + "goes", + `module.exports = { activate(api) { + api.onDeactivate(() => { globalThis.__t3PluginHostCleanupStarted(); return globalThis.__t3PluginHostCleanupGate; }); +} };`, + ); + yield* writePluginsConfig({ enabled: { slow: true, goes: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const fs = yield* FileSystem.FileSystem; + yield* waitForStatuses(host, { slow: "loading", goes: "active" }); + + // The rescan stops the removed plugin while the slow one finishes starting. + yield* fs.remove(goes, { recursive: true }); + const rescanning = yield* host.rescan.pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => cleanupStarted.promise); + gate.resolve(); + yield* waitForStatuses(host, { slow: "active" }); + cleanupGate.resolve(); + yield* Fiber.join(rescanning); + + assert.strictEqual(stateOf(yield* host.list, "slow")?.status, "active"); + assert.strictEqual( + yield* host.invoke({ + pluginId: PluginId.make("slow"), + method: "ping", + input: undefined, + scopes: operate, + }), + "pong", + ); + }).pipe(Effect.provide(layerHost())); + Reflect.deleteProperty(globalThis, "__t3PluginHostTestGate"); + Reflect.deleteProperty(globalThis, "__t3PluginHostCleanupStarted"); + Reflect.deleteProperty(globalThis, "__t3PluginHostCleanupGate"); + }).pipe(Effect.provide(layerConfig())), + ); +}); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts new file mode 100644 index 0000000000000..f060ebb080777 --- /dev/null +++ b/apps/server/src/plugins/PluginHost.ts @@ -0,0 +1,855 @@ +// @effect-diagnostics nodeBuiltinImport:off - plugins load through Node's CommonJS loader, which +// the FileSystem service does not wrap. +import * as NodeModule from "node:module"; + +import { + AuthEnvironmentScope, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + PluginId, + type PluginListResult, + type PluginSettingsGetResult, + type PluginState, + type PluginStatesEvent, +} from "@t3tools/contracts"; +import type { PluginDefinition } from "@t3tools/plugin-sdk/server"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FiberMap from "effect/FiberMap"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { + HttpRouter, + HttpServerRequest, + HttpServerRespondable, + HttpServerResponse, +} from "effect/http"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import { authenticateRawRouteWithScope } from "../http.ts"; +import { forkParked } from "../serverActivation.ts"; +import { + makePluginActivation, + makePluginApi, + type PluginActivation, + type PluginApiServices, +} from "./PluginApi.ts"; +import { errorText, PluginCodeError, pluginCodeScope } from "./api/facade.ts"; +import { makePluginFailureGuard } from "./pluginFailureGuard.ts"; +import { makePluginFailures } from "./pluginFailures.ts"; +import { discoverPlugins, type DiscoveredPlugin } from "./pluginDiscovery.ts"; +import * as PluginInstaller from "./PluginInstaller.ts"; +import * as PluginMcpTools from "./PluginMcpTools.ts"; +import { makePluginsConfig, type PluginConfigError } from "./pluginsConfig.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import * as PluginStorage from "./PluginStorage.ts"; +import { makePluginStates, manifestOf, type PluginRecord } from "./pluginStates.ts"; +import { makePluginWebFiles, type PluginWebFile } from "./pluginWebFiles.ts"; + +// Plugin code that never settles must not keep a plugin loading, a client's call open, or Disable +// and Reload waiting, forever. The cleanup deadline covers all of a plugin's cleanups together. +const ACTIVATE_DEADLINE = Duration.seconds(30); +const INVOKE_DEADLINE = Duration.seconds(30); +const CLEANUP_DEADLINE = Duration.seconds(5); +// Values of one channel a subscriber has not taken yet; past this the oldest go. +const CHANNEL_BUFFER = 64; + +export class PluginNotFoundError extends Schema.TaggedError()( + "PluginNotFoundError", + { pluginId: PluginId }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is not installed.`; + } +} + +export class PluginNotActiveError extends Schema.TaggedError()( + "PluginNotActiveError", + { pluginId: PluginId, status: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is ${this.status}.`; + } +} + +export class PluginNoServerError extends Schema.TaggedError()( + "PluginNoServerError", + { pluginId: PluginId }, +) { + override get message(): string { + return `Plugin ${this.pluginId} has no server half, so it has no methods or channels.`; + } +} + +export class PluginMethodNotFoundError extends Schema.TaggedError()( + "PluginMethodNotFoundError", + { pluginId: PluginId, method: Schema.String, methods: Schema.Array(Schema.String) }, +) { + // The methods it has, since nothing else lists them. + override get message(): string { + return `Plugin ${this.pluginId} has no method ${this.method}. Its methods: ${this.methods.join(", ") || "none"}.`; + } +} + +export class PluginAccessDeniedError extends Schema.TaggedError()( + "PluginAccessDeniedError", + { pluginId: PluginId, method: Schema.String, requiredScope: AuthEnvironmentScope }, +) { + override get message(): string { + return `Calling ${this.method} on plugin ${this.pluginId} needs the ${this.requiredScope} scope.`; + } +} +export const isPluginAccessDeniedError = Schema.is(PluginAccessDeniedError); + +// `reason` is the plugin's own error text, bounded. Plugins are trusted code, and their UI needs +// to show why its own call failed. +export class PluginHandlerError extends Schema.TaggedError()( + "PluginHandlerError", + { pluginId: PluginId, method: Schema.String, reason: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Plugin ${this.pluginId} failed in ${this.method}: ${this.reason}`; + } +} + +export class PluginHandlerTimeoutError extends Schema.TaggedError()( + "PluginHandlerTimeoutError", + { pluginId: PluginId, method: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} did not answer ${this.method} within ${Duration.toSeconds(INVOKE_DEADLINE)} seconds.`; + } +} + +export class PluginHost extends Context.Service< + PluginHost, + { + readonly list: Effect.Effect>; + /** The current list, then the whole list after every change. */ + readonly streamStates: Stream.Stream>; + /** `list` as a client with `scopes` sees it: server folder paths only if it manages plugins. */ + readonly listForClient: ( + scopes: ReadonlyArray, + ) => Effect.Effect; + /** `listForClient` now, then the rows that differ after every change. */ + readonly streamForClient: ( + scopes: ReadonlyArray, + ) => Stream.Stream; + /** Persists the flag, then waits for activation or deactivation to finish. */ + readonly setEnabled: ( + pluginId: PluginId, + enabled: boolean, + ) => Effect.Effect; + /** Rescans the plugin folders and restarts this plugin with fresh code. */ + readonly reload: ( + pluginId: PluginId, + ) => Effect.Effect; + /** + * Rescans the plugin folders without restarting anything. Plugins whose folders appeared are + * listed, turned off unless plugins.json turns them on; ones whose folders are gone stop, and + * plugins.json forgets them. + */ + readonly rescan: Effect.Effect< + { readonly plugins: ReadonlyArray }, + PluginConfigError + >; + /** The settings clients see, with the schema the settings form is drawn from. */ + readonly getSettings: ( + pluginId: PluginId, + ) => Effect.Effect< + Omit, + PluginNotFoundError | PluginNotActiveError | PluginSettingsError + >; + readonly updateSettings: ( + pluginId: PluginId, + patch: Schema.JsonObject, + ) => Effect.Effect< + Schema.JsonObject, + PluginNotFoundError | PluginNotActiveError | PluginSettingsError + >; + /** + * `scopes` are the caller's; methods the plugin registered as writes need the operate scope. A + * handler that has not answered within 30 seconds is aborted. + */ + readonly invoke: (input: { + readonly pluginId: PluginId; + readonly method: string; + readonly input: Schema.Json | undefined; + readonly scopes: ReadonlyArray; + }) => Effect.Effect< + Schema.Json, + | PluginNotFoundError + | PluginNotActiveError + | PluginNoServerError + | PluginMethodNotFoundError + | PluginAccessDeniedError + | PluginHandlerError + | PluginHandlerTimeoutError + >; + /** + * What an active plugin serves at `/api/plugins/`: its `web.js`, or a file in + * its `assets/` or `styles/`. A `revision` that names the file's contents lets it be cached. + */ + readonly readWebFile: (input: { + readonly pluginId: string; + readonly path: string; + readonly revision: string | undefined; + }) => Effect.Effect>; + /** Values the plugin publishes on `channel` until it deactivates. */ + readonly subscribe: (input: { + readonly pluginId: PluginId; + readonly channel: string; + }) => Stream.Stream< + Schema.Json, + PluginNotFoundError | PluginNotActiveError | PluginNoServerError + >; + } +>()("t3/plugins/PluginHost") {} + +type PluginSettingsError = + | PluginSettingsStore.PluginSettingsPersistenceError + | PluginSettingsStore.PluginSettingsSchemaError + | PluginSettingsStore.PluginSettingsValidationError; + +const isJson = Schema.is(Schema.Json); + +/** What plugins.json says about the plugins a discovery found. */ +interface Found { + readonly enabled: Readonly>; + readonly crashed: Readonly>; +} + +// One loader for the packaged single executable and plain Node: a require created from an +// absolute path loads files from disk in both, and plugin code resolves its own imports. +const requireFromDisk = NodeModule.createRequire(process.execPath); + +const isPluginDefinition = (value: unknown): value is PluginDefinition => + typeof value === "object" && + value !== null && + "activate" in value && + typeof value.activate === "function"; + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const context = yield* Effect.context(); + const services: PluginApiServices = { + settings: yield* PluginSettingsStore.PluginSettingsStore, + storage: yield* PluginStorage.PluginStorage, + mcpTools: yield* PluginMcpTools.PluginMcpTools, + }; + const settingsStore = services.settings; + + const pluginsDir = path.join(config.stateDir, "plugins"); + const pluginsConfig = yield* makePluginsConfig; + const webFiles = yield* makePluginWebFiles; + + const records = yield* Ref.make>([]); + // Changes on every start, so a client can tell a restarted plugin from one that kept running. + // Seeded with the clock so a restarted server never repeats a revision a client already saw. + let revision = yield* Clock.currentTimeMillis; + const nextRevision = () => ++revision; + const lifecycle = yield* Semaphore.make(1); + const activations = yield* FiberMap.make(); + const hostScope = yield* Effect.scope; + + const failures = yield* makePluginFailures; + const pluginStates = yield* makePluginStates({ + records, + config: pluginsConfig.current, + failures, + }); + const currentStates = pluginStates.current; + const publishStates = pluginStates.publish; + const findRecord = (pluginId: string) => + Ref.get(records).pipe(Effect.map((all) => all.find((record) => record.plugin.id === pluginId))); + const stateOf = (pluginId: PluginId) => + currentStates.pipe( + Effect.flatMap((states) => { + const state = states.find((candidate) => candidate.id === pluginId); + return state === undefined + ? Effect.fail(new PluginNotFoundError({ pluginId })) + : Effect.succeed(state); + }), + ); + // By id, which finds the copy in use: discovery lists it before any duplicate. + const setRecord = (pluginId: PluginId, update: Omit) => + Ref.update(records, (all) => { + const index = all.findIndex((record) => record.plugin.id === pluginId); + if (index === -1) return all; + const next = [...all]; + next[index] = { plugin: all[index]!.plugin, ...update }; + return next; + }).pipe(Effect.andThen(publishStates)); + + // Untraced: a plugin's timer can fail every few milliseconds. + const recordServerFailure = Effect.fnUntraced(function* (pluginId: PluginId, error: unknown) { + const record = yield* findRecord(pluginId); + if (yield* failures.recordServer(pluginId, record?.revision, error)) yield* publishStates; + }); + // Recorded at once and outside the plugin's scope, where the guard hears of it, and in the + // host's scope, so a publish still waiting ends with the host. + const reportServerFailure = (pluginId: PluginId, error: unknown) => + pluginCodeScope.exit(() => { + Effect.runForkWith(context)( + Effect.forkIn(recordServerFailure(pluginId, error), hostScope, { startImmediately: true }), + ); + }); + const failureGuard = makePluginFailureGuard({ + onRejection: reportServerFailure, + onCrash: (pluginId, error) => pluginsConfig.recordCrash(pluginId, errorText(error)), + }); + // Added before the teardown finalizer, so it runs after plugin cleanups. + yield* Effect.addFinalizer(() => Effect.sync(failureGuard.uninstall)); + + const runCleanup = (pluginId: PluginId, cleanup: () => void | Promise) => + Effect.tryPromise({ + try: () => pluginCodeScope.run(pluginId, () => Promise.resolve().then(cleanup)), + catch: (cause) => new PluginCodeError({ cause }), + }).pipe( + Effect.catchTags({ + PluginCodeError: (error) => + Effect.logWarning("plugin cleanup failed", { pluginId, reason: error.message }), + }), + ); + + // Aborts first so handlers and iterators stop. Teardown also runs where interruption is off, so + // the deadline gets an interruptible region to cut. + const teardown = Effect.fn("PluginHost.teardown")(function* ( + pluginId: PluginId, + activation: PluginActivation, + ) { + activation.controller.abort(); + const timedOut = yield* Effect.forEach( + activation.cleanups.toReversed(), + (cleanup) => runCleanup(pluginId, cleanup), + { discard: true }, + ).pipe(Effect.interruptible, Effect.timeoutOption(CLEANUP_DEADLINE), Effect.map(Option.isNone)); + if (timedOut) yield* Effect.logWarning("plugin cleanup timed out", { pluginId }); + // In one step with the abort, so a subscriber that joins meanwhile sees one or the other. + activation.ended.abort(); + for (const queues of activation.channels.values()) { + for (const queue of queues) Queue.endUnsafe(queue); + } + }); + + const loadDefinition = (plugin: DiscoveredPlugin, entry: string) => + Effect.try({ + try: () => { + const entryPath = path.join(plugin.rootDir, entry); + // Drop every cached module under the plugin so reload runs current code. Node still + // holds the old instances until the server restarts. + const prefix = `${plugin.rootDir}${path.sep}`; + for (const cached of Object.keys(requireFromDisk.cache)) { + if (cached.startsWith(prefix)) Reflect.deleteProperty(requireFromDisk.cache, cached); + } + const exported: unknown = pluginCodeScope.run(plugin.id, () => requireFromDisk(entryPath)); + // Bundlers that compile `export default` to CommonJS put the definition on `default`. + const definition = + typeof exported === "object" && exported !== null && "default" in exported + ? exported.default + : exported; + if (!isPluginDefinition(definition)) { + throw new Error("The server entry does not export a plugin with an activate function."); + } + return definition; + }, + catch: (cause) => new PluginCodeError({ cause }), + }); + + const activatePlugin = Effect.fn("PluginHost.activate")(function* (plugin: DiscoveredPlugin) { + const manifest = manifestOf(plugin); + if (manifest === undefined) return; + if (!satisfiesSemverRange(packageJson.version, manifest.engines.t3)) { + return yield* setRecord(plugin.id, { + status: "failed", + failure: { + stage: "compatibility", + message: `Needs T3 Code ${manifest.engines.t3}; this server is ${packageJson.version}.`, + }, + }); + } + // Before Node 24, `pluginCodeScope` slows every promise in the process about 3x, and without + // it a plugin's rejection with only Node's frames in its stack stops the server. + const node = process.versions.node; + if (manifest.server !== undefined && Number(node.split(".")[0]) < 24) { + return yield* setRecord(plugin.id, { + status: "failed", + failure: { + stage: "compatibility", + message: `Needs Node 24 or later for its server code; this server runs Node ${node}.`, + }, + }); + } + const web = yield* webFiles.find(plugin, manifest.web); + const started = { + status: "active" as const, + webFiles: web.folders, + ...(web.webRevision === undefined ? {} : { webRevision: web.webRevision }), + styles: web.styles, + }; + if (manifest.server === undefined) { + return yield* setRecord(plugin.id, { ...started, revision: nextRevision() }); + } + yield* setRecord(plugin.id, { status: "loading" }); + // Before loading, since a module can start timers as it loads. + failureGuard.watch(`${plugin.rootDir}${path.sep}`, plugin.id); + const definition = yield* Effect.exit(loadDefinition(plugin, manifest.server)); + if (definition._tag === "Failure") { + return yield* setRecord(plugin.id, { + status: "failed", + failure: { stage: "load", message: errorText(Cause.squash(definition.cause)) }, + }); + } + const activation = makePluginActivation(); + const api = makePluginApi({ + id: plugin.id, + rootDir: plugin.rootDir, + settingsSchema: manifest.settingsSchema, + activation, + context, + services, + }); + // Only `activate` can be interrupted. A stop that lands after it waits until the record holds + // the activation, so `stopPlugin` always finds what to tear down. + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const activated = yield* restore( + Effect.tryPromise({ + try: () => + pluginCodeScope.run(plugin.id, () => + Promise.resolve().then(() => definition.value.activate(api)), + ), + catch: (cause) => new PluginCodeError({ cause }), + }).pipe(Effect.timeoutOption(ACTIVATE_DEADLINE)), + ).pipe(Effect.exit); + if (activated._tag === "Success" && Option.isSome(activated.value)) { + yield* setRecord(plugin.id, { ...started, activation, revision: nextRevision() }); + return yield* Effect.logInfo("plugin activated", { pluginId: plugin.id }); + } + yield* teardown(plugin.id, activation); + // Whatever stopped the activation sets the plugin's next status. + if (activated._tag === "Failure" && Cause.hasInterruptsOnly(activated.cause)) return; + yield* setRecord(plugin.id, { + status: "failed", + failure: { + stage: "activate", + message: + activated._tag === "Failure" + ? errorText(Cause.squash(activated.cause)) + : `activate did not finish within ${Duration.toSeconds(ACTIVATE_DEADLINE)} seconds.`, + }, + }); + }), + ); + }); + + /** + * An active plugin's record stops routing calls and serving files before cleanups run. The caller + * sets the plugin's next status. + */ + const stopPlugin = Effect.fn("PluginHost.stop")(function* (pluginId: PluginId) { + yield* FiberMap.remove(activations, pluginId); + // A failure from before the stop, even one while loading, which has no revision, belongs to + // that start; failures of code left running after it are recorded again. + yield* failures.clearServer(pluginId); + // A plugin without server code is active with no activation. + const record = yield* findRecord(pluginId); + if (record?.status !== "active") return; + yield* setRecord(pluginId, { status: "disabled" }); + if (record.activation !== undefined) yield* teardown(pluginId, record.activation); + }); + + const startPlugin = (plugin: DiscoveredPlugin) => + FiberMap.run(activations, plugin.id, activatePlugin(plugin)); + + // A plugin that stopped the server was turned off then, or the restart would only crash again. + const initialRecord = (plugin: DiscoveredPlugin, found: Found): PluginRecord => { + if ("failure" in plugin) return { plugin, status: "failed", failure: plugin.failure }; + const crashed = found.crashed[plugin.id]; + if (crashed !== undefined) { + return { plugin, status: "failed", failure: { stage: "crash", message: crashed } }; + } + return found.enabled[plugin.id] === true + ? { plugin, status: "loading" } + : { plugin, status: "disabled" }; + }; + const startsAtDiscovery = (plugin: DiscoveredPlugin, found: Found) => + initialRecord(plugin, found).status === "loading"; + + // Startup tolerates an unreadable plugins.json; an explicit reload reports it instead. + const discover = Effect.fn("PluginHost.discover")(function* (lenient: boolean) { + const current = yield* pluginsConfig.reread(lenient); + const plugins = yield* discoverPlugins({ pluginsDir, devPaths: current.devPaths ?? [] }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + yield* pluginsConfig.forgetMissing(new Set(plugins.map((plugin) => plugin.id))).pipe( + Effect.catchTags({ + PluginConfigError: (error) => + lenient + ? Effect.logWarning("could not update plugins.json", { path: error.path }) + : Effect.fail(error), + }), + ); + const found: Found = { enabled: current.enabled ?? {}, crashed: current.crashed ?? {} }; + return { plugins, found }; + }); + + // Lifecycle changes run in the host's scope, so a caller that disconnects only stops waiting + // and never leaves a plugin half torn down. + const runLifecycle = (body: Effect.Effect) => + lifecycle.withPermits(1)(body).pipe(Effect.forkIn(hostScope), Effect.flatMap(Fiber.join)); + + const awaitActivation = (pluginId: PluginId) => + FiberMap.get(activations, pluginId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: (fiber) => Effect.asVoid(Fiber.await(fiber)), + }), + ), + ); + + const setEnabled: PluginHost["Service"]["setEnabled"] = Effect.fn("PluginHost.setEnabled")( + function* (pluginId, enabled) { + yield* runLifecycle( + Effect.gen(function* () { + const record = yield* findRecord(pluginId); + if (record === undefined) return yield* new PluginNotFoundError({ pluginId }); + yield* pluginsConfig.writeEnabled(pluginId, enabled); + if ("failure" in record.plugin) return yield* publishStates; + if (enabled && (record.status === "active" || record.status === "loading")) { + return yield* publishStates; + } + yield* stopPlugin(pluginId); + if (enabled) { + yield* startPlugin(record.plugin); + } else { + yield* setRecord(pluginId, { status: "disabled" }); + } + }), + ); + yield* awaitActivation(pluginId); + return yield* stateOf(pluginId); + }, + ); + + /** Rescans the plugin folders and lists what appeared; `restart` also restarts with new code. */ + const rediscover = Effect.fn("PluginHost.rediscover")(function* (restart: PluginId | undefined) { + // Reloading a plugin that stopped the server clears that failure; it stays off until turned on. + if (restart !== undefined) yield* pluginsConfig.clearCrash(restart); + const { plugins, found } = yield* discover(false); + if (restart !== undefined) yield* stopPlugin(restart); + const known = yield* Ref.get(records); + const next = plugins.map((plugin) => ({ + plugin, + kept: + plugin.id === restart || "failure" in plugin + ? undefined + : known.find( + (record) => + record.plugin.id === plugin.id && + record.plugin.folder === plugin.folder && + !("failure" in record.plugin), + ), + })); + // Copies that are gone or moved stop, or they would keep running unlisted. + for (const record of known) { + if ( + record.plugin.id !== restart && + !("failure" in record.plugin) && + !next.some(({ kept }) => kept === record) + ) { + yield* stopPlugin(record.plugin.id); + } + } + // Kept records are read again here: one still starting may have finished while others stopped. + yield* Ref.update(records, (current) => + next.map(({ plugin, kept }) => { + if (kept === undefined) return initialRecord(plugin, found); + const latest = current.find((record) => record.plugin === kept.plugin) ?? kept; + // One that runs nothing takes the files found now, so starting it loads their manifest. + return latest.status === "active" || latest.status === "loading" + ? latest + : { ...latest, plugin }; + }), + ); + yield* publishStates; + for (const { plugin, kept } of next) { + if (kept === undefined && startsAtDiscovery(plugin, found)) yield* startPlugin(plugin); + } + return plugins; + }); + + const reload: PluginHost["Service"]["reload"] = Effect.fn("PluginHost.reload")( + function* (pluginId) { + yield* runLifecycle( + rediscover(pluginId).pipe( + Effect.flatMap((plugins) => + plugins.some((plugin) => plugin.id === pluginId) + ? Effect.void + : Effect.fail(new PluginNotFoundError({ pluginId })), + ), + ), + ); + yield* awaitActivation(pluginId); + return yield* stateOf(pluginId); + }, + ); + + const rescan = Effect.fn("PluginHost.rescan")(function* () { + // The docs tell people to copy plugin folders here, so it has to exist. + yield* fs.makeDirectory(pluginsDir, { recursive: true }).pipe(Effect.ignore); + yield* runLifecycle(rediscover(undefined)); + return { plugins: yield* currentStates }; + }); + + // Lookups inside an operation that already has its span. + const manifestFor = Effect.fnUntraced(function* (pluginId: PluginId) { + const record = yield* findRecord(pluginId); + if (record === undefined) return yield* new PluginNotFoundError({ pluginId }); + const manifest = manifestOf(record.plugin); + if (manifest === undefined) { + return yield* new PluginNotActiveError({ pluginId, status: record.status }); + } + return manifest; + }); + + const activeRecord = Effect.fnUntraced(function* (pluginId: PluginId) { + const record = yield* findRecord(pluginId); + if (record === undefined) return yield* new PluginNotFoundError({ pluginId }); + const manifest = manifestOf(record.plugin); + if (manifest !== undefined && manifest.server === undefined) { + return yield* new PluginNoServerError({ pluginId }); + } + if (record.status !== "active" || record.activation === undefined) { + return yield* new PluginNotActiveError({ pluginId, status: record.status }); + } + return record.activation; + }); + + const { installer, recoverStaging } = yield* PluginInstaller.make({ + lifecycle: runLifecycle, + copies: Ref.get(records).pipe(Effect.map((all) => all.map((record) => record.plugin))), + copyOf: (pluginId) => + findRecord(pluginId).pipe( + Effect.flatMap((record) => + record === undefined + ? Effect.fail(new PluginNotFoundError({ pluginId })) + : Effect.succeed(record.plugin), + ), + ), + stop: stopPlugin, + rescan: rediscover(undefined).pipe(Effect.asVoid), + reload, + config: pluginsConfig.current, + writeConfig: pluginsConfig.write, + }); + + const startup = lifecycle + .withPermits(1)( + Effect.gen(function* () { + yield* recoverStaging; + const { plugins, found } = yield* discover(true); + yield* Ref.set( + records, + plugins.map((plugin) => initialRecord(plugin, found)), + ); + yield* publishStates; + // Each plugin activates in its own fiber, so a slow one delays nobody else. + for (const plugin of plugins) { + if (startsAtDiscovery(plugin, found)) yield* startPlugin(plugin); + } + }), + ) + .pipe(Effect.withSpan("PluginHost.startup")); + + yield* forkParked(startup); + yield* Effect.addFinalizer(() => + Ref.get(records).pipe( + Effect.flatMap((all) => + Effect.forEach( + all, + (record) => + record.activation === undefined + ? Effect.void + : teardown(record.plugin.id, record.activation), + { concurrency: "unbounded", discard: true }, + ), + ), + ), + ); + + const service = PluginHost.of({ + list: currentStates, + streamStates: pluginStates.stream, + listForClient: pluginStates.listForClient, + streamForClient: pluginStates.streamForClient, + setEnabled, + reload, + rescan: rescan(), + getSettings: Effect.fn("PluginHost.getSettings")(function* (pluginId) { + const { settingsSchema } = yield* manifestFor(pluginId); + const settings = yield* settingsStore.getForClient(pluginId, settingsSchema); + return settingsSchema === undefined ? { settings } : { settings, settingsSchema }; + }), + updateSettings: Effect.fn("PluginHost.updateSettings")(function* (pluginId, patch) { + const manifest = yield* manifestFor(pluginId); + return yield* settingsStore.update(pluginId, manifest.settingsSchema, patch); + }), + invoke: Effect.fn("PluginHost.invoke")(function* (request) { + yield* Effect.annotateCurrentSpan({ + "plugin.id": request.pluginId, + "plugin.method": request.method, + }); + const activation = yield* activeRecord(request.pluginId); + const registered = activation.methods.get(request.method); + if (registered === undefined) { + return yield* new PluginMethodNotFoundError({ + pluginId: request.pluginId, + method: request.method, + methods: [...activation.methods.keys()], + }); + } + const access = request.scopes.includes(AuthOrchestrationOperateScope) ? "write" : "read"; + if (registered.access === "write" && access !== "write") { + return yield* new PluginAccessDeniedError({ + pluginId: request.pluginId, + method: request.method, + requiredScope: AuthOrchestrationOperateScope, + }); + } + const handlerError = (cause: unknown) => + new PluginHandlerError({ + pluginId: request.pluginId, + method: request.method, + reason: errorText(cause), + cause, + }); + const deactivated = activation.controller.signal; + const answered = yield* Effect.raceFirst( + // The deadline interrupts the call, which aborts the handler's signal. + Effect.tryPromise({ + try: (signal) => + pluginCodeScope.run(request.pluginId, () => + Promise.resolve().then(() => + registered.handler(request.input, { + signal: AbortSignal.any([signal, deactivated]), + access, + }), + ), + ), + catch: handlerError, + }).pipe(Effect.timeoutOption(INVOKE_DEADLINE)), + // A handler that ignores its abort signal must not hold the call open after disable. + Effect.callback((resume) => { + const onAbort = () => + resume( + Effect.fail( + new PluginNotActiveError({ pluginId: request.pluginId, status: "disabled" }), + ), + ); + if (deactivated.aborted) return onAbort(); + deactivated.addEventListener("abort", onAbort, { once: true }); + return Effect.sync(() => deactivated.removeEventListener("abort", onAbort)); + }), + ); + if (Option.isNone(answered)) { + return yield* new PluginHandlerTimeoutError({ + pluginId: request.pluginId, + method: request.method, + }); + } + const output = answered.value; + if (output === undefined) return null; + if (!isJson(output)) { + return yield* handlerError(new TypeError("The method returned a value that is not JSON.")); + } + return output; + }), + readWebFile: Effect.fn("PluginHost.readWebFile")(function* (request) { + const record = yield* findRecord(request.pluginId); + if (record?.status !== "active" || record.webFiles === undefined) return Option.none(); + return yield* webFiles.read( + record.plugin.rootDir, + record.webFiles, + request.path, + request.revision, + ); + }), + subscribe: (request) => + Stream.unwrap( + Effect.gen(function* () { + const activation = yield* activeRecord(request.pluginId); + const queue = yield* Queue.sliding(CHANNEL_BUFFER); + yield* Effect.acquireRelease( + Effect.sync(() => { + if (activation.ended.signal.aborted) return Queue.endUnsafe(queue); + const queues = activation.channels.get(request.channel) ?? new Set(); + activation.channels.set(request.channel, queues.add(queue)); + }), + () => + Effect.sync(() => { + const queues = activation.channels.get(request.channel); + queues?.delete(queue); + if (queues?.size === 0) activation.channels.delete(request.channel); + }), + ); + return Stream.fromQueue(queue); + }), + ), + }); + return Context.make(PluginHost, service).pipe( + Context.add(PluginInstaller.PluginInstaller, installer), + ); +}); + +/** Provides the host and the installer it builds. */ +export const layer = Layer.effectContext(make); + +const WEB_FILES_ROUTE_PREFIX = "/api/plugins"; + +/** `GET /api/plugins//`, answered with what the active plugin serves there. */ +export const layerWebFilesRoute = HttpRouter.add( + "GET", + `${WEB_FILES_ROUTE_PREFIX}/*`, + Effect.gen(function* () { + // Same sign-in as reads on the rest of `/api`. + yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); + const url = HttpServerRequest.toURL(yield* HttpServerRequest.HttpServerRequest); + if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 }); + const rest = url.value.pathname.slice(`${WEB_FILES_ROUTE_PREFIX}/`.length); + const slash = rest.indexOf("/"); + const host = yield* PluginHost; + const file = yield* host.readWebFile({ + pluginId: slash === -1 ? rest : rest.slice(0, slash), + path: slash === -1 ? "/" : rest.slice(slash), + revision: url.value.searchParams.get("v") ?? undefined, + }); + return Option.match(file, { + onNone: () => HttpServerResponse.text("Not Found", { status: 404 }), + onSome: ({ headers, body }) => HttpServerResponse.uint8Array(body, { headers }), + }); + }).pipe( + Effect.catchTags({ + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, + }), + ), +); diff --git a/apps/server/src/plugins/PluginInstaller.test.ts b/apps/server/src/plugins/PluginInstaller.test.ts new file mode 100644 index 0000000000000..c0744d9fc1d61 --- /dev/null +++ b/apps/server/src/plugins/PluginInstaller.test.ts @@ -0,0 +1,80 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { PluginId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; +import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; +import { PluginNotFoundError } from "./PluginHost.ts"; +import * as PluginInstaller from "./PluginInstaller.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import * as PluginStorage from "./PluginStorage.ts"; + +const linked = PluginId.make("linked"); +const linkedCopy: DiscoveredPlugin = { + id: linked, + rootDir: "/plugins/linked", + folder: "/plugins/linked", + source: "dev", + manifest: { id: linked, name: "Linked", version: "1.0.0", engines: { t3: ">=0.0.1" } }, +}; + +/** + * A host holding one linked plugin, whose lifecycle lock records whether it is held, and stores + * that record what they deleted and whether the lock was held then. + */ +const harness = Effect.fn(function* () { + let locked = false; + const cleared: Array<{ readonly what: string; readonly locked: boolean }> = []; + const host: PluginInstaller.PluginInstallerHost = { + lifecycle: (body) => + Effect.acquireUseRelease( + Effect.sync(() => { + locked = true; + }), + () => body, + () => + Effect.sync(() => { + locked = false; + }), + ), + copies: Effect.succeed([linkedCopy]), + copyOf: (pluginId) => + pluginId === linked + ? Effect.succeed(linkedCopy) + : Effect.fail(new PluginNotFoundError({ pluginId })), + stop: () => Effect.void, + rescan: Effect.void, + reload: (pluginId) => Effect.fail(new PluginNotFoundError({ pluginId })), + config: Effect.succeed({}), + writeConfig: () => Effect.void, + }; + const { installer } = yield* PluginInstaller.make(host).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(PluginSettingsStore.PluginSettingsStore)({ + clear: () => Effect.sync(() => cleared.push({ what: "settings", locked })), + }), + Layer.mock(PluginStorage.PluginStorage)({ + clear: () => Effect.sync(() => cleared.push({ what: "storage", locked })), + }), + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-installer-test-" }), + ), + ), + ); + return { installer, cleared }; +}); + +it.layer(NodeServices.layer)("PluginInstaller", (it) => { + it.effect("deletes a removed plugin's data before another plugin change can start", () => + Effect.gen(function* () { + const { installer, cleared } = yield* harness(); + yield* installer.uninstall(linked); + assert.deepStrictEqual(cleared, [ + { what: "settings", locked: true }, + { what: "storage", locked: true }, + ]); + }), + ); +}); diff --git a/apps/server/src/plugins/PluginInstaller.ts b/apps/server/src/plugins/PluginInstaller.ts new file mode 100644 index 0000000000000..eabef26cd3600 --- /dev/null +++ b/apps/server/src/plugins/PluginInstaller.ts @@ -0,0 +1,384 @@ +import { + type PluginId, + PluginManifest, + type PluginState, + type PluginUploadFile, +} from "@t3tools/contracts"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; +import type { PluginNotFoundError } from "./PluginHost.ts"; +import { type PluginConfigError, type PluginsConfigFile, withoutCrash } from "./pluginsConfig.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import * as PluginStorage from "./PluginStorage.ts"; + +// `reason` is shown to the user as is, so it says what to fix. +export class PluginInstallError extends Schema.TaggedError()( + "PluginInstallError", + { reason: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return this.reason; + } +} + +/** + * Installs and removes plugins. The host builds it and hands it the lifecycle lock, the plugin + * list and plugins.json, so the host keeps discovery, activation and state, and a new install + * source only changes this file. + */ +export class PluginInstaller extends Context.Service< + PluginInstaller, + { + /** + * Installs a plugin folder a client sent as files into the plugins folder. A new plugin is + * turned off; a replaced one keeps its switch and restarts if on. It is staged first and + * swapped in with a rename, so a failed install leaves the previous version. + */ + readonly installFiles: ( + files: ReadonlyArray, + ) => Effect.Effect; + /** + * Stops a plugin and deletes its folder, settings, secrets and storage; a linked folder is + * only forgotten. `folder` picks a copy. A duplicate copy's folder goes alone, and the copy in + * use keeps running with the data. + */ + readonly uninstall: ( + pluginId: PluginId, + folder?: string, + ) => Effect.Effect; + } +>()("t3/plugins/PluginInstaller") {} + +/** What the installer needs from the host that builds it. */ +export interface PluginInstallerHost { + /** + * Runs `body` under the lifecycle lock in the host's scope, so a caller that disconnects only + * stops waiting and never leaves a plugin half replaced. + */ + readonly lifecycle: (body: Effect.Effect) => Effect.Effect; + /** Every listed copy in discovery's order: an id's first copy is the one in use. */ + readonly copies: Effect.Effect>; + /** The copy of `pluginId` in use. */ + readonly copyOf: (pluginId: PluginId) => Effect.Effect; + /** Stops a plugin; the caller sets what comes next. Only under `lifecycle`. */ + readonly stop: (pluginId: PluginId) => Effect.Effect; + /** Rescans the plugin folders without restarting anything. Only under `lifecycle`. */ + readonly rescan: Effect.Effect; + /** Rescans the plugin folders and restarts the plugin, waiting until it has started. */ + readonly reload: ( + pluginId: PluginId, + ) => Effect.Effect; + /** plugins.json as last read or written. */ + readonly config: Effect.Effect; + readonly writeConfig: ( + update: (current: PluginsConfigFile) => PluginsConfigFile, + ) => Effect.Effect; +} + +/** What an install takes a plugin from: files a client sent. */ +type InstallOrigin = { readonly files: ReadonlyArray }; + +const decodeManifestJson = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); + +const installFailed = (reason: string) => (cause: unknown) => + new PluginInstallError({ reason, cause }); + +/** Builds the installer for the host. `recoverStaging` runs before the host's first discovery. */ +export const make = Effect.fn("PluginInstaller.make")(function* (host: PluginInstallerHost) { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const settingsStore = yield* PluginSettingsStore.PluginSettingsStore; + const storage = yield* PluginStorage.PluginStorage; + const scope = yield* Effect.scope; + + const pluginsDir = path.join(config.stateDir, "plugins"); + const stagingRoot = path.join(config.stateDir, "plugin-staging"); + + // An install interrupted mid-swap (a crash) can leave the old version only in staging. Listed + // while the layer builds, before the server serves requests, so recovery never touches the + // staging folder of an install that starts after that. + const leftoverStaging = yield* fs + .readDirectory(stagingRoot) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + const recoverStaging = Effect.gen(function* () { + for (const entry of leftoverStaging) { + const previous = path.join(stagingRoot, entry, "previous"); + const manifest = yield* fs + .readFileString(path.join(previous, "t3-plugin.json")) + .pipe(Effect.flatMap(decodeManifestJson), Effect.option); + if (Option.isSome(manifest)) { + const target = path.join(pluginsDir, manifest.value.id); + const replaced = yield* fs.exists(target).pipe(Effect.orElseSucceed(() => true)); + if (!replaced) { + yield* fs.makeDirectory(pluginsDir, { recursive: true }).pipe(Effect.ignore); + yield* fs.rename(previous, target).pipe(Effect.ignore); + } + } + yield* fs + .remove(path.join(stagingRoot, entry), { recursive: true, force: true }) + .pipe(Effect.ignore); + } + }).pipe(Effect.withSpan("PluginInstaller.recoverStaging")); + + // A caller that disconnects stops waiting but cannot interrupt a swap or a staging cleanup. + const inHostScope = (body: Effect.Effect) => + body.pipe(Effect.forkIn(scope), Effect.flatMap(Fiber.join)); + + const readSourceManifest = (folder: string) => + fs.readFileString(path.join(folder, "t3-plugin.json")).pipe( + Effect.mapError(installFailed("The source has no t3-plugin.json.")), + Effect.flatMap((raw) => + decodeManifestJson(raw).pipe( + Effect.mapError(installFailed("The source's t3-plugin.json is not a valid manifest.")), + ), + ), + ); + + // The upload schema checks that every path stays inside `into`. + const writeFiles = Effect.fn("PluginInstaller.writeFiles")(function* ( + files: ReadonlyArray<{ readonly path: string; readonly contents: Uint8Array }>, + into: string, + ) { + for (const file of files) { + const target = path.join(into, ...file.path.split("/")); + yield* fs + .makeDirectory(path.dirname(target), { recursive: true }) + .pipe( + Effect.andThen(fs.writeFile(target, file.contents)), + Effect.mapError(installFailed(`Could not write ${file.path}.`)), + ); + } + }); + + /** Why this server cannot run `manifest`, checked before anything is stopped or moved. */ + const refusal = (manifest: PluginManifest) => { + if (!satisfiesSemverRange(packageJson.version, manifest.engines.t3)) { + return `${manifest.name} ${manifest.version} needs T3 Code ${manifest.engines.t3}, and this server is ${packageJson.version}.`; + } + const schemaProblem = + manifest.settingsSchema === undefined + ? undefined + : PluginSettingsStore.settingsSchemaProblem(manifest.settingsSchema); + return schemaProblem === undefined + ? undefined + : `${manifest.name} ${manifest.version} has a settingsSchema T3 Code cannot validate: ${schemaProblem}`; + }; + + /** + * Swaps `/plugin` into the plugins folder under the lifecycle lock and starts it if it + * is on. The replaced version waits in `/previous` and goes back if the new one fails + * to start. `stagingDir` is removed unless it holds a version's only copy, which startup then + * restores. + */ + const swapIn = Effect.fn("PluginInstaller.swapIn")(function* ( + manifest: PluginManifest, + stagingDir: string, + ) { + const staged = path.join(stagingDir, "plugin"); + const previous = path.join(stagingDir, "previous"); + let stopped = false; + let stranded = false; + yield* Effect.addFinalizer(() => (stranded ? Effect.void : removeStaging(stagingDir))); + const swapped = yield* host + .lifecycle( + // Read under the lock, so another install or a removal cannot change them before the swap. + Effect.gen(function* () { + const copies = yield* host.copies; + const existing = copies.find((copy) => copy.id === manifest.id); + if (existing?.source === "dev") { + return yield* new PluginInstallError({ + reason: `${manifest.id} is linked from ${existing.folder}. Unlink it first.`, + }); + } + const target = + existing === undefined ? path.join(pluginsDir, manifest.id) : existing.folder; + // A folder put there by hand holds another plugin, or none, which the swap would delete. + if ( + existing === undefined && + (yield* fs.exists(target).pipe(Effect.orElseSucceed(() => true))) + ) { + const owner = copies.find((copy) => copy.folder === target); + return yield* new PluginInstallError({ + reason: `The plugins folder already has a ${manifest.id} folder${owner === undefined ? "" : ` holding ${owner.id}`}. Move it, then install again.`, + }); + } + // A new plugin starts off, so none of it runs until the user has seen its row and turned + // it on. A replaced one keeps its switch. + const enabled = + existing !== undefined && (yield* host.config).enabled?.[manifest.id] === true; + stopped = true; + yield* host.stop(manifest.id); + yield* fs + .makeDirectory(pluginsDir, { recursive: true }) + .pipe(Effect.mapError(installFailed("Could not create the plugins folder."))); + const hadPrevious = yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false)); + if (hadPrevious) { + yield* fs + .rename(target, previous) + .pipe(Effect.mapError(installFailed("Could not move the installed version aside."))); + } + yield* fs.rename(staged, target).pipe( + Effect.mapError(installFailed("Could not move the new version into place.")), + Effect.tapError(() => + hadPrevious ? fs.rename(previous, target).pipe(Effect.ignore) : Effect.void, + ), + ); + yield* host.writeConfig((current) => ({ + ...current, + enabled: { ...current.enabled, [manifest.id]: enabled }, + })); + return { + target, + hadPrevious, + replacedVersion: + existing !== undefined && "manifest" in existing + ? existing.manifest.version + : undefined, + }; + }), + ) + .pipe( + // Whatever failed after the stop, the plugin folder holds one version; start it again. + Effect.onError(() => + stopped ? host.reload(manifest.id).pipe(Effect.ignore) : Effect.void, + ), + ); + const started = yield* host.reload(manifest.id); + if (!swapped.hadPrevious || started.status !== "failed") return started; + yield* host + .lifecycle( + Effect.gen(function* () { + yield* host.stop(manifest.id); + yield* fs + .rename(swapped.target, path.join(stagingDir, "failed")) + .pipe(Effect.mapError(installFailed("Could not move the failed version aside."))); + yield* fs.rename(previous, swapped.target).pipe( + Effect.tapError(() => + Effect.sync(() => { + stranded = true; + }), + ), + Effect.mapError( + installFailed("Could not put the previous version back. Restart T3 Code to finish."), + ), + ); + }), + ) + .pipe(Effect.ensuring(host.reload(manifest.id).pipe(Effect.ignore))); + return yield* new PluginInstallError({ + reason: `${manifest.name} ${manifest.version} failed to start, so ${swapped.replacedVersion ?? "the previous version"} is back. It failed with: ${started.failure?.message ?? "an unknown error"}`, + }); + }, Effect.scoped); + + /** Copies `origin` into a new staging folder and returns the folder and what it holds. */ + const stage = Effect.fn("PluginInstaller.stage")(function* (origin: InstallOrigin) { + const stagingDir = path.join(stagingRoot, yield* crypto.randomUUIDv4().pipe(Effect.orDie)); + const staged = path.join(stagingDir, "plugin"); + yield* fs + .makeDirectory(stagingDir, { recursive: true }) + .pipe(Effect.mapError(installFailed("Could not create the staging folder."))); + return yield* Effect.gen(function* () { + yield* writeFiles(origin.files, staged); + return { stagingDir, manifest: yield* readSourceManifest(staged) }; + }).pipe(Effect.tapError(() => removeStaging(stagingDir))); + }); + + const removeStaging = (stagingDir: string) => + fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore); + + const installOrigin = Effect.fn("PluginInstaller.installOrigin")(function* ( + origin: InstallOrigin, + ) { + const { stagingDir, manifest } = yield* stage(origin); + const reason = refusal(manifest); + if (reason !== undefined) { + yield* removeStaging(stagingDir); + return yield* new PluginInstallError({ reason }); + } + return yield* swapIn(manifest, stagingDir); + }); + + // `PluginUploadFiles` bounds the folder's size before it gets here. + const installFiles: PluginInstaller["Service"]["installFiles"] = Effect.fn( + "PluginInstaller.installFiles", + )((files) => inHostScope(installOrigin({ files }))); + + const removeFromConfig = (pluginId: PluginId) => + host.writeConfig((current) => { + const { [pluginId]: _removed, ...enabled } = current.enabled ?? {}; + return { ...withoutCrash(current, pluginId), enabled }; + }); + + const forgetDevPath = (folder: string) => + host.writeConfig((current) => ({ + ...current, + devPaths: (current.devPaths ?? []).filter((entry) => path.resolve(entry) !== folder), + })); + + // The folder as listed, not `rootDir`: that is the real path, so a symlinked plugin would delete + // the user's own folder behind the link. A linked folder is the author's own; only plugins.json + // forgets it. + const removeFolder = (copy: DiscoveredPlugin) => + copy.source === "dev" + ? forgetDevPath(copy.folder) + : fs + .remove(copy.folder, { recursive: true, force: true }) + .pipe(Effect.mapError(installFailed(`Could not delete ${copy.folder}.`))); + + const uninstall: PluginInstaller["Service"]["uninstall"] = Effect.fn("PluginInstaller.uninstall")( + (pluginId, folder) => + host.lifecycle( + Effect.gen(function* () { + const inUse = yield* host.copyOf(pluginId); + const copy = + folder === undefined + ? inUse + : (yield* host.copies).find( + (candidate) => + candidate.id === pluginId && candidate.folder === path.resolve(folder), + ); + if (copy === undefined) { + return yield* new PluginInstallError({ + reason: `No copy of ${pluginId} is in ${folder}.`, + }); + } + if (copy !== inUse) { + yield* removeFolder(copy); + return yield* host.rescan; + } + yield* host.stop(pluginId); + yield* removeFolder(copy); + yield* removeFromConfig(pluginId); + // Lists a duplicate copy afresh, turned off. + yield* host.rescan; + // Under the lock, or an install that starts meanwhile could lose its new settings. + yield* settingsStore + .clear(pluginId) + .pipe( + Effect.mapError(installFailed("The plugin is removed, but its settings are not.")), + ); + yield* storage + .clear(pluginId) + .pipe(Effect.mapError(installFailed("The plugin is removed, but its storage is not."))); + }), + ), + ); + + return { + installer: PluginInstaller.of({ installFiles, uninstall }), + recoverStaging, + }; +}); diff --git a/apps/server/src/plugins/PluginMcpTools.test.ts b/apps/server/src/plugins/PluginMcpTools.test.ts new file mode 100644 index 0000000000000..c8301a3db0943 --- /dev/null +++ b/apps/server/src/plugins/PluginMcpTools.test.ts @@ -0,0 +1,297 @@ +import { assert, it } from "@effect/vitest"; +import { EnvironmentId, PluginId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import type { PluginApi, PluginDefinition } from "@t3tools/plugin-sdk/server"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { McpSchema, McpServer } from "effect/ai"; + +import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import { liveThreadsLayer } from "../mcp/McpToolAccess.testkit.ts"; +import { makeFacadeContext } from "./api/facade.ts"; +import { makeMcpApi } from "./api/mcp.ts"; +import { makePluginActivation } from "./PluginApi.ts"; +import * as PluginMcpTools from "./PluginMcpTools.ts"; + +const fixturesDir = new URL("../../../../packages/plugin-sdk/fixtures/", import.meta.url).pathname; +const mcpPlugin = PluginId.make("mcp-plugin"); + +const layerTest = McpHttpServer.layerPluginTools.pipe( + Layer.provideMerge(PluginMcpTools.layer), + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(liveThreadsLayer), +); + +const mcpClient = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "mcp-test", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "mcp-test", version: "1" }, + }, + getClient: Effect.die("unused"), +}); + +const threadCaller: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment"), + requestNamespace: "provider:thread", + thread: { + threadId: ThreadId.make("thread:caller"), + providerSessionId: "provider:thread", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(), + issuedAt: 0, +}; + +// Plugin write tools need what environment writes need: a full-access caller. +const fullAccessClient: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment"), + requestNamespace: "client:full", + thread: undefined, + client: { sessionId: "full", label: "Claude Code", access: "full-access" }, + capabilities: new Set(["orchestration"]), + issuedAt: 0, +}; + +const readOnlyClient: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment"), + requestNamespace: "client:session", + thread: undefined, + client: { sessionId: "session", label: "Claude Code", access: "read-only" }, + capabilities: new Set(), + issuedAt: 0, +}; + +const call = ( + name: string, + scope: McpInvocationContext.McpInvocationScope, + args: Record = {}, +) => + McpServer.McpServer.pipe( + Effect.flatMap((server) => server.callTool({ name, arguments: args })), + Effect.map((result) => ({ + isError: result.isError === true, + text: result.content.map((part) => (part.type === "text" ? part.text : "")).join(""), + })), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, mcpClient), + ); + +// What `tools/list` shows the client: the tools whose `EnabledWhen` passes. +const toolNames = McpServer.McpServer.pipe( + Effect.map((server) => + server.tools + .filter( + ({ annotations }) => + Context.getOrUndefined( + annotations, + McpSchema.EnabledWhen, + )?.({ + protocolVersion: mcpClient.protocolVersion, + capabilities: mcpClient.clientCapabilities, + clientInfo: mcpClient.clientInfo, + }) ?? true, + ) + .map(({ tool }) => tool.name), + ), +); + +/** Lists tools, then calls `name`, which must be gone from both. */ +const assertGone = Effect.fn(function* (name: string) { + assert.notInclude(yield* toolNames, name); + const notFound = yield* call(name, fullAccessClient).pipe(Effect.flip); + assert.include(notFound.message, "not found"); +}); + +const isDefinition = (value: unknown): value is PluginDefinition => + typeof value === "object" && + value !== null && + "activate" in value && + typeof value.activate === "function"; + +/** Activates the fixture with only the `mcp` group, the one it uses. */ +const activateFixture = Effect.fn(function* () { + const loaded: unknown = yield* Effect.promise( + () => import(`${fixturesDir}mcp-plugin/server.cjs`), + ); + const definition = + typeof loaded === "object" && loaded !== null && "default" in loaded + ? loaded.default + : undefined; + assert(isDefinition(definition)); + const activation = makePluginActivation(); + const facade = makeFacadeContext({ + pluginId: mcpPlugin, + activation, + context: yield* Effect.context(), + }); + const api: Pick = { + mcp: makeMcpApi(facade, { mcpTools: yield* PluginMcpTools.PluginMcpTools }), + }; + yield* Effect.promise(() => Promise.resolve(definition.activate(api as PluginApi))); + return { ...api, activation }; +}); + +it.effect("answer under plg_ names with access checks, and leave the list once stopped", () => + Effect.gen(function* () { + const api = yield* activateFixture(); + api.mcp.registerTool( + { name: "fail", description: "Throws.", inputSchema: { type: "object" }, access: "read" }, + () => { + throw new Error("boom"); + }, + ); + api.mcp.registerTool( + { + name: "caller", + description: "Names its caller.", + inputSchema: { type: "object" }, + access: "read", + }, + (_input, { threadId }) => ({ threadId }), + ); + assert.throws( + () => + api.mcp.registerTool( + { name: "no spaces", description: "", inputSchema: {}, access: "read" }, + () => null, + ), + TypeError, + ); + // Anthropic's API refuses the whole tool list over one of these at the top. + assert.throws( + () => + api.mcp.registerTool( + { + name: "either", + description: "Takes one of two shapes.", + inputSchema: { type: "object", oneOf: [{ required: ["a"] }, { required: ["b"] }] }, + access: "read", + }, + () => null, + ), + /oneOf at the top of its input schema/, + ); + assert.throws( + () => + api.mcp.registerTool( + { + name: "verbose", + description: "x".repeat(1001), + inputSchema: { type: "object" }, + access: "read", + }, + () => null, + ), + /1000 characters/, + ); + assert.throws( + () => + api.mcp.registerTool( + { + name: "wide", + description: "Takes a long schema.", + inputSchema: { type: "object", description: "x".repeat(4000) }, + access: "read", + }, + () => null, + ), + /4000 characters/, + ); + + const names = yield* toolNames; + assert.includeMembers(names, ["plg_mcp-plugin_echo", "plg_mcp-plugin_touch"]); + assert.deepStrictEqual(yield* call("plg_mcp-plugin_echo", readOnlyClient, { text: "hi" }), { + isError: false, + text: '{"echoed":"hi"}', + }); + const refused = yield* call("plg_mcp-plugin_touch", readOnlyClient); + assert.isTrue(refused.isError); + assert.include(refused.text, "read-only"); + assert.deepStrictEqual(yield* call("plg_mcp-plugin_touch", fullAccessClient), { + isError: false, + text: '{"touches":1}', + }); + assert.deepStrictEqual(yield* call("plg_mcp-plugin_caller", threadCaller), { + isError: false, + text: '{"threadId":"thread:caller"}', + }); + assert.deepStrictEqual(yield* call("plg_mcp-plugin_caller", fullAccessClient), { + isError: false, + text: '{"threadId":null}', + }); + assert.deepStrictEqual(yield* call("plg_mcp-plugin_fail", threadCaller), { + isError: true, + text: "Plugin mcp-plugin failed in fail: boom", + }); + + // A call to a handler that ignores its abort signal ends at the deadline, which aborts the + // signal, or when the plugin turns off. + const signals: Array = []; + let reached = Promise.withResolvers(); + api.mcp.registerTool( + { + name: "hang", + description: "Never answers.", + inputSchema: { type: "object" }, + access: "read", + }, + (_input, { signal }) => { + signals.push(signal); + reached.resolve(); + return new Promise(() => undefined); + }, + ); + const timedOut = yield* call("plg_mcp-plugin_hang", threadCaller).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.promise(() => reached.promise); + yield* TestClock.adjust(Duration.minutes(5)); + assert.deepStrictEqual(yield* Fiber.join(timedOut), { + isError: true, + text: "Plugin mcp-plugin did not answer hang within 5 minutes.", + }); + assert.isTrue(signals[0]?.aborted); + + reached = Promise.withResolvers(); + const hanging = yield* call("plg_mcp-plugin_hang", threadCaller).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.promise(() => reached.promise); + + const offered = (yield* toolNames).filter((name) => name.startsWith("plg_mcp-plugin_")); + const extra = { + description: "Extra.", + inputSchema: { type: "object" }, + access: "read", + } as const; + for (let index = offered.length; index < 32; index++) { + api.mcp.registerTool({ ...extra, name: `extra-${index}` }, () => null); + } + assert.throws( + () => api.mcp.registerTool({ ...extra, name: "one-more" }, () => null), + /at most 32 tools/, + ); + // Replacing a tool doesn't add one. + api.mcp.registerTool({ ...extra, name: "caller" }, () => null); + + api.activation.controller.abort(); + assert.deepStrictEqual(yield* Fiber.join(hanging), { + isError: true, + text: "Plugin mcp-plugin is disabled.", + }); + + const tools = yield* PluginMcpTools.PluginMcpTools; + yield* tools.unregisterPlugin(mcpPlugin); + yield* assertGone("plg_mcp-plugin_echo"); + }).pipe(Effect.provide(layerTest)), +); diff --git a/apps/server/src/plugins/PluginMcpTools.ts b/apps/server/src/plugins/PluginMcpTools.ts new file mode 100644 index 0000000000000..ad4826c559669 --- /dev/null +++ b/apps/server/src/plugins/PluginMcpTools.ts @@ -0,0 +1,220 @@ +import { PluginId } from "@t3tools/contracts"; +import type { PluginMcpTool, PluginRpcAccess } from "@t3tools/plugin-sdk/server"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; + +import { abortEffect, errorText } from "./api/facade.ts"; + +// MCP clients reject longer tool names. +const MAX_TOOL_NAME_CHARS = 64; +const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; +// Every thread's agent reads every enabled plugin's tools. +const MAX_TOOLS_PER_PLUGIN = 32; +const MAX_DESCRIPTION_CHARS = 1000; +// Measured as the JSON clients receive. +const MAX_INPUT_SCHEMA_CHARS = 4000; +// Longer than a client's call, because a tool may do real work, but a hung one frees the turn. +const TOOL_CALL_DEADLINE = Duration.minutes(5); + +const isJson = Schema.is(Schema.Json); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.JsonObject)); + +export class PluginMcpToolNameError extends Schema.TaggedError()( + "PluginMcpToolNameError", + { pluginId: PluginId }, +) { + override get message(): string { + return `Tool names use letters, digits, "_" and "-", and plg_${this.pluginId}_ must fit in ${MAX_TOOL_NAME_CHARS} characters.`; + } +} +export const isPluginMcpToolNameError = Schema.is(PluginMcpToolNameError); + +export class PluginMcpToolLimitError extends Schema.TaggedError()( + "PluginMcpToolLimitError", + { tool: Schema.String, limit: Schema.Literals(["tools", "description", "inputSchema"]) }, +) { + override get message(): string { + switch (this.limit) { + case "tools": + return `A plugin registers at most ${MAX_TOOLS_PER_PLUGIN} tools, so "${this.tool}" was not added.`; + case "description": + return `Tool "${this.tool}" needs a description of at most ${MAX_DESCRIPTION_CHARS} characters.`; + case "inputSchema": + return `Tool "${this.tool}" needs an input schema of at most ${MAX_INPUT_SCHEMA_CHARS} characters as JSON.`; + } + } +} + +// `reason` is the plugin's own error text, bounded, so the agent can see why the tool failed. +export class PluginMcpToolError extends Schema.TaggedError()( + "PluginMcpToolError", + { pluginId: PluginId, tool: Schema.String, reason: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Plugin ${this.pluginId} failed in ${this.tool}: ${this.reason}`; + } +} + +export class PluginMcpToolTimeoutError extends Schema.TaggedError()( + "PluginMcpToolTimeoutError", + { pluginId: PluginId, tool: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} did not answer ${this.tool} within ${Duration.toMinutes(TOOL_CALL_DEADLINE)} minutes.`; + } +} + +export class PluginMcpToolDisabledError extends Schema.TaggedError()( + "PluginMcpToolDisabledError", + { pluginId: PluginId }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is disabled.`; + } +} + +/** One tool as `/mcp` advertises it. `call` runs it; McpHttpServer checks `access` first. */ +export interface PluginMcpToolEntry { + readonly name: string; + readonly description: string; + readonly inputSchema: Schema.JsonObject; + readonly access: PluginRpcAccess; + readonly call: ( + input: Schema.JsonObject, + caller: { readonly threadId: string | null }, + ) => Effect.Effect< + Schema.Json, + PluginMcpToolError | PluginMcpToolTimeoutError | PluginMcpToolDisabledError + >; +} + +export class PluginMcpTools extends Context.Service< + PluginMcpTools, + { + /** Offers `tool` to agents as `plg__`, replacing a tool of that name. */ + readonly register: (input: { + readonly pluginId: PluginId; + readonly tool: PluginMcpTool; + /** `signal` aborts when the agent cancels the call or the deadline passes. */ + readonly handler: ( + input: Schema.JsonObject, + context: { readonly signal: AbortSignal; readonly threadId: string | null }, + ) => Promise; + /** Aborts when the plugin turns off; a call still running then answers that it is disabled. */ + readonly deactivated: AbortSignal; + }) => Effect.Effect; + /** Withdraws the plugin's tools. */ + readonly unregisterPlugin: (pluginId: PluginId) => Effect.Effect; + /** + * Whether `/mcp` lists and runs the tool, read on every list and call. Withdrawn tools stay + * registered, because MCP servers can't remove one, so this hides them. + */ + readonly isOffered: (name: string) => boolean; + /** + * Hands `publish` every tool now and each change until the scope closes. A withdrawn tool is + * published again, so clients hear the list changed. + */ + readonly attach: ( + publish: (tool: PluginMcpToolEntry) => Effect.Effect, + ) => Effect.Effect; + } +>()("t3/plugins/PluginMcpTools") {} + +const make = () => { + // Every change runs synchronously, so plugin activation can register a tool and return. + const tools = new Map(); + const toolsByPlugin = new Map>(); + const publishers = new Set<(tool: PluginMcpToolEntry) => Effect.Effect>(); + // Inside `register` or `unregisterPlugin`, which have their spans. + const publish = Effect.fnUntraced(function* (entry: PluginMcpToolEntry) { + tools.set(entry.name, entry); + for (const publisher of publishers) yield* publisher(entry); + }); + const withdraw = Effect.fnUntraced(function* (name: string) { + const entry = tools.get(name); + if (entry === undefined) return; + tools.delete(name); + for (const publisher of publishers) yield* publisher(entry); + }); + + return PluginMcpTools.of({ + register: Effect.fn("PluginMcpTools.register")(function* ({ + pluginId, + tool, + handler, + deactivated, + }) { + const name = `plg_${pluginId}_${tool.name}`; + if (!TOOL_NAME_PATTERN.test(tool.name) || name.length > MAX_TOOL_NAME_CHARS) { + return yield* new PluginMcpToolNameError({ pluginId }); + } + if (tool.description.length > MAX_DESCRIPTION_CHARS) { + return yield* new PluginMcpToolLimitError({ tool: tool.name, limit: "description" }); + } + if (encodeJson(tool.inputSchema).length > MAX_INPUT_SCHEMA_CHARS) { + return yield* new PluginMcpToolLimitError({ tool: tool.name, limit: "inputSchema" }); + } + const names = toolsByPlugin.get(pluginId) ?? new Set(); + if (!names.has(name) && names.size >= MAX_TOOLS_PER_PLUGIN) { + return yield* new PluginMcpToolLimitError({ tool: tool.name, limit: "tools" }); + } + const failed = (cause: unknown) => + new PluginMcpToolError({ pluginId, tool: tool.name, reason: errorText(cause), cause }); + toolsByPlugin.set(pluginId, names.add(name)); + yield* publish({ + name, + description: tool.description, + inputSchema: tool.inputSchema, + access: tool.access, + call: (input, caller) => + Effect.raceFirst( + Effect.tryPromise({ + try: (signal) => handler(input, { signal, threadId: caller.threadId }), + catch: failed, + }).pipe( + // The deadline interrupts the call, which aborts the handler's signal. + Effect.timeoutOrElse({ + duration: TOOL_CALL_DEADLINE, + orElse: () => + Effect.fail(new PluginMcpToolTimeoutError({ pluginId, tool: tool.name })), + }), + Effect.flatMap((output: unknown) => + output === undefined + ? Effect.succeed(null) + : isJson(output) + ? Effect.succeed(output) + : Effect.fail( + failed(new TypeError("The tool returned a value that is not JSON.")), + ), + ), + ), + // A handler that ignores its abort signal must not hold the agent's call open. + abortEffect(deactivated).pipe( + Effect.andThen(Effect.fail(new PluginMcpToolDisabledError({ pluginId }))), + ), + ), + }); + }), + unregisterPlugin: Effect.fn("PluginMcpTools.unregisterPlugin")(function* (pluginId) { + const names = toolsByPlugin.get(pluginId); + toolsByPlugin.delete(pluginId); + for (const name of names ?? []) yield* withdraw(name); + }), + isOffered: (name) => tools.has(name), + attach: Effect.fn("PluginMcpTools.attach")((publisher) => + Effect.acquireRelease( + Effect.gen(function* () { + publishers.add(publisher); + for (const entry of tools.values()) yield* publisher(entry); + }), + () => Effect.sync(() => publishers.delete(publisher)), + ), + ), + }); +}; + +export const layer = Layer.sync(PluginMcpTools, make); diff --git a/apps/server/src/plugins/PluginSettingsStore.test.ts b/apps/server/src/plugins/PluginSettingsStore.test.ts new file mode 100644 index 0000000000000..b965885668d52 --- /dev/null +++ b/apps/server/src/plugins/PluginSettingsStore.test.ts @@ -0,0 +1,219 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PluginId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { SECRET_REDACTED } from "../settingsSecrets.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; + +const hello = PluginId.make("hello"); + +const settingsSchema: Schema.JsonObject = { + type: "object", + properties: { + count: { type: "integer", minimum: 0 }, + name: { type: "string" }, + token: { type: "string", format: "t3-secret" }, + }, + additionalProperties: false, +}; + +// Like a keychain, this store can't list what it holds. +const layerSecretStoreWithoutFolder = Layer.effect( + ServerSecretStore.ServerSecretStore, + Effect.map(ServerSecretStore.make, ({ directory: _directory, ...store }) => + ServerSecretStore.ServerSecretStore.of(store), + ), +); + +const layerTest = () => + Layer.mergeAll(PluginSettingsStore.layer, ServerSettings.layer).pipe( + Layer.provideMerge(layerSecretStoreWithoutFolder), + Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-settings-test-" }), + ), + ), + ); + +const readSettingsFile = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fileSystem.readFileString( + path.join(stateDir, "plugin-data", hello, "settings.json"), + ); +}); + +it.layer(NodeServices.layer)("PluginSettingsStore", (it) => { + it.effect("rejects values outside the schema without writing them", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + yield* store.update(hello, settingsSchema, { count: 1 }); + + const error = yield* Effect.flip( + store.update(hello, settingsSchema, { count: -1, extra: true }), + ); + assert.strictEqual(error._tag, "PluginSettingsValidationError"); + assert.include(error.message, 'at ["count"]: Expected a value greater than or equal to 0'); + assert.include(error.message, 'at ["extra"]: Expected no excess property'); + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { count: 1 }); + + const unreadable = yield* Effect.flip( + store.update(hello, { type: "object", properties: { count: { $ref: "#/$defs/no" } } }, {}), + ); + assert.strictEqual(unreadable._tag, "PluginSettingsSchemaError"); + assert.include(unreadable.message, 'Missing definition "no"'); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("drops keys a newer schema no longer has instead of refusing every save", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const withFoo: Schema.JsonObject = { + ...settingsSchema, + properties: { + ...(settingsSchema.properties as Schema.JsonObject), + foo: { type: "string" }, + }, + }; + yield* store.update(hello, withFoo, { foo: "old", count: 1 }); + + assert.deepStrictEqual(yield* store.update(hello, settingsSchema, { count: 2 }), { + count: 2, + }); + assert.deepStrictEqual(JSON.parse(yield* readSettingsFile), { count: 2 }); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("gives the plugin its schema's defaults and clients only what is saved", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const withDefaults: Schema.JsonObject = { + type: "object", + properties: { + enabled: { type: "boolean", default: true }, + name: { type: "string", default: "T3" }, + token: { type: "string", format: "t3-secret", default: "not-a-secret" }, + }, + // A required field with a default must not block a save that leaves it unsaved. + required: ["enabled"], + }; + const next = yield* store + .changes(hello) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + assert.deepStrictEqual(yield* store.get(hello, withDefaults), { enabled: true, name: "T3" }); + + yield* store.update(hello, withDefaults, { name: "Mine" }); + assert.deepStrictEqual(yield* store.get(hello, withDefaults), { + enabled: true, + name: "Mine", + }); + assert.deepStrictEqual(yield* Fiber.join(next), Option.some({ enabled: true, name: "Mine" })); + assert.deepStrictEqual(yield* store.getForClient(hello, withDefaults), { name: "Mine" }); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("keeps secrets out of settings.json and redacts them for clients", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const { secretsDir } = yield* ServerConfig.ServerConfig; + + assert.deepStrictEqual( + yield* store.update(hello, settingsSchema, { name: "a", token: "s3cret" }), + { name: "a", token: SECRET_REDACTED }, + ); + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { + name: "a", + token: "s3cret", + }); + assert.deepStrictEqual(JSON.parse(yield* readSettingsFile), { name: "a" }); + + yield* store.update(hello, settingsSchema, { name: "b", token: SECRET_REDACTED }); + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { + name: "b", + token: "s3cret", + }); + + yield* store.update(hello, settingsSchema, { token: null }); + assert.deepStrictEqual(yield* store.getForClient(hello, settingsSchema), { name: "b" }); + assert.isEmpty( + yield* FileSystem.FileSystem.use((fileSystem) => fileSystem.readDirectory(secretsDir)), + ); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("clears every secret a plugin saved, and only its own", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const { secretsDir, stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const longerId = PluginId.make("hello-world"); + yield* store.update(hello, settingsSchema, { token: "old-secret" }); + yield* store.update(longerId, settingsSchema, { token: "other-secret" }); + // A longer id's secrets are kept even when it has no settings folder. + yield* fileSystem.remove(path.join(stateDir, "plugin-data", longerId), { recursive: true }); + + // T3's own upload signing key, whose name starts like this plugin's secrets would. + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const signingKey = yield* secretStore.getOrCreateRandom("plugin-upload-signing-key", 32); + + yield* store.clear(hello); + yield* store.clear(PluginId.make("upload-signing-key")); + + assert.sameMembers(yield* fileSystem.readDirectory(secretsDir), [ + `plugin.${longerId}.bin`, + "plugin-upload-signing-key.bin", + ]); + assert.deepStrictEqual(yield* store.get(longerId, settingsSchema), { token: "other-secret" }); + // Reinstalling starts fresh, not from the copy the store keeps in memory. + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), {}); + assert.deepStrictEqual( + yield* secretStore.get("plugin-upload-signing-key"), + Option.some(signingKey), + ); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("keeps plugin settings when the server saves its own settings", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const serverSettings = yield* ServerSettings.ServerSettingsService; + yield* store.update(hello, settingsSchema, { count: 2, token: "s3cret" }); + + yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/next" }); + + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { + count: 2, + token: "s3cret", + }); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("emits the plugin view after an update of that plugin", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const next = yield* store + .changes(hello) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + + yield* store.update(PluginId.make("other"), undefined, { count: 9 }); + yield* store.update(hello, settingsSchema, { count: 3, token: "s3cret" }); + + assert.deepStrictEqual(yield* Fiber.join(next), Option.some({ count: 3, token: "s3cret" })); + }).pipe(Effect.provide(layerTest())), + ); +}); diff --git a/apps/server/src/plugins/PluginSettingsStore.ts b/apps/server/src/plugins/PluginSettingsStore.ts new file mode 100644 index 0000000000000..d3c7c0f51f4d6 --- /dev/null +++ b/apps/server/src/plugins/PluginSettingsStore.ts @@ -0,0 +1,463 @@ +import { PluginId } from "@t3tools/contracts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as JsonSchema from "effect/JsonSchema"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Predicate from "effect/Predicate"; +import * as PubSub from "effect/PubSub"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; +import * as SchemaRepresentation from "effect/SchemaRepresentation"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { SECRET_REDACTED } from "../settingsSecrets.ts"; + +// The Settings page shows the summary, so it stays readable when a patch breaks many fields. +const MAX_VALIDATION_SUMMARY_LENGTH = 1000; +const MAX_SCHEMA_REASON_LENGTH = 500; + +const persistenceOperations = { + read: "read the settings", + write: "save the settings", + readSecret: "read a secret setting", + writeSecret: "save a secret setting", +} as const; + +export class PluginSettingsPersistenceError extends Schema.TaggedError()( + "PluginSettingsPersistenceError", + { + pluginId: PluginId, + operation: Schema.Literals(["read", "write", "readSecret", "writeSecret"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${persistenceOperations[this.operation]} of plugin ${this.pluginId}.`; + } +} + +export class PluginSettingsSchemaError extends Schema.TaggedError()( + "PluginSettingsSchemaError", + { + pluginId: PluginId, + /** The importer's reason, which comes from the plugin's own manifest. */ + reason: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Plugin ${this.pluginId} has a settings schema T3 Code cannot validate: ${this.reason}`; + } +} + +export class PluginSettingsValidationError extends Schema.TaggedError()( + "PluginSettingsValidationError", + { + pluginId: PluginId, + summary: Schema.String, + }, +) { + override get message(): string { + return `Invalid settings for plugin ${this.pluginId}: ${this.summary}`; + } +} + +export class PluginSettingsStore extends Context.Service< + PluginSettingsStore, + { + /** Stored settings with secret values, over the schema's top-level defaults, for the plugin. */ + readonly get: ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) => Effect.Effect; + /** + * Stored settings for clients, without defaults. A secret that has a value reads as + * `SECRET_REDACTED`. + */ + readonly getForClient: ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) => Effect.Effect; + /** + * Merges `patch` over the stored settings at the top level, where `null` removes a key and + * `SECRET_REDACTED` keeps a secret, validates the result, saves it and returns the client view. + * Stored keys a schema with `additionalProperties: false` no longer lists are dropped first. + */ + readonly update: ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + patch: Schema.JsonObject, + ) => Effect.Effect< + Schema.JsonObject, + PluginSettingsPersistenceError | PluginSettingsSchemaError | PluginSettingsValidationError + >; + /** The plugin view after each successful `update` of that plugin. */ + readonly changes: (pluginId: PluginId) => Stream.Stream; + /** Deletes the plugin's settings file and every secret it saved. */ + readonly clear: (pluginId: PluginId) => Effect.Effect; + } +>()("t3/plugins/PluginSettingsStore") {} + +interface StoredSettings { + readonly values: Schema.JsonObject; + readonly secrets: Readonly>; +} + +/** The text of settings.json and of the plugin's secret entry, undefined when there is none. */ +interface SavedFiles { + readonly settings: string | undefined; + readonly secrets: string | undefined; +} + +interface SettingsChange { + readonly pluginId: PluginId; + readonly settings: Schema.JsonObject; +} + +const SettingsJson = fromJsonStringPretty(Schema.JsonObject); +const decodeSettingsJson = Schema.decodeUnknownEffect(SettingsJson); +const encodeSettingsJson = Schema.encodeEffect(SettingsJson); +const SecretsJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)); +const decodeSecretsJson = Schema.decodeUnknownEffect(SecretsJson); +const encodeSecretsJson = Schema.encodeEffect(SecretsJson); +const isJson = Schema.is(Schema.Json); +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +// Top-level string fields with `format: "t3-secret"` live in ServerSecretStore, never settings.json. +const secretFields = (settingsSchema: Schema.JsonObject | undefined): ReadonlySet => { + const properties = settingsSchema?.properties; + if (!Predicate.isObject(properties)) return new Set(); + return new Set( + Object.entries(properties).flatMap(([field, property]) => + Predicate.isObject(property) && property.type === "string" && property.format === "t3-secret" + ? [field] + : [], + ), + ); +}; + +// Top-level `default`s, which the plugin reads for fields nobody saved. Secrets have none. +const defaultsOf = (settingsSchema: Schema.JsonObject | undefined): Schema.JsonObject => { + const properties = settingsSchema?.properties; + if (!Predicate.isObject(properties)) return {}; + const secrets = secretFields(settingsSchema); + return Object.fromEntries( + Object.entries(properties).flatMap(([field, property]) => + Predicate.isObject(property) && !secrets.has(field) && isJson(property.default) + ? [[field, property.default]] + : [], + ), + ); +}; + +// Keys saved under an older schema that a closed schema no longer lists. Validating them would +// refuse every save, and the form cannot remove a key it does not show. +const keysOutside = (settingsSchema: Schema.JsonObject | undefined, values: Schema.JsonObject) => { + const properties = settingsSchema?.properties; + if (settingsSchema?.additionalProperties !== false || !Predicate.isObject(properties)) return []; + return Object.keys(values).filter((key) => !Object.hasOwn(properties, key)); +}; + +// One entry holds all of a plugin's secrets, so removal finds them in a store that can't list +// names. T3's own secret names have no `.`, so this name can't be one of them. +const secretName = (pluginId: PluginId) => `plugin.${pluginId}`; + +const formatPathSegment = (segment: PropertyKey | { readonly key: PropertyKey }) => { + const key = Predicate.isObject(segment) ? segment.key : segment; + return typeof key === "number" ? `[${key}]` : `["${String(key)}"]`; +}; + +// Issues never carry input values because decoding does not set `reportInput`. +const summarizeIssue = (issue: SchemaIssue.Issue) => { + const summary = SchemaIssue.makeFormatterStandardSchemaV1()(issue) + .issues.map(({ path, message }) => + path === undefined || path.length === 0 + ? message + : `at ${path.map(formatPathSegment).join("")}: ${message}`, + ) + .join("; "); + return summary.length > MAX_VALIDATION_SUMMARY_LENGTH + ? `${summary.slice(0, MAX_VALIDATION_SUMMARY_LENGTH - 1)}…` + : summary; +}; + +const compileSettingsSchema = (settingsSchema: Schema.JsonObject) => + Result.try(() => + Schema.decodeUnknownResult( + // Imported JSON Schemas need no decoding services, which `Schema.Top` cannot express. + Schema.make>( + SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12(settingsSchema), + // Plugins are trusted code, so their patterns are applied rather than rejected. + { patterns: "apply" }, + ).ast, + ), + { onExcessProperty: "error", errors: "all" }, + ), + ); + +const importerReason = (cause: unknown) => + (cause instanceof Error ? cause.message : String(cause)).slice(0, MAX_SCHEMA_REASON_LENGTH); + +/** Why settings cannot be validated against `settingsSchema`, or `undefined` when they can. */ +export const settingsSchemaProblem = (settingsSchema: Schema.JsonObject) => { + const compiled = compileSettingsSchema(settingsSchema); + return Result.isFailure(compiled) ? importerReason(compiled.failure) : undefined; +}; + +const make = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const writeSemaphore = yield* Semaphore.make(1); + // Sliding, so a subscriber that stops pulling drops old changes instead of blocking `update`. + const changesPubSub = yield* PubSub.sliding(64); + // Keyed by schema identity: a reloaded plugin brings a new manifest object. + const validators = new WeakMap>(); + // Only `update` and `clear` write these files, under the write lock, so the copy stays true and + // a hand edit shows after a restart. Text, so a plugin that mutates what it read can't change it. + const savedFiles = new Map(); + + const settingsPath = (pluginId: PluginId) => + path.join(stateDir, "plugin-data", pluginId, "settings.json"); + + const validatorFor = (pluginId: PluginId, settingsSchema: Schema.JsonObject) => { + let validator = validators.get(settingsSchema); + if (validator === undefined) { + validator = compileSettingsSchema(settingsSchema); + validators.set(settingsSchema, validator); + } + return Result.isSuccess(validator) + ? Effect.succeed(validator.success) + : Effect.fail( + new PluginSettingsSchemaError({ + pluginId, + reason: importerReason(validator.failure), + cause: validator.failure, + }), + ); + }; + + // The caller holds the write lock, so a read from disk can't save a copy older than a write. + const readSavedFiles = Effect.fnUntraced(function* (pluginId: PluginId) { + const cached = savedFiles.get(pluginId); + if (cached !== undefined) return cached; + const settings = yield* fileSystem.readFileString(settingsPath(pluginId)).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.undefined, + ), + Effect.mapError( + (cause) => new PluginSettingsPersistenceError({ pluginId, operation: "read", cause }), + ), + ); + const secrets = yield* secretStore.get(secretName(pluginId)).pipe( + Effect.map((bytes) => + Option.getOrUndefined(Option.map(bytes, (value) => textDecoder.decode(value))), + ), + Effect.mapError( + (cause) => new PluginSettingsPersistenceError({ pluginId, operation: "readSecret", cause }), + ), + ); + const files: SavedFiles = { settings, secrets }; + savedFiles.set(pluginId, files); + return files; + }); + + // Shares the span of the public operation that reads. + const load = Effect.fnUntraced( + function* (pluginId: PluginId, fields: ReadonlySet, files: SavedFiles) { + const stored = files.settings === undefined ? {} : yield* decodeSettingsJson(files.settings); + const values = Object.fromEntries(Object.entries(stored).filter(([key]) => !fields.has(key))); + const saved = yield* decodeSecretsJson(files.secrets ?? "{}").pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ pluginId, operation: "readSecret", cause }), + ), + ); + return { + values, + secrets: Object.fromEntries(Object.entries(saved).filter(([field]) => fields.has(field))), + } satisfies StoredSettings; + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail(new PluginSettingsPersistenceError({ pluginId, operation: "read", cause })), + }), + ), + ); + + const loadCurrent = (pluginId: PluginId, fields: ReadonlySet) => { + const cached = savedFiles.get(pluginId); + return ( + cached === undefined + ? writeSemaphore.withPermits(1)(readSavedFiles(pluginId)) + : Effect.succeed(cached) + ).pipe(Effect.flatMap((files) => load(pluginId, fields, files))); + }; + + const clientView = ({ values, secrets }: StoredSettings): Schema.JsonObject => ({ + ...values, + ...Object.fromEntries(Object.keys(secrets).map((field) => [field, SECRET_REDACTED])), + }); + + const get = Effect.fn("PluginSettingsStore.get")(function* ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) { + const { values, secrets } = yield* loadCurrent(pluginId, secretFields(settingsSchema)); + return { ...defaultsOf(settingsSchema), ...values, ...secrets } satisfies Schema.JsonObject; + }); + + const getForClient = Effect.fn("PluginSettingsStore.getForClient")(function* ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) { + return clientView(yield* loadCurrent(pluginId, secretFields(settingsSchema))); + }); + + const update = Effect.fn("PluginSettingsStore.update")(function* ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + patch: Schema.JsonObject, + ) { + const fields = secretFields(settingsSchema); + const validate = + settingsSchema === undefined ? undefined : yield* validatorFor(pluginId, settingsSchema); + return yield* writeSemaphore.withPermits(1)( + Effect.gen(function* () { + const current = yield* load(pluginId, fields, yield* readSavedFiles(pluginId)); + const stale = keysOutside(settingsSchema, current.values); + if (stale.length > 0) { + yield* Effect.logWarning("dropping plugin settings its schema no longer has", { + pluginId, + keys: stale, + }); + } + const merged: Record = { ...current.values, ...current.secrets }; + for (const key of stale) delete merged[key]; + for (const [key, value] of Object.entries(patch)) { + if (fields.has(key) && value === SECRET_REDACTED) continue; + if (value === null) delete merged[key]; + else merged[key] = value; + } + + // Checked as the plugin reads them: over the schema's defaults, and with secrets' real + // values, so a required field that has a default or a secret passes. Defaults aren't saved. + const validation = validate?.({ ...defaultsOf(settingsSchema), ...merged }); + if (validation !== undefined && Result.isFailure(validation)) { + return yield* new PluginSettingsValidationError({ + pluginId, + summary: summarizeIssue(validation.failure.issue), + }); + } + + const next: StoredSettings = { + values: Object.fromEntries(Object.entries(merged).filter(([key]) => !fields.has(key))), + secrets: Object.fromEntries( + Object.entries(merged).filter( + (entry): entry is [string, string] => + fields.has(entry[0]) && typeof entry[1] === "string", + ), + ), + }; + const secrets = + Object.keys(next.secrets).length === 0 + ? undefined + : yield* encodeSecretsJson(next.secrets).pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ + pluginId, + operation: "writeSecret", + cause, + }), + ), + ); + const settings = yield* encodeSettingsJson(next.values).pipe( + Effect.mapError( + (cause) => new PluginSettingsPersistenceError({ pluginId, operation: "write", cause }), + ), + ); + // A write that fails partway leaves the next read to the disk. + savedFiles.delete(pluginId); + // Written on every save, which drops the secrets of fields the schema no longer has. + yield* ( + secrets === undefined + ? secretStore.remove(secretName(pluginId)) + : secretStore.set(secretName(pluginId), textEncoder.encode(secrets)) + ).pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ pluginId, operation: "writeSecret", cause }), + ), + ); + yield* writeFileStringAtomically({ + filePath: settingsPath(pluginId), + contents: settings, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => new PluginSettingsPersistenceError({ pluginId, operation: "write", cause }), + ), + ); + savedFiles.set(pluginId, { settings, secrets }); + + yield* PubSub.publish(changesPubSub, { + pluginId, + settings: { ...defaultsOf(settingsSchema), ...next.values, ...next.secrets }, + }); + return clientView(next); + }), + ); + }); + + const changes = (pluginId: PluginId) => + Stream.fromPubSub(changesPubSub).pipe( + Stream.filter((change) => change.pluginId === pluginId), + Stream.map((change) => change.settings), + ); + + const clear: PluginSettingsStore["Service"]["clear"] = Effect.fn("PluginSettingsStore.clear")( + (pluginId) => + writeSemaphore.withPermits(1)( + Effect.gen(function* () { + savedFiles.delete(pluginId); + yield* secretStore + .remove(secretName(pluginId)) + .pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ pluginId, operation: "writeSecret", cause }), + ), + ); + yield* fileSystem + .remove(path.dirname(settingsPath(pluginId)), { recursive: true, force: true }) + .pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ pluginId, operation: "write", cause }), + ), + ); + }), + ), + ); + + return PluginSettingsStore.of({ get, getForClient, update, changes, clear }); +}); + +export const layer = Layer.effect(PluginSettingsStore, make); diff --git a/apps/server/src/plugins/PluginStorage.test.ts b/apps/server/src/plugins/PluginStorage.test.ts new file mode 100644 index 0000000000000..71a8874f51289 --- /dev/null +++ b/apps/server/src/plugins/PluginStorage.test.ts @@ -0,0 +1,115 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PluginId } from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/sql/SqlClient"; + +import * as ServerConfig from "../config.ts"; +import * as PluginStorage from "./PluginStorage.ts"; + +const hello = PluginId.make("hello"); +const other = PluginId.make("other"); + +const layerConfig = () => + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-storage-test-" })); + +// A fresh layer per call, so closing it closes its SQLite connection. +const withStorage = ( + use: (storage: PluginStorage.PluginStorage["Service"]) => Effect.Effect, +) => PluginStorage.PluginStorage.use(use).pipe(Effect.provide(Layer.fresh(PluginStorage.layer))); + +it.layer(NodeServices.layer)("PluginStorage", (it) => { + it.effect("creates its own plugins.sqlite on first use", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const filename = path.join(stateDir, "plugins.sqlite"); + + yield* withStorage((storage) => + Effect.gen(function* () { + // Removing a plugin's data on a server that never stored any leaves no file behind. + yield* storage.clear(other); + assert.isFalse(yield* fileSystem.exists(filename)); + yield* storage.set(hello, "greeting", { text: "hi" }); + yield* storage.set(other, "gone", true); + yield* storage.clear(other); + assert.deepStrictEqual(yield* storage.list(other), []); + }), + ); + + const rows = yield* SqlClient.SqlClient.use( + (sql) => sql<{ readonly key: string }>`SELECT key FROM plugin_storage`, + ).pipe(Effect.provide(NodeSqliteClient.layer({ filename, readonly: true }))); + assert.deepStrictEqual( + rows.map((row) => row.key), + ["greeting"], + ); + assert.isFalse(yield* fileSystem.exists(path.join(stateDir, "statev2.sqlite"))); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("round-trips values and keeps them after the layer closes", () => + Effect.gen(function* () { + yield* withStorage((storage) => + Effect.gen(function* () { + yield* storage.set(hello, "b", [1, "two", null]); + yield* storage.set(hello, "a", { nested: { ok: true } }); + yield* storage.set(hello, "gone", 1); + yield* storage.set(other, "a", "other plugin"); + yield* storage.set(hello, "a", { nested: { ok: false } }); + yield* storage.remove(hello, "gone"); + + assert.deepStrictEqual(yield* storage.list(hello), ["a", "b"]); + assert.deepStrictEqual(yield* storage.get(hello, "gone"), Option.none()); + }), + ); + + yield* withStorage((storage) => + Effect.gen(function* () { + assert.deepStrictEqual( + yield* storage.get(hello, "a"), + Option.some({ nested: { ok: false } }), + ); + assert.deepStrictEqual(yield* storage.get(hello, "b"), Option.some([1, "two", null])); + assert.deepStrictEqual(yield* storage.get(other, "a"), Option.some("other plugin")); + }), + ); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("matches list prefixes literally", () => + withStorage((storage) => + Effect.gen(function* () { + for (const key of ["a_b", "axb", "A_b", "a%c", "abc"]) { + yield* storage.set(hello, key, true); + } + yield* storage.set(other, "a_other", true); + + assert.deepStrictEqual(yield* storage.list(hello, "a_"), ["a_b"]); + assert.deepStrictEqual(yield* storage.list(hello, "a%"), ["a%c"]); + }), + ).pipe(Effect.provide(layerConfig())), + ); + + it.effect("rejects oversize values and keys", () => + withStorage((storage) => + Effect.gen(function* () { + const tooLarge = yield* Effect.flip(storage.set(hello, "big", "x".repeat(1024 * 1024))); + assert.strictEqual(tooLarge._tag, "PluginStorageValueTooLargeError"); + + const emptyKey = yield* Effect.flip(storage.set(hello, "", 1)); + assert.strictEqual(emptyKey._tag, "PluginStorageKeyError"); + const longKey = yield* Effect.flip(storage.get(hello, "k".repeat(513))); + assert.strictEqual(longKey._tag, "PluginStorageKeyError"); + + assert.deepStrictEqual(yield* storage.list(hello), []); + }), + ).pipe(Effect.provide(layerConfig())), + ); +}); diff --git a/apps/server/src/plugins/PluginStorage.ts b/apps/server/src/plugins/PluginStorage.ts new file mode 100644 index 0000000000000..e521a5663c957 --- /dev/null +++ b/apps/server/src/plugins/PluginStorage.ts @@ -0,0 +1,265 @@ +import { PluginId } from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as SqlClient from "effect/sql/SqlClient"; + +import * as ServerConfig from "../config.ts"; + +const MAX_KEY_LENGTH = 512; +const MAX_VALUE_BYTES = 1024 * 1024; + +export class PluginStoragePersistenceError extends Schema.TaggedError()( + "PluginStoragePersistenceError", + { + pluginId: PluginId, + operation: Schema.Literals(["open", "get", "set", "remove", "list"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Plugin storage ${this.operation} failed for plugin ${this.pluginId}.`; + } +} + +export class PluginStorageKeyError extends Schema.TaggedError()( + "PluginStorageKeyError", + { + pluginId: PluginId, + length: Schema.Number, + }, +) { + override get message(): string { + return `Plugin storage keys must be 1 to ${MAX_KEY_LENGTH} characters long, got ${this.length}.`; + } +} + +export class PluginStorageInvalidValueError extends Schema.TaggedError()( + "PluginStorageInvalidValueError", + { + pluginId: PluginId, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Plugin storage values must be JSON."; + } +} + +export class PluginStorageValueTooLargeError extends Schema.TaggedError()( + "PluginStorageValueTooLargeError", + { + pluginId: PluginId, + bytes: Schema.Number, + }, +) { + override get message(): string { + return `Plugin storage values must be at most ${MAX_VALUE_BYTES} bytes of JSON, got ${this.bytes}.`; + } +} + +export class PluginStorage extends Context.Service< + PluginStorage, + { + readonly get: ( + pluginId: PluginId, + key: string, + ) => Effect.Effect< + Option.Option, + PluginStoragePersistenceError | PluginStorageKeyError + >; + readonly set: ( + pluginId: PluginId, + key: string, + value: Schema.Json, + ) => Effect.Effect< + void, + | PluginStoragePersistenceError + | PluginStorageKeyError + | PluginStorageInvalidValueError + | PluginStorageValueTooLargeError + >; + readonly remove: ( + pluginId: PluginId, + key: string, + ) => Effect.Effect; + /** Keys that start with `prefix`, sorted. */ + readonly list: ( + pluginId: PluginId, + prefix?: string, + ) => Effect.Effect, PluginStoragePersistenceError>; + /** Deletes every key of the plugin, without creating the database when there is none. */ + readonly clear: (pluginId: PluginId) => Effect.Effect; + } +>()("t3/plugins/PluginStorage") {} + +const ValueJson = Schema.fromJsonString(Schema.Json); +const encodeValue = Schema.encodeUnknownEffect(ValueJson); +const decodeValue = Schema.decodeUnknownEffect(ValueJson); + +const checkKey = (pluginId: PluginId, key: string) => + key.length >= 1 && key.length <= MAX_KEY_LENGTH + ? Effect.void + : Effect.fail(new PluginStorageKeyError({ pluginId, length: key.length })); + +const make = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const layerScope = yield* Effect.scope; + const filename = path.join(stateDir, "plugins.sqlite"); + + // Opened on first use so a server without plugins never creates the file. A failed open is + // retried on the next call; a successful one stays open until the layer closes. + const sqlClient = yield* Effect.cachedWithTTL( + Effect.gen(function* () { + const scope = yield* Scope.fork(layerScope); + return yield* Layer.buildWithScope(NodeSqliteClient.layer({ filename }), scope).pipe( + Effect.map((context) => Context.get(context, SqlClient.SqlClient)), + Effect.tap( + (sql) => sql` + CREATE TABLE IF NOT EXISTS plugin_storage ( + plugin_id TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (plugin_id, key) + ) + `, + ), + Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))), + ); + }), + (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), + ); + + const open = (pluginId: PluginId) => + sqlClient.pipe( + Effect.mapError( + (cause) => new PluginStoragePersistenceError({ pluginId, operation: "open", cause }), + ), + ); + + const get = Effect.fn("PluginStorage.get")( + function* (pluginId: PluginId, key: string) { + yield* checkKey(pluginId, key); + const sql = yield* open(pluginId); + const rows = yield* sql<{ readonly value_json: string }>` + SELECT value_json FROM plugin_storage WHERE plugin_id = ${pluginId} AND key = ${key} + `; + const row = rows[0]; + return row === undefined ? Option.none() : Option.some(yield* decodeValue(row.value_json)); + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "get", cause })), + SchemaError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "get", cause })), + }), + ), + ); + + const set = Effect.fn("PluginStorage.set")( + function* (pluginId: PluginId, key: string, value: Schema.Json) { + yield* checkKey(pluginId, key); + const valueJson = yield* encodeValue(value).pipe( + Effect.mapError((cause) => new PluginStorageInvalidValueError({ pluginId, cause })), + ); + const bytes = Buffer.byteLength(valueJson, "utf8"); + if (bytes > MAX_VALUE_BYTES) { + return yield* new PluginStorageValueTooLargeError({ pluginId, bytes }); + } + const sql = yield* open(pluginId); + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* sql` + INSERT INTO plugin_storage (plugin_id, key, value_json, updated_at) + VALUES (${pluginId}, ${key}, ${valueJson}, ${updatedAt}) + ON CONFLICT (plugin_id, key) + DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at + `; + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "set", cause })), + }), + ), + ); + + const remove = Effect.fn("PluginStorage.remove")( + function* (pluginId: PluginId, key: string) { + yield* checkKey(pluginId, key); + const sql = yield* open(pluginId); + yield* sql`DELETE FROM plugin_storage WHERE plugin_id = ${pluginId} AND key = ${key}`; + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail( + new PluginStoragePersistenceError({ pluginId, operation: "remove", cause }), + ), + }), + ), + ); + + const list = Effect.fn("PluginStorage.list")( + function* (pluginId: PluginId, prefix = "") { + const sql = yield* open(pluginId); + // Not LIKE: it treats `%` and `_` as wildcards and ignores ASCII case. + const rows = yield* sql<{ readonly key: string }>` + SELECT key FROM plugin_storage + WHERE plugin_id = ${pluginId} AND substr(key, 1, length(${prefix})) = ${prefix} + ORDER BY key + `; + return rows.map((row) => row.key); + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "list", cause })), + }), + ), + ); + + const clear = Effect.fn("PluginStorage.clear")( + function* (pluginId: PluginId) { + const exists = yield* fileSystem + .exists(filename) + .pipe( + Effect.mapError( + (cause) => new PluginStoragePersistenceError({ pluginId, operation: "open", cause }), + ), + ); + if (!exists) return; + const sql = yield* open(pluginId); + yield* sql`DELETE FROM plugin_storage WHERE plugin_id = ${pluginId}`; + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail( + new PluginStoragePersistenceError({ pluginId, operation: "remove", cause }), + ), + }), + ), + ); + + return PluginStorage.of({ get, set, remove, list, clear }); +}); + +export const layer = Layer.effect(PluginStorage, make); diff --git a/apps/server/src/plugins/api/facade.test.ts b/apps/server/src/plugins/api/facade.test.ts new file mode 100644 index 0000000000000..6425fb2c83c19 --- /dev/null +++ b/apps/server/src/plugins/api/facade.test.ts @@ -0,0 +1,84 @@ +import { assert, it } from "@effect/vitest"; +import { PluginId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { makePluginActivation } from "../PluginApi.ts"; +import { makeFacadeContext, pluginCodeScope } from "./facade.ts"; + +class CallFailedError extends Schema.TaggedError()("CallFailedError", {}) {} + +// Two macrotask turns: Node reports a rejection nobody observed after the first. +const afterNodeReportsRejections = Effect.promise(async () => { + for (let turn = 0; turn < 2; turn++) { + // oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- Node emits unhandledRejection only between macrotasks; no promise can mark that point. + await new Promise((resolve) => setImmediate(resolve)); + } +}); + +it.effect("keeps a failed call the plugin never awaits from stopping the server", () => + Effect.gen(function* () { + const activation = makePluginActivation(); + const facade = makeFacadeContext({ + pluginId: PluginId.make("unawaited"), + activation, + context: yield* Effect.context(), + }); + const unobserved: Array = []; + const onUnhandled = (reason: unknown) => unobserved.push(reason); + process.on("unhandledRejection", onUnhandled); + yield* Effect.addFinalizer(() => + Effect.sync(() => process.off("unhandledRejection", onUnhandled)), + ); + + void facade.call(Effect.fail(new CallFailedError())); + // A stream it reads, such as `api.settings.changes()`, fails like a call. + const pull = () => + facade.iterate(Stream.fail(new CallFailedError()))[Symbol.asyncIterator]().next(); + void pull(); + const pulled = yield* Effect.promise(() => + pull().then( + () => undefined, + (error: { readonly code: string }) => error.code, + ), + ); + assert.strictEqual(pulled, "CallFailedError"); + activation.controller.abort(); + activation.ended.abort(); + // A timer such as `setInterval(() => void api.storage.set(...))` outliving the plugin. + void facade.call(Effect.void); + yield* afterNodeReportsRejections; + assert.deepStrictEqual(unobserved, []); + + const awaited = yield* Effect.promise(() => + facade.call(Effect.void).then( + () => undefined, + (error: { readonly code: string }) => error.code, + ), + ); + assert.strictEqual(awaited, "PluginDeactivated"); + }).pipe(Effect.scoped), +); + +// A failure there would be tied to the plugin, and the guard would not stop the server for it. +it.effect("runs T3's side of a stream the plugin reads outside the plugin's scope", () => + Effect.gen(function* () { + const pluginId = PluginId.make("reading"); + const facade = makeFacadeContext({ + pluginId, + activation: makePluginActivation(), + context: yield* Effect.context(), + }); + const scopes = facade.iterate(Stream.fromEffect(Effect.sync(() => pluginCodeScope.getStore()))); + // As a plugin's `for await` over `api.settings.changes()`. + const seen = yield* Effect.promise(() => + pluginCodeScope.run(pluginId, async () => { + const values: Array = []; + for await (const value of scopes) values.push(value); + return values; + }), + ); + assert.deepStrictEqual(seen, [undefined]); + }), +); diff --git a/apps/server/src/plugins/api/facade.ts b/apps/server/src/plugins/api/facade.ts new file mode 100644 index 0000000000000..ee9dace7fa476 --- /dev/null +++ b/apps/server/src/plugins/api/facade.ts @@ -0,0 +1,175 @@ +// @effect-diagnostics nodeBuiltinImport:off - AsyncLocalStorage has no Effect counterpart, and it +// must follow plugin code through Node's own callbacks. +import * as NodeAsyncHooks from "node:async_hooks"; + +import type { PluginId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import type * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import type { PluginActivation } from "../PluginApi.ts"; + +const MAX_ERROR_CHARS = 500; + +/** A plugin error's text for logs, states and replies, bounded. */ +export const errorText = (cause: unknown) => { + let text = "Unknown error"; + try { + text = + cause instanceof Error && typeof cause.message === "string" ? cause.message : String(cause); + } catch { + // Plugin code can throw values whose conversion to a string throws again. + } + return text.slice(0, MAX_ERROR_CHARS); +}; + +/** Plugin code threw or rejected. Its message is the plugin's own. */ +export class PluginCodeError extends Schema.TaggedError()("PluginCodeError", { + cause: Schema.Defect(), +}) { + override get message(): string { + return errorText(this.cause); + } +} + +/** + * The plugin whose code is running, carried through every timer, promise and Node callback that + * code starts, so a failure whose stack shows only Node's internals is still the plugin's. The host + * runs no server code before Node 24, where this needs promise hooks. + */ +export const pluginCodeScope = new NodeAsyncHooks.AsyncLocalStorage(); + +/** What every facade group needs from the activation it belongs to. */ +export interface FacadeContext { + readonly pluginId: PluginId; + readonly activation: PluginActivation; + readonly context: Context.Context; + /** Runs the plugin's own code, such as a handler, in its scope. */ + readonly asPlugin: (run: () => A) => A; + /** + * Runs an effect as a Promise that rejects with an `Error` carrying the failure's `_tag` as `code`. + * Map results inside the effect: a `.then` on the Promise is a new one that nothing handles. + */ + readonly call: (effect: Effect.Effect) => Promise; + /** Runs an effect in the background for the plugin; failures are logged, not thrown. */ + readonly fork: (effect: Effect.Effect) => void; + /** Exposes a stream as an iterable that ends when the plugin deactivates. */ + readonly iterate: (stream: Stream.Stream) => AsyncIterable; +} + +/** + * Hands a plugin a promise whose failure only the plugin sees: one it never awaits must not be an + * unhandled rejection, which stops the server. + */ +const forPlugin = (promise: Promise): Promise => { + promise.catch(() => undefined); + return promise; +}; + +/** A plugin API call refused before it ran, such as one with a bad argument. */ +export const refused = (error: Error): Promise => forPlugin(Promise.reject(error)); + +/** Carries its plugin, so a failure no plugin code handles is still the plugin's. */ +export class PluginApiCallError extends Error { + readonly code: string; + readonly pluginId: PluginId; + constructor(pluginId: PluginId, code: string, message: string) { + super(message); + this.name = "PluginApiError"; + this.code = code; + this.pluginId = pluginId; + } +} + +/** Completes when `signal` aborts, at once if it already has. */ +export const abortEffect = (signal: AbortSignal) => + Effect.callback((resume) => { + const onAbort = () => resume(Effect.void); + if (signal.aborted) return onAbort(); + signal.addEventListener("abort", onAbort, { once: true }); + return Effect.sync(() => signal.removeEventListener("abort", onAbort)); + }); + +export const makeFacadeContext = (input: { + readonly pluginId: PluginId; + readonly activation: PluginActivation; + readonly context: Context.Context; +}): FacadeContext => { + const { activation, context, pluginId } = input; + // T3's work for a call is not the plugin's code, so it leaves the plugin's scope. + const runExit = (effect: Effect.Effect) => + pluginCodeScope.exit(() => Effect.runPromiseExitWith(context)(effect)); + // Cleanups may still save state; afterwards a disabled plugin's leftovers cannot act. + const call: FacadeContext["call"] = (effect) => + activation.ended.signal.aborted + ? refused( + new PluginApiCallError( + pluginId, + "PluginDeactivated", + "This plugin activation has ended.", + ), + ) + : forPlugin( + runExit(effect).then((exit) => { + if (Exit.isSuccess(exit)) return exit.value; + const error = Cause.squash(exit.cause); + const code = + typeof error === "object" && + error !== null && + "_tag" in error && + typeof error._tag === "string" + ? error._tag + : "PluginApiFailure"; + throw new PluginApiCallError( + pluginId, + code, + error instanceof Error ? error.message : "The plugin API call failed.", + ); + }), + ); + return { + pluginId, + activation, + context, + asPlugin: (run) => pluginCodeScope.run(pluginId, run), + call, + fork: (effect) => { + pluginCodeScope.exit(() => + Effect.runForkWith(context)( + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("plugin background work failed", { + pluginId, + cause: Cause.pretty(cause).slice(0, 500), + }), + ), + ), + ), + ); + }, + // The plugin pulls, but each pull runs T3's side of the stream, so it is a call too. + iterate: (stream) => { + const iterable = Stream.toAsyncIterableWith( + stream.pipe(Stream.interruptWhen(abortEffect(activation.controller.signal))), + context, + ); + return { + [Symbol.asyncIterator]: () => { + const iterator = pluginCodeScope.exit(() => iterable[Symbol.asyncIterator]()); + return { + next: () => call(Effect.promise(() => iterator.next())), + return: () => + call( + Effect.promise( + () => iterator.return?.() ?? Promise.resolve({ done: true, value: undefined }), + ), + ), + }; + }, + }; + }, + }; +}; diff --git a/apps/server/src/plugins/api/mcp.ts b/apps/server/src/plugins/api/mcp.ts new file mode 100644 index 0000000000000..1db23dba58e28 --- /dev/null +++ b/apps/server/src/plugins/api/mcp.ts @@ -0,0 +1,78 @@ +import type { PluginApi } from "@t3tools/plugin-sdk/server"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import * as PluginMcpTools from "../PluginMcpTools.ts"; +import { type FacadeContext, pluginCodeScope } from "./facade.ts"; + +const isAccess = Schema.is(Schema.Literals(["read", "write"])); +const isJsonObject = Schema.is(Schema.JsonObject); +// Anthropic's API refuses a tool whose input schema combines schemas at its top level. +const TOP_LEVEL_COMBINATORS = ["anyOf", "oneOf", "allOf"]; + +export const makeMcpApi = ( + facade: FacadeContext, + services: { readonly mcpTools: PluginMcpTools.PluginMcpTools["Service"] }, +): PluginApi["mcp"] => { + const { activation, pluginId } = facade; + const deactivated = activation.controller.signal; + let unregistersOnAbort = false; + return { + registerTool: (tool, handler) => { + if (deactivated.aborted) throw new Error("This plugin activation has ended."); + // Plugins are untyped JavaScript, and a misspelled access level must not open a write. + if (!isAccess(tool.access)) { + throw new TypeError(`Tool "${tool.name}" needs access "read" or "write".`); + } + // Model APIs reject a tool whose schema is not an object, which would fail every turn. + if ( + typeof tool.description !== "string" || + !isJsonObject(tool.inputSchema) || + tool.inputSchema.type !== "object" + ) { + throw new TypeError( + `Tool "${tool.name}" needs a description and an object JSON Schema for its input.`, + ); + } + const combinator = TOP_LEVEL_COMBINATORS.find((keyword) => keyword in tool.inputSchema); + if (combinator !== undefined) { + throw new TypeError( + `Tool "${tool.name}" has ${combinator} at the top of its input schema, which model APIs refuse. Put the alternatives in a property instead.`, + ); + } + // Registering tells `/mcp` clients, which is T3's work, not the plugin's. + const registered = pluginCodeScope.exit(() => + Effect.runSyncExitWith(facade.context)( + services.mcpTools.register({ + pluginId, + tool, + handler: (input, { signal, threadId }) => + facade.asPlugin(() => + Promise.resolve().then(() => + handler(input, { signal: AbortSignal.any([signal, deactivated]), threadId }), + ), + ), + deactivated, + }), + ), + ); + if (Exit.isFailure(registered)) { + const error = Cause.squash(registered.cause); + throw PluginMcpTools.isPluginMcpToolNameError(error) + ? new TypeError(`"${tool.name}" is not a valid tool name. ${error.message}`) + : error; + } + // Abort comes before any cleanup on deactivate, so a cleanup that hangs cannot keep tools live. + if (!unregistersOnAbort) { + unregistersOnAbort = true; + deactivated.addEventListener( + "abort", + () => facade.fork(services.mcpTools.unregisterPlugin(pluginId)), + { once: true }, + ); + } + }, + }; +}; diff --git a/apps/server/src/plugins/pluginDiscovery.test.ts b/apps/server/src/plugins/pluginDiscovery.test.ts new file mode 100644 index 0000000000000..aacc06f5258e5 --- /dev/null +++ b/apps/server/src/plugins/pluginDiscovery.test.ts @@ -0,0 +1,77 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverPlugins } from "./pluginDiscovery.ts"; + +const manifest = (id: string) => + JSON.stringify({ id, name: id, version: "1.0.0", engines: { t3: ">=0.1.0" } }); + +it.layer(NodeServices.layer)("discoverPlugins", (it) => { + it.effect("reports valid, missing, invalid, renamed and duplicate plugins", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-plugin-discovery-" }); + const pluginsDir = path.join(root, "plugins"); + const write = Effect.fn(function* (dir: string, contents: string | undefined) { + yield* fs.makeDirectory(dir, { recursive: true }); + if (contents !== undefined) { + yield* fs.writeFileString(path.join(dir, "t3-plugin.json"), contents); + } + }); + yield* write(path.join(pluginsDir, "valid"), manifest("valid")); + yield* write(path.join(pluginsDir, "empty"), undefined); + yield* write( + path.join(pluginsDir, "invalid"), + JSON.stringify({ ...JSON.parse(manifest("invalid")), version: "1" }), + ); + yield* write(path.join(pluginsDir, "renamed"), manifest("other-name")); + // A schema the settings validator cannot compile would refuse every save. + yield* write( + path.join(pluginsDir, "unchecked"), + JSON.stringify({ + ...JSON.parse(manifest("unchecked")), + settingsSchema: { type: "object", properties: { count: { $ref: "#/$defs/no" } } }, + }), + ); + yield* write(path.join(pluginsDir, "My Plugin"), manifest("spaced")); + yield* write(path.join(pluginsDir, "Not An Id"), undefined); + // Sorts before "valid", so it is the copy used. + yield* write(path.join(pluginsDir, "Copy of valid"), manifest("valid")); + const devCopy = path.join(root, "dev", "valid"); + yield* write(devCopy, manifest("valid")); + const devOnly = path.join(root, "dev", "workbench"); + yield* write(devOnly, manifest("dev-only")); + + const plugins = yield* discoverPlugins({ pluginsDir, devPaths: [devCopy, devOnly] }); + const summary = plugins.map((plugin) => ({ + id: plugin.id, + source: plugin.source, + stage: "failure" in plugin ? plugin.failure.stage : "ok", + })); + + assert.deepStrictEqual(summary, [ + { id: "valid", source: "installed", stage: "ok" }, + { id: "spaced", source: "installed", stage: "ok" }, + { id: "empty", source: "installed", stage: "discovery" }, + { id: "invalid", source: "installed", stage: "manifest" }, + { id: "other-name", source: "installed", stage: "ok" }, + { id: "unchecked", source: "installed", stage: "manifest" }, + { id: "valid", source: "installed", stage: "duplicate" }, + { id: "valid", source: "dev", stage: "duplicate" }, + { id: "dev-only", source: "dev", stage: "ok" }, + ]); + assert.strictEqual(plugins[0]?.folder, path.join(pluginsDir, "Copy of valid")); + const failures = plugins.map((plugin) => ("failure" in plugin ? plugin.failure.message : "")); + assert.include(failures[3], "version"); + assert.include(failures[5], 'Missing definition "no"'); + assert.strictEqual( + failures[7], + 'The installed folder "Copy of valid" already has the id valid. Remove or unlink one of them.', + ); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/plugins/pluginDiscovery.ts b/apps/server/src/plugins/pluginDiscovery.ts new file mode 100644 index 0000000000000..9aae338cc9882 --- /dev/null +++ b/apps/server/src/plugins/pluginDiscovery.ts @@ -0,0 +1,187 @@ +import { + PluginId, + PluginManifest, + type PluginFailure, + type PluginSource, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { settingsSchemaProblem } from "./PluginSettingsStore.ts"; + +const PLUGIN_MANIFEST_FILE = "t3-plugin.json"; + +// Discovery runs at startup and on every reload, so a plugins folder full of unrelated +// directories or an oversized manifest must not stall the server. +const MAX_PLUGIN_DIRECTORIES = 64; +const MAX_MANIFEST_BYTES = 64 * 1024; +const MAX_FAILURE_MESSAGE_CHARS = 500; + +const decodeManifest = Schema.decodeUnknownExit(Schema.fromJsonString(PluginManifest)); +const decodeLooseId = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ id: PluginId })), +); +const isPluginId = Schema.is(PluginId); + +const causeMessage = (cause: Cause.Cause) => { + const error = Cause.squash(cause); + return error instanceof Error ? error.message : String(error); +}; + +interface PluginLocation { + readonly id: PluginId; + readonly rootDir: string; + /** The folder as listed, before links resolve. Installed ones are inside the plugins folder. */ + readonly folder: string; + readonly source: PluginSource; +} + +/** A plugin directory, with its manifest when it could be read and decoded. */ +export type DiscoveredPlugin = PluginLocation & + ({ readonly manifest: PluginManifest } | { readonly failure: PluginFailure }); + +const failed = (location: PluginLocation, failure: PluginFailure): DiscoveredPlugin => ({ + ...location, + failure: { ...failure, message: failure.message.slice(0, MAX_FAILURE_MESSAGE_CHARS) }, +}); + +/** + * Lists installed plugins (`//t3-plugin.json`), then dev plugins + * (`devPaths`). An id is used once: a later folder with an id already listed is reported as failed. + */ +export const discoverPlugins = Effect.fn("PluginDiscovery.discover")(function* (input: { + readonly pluginsDir: string; + readonly devPaths: ReadonlyArray; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // The helpers below run for every plugin folder inside the discovery span. + const readManifest = Effect.fnUntraced(function* (rootDir: string) { + const manifestPath = path.join(rootDir, PLUGIN_MANIFEST_FILE); + const info = yield* fs.stat(manifestPath).pipe(Effect.option); + if (info._tag === "None" || info.value.type !== "File") { + return { _tag: "Missing" } as const; + } + if (Number(info.value.size) > MAX_MANIFEST_BYTES) { + return { _tag: "TooLarge" } as const; + } + const raw = yield* fs.readFileString(manifestPath).pipe(Effect.option); + return raw._tag === "None" + ? ({ _tag: "Missing" } as const) + : ({ _tag: "Read", raw: raw.value } as const); + }); + + // Node caches modules by real path, so reload can only find a plugin's modules by its real root. + const inspect = Effect.fnUntraced(function* ( + location: string, + source: PluginSource, + fallbackId: PluginId | undefined, + ) { + const rootDir = yield* fs.realPath(location).pipe(Effect.orElseSucceed(() => location)); + const manifest = yield* readManifest(rootDir); + if (manifest._tag !== "Read") { + if (fallbackId === undefined) return undefined; + return failed( + { id: fallbackId, rootDir, folder: location, source }, + { + stage: "discovery", + message: + manifest._tag === "Missing" + ? `No ${PLUGIN_MANIFEST_FILE} in the plugin folder.` + : `${PLUGIN_MANIFEST_FILE} is larger than ${MAX_MANIFEST_BYTES} bytes.`, + }, + ); + } + const decoded = decodeManifest(manifest.raw); + if (decoded._tag === "Success") { + const copy = { id: decoded.value.id, rootDir, folder: location, source }; + const { settingsSchema } = decoded.value; + const schemaProblem = + settingsSchema === undefined ? undefined : settingsSchemaProblem(settingsSchema); + return schemaProblem === undefined + ? { ...copy, manifest: decoded.value } + : failed(copy, { + stage: "manifest", + message: `T3 Code cannot validate its settingsSchema: ${schemaProblem}`, + }); + } + const looseId = decodeLooseId(manifest.raw); + const id = looseId._tag === "Some" ? looseId.value.id : fallbackId; + if (id === undefined) return undefined; + return failed( + { id, rootDir, folder: location, source }, + { + stage: "manifest", + message: `Invalid ${PLUGIN_MANIFEST_FILE}: ${causeMessage(decoded.cause)}`, + }, + ); + }); + + const plugins: Array = []; + const firstWithId = new Map(); + // Settings, storage and the switch belong to the id, so two folders with one id cannot both run. + const add = (plugin: DiscoveredPlugin) => { + const first = firstWithId.get(plugin.id); + if (first === undefined) { + firstWithId.set(plugin.id, plugin); + plugins.push(plugin); + return; + } + // The folder's name only: clients that cannot manage plugins see failures but not paths. + const kind = first.source === "dev" ? "linked" : "installed"; + plugins.push( + failed(plugin, { + stage: "duplicate", + message: `The ${kind} folder "${path.basename(first.folder)}" already has the id ${plugin.id}. Remove or unlink one of them.`, + }), + ); + }; + let examined = 0; + const underLimit = Effect.fnUntraced(function* (location: string) { + examined += 1; + if (examined <= MAX_PLUGIN_DIRECTORIES) return true; + yield* Effect.logWarning("ignoring plugin folders past the limit", { + path: location, + limit: MAX_PLUGIN_DIRECTORIES, + }); + return false; + }); + + const entries = yield* fs + .readDirectory(input.pluginsDir) + .pipe(Effect.orElseSucceed((): Array => [])); + // People copy plugin folders in by hand, so the manifest names the plugin, not the folder. + for (const entry of entries.toSorted()) { + if (entry.startsWith(".")) continue; + const folder = path.join(input.pluginsDir, entry); + const info = yield* fs.stat(folder).pipe(Effect.option); + if (info._tag === "None" || info.value.type !== "Directory") continue; + if (!(yield* underLimit(input.pluginsDir))) break; + const plugin = yield* inspect(folder, "installed", isPluginId(entry) ? entry : undefined); + if (plugin === undefined) { + yield* Effect.logWarning("ignoring plugin folder without a plugin id", { path: folder }); + continue; + } + add(plugin); + } + + for (const devPath of input.devPaths) { + const rootDir = path.resolve(devPath); + if (!(yield* underLimit(rootDir))) break; + const basename = path.basename(rootDir); + const plugin = yield* inspect(rootDir, "dev", isPluginId(basename) ? basename : undefined); + if (plugin === undefined) { + yield* Effect.logWarning("ignoring dev plugin path without a usable plugin id", { + path: rootDir, + }); + continue; + } + add(plugin); + } + + return plugins; +}); diff --git a/apps/server/src/plugins/pluginFailureGuard.test.ts b/apps/server/src/plugins/pluginFailureGuard.test.ts new file mode 100644 index 0000000000000..1c6ce9f4c3be7 --- /dev/null +++ b/apps/server/src/plugins/pluginFailureGuard.test.ts @@ -0,0 +1,119 @@ +import { assert, it } from "@effect/vitest"; +import { PluginId } from "@t3tools/contracts"; + +import { pluginCodeScope } from "./api/facade.ts"; +import { makePluginFailureGuard } from "./pluginFailureGuard.ts"; + +const leaky = PluginId.make("leaky"); + +/** Takes the runner's crash listeners off, which would report the fake crashes below as real. */ +const withoutRunnerListeners = () => { + const exceptions = process.listeners("uncaughtException"); + const rejections = process.listeners("unhandledRejection"); + process.removeAllListeners("uncaughtException"); + process.removeAllListeners("unhandledRejection"); + return { + [Symbol.dispose]: () => { + process.removeAllListeners("uncaughtException"); + process.removeAllListeners("unhandledRejection"); + for (const listener of exceptions) process.on("uncaughtException", listener); + for (const listener of rejections) process.on("unhandledRejection", listener); + }, + }; +}; + +/** A guard watching the `leaky` plugin, with what it recorded. */ +const watchLeaky = () => { + const rejected: Array = []; + const crashed: Array = []; + const guard = makePluginFailureGuard({ + onRejection: (pluginId) => rejected.push(pluginId), + onCrash: (pluginId) => crashed.push(pluginId), + }); + guard.watch("/plugins/leaky/", leaky); + return { rejected, crashed, uninstall: guard.uninstall, [Symbol.dispose]: guard.uninstall }; +}; + +const pluginError = (message: string) => { + const error = new Error(message); + error.stack = `Error: ${message}\n at /plugins/leaky/server.cjs:1:1`; + return error; +}; + +// As Node reports a rejection nobody handled. +const reject = (reason: Error) => process.emit("unhandledRejection", reason, Promise.resolve()); +const messageOf = (reason: unknown) => (reason instanceof Error ? reason.message : String(reason)); + +it("records a plugin's uncaught exception and leaves the exit to Node", () => { + using _runner = withoutRunnerListeners(); + using guard = watchLeaky(); + + // Node tells the monitor before the listeners, or its own exit when there are none. + process.emit("uncaughtExceptionMonitor", pluginError("thrown in a timer")); + process.emit("uncaughtExceptionMonitor", new Error("T3 crashed")); + + assert.deepStrictEqual(guard.crashed, ["leaky"]); + // It adds no listener, so Node, or a guard that exits only while it listens alone, still exits. + assert.strictEqual(process.listenerCount("uncaughtException"), 0); + guard.uninstall(); + assert.strictEqual(process.listenerCount("uncaughtExceptionMonitor"), 0); +}); + +it("hands T3's own rejections to a listener added after it, and only those", async () => { + using _runner = withoutRunnerListeners(); + using guard = watchLeaky(); + const seen: Array = []; + // Such as a provider SDK's guard, when its layer builds after a plugin started. + const late = (reason: unknown) => seen.push(messageOf(reason)); + process.on("unhandledRejection", late); + await Promise.resolve(); + + reject(pluginError("rejected in a timer")); + reject(new Error("T3 rejected")); + + assert.deepStrictEqual(guard.rejected, ["leaky"]); + assert.deepStrictEqual(seen, ["T3 rejected"]); + // It listens alone, so a guard that acts only then still does. + assert.strictEqual(process.listenerCount("unhandledRejection"), 1); + guard.uninstall(); + assert.deepStrictEqual(process.listeners("unhandledRejection"), [late]); +}); + +it("calls a listener added with once for one rejection", () => { + using _runner = withoutRunnerListeners(); + const seen: Array = []; + process.once("unhandledRejection", (reason) => seen.push(messageOf(reason))); + using _guard = watchLeaky(); + + reject(new Error("first")); + // With nothing else listening, the second stops the server as Node would. + assert.throws(() => reject(new Error("second")), "second"); + assert.deepStrictEqual(seen, ["first"]); +}); + +it("stops calling a listener its owner removes", () => { + using _runner = withoutRunnerListeners(); + const seen: Array = []; + const listener = (reason: unknown) => seen.push(messageOf(reason)); + process.on("unhandledRejection", listener); + using _guard = watchLeaky(); + + process.off("unhandledRejection", listener); + + assert.throws(() => reject(new Error("T3 rejected")), "T3 rejected"); + assert.deepStrictEqual(seen, []); +}); + +it("stops calling a plugin's own listener once the plugin removes it on disable", async () => { + using _runner = withoutRunnerListeners(); + using _guard = watchLeaky(); + const seen: Array = []; + const listener = (reason: unknown) => seen.push(messageOf(reason)); + // As the plugin's `activate`, then its `onDeactivate`. + pluginCodeScope.run(leaky, () => process.on("unhandledRejection", listener)); + await Promise.resolve(); + process.off("unhandledRejection", listener); + + assert.throws(() => reject(new Error("T3 rejected")), "T3 rejected"); + assert.deepStrictEqual(seen, []); +}); diff --git a/apps/server/src/plugins/pluginFailureGuard.ts b/apps/server/src/plugins/pluginFailureGuard.ts new file mode 100644 index 0000000000000..43e1240adb5d1 --- /dev/null +++ b/apps/server/src/plugins/pluginFailureGuard.ts @@ -0,0 +1,147 @@ +// @effect-diagnostics globalConsole:off - The guard runs in Node's process callbacks, outside an Effect runtime, where printing is all it can do with its own failure. +import type { PluginId } from "@t3tools/contracts"; +import * as Predicate from "effect/Predicate"; + +import { PluginApiCallError, pluginCodeScope } from "./api/facade.ts"; + +const REJECTION = "unhandledRejection"; +type Listener = (...args: ReadonlyArray) => unknown; +const isListener = (value: unknown): value is Listener => typeof value === "function"; + +/** + * Ties plugin failures outside a call to the plugin. An uncaught exception goes to `onCrash` and + * still stops the server, since Node calls resuming unsafe. A plugin's unhandled rejection goes to + * `onRejection` and skips the other listeners, which the guard wraps; it listens only while none do. + */ +export const makePluginFailureGuard = (handlers: { + readonly onRejection: (pluginId: PluginId, reason: unknown) => void; + /** Runs just before Node reports the exception, so it has to finish synchronously. */ + readonly onCrash: (pluginId: PluginId, error: unknown) => void; +}) => { + // Node keeps loaded plugin code and its timers until exit, so a root is never dropped. + const roots = new Map(); + // Each wrapper and what `process.rawListeners` held before it: the listener or its `once` wrapper. + const wrapped = new WeakMap(); + let installed = false; + let syncing = false; + + // The plugin whose scope the failure ran in, else one whose files its stack names. + const pluginOf = (error: unknown): PluginId | undefined => { + const running = pluginCodeScope.getStore(); + if (running !== undefined) return running; + if (error instanceof PluginApiCallError) return error.pluginId; + let stack: unknown; + try { + stack = Predicate.isObject(error) ? error.stack : undefined; + } catch { + // A plugin can throw an object whose `stack` getter throws. + return undefined; + } + if (typeof stack !== "string") return undefined; + for (const [root, pluginId] of roots) if (stack.includes(root)) return pluginId; + return undefined; + }; + + // Node calls monitors before the exception's listeners or its own exit. It adds no listener, so + // whatever stops the server for T3's crashes stops it for a plugin's too. + const onUncaught = (error: unknown) => { + const pluginId = pluginOf(error); + if (pluginId === undefined) return; + try { + handlers.onCrash(pluginId, error); + } catch (recordError) { + // Node still reports the crash itself. + console.error(recordError); + } + }; + + // What Node does with no listener, for rejections that aren't a plugin's. + const alone = (reason: unknown) => { + const pluginId = pluginOf(reason); + if (pluginId !== undefined) return handlers.onRejection(pluginId, reason); + throw reason; + }; + + // Node's `off` and `listeners()` read `listener`, as on its own `once` wrappers. + const wrap = (raw: Listener) => { + const listener: unknown = Reflect.get(raw, "listener"); + const original = isListener(listener) ? listener : raw; + const gated = Object.assign( + (reason: unknown, promise: unknown) => { + const pluginId = pluginOf(reason); + if (pluginId !== undefined) { + // Every wrapper sees the rejection; the first records it. + const first = process + .rawListeners(REJECTION) + .find((other) => isListener(other) && wrapped.has(other)); + if (first === gated) handlers.onRejection(pluginId, reason); + return; + } + if (original !== raw) process.off(REJECTION, gated); + Reflect.apply(original, process, [reason, promise]); + }, + { listener: original }, + ); + wrapped.set(gated, raw); + return gated; + }; + + // The server always installs the Cursor SDK's listener (CursorSdk.ts). It exits when it listens + // alone and leaves a rejection to the others otherwise, so beside a plain listener T3's own + // rejections would no longer stop the server. Wrapped, it still sees those, and only those. + const sync = () => { + if (!installed || syncing) return; + syncing = true; + try { + const listeners = process.rawListeners(REJECTION).filter(isListener); + const others = listeners.filter((other) => other !== alone); + if ( + others.every((other) => wrapped.has(other)) && + (others.length === 0) === listeners.includes(alone) + ) { + return; + } + // Re-adding them all keeps their order. + process.removeAllListeners(REJECTION); + for (const other of others) { + process.on(REJECTION, wrapped.has(other) ? other : wrap(other)); + } + if (others.length === 0) process.on(REJECTION, alone); + } finally { + syncing = false; + } + }; + // Node adds a listener after announcing it, so it is wrapped once it is in. + const onNewListener = (event: string | symbol) => { + if (!syncing && event === REJECTION) queueMicrotask(sync); + }; + const onRemoveListener = (event: string | symbol) => { + if (!syncing && event === REJECTION) sync(); + }; + + return { + /** Ties failures whose stack names a file under `root` to the plugin; the first call installs. */ + watch: (root: string, pluginId: PluginId) => { + roots.set(root, pluginId); + if (installed) return; + installed = true; + sync(); + process.on("newListener", onNewListener); + process.on("removeListener", onRemoveListener); + process.on("uncaughtExceptionMonitor", onUncaught); + }, + /** Puts back the listeners it wrapped. */ + uninstall: () => { + if (!installed) return; + installed = false; + process.off("newListener", onNewListener); + process.off("removeListener", onRemoveListener); + process.off("uncaughtExceptionMonitor", onUncaught); + const others = process.rawListeners(REJECTION).filter(isListener); + process.removeAllListeners(REJECTION); + for (const other of others) { + if (other !== alone) process.on(REJECTION, wrapped.get(other) ?? other); + } + }, + }; +}; diff --git a/apps/server/src/plugins/pluginFailures.ts b/apps/server/src/plugins/pluginFailures.ts new file mode 100644 index 0000000000000..0d55610b87012 --- /dev/null +++ b/apps/server/src/plugins/pluginFailures.ts @@ -0,0 +1,82 @@ +import type { PluginId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; + +import { errorText } from "./api/facade.ts"; + +// A plugin's failures outside a call reach clients at most this often, the latest one last, so a +// timer failing every few milliseconds with new text does not resend the list each time. +const FAILURE_PUBLISH_INTERVAL = Duration.seconds(5); + +interface Failure { + readonly revision: number | undefined; + readonly message: string; +} + +/** + * Builds the host's record of the failures a plugin's row shows: server code failing outside a + * call. Each belongs to the start, the revision, it happened in. + */ +export const makePluginFailures = Effect.gen(function* () { + // By plugin id, for the start it happened in: a new start's revision hides the old one. A + // stopped plugin's leftover timers fail with no revision. + const serverFailures = yield* Ref.make>(new Map()); + // When each plugin's failure was last published, and the plugins whose next one is waiting. + const publishedAt = new Map(); + const waiting = new Set(); + + return { + /** Looks up a plugin's server failure for the start `revision`. */ + current: Effect.gen(function* () { + const server = yield* Ref.get(serverFailures); + return (pluginId: PluginId, revision: number | undefined) => { + const failure = server.get(pluginId); + return failure !== undefined && failure.revision === revision ? failure.message : undefined; + }; + }), + /** + * Records a failure of the start `revision` outside a call, then succeeds with whether to + * publish: a timer that fails every second with the same text logs and publishes once. + * Untraced: a plugin's timer can fail every few milliseconds. + */ + recordServer: Effect.fnUntraced(function* ( + pluginId: PluginId, + revision: number | undefined, + error: unknown, + ) { + const message = errorText(error); + const known = (yield* Ref.get(serverFailures)).get(pluginId); + if (known?.revision === revision && known?.message === message) return false; + yield* Ref.update(serverFailures, (all) => new Map(all).set(pluginId, { revision, message })); + if (waiting.has(pluginId)) return false; + const last = publishedAt.get(pluginId); + const wait = + last === undefined + ? 0 + : last + Duration.toMillis(FAILURE_PUBLISH_INTERVAL) - (yield* Clock.currentTimeMillis); + if (wait > 0) { + waiting.add(pluginId); + yield* Effect.sleep(Duration.millis(wait)).pipe( + Effect.ensuring(Effect.sync(() => waiting.delete(pluginId))), + ); + } + publishedAt.set(pluginId, yield* Clock.currentTimeMillis); + yield* Effect.logWarning("plugin code failed outside a call", { + pluginId, + reason: (yield* Ref.get(serverFailures)).get(pluginId)?.message ?? message, + }); + return true; + }), + /** Forgets the plugin's server failures, as a stop does. */ + clearServer: (pluginId: PluginId) => + Ref.update(serverFailures, (all) => { + const next = new Map(all); + next.delete(pluginId); + return next; + }), + }; +}); + +export type PluginFailures = Effect.Success; diff --git a/apps/server/src/plugins/pluginHostTestHarness.ts b/apps/server/src/plugins/pluginHostTestHarness.ts new file mode 100644 index 0000000000000..7cc40d98b8c60 --- /dev/null +++ b/apps/server/src/plugins/pluginHostTestHarness.ts @@ -0,0 +1,102 @@ +// @effect-diagnostics preferSchemaOverJson:off - like the tests, it writes manifests as plain JSON. +// The plugin host with its real stores and MCP tool registry, for its tests and the tests of the +// modules it builds. +import type { PluginState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as EnvironmentTheme from "../environmentTheme.ts"; +import * as ServerConfig from "../config.ts"; +import * as PluginHost from "./PluginHost.ts"; +import * as PluginMcpTools from "./PluginMcpTools.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import * as PluginStorage from "./PluginStorage.ts"; + +export const fixturesDir = new URL("../../../../packages/plugin-sdk/fixtures/", import.meta.url) + .pathname; + +export const layerConfig = () => + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-host-test-" })); + +// Every list of theme folders the host published, for the web files case. +export const publishedThemeDirectories: Array> = []; +const layerThemes = Layer.mock(EnvironmentTheme.EnvironmentThemeService)({ + setPluginThemeDirectories: (directories) => + Effect.sync(() => { + publishedThemeDirectories.push(directories); + }), +}); + +/** `themes` replaces the recording theme service, such as to hold a publish where it calls it. */ +export const layerHost = (themes = layerThemes) => + Layer.fresh( + PluginHost.layer.pipe( + Layer.provideMerge(Layer.mergeAll(PluginSettingsStore.layer, PluginStorage.layer)), + Layer.provide(ServerSecretStore.layer), + Layer.provide(themes), + Layer.provideMerge(PluginMcpTools.layer), + ), + ); + +/** The real file system with some of its methods replaced, for the host's layer. */ +export const layerFileSystemWith = ( + replace: (fs: FileSystem.FileSystem) => Partial, +) => + Layer.effect( + FileSystem.FileSystem, + FileSystem.FileSystem.use((fs) => Effect.succeed({ ...fs, ...replace(fs) })), + ); + +export const writeFile = Effect.fn(function* (filePath: string, contents: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, contents); +}); + +export const writePluginsConfig = Effect.fn(function* (config: { + readonly enabled?: Record; + readonly devPaths?: ReadonlyArray; +}) { + const { stateDir } = yield* ServerConfig.ServerConfig; + yield* writeFile(`${stateDir}/plugins.json`, JSON.stringify(config)); +}); + +/** A plugin written for one test, under `/plugins/`. */ +export const writeInstalledPlugin = Effect.fn(function* (id: string, serverSource: string) { + const { stateDir } = yield* ServerConfig.ServerConfig; + const rootDir = `${stateDir}/plugins/${id}`; + yield* writeFile( + `${rootDir}/t3-plugin.json`, + JSON.stringify({ + id, + name: id, + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + server: "./server.cjs", + }), + ); + yield* writeFile(`${rootDir}/server.cjs`, serverSource); + return rootDir; +}); + +export const stateOf = (states: ReadonlyArray, id: string) => + states.find((state) => state.id === id); + +/** Resolves with the first list where every listed plugin has its expected status. */ +export const waitForStatuses = ( + host: PluginHost.PluginHost["Service"], + expected: Readonly>, +) => + host.streamStates.pipe( + Stream.filter((states) => + Object.entries(expected).every(([id, status]) => stateOf(states, id)?.status === status), + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); diff --git a/apps/server/src/plugins/pluginRpcHandlers.ts b/apps/server/src/plugins/pluginRpcHandlers.ts new file mode 100644 index 0000000000000..4288e7a866f3c --- /dev/null +++ b/apps/server/src/plugins/pluginRpcHandlers.ts @@ -0,0 +1,80 @@ +import { + type AuthEnvironmentScope, + PluginError, + type PluginCreateUploadUrlInput, + type PluginId, + type PluginInvokeInput, + type PluginListInput, + type PluginReloadInput, + type PluginRescanInput, + type PluginSetEnabledInput, + type PluginSettingsGetInput, + type PluginSettingsUpdateInput, + type PluginSubscribeInput, + type PluginUninstallInput, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import { rpcAuthorizationError } from "../auth/RpcAuthorization.ts"; +import * as PluginHost from "./PluginHost.ts"; +import type * as PluginInstaller from "./PluginInstaller.ts"; +import type * as PluginUploads from "./pluginUploads.ts"; + +const toRpcError = (pluginId: PluginId) => (error: { readonly message: string }) => + PluginHost.isPluginAccessDeniedError(error) + ? rpcAuthorizationError(error.requiredScope) + : new PluginError({ message: error.message, pluginId }); + +/** The `plugins.*` WebSocket handlers. `scopes` are the connected session's. */ +export const makePluginRpcHandlers = ( + pluginHost: PluginHost.PluginHost["Service"], + installer: PluginInstaller.PluginInstaller["Service"], + uploads: PluginUploads.PluginUploads["Service"], + scopes: ReadonlyArray, +) => ({ + [WS_METHODS.pluginsList]: (_input: PluginListInput) => pluginHost.listForClient(scopes), + [WS_METHODS.pluginsSubscribeStates]: (_input: PluginListInput) => + pluginHost.streamForClient(scopes), + [WS_METHODS.pluginsSetEnabled]: (input: PluginSetEnabledInput) => + pluginHost.setEnabled(input.pluginId, input.enabled).pipe( + Effect.map((plugin) => ({ plugin })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsReload]: (input: PluginReloadInput) => + pluginHost.reload(input.pluginId).pipe( + Effect.map((plugin) => ({ plugin })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsCreateUploadUrl]: (_input: PluginCreateUploadUrlInput) => + uploads.issueUrl.pipe(Effect.mapError((error) => new PluginError({ message: error.message }))), + [WS_METHODS.pluginsRescan]: (_input: PluginRescanInput) => + pluginHost.rescan.pipe(Effect.mapError((error) => new PluginError({ message: error.message }))), + [WS_METHODS.pluginsUninstall]: (input: PluginUninstallInput) => + installer + .uninstall(input.pluginId, input.folder) + .pipe(Effect.as({ pluginId: input.pluginId }), Effect.mapError(toRpcError(input.pluginId))), + [WS_METHODS.pluginsSettingsGet]: (input: PluginSettingsGetInput) => + pluginHost.getSettings(input.pluginId).pipe( + Effect.map((result) => ({ pluginId: input.pluginId, ...result })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsSettingsUpdate]: (input: PluginSettingsUpdateInput) => + pluginHost.updateSettings(input.pluginId, input.patch).pipe( + Effect.map((settings) => ({ pluginId: input.pluginId, settings })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsInvoke]: (input: PluginInvokeInput) => + pluginHost + .invoke({ pluginId: input.pluginId, method: input.method, input: input.input, scopes }) + .pipe( + Effect.map((output) => ({ output })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsSubscribe]: (input: PluginSubscribeInput) => + pluginHost.subscribe(input).pipe( + Stream.map((value) => ({ value })), + Stream.mapError(toRpcError(input.pluginId)), + ), +}); diff --git a/apps/server/src/plugins/pluginStates.ts b/apps/server/src/plugins/pluginStates.ts new file mode 100644 index 0000000000000..20f5b7e2d524b --- /dev/null +++ b/apps/server/src/plugins/pluginStates.ts @@ -0,0 +1,190 @@ +import { + type AuthEnvironmentScope, + AuthSettingsWriteScope, + type PluginFailure, + type PluginManifest, + type PluginState, + type PluginStatesEvent, + type PluginStatus, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import * as EnvironmentTheme from "../environmentTheme.ts"; +import type { PluginActivation } from "./PluginApi.ts"; +import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; +import type { PluginFailures } from "./pluginFailures.ts"; +import type { PluginsConfigFile } from "./pluginsConfig.ts"; +import type { PluginWebFolders } from "./pluginWebFiles.ts"; + +/** What the host knows about one listed copy of a plugin. */ +export interface PluginRecord { + readonly plugin: DiscoveredPlugin; + readonly status: PluginStatus; + readonly failure?: PluginFailure; + readonly activation?: PluginActivation; + readonly revision?: number; + readonly webRevision?: string; + readonly styles?: PluginState["styles"]; + readonly webFiles?: PluginWebFolders; +} + +export const manifestOf = (plugin: DiscoveredPlugin): PluginManifest | undefined => + "manifest" in plugin ? plugin.manifest : undefined; + +// Folders on the server are for clients that manage plugins; read access can be a guest's. +const withoutServerPaths = (state: PluginState): PluginState => { + const { folder: _folder, ...shown } = state; + return shown; +}; +const managesPlugins = (scopes: ReadonlyArray) => + scopes.includes(AuthSettingsWriteScope); + +interface ClientList { + readonly plugins: ReadonlyArray; +} + +// Compared per subscriber, since each one sees its own view of the list. +const statesEvent = ( + previous: ClientList | undefined, + next: ClientList, +): PluginStatesEvent | undefined => { + if (previous === undefined) return { _tag: "snapshot", ...next }; + const rows = next.plugins.flatMap((plugin, index) => + Equal.equals(plugin, previous.plugins[index]) ? [] : [{ index, plugin }], + ); + return rows.length === 0 && next.plugins.length === previous.plugins.length + ? undefined + : { _tag: "changed", length: next.plugins.length, rows }; +}; + +const toState = ( + record: PluginRecord, + config: PluginsConfigFile, + serverFailure: string | undefined, +): PluginState => { + const manifest = manifestOf(record.plugin); + const { id } = record.plugin; + return { + id: record.plugin.id, + name: manifest?.name ?? record.plugin.id, + ...(manifest === undefined + ? {} + : { + version: manifest.version, + ...(manifest.description === undefined ? {} : { description: manifest.description }), + }), + permissions: manifest?.permissions ?? [], + source: record.plugin.source, + folder: record.plugin.folder, + enabled: config.enabled?.[id] === true, + status: record.status, + ...(record.failure === undefined ? {} : { failure: record.failure }), + ...(record.revision === undefined ? {} : { revision: record.revision }), + ...(record.webRevision === undefined ? {} : { webRevision: record.webRevision }), + ...(record.styles === undefined || record.styles.length === 0 ? {} : { styles: record.styles }), + hasServer: manifest?.server !== undefined, + hasWeb: manifest?.web !== undefined, + hasSettings: manifest?.settingsSchema !== undefined, + ...(serverFailure === undefined ? {} : { serverFailure }), + }; +}; + +/** + * Builds the plugin list clients see from the host's records, plugins.json and the failures, and + * publishes it after each change, with the theme folders of plugins that are on when those change. + */ +export const makePluginStates = Effect.fn("PluginStates.make")(function* (input: { + readonly records: Ref.Ref>; + readonly config: Effect.Effect; + readonly failures: PluginFailures; +}) { + const { records, failures } = input; + const environmentThemes = yield* EnvironmentTheme.EnvironmentThemeService; + const path = yield* Path.Path; + const changes = yield* PubSub.sliding>(1); + const publishLock = yield* Semaphore.make(1); + + const statesOf = Effect.fn("PluginHost.statesOf")(function* (all: ReadonlyArray) { + const config = yield* input.config; + const failureOf = yield* failures.current; + return all.map((record) => + toState(record, config, failureOf(record.plugin.id, record.revision)), + ); + }); + const current = Ref.get(records).pipe(Effect.flatMap(statesOf)); + // What the last publish sent, starting from the empty list clients get before any. Settings + // rescans on every visit, and a list sent again would make every client stream rebuild what it + // already shows. + let published: ReadonlyArray = []; + // The copies whose theme folders the theme service last got, which reads every folder each time. + // A reload discovers its plugin afresh, so the new copy's themes are read again. + let themed: ReadonlyArray = []; + // Reading and publishing under one lock, so a slower fiber cannot publish an older list last. + const publishUnderLock = publishLock.withPermits(1)( + Effect.gen(function* () { + const all = yield* Ref.get(records); + const states = yield* statesOf(all); + if (Equal.equals(states, published)) return; + // Every enabled plugin that has not failed, so a starting or restarting one keeps its themes: + // windows would otherwise paint T3's colors, forget the saved theme, then switch back. + const copies = all.flatMap((record, index) => { + const state = states[index]; + return state?.enabled === true && state.status !== "failed" ? [record.plugin] : []; + }); + if (copies.length !== themed.length || copies.some((copy, index) => copy !== themed[index])) { + yield* environmentThemes.setPluginThemeDirectories( + copies.map((copy) => path.join(copy.rootDir, "themes")), + ); + themed = copies; + } + yield* PubSub.publish(changes, states); + published = states; + }), + ); + + const stream = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + const snapshot = yield* current; + return Stream.concat(Stream.make(snapshot), Stream.fromSubscription(subscription)); + }), + ); + const forClient = + (scopes: ReadonlyArray) => + (plugins: ReadonlyArray): ClientList => ({ + plugins: managesPlugins(scopes) ? plugins : plugins.map(withoutServerPaths), + }); + + return { + current, + /** + * Sends the list if it changed. A stop that interrupts a starting plugin mid-publish cannot + * leave it unsent. + */ + publish: Effect.uninterruptible(publishUnderLock), + /** The current list, then the whole list after every change. */ + stream, + listForClient: Effect.fn("PluginHost.listForClient")(function* ( + scopes: ReadonlyArray, + ) { + return forClient(scopes)(yield* current); + }), + streamForClient: (scopes: ReadonlyArray) => + stream.pipe( + Stream.map(forClient(scopes)), + Stream.mapAccum( + (): ClientList | undefined => undefined, + (previous, next) => { + const event = statesEvent(previous, next); + return [next, event === undefined ? [] : [event]]; + }, + ), + ), + }; +}); diff --git a/apps/server/src/plugins/pluginUploads.test.ts b/apps/server/src/plugins/pluginUploads.test.ts new file mode 100644 index 0000000000000..619cf01ee4282 --- /dev/null +++ b/apps/server/src/plugins/pluginUploads.test.ts @@ -0,0 +1,126 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { PLUGIN_UPLOAD_MAX_BYTES } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as PluginHost from "./PluginHost.ts"; +import { fixturesDir, layerConfig, layerHost, waitForStatuses } from "./pluginHostTestHarness.ts"; +import * as PluginUploads from "./pluginUploads.ts"; + +/** Posts `files` to `url` through the upload route, over a running plugin host. */ +const uploader = Effect.fn(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, {}); + const app = yield* HttpRouter.toHttpEffect(PluginUploads.layerRoute); + return (url: string, files: Readonly>>) => { + const body = new FormData(); + for (const [path, contents] of Object.entries(files)) { + body.append("file", new Blob([contents]), encodeURIComponent(path)); + } + const request = new Request(`http://localhost${url}`, { method: "POST", body }); + return app.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(request), + ), + Effect.scoped, + Effect.flatMap((response) => + Effect.promise(() => HttpServerResponse.toWeb(response).text()).pipe( + Effect.map((text) => ({ status: response.status, text })), + ), + ), + ); + }; +}); + +const issueUrl = PluginUploads.PluginUploads.use((uploads) => uploads.issueUrl); + +const helloFiles = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return { + "t3-plugin.json": yield* fs.readFileString(`${fixturesDir}hello-plugin/t3-plugin.json`), + "server.cjs": yield* fs.readFileString(`${fixturesDir}hello-plugin/server.cjs`), + }; +}); + +const withHost = (body: Effect.Effect) => + body.pipe( + Effect.provide( + Layer.merge(layerHost(), PluginUploads.layer).pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(layerConfig()), + ), + ), + ); + +it.layer(NodeServices.layer)("PluginUploads", (it) => { + it.effect("installs an uploaded folder under the paths it was sent with", () => + withHost( + Effect.gen(function* () { + const upload = yield* uploader(); + const { relativeUrl } = yield* issueUrl; + const response = yield* upload(relativeUrl, { + ...(yield* helloFiles), + "notes/read me.md": "Hello.", + }); + assert.strictEqual(response.status, 200); + assert.include(JSON.parse(response.text).plugin, { + id: "hello-plugin", + status: "disabled", + }); + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + assert.strictEqual( + yield* fs.readFileString(`${stateDir}/plugins/hello-plugin/notes/read me.md`), + "Hello.", + ); + }), + ), + ); + + it.effect("installs nothing from a forged, expired or used URL", () => + withHost( + Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const upload = yield* uploader(); + const files = yield* helloFiles; + const { relativeUrl: expiring } = yield* issueUrl; + const forged = `${expiring.slice(0, expiring.lastIndexOf(".") + 1)}AAAA`; + assert.strictEqual((yield* upload(forged, files)).status, 404); + yield* TestClock.adjust("6 minutes"); + assert.strictEqual((yield* upload(expiring, files)).status, 404); + assert.deepStrictEqual(yield* host.list, []); + + // It installs code, so a URL that leaked after its upload installs nothing more. + const { relativeUrl } = yield* issueUrl; + assert.strictEqual((yield* upload(relativeUrl, files)).status, 200); + assert.strictEqual((yield* upload(relativeUrl, files)).status, 404); + }), + ), + ); + + it.effect("refuses a folder over the size limit before installing it", () => + withHost( + Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const upload = yield* uploader(); + const { relativeUrl } = yield* issueUrl; + const response = yield* upload(relativeUrl, { + ...(yield* helloFiles), + "big.bin": new Uint8Array(PLUGIN_UPLOAD_MAX_BYTES + 1), + }); + assert.deepStrictEqual(response, { + status: 413, + text: "A plugin folder can have up to 1000 files and 25 MB.", + }); + assert.deepStrictEqual(yield* host.list, []); + }), + ), + ); +}); diff --git a/apps/server/src/plugins/pluginUploads.ts b/apps/server/src/plugins/pluginUploads.ts new file mode 100644 index 0000000000000..7828bb43a83ff --- /dev/null +++ b/apps/server/src/plugins/pluginUploads.ts @@ -0,0 +1,210 @@ +import { + PLUGIN_UPLOAD_MAX_BYTES, + PLUGIN_UPLOAD_MAX_FILES, + type PluginCreateUploadUrlResult, + PluginMutationResult, + PluginUploadFiles, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpRouter, HttpServerRequest, HttpServerResponse, Multipart } from "effect/http"; + +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../auth/utils.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as PluginInstaller from "./PluginInstaller.ts"; + +/** + * A client installs a dropped plugin folder by uploading it here, to a URL `plugins.createUploadUrl` + * signed. Up to 25 MB as one WebSocket message would hold up every other stream on the socket. + * The signature is the upload's credential, as for attachments, so the client sends the request + * the same way on a local, remote or T3 Connect connection. Each URL installs once. + */ +export const PLUGIN_UPLOAD_ROUTE_PREFIX = "/api/plugin-uploads"; + +const SIGNING_SECRET_NAME = "plugin-upload-signing-key"; +// The client uploads as soon as it has the URL. +const UPLOAD_URL_TTL_MS = 5 * 60_000; + +const UploadClaims = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("plugin-upload"), + expiresAt: Schema.Number, + // Tells apart URLs signed in the same millisecond, so each one is used up on its own. + nonce: Schema.String, +}); +const uploadClaimsJson = Schema.fromJsonString(UploadClaims); +const encodeUploadClaims = Schema.encodeSync(uploadClaimsJson); +const decodeUploadClaims = Schema.decodeUnknownOption(uploadClaimsJson); +const claimsOf = (payload: string) => { + try { + return decodeUploadClaims(base64UrlDecodeUtf8(payload)); + } catch { + return Option.none(); + } +}; +const decodeUploadFiles = Schema.decodeUnknownEffect(PluginUploadFiles); +const mutationResponse = HttpServerResponse.schemaJson(PluginMutationResult); + +export class PluginUploadUrlError extends Schema.TaggedError()( + "PluginUploadUrlError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not sign a plugin upload URL."; + } +} + +export class PluginUploads extends Context.Service< + PluginUploads, + { + /** Signs a URL that installs one uploaded folder within 5 minutes. */ + readonly issueUrl: Effect.Effect; + /** + * Whether `token` is a URL this server signed that is unexpired and unused. A valid one is used + * up, because it installs code. + */ + readonly claim: (token: string) => Effect.Effect; + } +>()("t3/plugins/pluginUploads") {} + +const make = Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const crypto = yield* Crypto.Crypto; + const loadSigningSecret = secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); + // Unused URLs by nonce, with when each expires. A restart forgets them, which only refuses more. + const unused = new Map(); + + const issueUrl = Effect.fn("PluginUploads.issueUrl")(function* () { + const secret = yield* loadSigningSecret.pipe( + Effect.mapError((cause) => new PluginUploadUrlError({ cause })), + ); + const now = yield* Clock.currentTimeMillis; + for (const [nonce, expiresAt] of unused) if (expiresAt <= now) unused.delete(nonce); + const expiresAt = now + UPLOAD_URL_TTL_MS; + const nonce = yield* crypto + .randomUUIDv4() + .pipe(Effect.mapError((cause) => new PluginUploadUrlError({ cause }))); + const payload = base64UrlEncode( + encodeUploadClaims({ version: 1, kind: "plugin-upload", expiresAt, nonce }), + ); + unused.set(nonce, expiresAt); + const result: PluginCreateUploadUrlResult = { + relativeUrl: `${PLUGIN_UPLOAD_ROUTE_PREFIX}/${payload}.${signPayload(payload, secret)}`, + expiresAt, + }; + return result; + }); + + const claim = Effect.fn("PluginUploads.claim")(function* (token: string) { + const [payload, signature, unexpected] = token.split("."); + if (!payload || !signature || unexpected !== undefined) return false; + const secret = yield* loadSigningSecret.pipe( + Effect.tapError((cause) => + Effect.logError("Failed to load the plugin upload signing key.", { cause }), + ), + Effect.option, + ); + if ( + Option.isNone(secret) || + !timingSafeEqualBase64Url(signature, signPayload(payload, secret.value)) + ) { + return false; + } + const claims = claimsOf(payload); + if (Option.isNone(claims) || claims.value.expiresAt <= (yield* Clock.currentTimeMillis)) { + return false; + } + return unused.delete(claims.value.nonce); + }); + + return PluginUploads.of({ issueUrl: issueUrl(), claim }); +}); + +export const layer = Layer.effect(PluginUploads, make); + +// The total leaves room for each part's headers, whose file name is a path escaped with +// encodeURIComponent; the schema then checks the exact limits. +const UPLOAD_LIMITS = Multipart.limitsServices({ + maxParts: PLUGIN_UPLOAD_MAX_FILES, + maxFileSize: PLUGIN_UPLOAD_MAX_BYTES, + maxTotalSize: PLUGIN_UPLOAD_MAX_BYTES + PLUGIN_UPLOAD_MAX_FILES * 10 * 1024, + // By default a JSON part is read as a field, which would drop t3-plugin.json. + fieldMimeTypes: [], +}); + +const text = (body: string, status: number) => HttpServerResponse.text(body, { status }); +const notAnUpload = text("The request is not a plugin folder.", 400); + +// A part named with a malformed escape names no file. +const decodePath = (name: string) => { + try { + return Option.some(decodeURIComponent(name)); + } catch { + return Option.none(); + } +}; + +/** `POST /`: installs the folder like `PluginInstaller.installFiles`. */ +export const layerRoute = HttpRouter.add( + "POST", + `${PLUGIN_UPLOAD_ROUTE_PREFIX}/*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) return text("Bad Request", 400); + const uploads = yield* PluginUploads; + // An expired, forged or used URL looks like no route at all. + if ( + !(yield* uploads.claim(url.value.pathname.slice(`${PLUGIN_UPLOAD_ROUTE_PREFIX}/`.length))) + ) { + return text("Not Found", 404); + } + // In memory, as `installFiles` takes them. + const received = yield* request.multipartStream.pipe( + Stream.filter(Multipart.isFile), + Stream.mapEffect((file) => + file.contentEffect.pipe( + Effect.map((contents) => + Option.map(decodePath(file.name), (path) => ({ path, contents })), + ), + ), + ), + Stream.runCollect, + Effect.provideContext(UPLOAD_LIMITS), + Effect.result, + ); + if (received._tag === "Failure") { + return received.failure.reason._tag === "Parse" || + received.failure.reason._tag === "InternalError" + ? notAnUpload + : text( + `A plugin folder can have up to ${PLUGIN_UPLOAD_MAX_FILES} files and ${PLUGIN_UPLOAD_MAX_BYTES / 1024 / 1024} MB.`, + 413, + ); + } + const named = Option.all(received.success); + if (Option.isNone(named)) return notAnUpload; + const files = yield* decodeUploadFiles(named.value).pipe(Effect.result); + if (files._tag === "Failure") return text(files.failure.message, 400); + const installer = yield* PluginInstaller.PluginInstaller; + return yield* installer.installFiles(files.success).pipe( + Effect.flatMap((plugin) => mutationResponse({ plugin }).pipe(Effect.orDie)), + Effect.catchTags({ + PluginInstallError: (error) => Effect.succeed(text(error.message, 400)), + PluginNotFoundError: (error) => Effect.succeed(text(error.message, 404)), + PluginConfigError: (error) => Effect.succeed(text(error.message, 500)), + }), + ); + }), +); diff --git a/apps/server/src/plugins/pluginWebFiles.test.ts b/apps/server/src/plugins/pluginWebFiles.test.ts new file mode 100644 index 0000000000000..b66afb7958252 --- /dev/null +++ b/apps/server/src/plugins/pluginWebFiles.test.ts @@ -0,0 +1,218 @@ +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + type AuthEnvironmentScope, + AuthOrchestrationReadScope, + AuthSessionId, + PluginId, +} from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { HttpClient, HttpClientRequest, HttpRouter } from "effect/http"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerConfig from "../config.ts"; +import * as PluginHost from "./PluginHost.ts"; +import { + fixturesDir, + layerConfig, + layerHost, + publishedThemeDirectories, + stateOf, + waitForStatuses, + writeFile, + writePluginsConfig, +} from "./pluginHostTestHarness.ts"; + +// What the host serves at `/api/plugins/`, if anything. +const served = ( + host: PluginHost.PluginHost["Service"], + pluginId: string, + path: string, + revision?: string, +) => host.readWebFile({ pluginId, path, revision }).pipe(Effect.map(Option.getOrUndefined)); + +const scopesByToken: Record> = { + "Bearer read": [AuthOrchestrationReadScope], + "Bearer none": [], +}; +const layerAuth = Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + authenticateHttpRequest: (request) => { + const scopes = scopesByToken[request.headers.authorization ?? ""]; + return scopes === undefined + ? Effect.fail(new EnvironmentAuth.ServerAuthMissingCredentialError()) + : Effect.succeed({ + sessionId: AuthSessionId.make("session"), + subject: "test", + method: "bearer-access-token", + scopes, + }); + }, +}); + +it.layer(NodeServices.layer)("PluginWebFiles", (it) => { + it.effect("signs in like reads on the rest of /api and answers 404 for what is not served", () => + Effect.gen(function* () { + const layerHostAnswering = Layer.mock(PluginHost.PluginHost)({ + readWebFile: ({ pluginId, path, revision }) => + Effect.succeed( + pluginId === "styles-plugin" + ? Option.some({ headers: {}, body: new TextEncoder().encode(`${path} ${revision}`) }) + : Option.none(), + ), + }); + const services = yield* Layer.build( + HttpRouter.serve(PluginHost.layerWebFilesRoute, { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provideMerge(layerHostAnswering), + Layer.provideMerge(layerAuth), + Layer.provideMerge(NodeHttpServer.layerTest), + ), + ); + const client = Context.get(services, HttpClient.HttpClient); + const get = Effect.fn(function* (path: string, token?: string) { + const response = yield* client.execute( + HttpClientRequest.get(path).pipe( + token === undefined + ? (request) => request + : HttpClientRequest.setHeader("authorization", token), + ), + ); + return { status: response.status, body: yield* response.text }; + }); + + assert.strictEqual((yield* get("/api/plugins/styles-plugin/styles/base.css")).status, 401); + assert.strictEqual( + (yield* get("/api/plugins/styles-plugin/styles/base.css", "Bearer none")).status, + 403, + ); + assert.deepStrictEqual( + yield* get("/api/plugins/styles-plugin/styles/base.css?v=r1", "Bearer read"), + { status: 200, body: "/styles/base.css r1" }, + ); + assert.strictEqual( + (yield* get("/api/plugins/other-plugin/styles/base.css", "Bearer read")).status, + 404, + ); + }).pipe(Effect.scoped), + ); + + it.effect("serves an active plugin's web files and themes, and stops when disabled", () => + Effect.gen(function* () { + const helloWeb = PluginId.make("hello-web"); + yield* writePluginsConfig({ + enabled: { [helloWeb]: true }, + devPaths: [`${fixturesDir}hello-web`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { [helloWeb]: "active" }); + + const response = yield* served(host, helloWeb, "/web.js"); + assert.strictEqual(response?.headers["content-type"], "text/javascript; charset=utf-8"); + // The bundle's revision names its contents, so browsers keep that URL's copy for good. + const webRevision = stateOf(yield* host.list, helloWeb)?.webRevision ?? ""; + assert.match(webRevision, /^[0-9a-f]{16}$/); + const revised = yield* served(host, helloWeb, "/web.js", webRevision); + assert.strictEqual( + revised?.headers["cache-control"], + "private, max-age=31536000, immutable", + ); + assert.strictEqual(response?.headers["cache-control"], "no-store"); + const themes = `${fixturesDir}hello-web/themes`; + assert.deepStrictEqual(publishedThemeDirectories.at(-1), [themes]); + // A reload hands its folder over once more, so its files are read again, without dropping + // it first: windows would flash T3's colors. + publishedThemeDirectories.length = 0; + yield* host.reload(helloWeb); + assert.deepStrictEqual(publishedThemeDirectories, [[themes]]); + const asset = yield* served(host, helloWeb, "/assets/hello.txt"); + assert.strictEqual(asset?.headers["content-type"], "text/plain; charset=utf-8"); + // A path out of the assets folder names no asset; it is not the plugin's failure. + for (const path of ["/assets/..%2Fserver.cjs", "/assets/%E0%A4%A", "/assets/"]) { + assert.isUndefined(yield* served(host, helloWeb, path)); + } + + yield* host.setEnabled(helloWeb, false); + assert.isUndefined(yield* served(host, helloWeb, "/web.js")); + assert.deepStrictEqual(publishedThemeDirectories.at(-1), []); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect.skipIf(!symlinksSupported)("reads no file a link takes out of the plugin", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const rootDir = `${stateDir}/plugins/linking`; + yield* writeFile( + `${rootDir}/t3-plugin.json`, + JSON.stringify({ + id: "linking", + name: "Linking", + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + web: "./web.js", + }), + ); + yield* writeFile(`${rootDir}/styles/base.css`, "body {}"); + yield* writeFile(`${stateDir}/outside/secret.css`, "Not the plugin's."); + yield* writeFile(`${stateDir}/outside/web.js`, "export default {};"); + yield* fs.symlink(`${stateDir}/outside/secret.css`, `${rootDir}/styles/secret.css`); + yield* fs.symlink(`${stateDir}/outside/web.js`, `${rootDir}/web.js`); + yield* writePluginsConfig({ enabled: { linking: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const state = stateOf(yield* waitForStatuses(host, { linking: "active" }), "linking"); + assert.deepStrictEqual( + state?.styles?.map(({ file }) => file), + ["base.css"], + ); + assert.isUndefined(state?.webRevision); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("serves the stylesheets of a plugin with no code while it is active", () => + Effect.gen(function* () { + const stylesPlugin = PluginId.make("styles-plugin"); + yield* writePluginsConfig({ + enabled: { [stylesPlugin]: true }, + devPaths: [`${fixturesDir}styles-plugin`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { [stylesPlugin]: "active" }); + const call = yield* host + .invoke({ pluginId: stylesPlugin, method: "ping", input: undefined, scopes: [] }) + .pipe(Effect.flip); + assert.strictEqual(call._tag, "PluginNoServerError"); + // In name order, which is the order clients apply them in. + const styles = stateOf(yield* host.list, stylesPlugin)?.styles ?? []; + assert.deepStrictEqual( + styles.map(({ file }) => file), + ["base.css", "compact.css"], + ); + const base = () => served(host, stylesPlugin, "/styles/base.css", styles[0]?.revision); + const response = yield* base(); + assert.strictEqual(response?.headers["content-type"], "text/css; charset=utf-8"); + assert.strictEqual( + response?.headers["cache-control"], + "private, max-age=31536000, immutable", + ); + assert.isUndefined(yield* served(host, stylesPlugin, "/styles/..%2Ft3-plugin.json")); + + yield* host.setEnabled(stylesPlugin, false); + assert.isUndefined(stateOf(yield* host.list, stylesPlugin)?.styles); + assert.isUndefined(yield* base()); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); +}); diff --git a/apps/server/src/plugins/pluginWebFiles.ts b/apps/server/src/plugins/pluginWebFiles.ts new file mode 100644 index 0000000000000..466048cbc8b28 --- /dev/null +++ b/apps/server/src/plugins/pluginWebFiles.ts @@ -0,0 +1,168 @@ +// @effect-diagnostics nodeBuiltinImport:off - bundle revisions are sha256 digests. +import * as NodeCrypto from "node:crypto"; + +import type { PluginState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; + +const WEB_CONTENT_TYPES: Readonly> = { + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".woff2": "font/woff2", + ".txt": "text/plain; charset=utf-8", +}; + +const contentRevision = (contents: Uint8Array) => + NodeCrypto.createHash("sha256").update(contents).digest("hex").slice(0, 16); + +/** What an active plugin serves: its bundle, and the real paths of its `assets/` and `styles/`. */ +export interface PluginWebFolders { + readonly bundle: string | undefined; + readonly assets: string | undefined; + readonly styles: string | undefined; +} + +export interface PluginWebFile { + readonly headers: Readonly>; + readonly body: Uint8Array; +} + +/** Finds what a starting plugin serves to its web half, and reads those files for the host. */ +export const makePluginWebFiles = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // Reads a plain file whose real path stays inside the real folder `within`, so a path or link + // out of it, a link cycle, or a pipe reads nothing. Untraced: each caller has its span. + const readWithin = Effect.fnUntraced(function* (within: string, file: string) { + const resolved = yield* fs.realPath(file).pipe(Effect.option); + if (Option.isNone(resolved)) return Option.none(); + const real = resolved.value; + if (!real.startsWith(`${within}${path.sep}`)) return Option.none(); + const info = yield* fs.stat(real).pipe(Effect.option); + if (Option.isNone(info) || info.value.type !== "File") return Option.none(); + return yield* fs.readFile(real).pipe( + Effect.map((body) => Option.some({ real, body })), + Effect.orElseSucceed(() => Option.none()), + ); + }); + + // The real path of the plugin's `name` folder, unless a link takes it out of the plugin. + const folderOf = Effect.fnUntraced(function* ( + plugin: DiscoveredPlugin, + name: "assets" | "styles", + ) { + const dir = yield* fs.realPath(path.join(plugin.rootDir, name)).pipe(Effect.option); + return Option.isSome(dir) && dir.value.startsWith(`${plugin.rootDir}${path.sep}`) + ? dir.value + : undefined; + }); + + // The `.css` files directly in `styles/`, in name order, each with the revision for its URL. + const stylesIn = Effect.fnUntraced(function* (dir: string) { + const names = yield* fs + .readDirectory(dir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + // Only the ones it serves, so not a folder named like a stylesheet or a link out of `styles/`. + const styles = yield* Effect.forEach( + names.filter((name) => name.endsWith(".css")).toSorted(), + (file) => + readWithin(dir, path.join(dir, file)).pipe( + Effect.map( + Option.match({ + onNone: () => [], + onSome: ({ body }) => [{ file, revision: contentRevision(body) }], + }), + ), + ), + ); + return styles.flat(); + }); + + /** What a starting plugin serves: `entry` and `assets/` if it has a web half, and `styles/`. */ + const find = Effect.fn("PluginHost.findWebFiles")(function* ( + plugin: DiscoveredPlugin, + entry: string | undefined, + ) { + const bundle = entry === undefined ? undefined : path.join(plugin.rootDir, entry); + const folders: PluginWebFolders = { + bundle, + assets: bundle === undefined ? undefined : yield* folderOf(plugin, "assets"), + styles: yield* folderOf(plugin, "styles"), + }; + // The bundle's contents now, for its URL; one that cannot be served gets no revision. + const read = bundle === undefined ? Option.none() : yield* readWithin(plugin.rootDir, bundle); + const styles: PluginState["styles"] = + folders.styles === undefined ? [] : yield* stylesIn(folders.styles); + return { + folders, + webRevision: Option.isSome(read) ? contentRevision(read.value.body) : undefined, + styles, + }; + }); + + // The file `requestPath` names, the folder it has to stay inside, and whether a revision in + // its URL may name its contents. + const locate = (rootDir: string, folders: PluginWebFolders, requestPath: string) => { + if (requestPath === "/web.js") { + return folders.bundle === undefined + ? undefined + : { file: folders.bundle, within: rootDir, revisioned: true }; + } + const [, name, rest] = /^\/(assets|styles)\/(.*)$/.exec(requestPath) ?? []; + const dir = name === "assets" ? folders.assets : name === "styles" ? folders.styles : undefined; + if (dir === undefined || rest === undefined) return undefined; + try { + return { + file: path.join(dir, decodeURIComponent(rest)), + within: dir, + revisioned: name === "styles", + }; + } catch { + // A malformed escape names no file. + return undefined; + } + }; + + /** + * Reads `/web.js`, `/assets/` or `/styles/` of the plugin at `rootDir`. A request + * whose `revision` names the file's contents may be cached for good. Untraced: the host's + * lookup has the span. + */ + const read = Effect.fnUntraced(function* ( + rootDir: string, + folders: PluginWebFolders, + requestPath: string, + revision: string | undefined, + ) { + const located = locate(rootDir, folders, requestPath); + if (located === undefined) return Option.none(); + const found = yield* readWithin(located.within, located.file); + if (Option.isNone(found)) return Option.none(); + const { real, body } = found.value; + return Option.some({ + headers: { + "content-type": + WEB_CONTENT_TYPES[path.extname(real).toLowerCase()] ?? "application/octet-stream", + "cache-control": + located.revisioned && revision !== undefined && revision === contentRevision(body) + ? "private, max-age=31536000, immutable" + : "no-store", + }, + body, + }); + }); + + return { find, read }; +}); diff --git a/apps/server/src/plugins/pluginsConfig.ts b/apps/server/src/plugins/pluginsConfig.ts new file mode 100644 index 0000000000000..78b96621fcdce --- /dev/null +++ b/apps/server/src/plugins/pluginsConfig.ts @@ -0,0 +1,159 @@ +// @effect-diagnostics nodeBuiltinImport:off - a crash is saved while the process exits, which +// needs synchronous writes the FileSystem service does not offer. +import * as NodeFS from "node:fs"; + +import type { PluginId } from "@t3tools/contracts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import * as ServerConfig from "../config.ts"; + +export class PluginConfigError extends Schema.TaggedError()( + "PluginConfigError", + { path: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not update ${this.path}. If it was edited by hand, fix or delete it.`; + } +} + +// Unknown keys survive a rewrite, so a newer build's additions outlive a toggle by an older one. +const PluginsConfigFile = Schema.StructWithRest( + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)), + devPaths: Schema.optionalKey(Schema.Array(Schema.String)), + /** Why each plugin stopped the server, kept until the user turns it on again or reloads it. */ + crashed: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + }), + [Schema.Record(Schema.String, Schema.Json)], +); +export type PluginsConfigFile = typeof PluginsConfigFile.Type; +const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginsConfigFile)); +const encodeConfig = Schema.encodeEffect(fromJsonStringPretty(PluginsConfigFile)); +const decodeConfigSync = Schema.decodeUnknownSync(Schema.fromJsonString(PluginsConfigFile)); +const encodeConfigSync = Schema.encodeSync(fromJsonStringPretty(PluginsConfigFile)); + +export const withoutCrash = (current: PluginsConfigFile, pluginId: PluginId): PluginsConfigFile => { + if (current.crashed?.[pluginId] === undefined) return current; + const { [pluginId]: _cleared, ...crashed } = current.crashed; + return { ...current, crashed }; +}; + +/** Builds the host's copy of `/plugins.json`, which it reads and writes through this. */ +export const makePluginsConfig = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configPath = path.join(config.stateDir, "plugins.json"); + const current = yield* Ref.make({}); + + const read = Effect.gen(function* () { + const exists = yield* fs.exists(configPath); + if (!exists) { + const emptyConfig: PluginsConfigFile = {}; + return emptyConfig; + } + const raw = yield* fs.readFileString(configPath); + return yield* decodeConfig(raw); + }).pipe(Effect.mapError((cause) => new PluginConfigError({ path: configPath, cause }))); + + const write = Effect.fn("PluginHost.writeConfig")(function* ( + update: (current: PluginsConfigFile) => PluginsConfigFile, + ) { + const next = update(yield* read); + const contents = yield* encodeConfig(next).pipe( + Effect.mapError((cause) => new PluginConfigError({ path: configPath, cause })), + ); + yield* writeFileStringAtomically({ filePath: configPath, contents: `${contents}\n` }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError((cause) => new PluginConfigError({ path: configPath, cause })), + ); + yield* Ref.set(current, next); + }); + + const emptyConfig: PluginsConfigFile = {}; + return { + /** plugins.json as last read or written. */ + current: Ref.get(current), + /** Reads the file again and keeps it. Startup tolerates an unreadable file; a reload reports it. */ + reread: (lenient: boolean) => + (lenient + ? read.pipe( + Effect.catchTags({ + PluginConfigError: (error) => + Effect.logWarning("ignoring unreadable plugins.json", { path: error.path }).pipe( + Effect.as(emptyConfig), + ), + }), + ) + : read + ).pipe(Effect.tap((next) => Ref.set(current, next))), + write, + /** Also forgets that the plugin crashed the server: the user chose what happens next. */ + writeEnabled: (pluginId: PluginId, enabled: boolean) => + write((current) => ({ + ...withoutCrash(current, pluginId), + enabled: { ...current.enabled, [pluginId]: enabled }, + })), + /** + * Forgets the switch and crash of every plugin not in `found`, writing only if there are any. + * A switch left on by a folder deleted by hand would start the next folder with its id unseen. + */ + forgetMissing: (found: ReadonlySet) => { + const keep = (entries: Readonly>) => + Object.fromEntries(Object.entries(entries).filter(([id]) => found.has(id))); + const missing = (config: PluginsConfigFile) => + [...Object.keys(config.enabled ?? {}), ...Object.keys(config.crashed ?? {})].some( + (id) => !found.has(id), + ); + return Ref.get(current).pipe( + Effect.flatMap((last) => + missing(last) + ? write((latest) => ({ + ...latest, + ...(latest.enabled === undefined ? {} : { enabled: keep(latest.enabled) }), + ...(latest.crashed === undefined ? {} : { crashed: keep(latest.crashed) }), + })) + : Effect.void, + ), + ); + }, + /** Forgets that the plugin crashed the server, writing only if it had. */ + clearCrash: (pluginId: PluginId) => + read.pipe( + Effect.flatMap((latest) => + latest.crashed?.[pluginId] === undefined + ? Effect.void + : write((last) => withoutCrash(last, pluginId)), + ), + ), + /** + * Saves that the plugin crashed the server and turns it off, so the next start lists it as + * failed with its switch off. Synchronous, because Node exits right after; it throws if the + * write fails. + */ + recordCrash: (pluginId: PluginId, message: string) => { + // From disk, like `write`, unless it can't be read. + let last = Ref.getUnsafe(current); + try { + last = decodeConfigSync(NodeFS.readFileSync(configPath, "utf8")); + } catch { + // Such as a file edited by hand into invalid JSON. + } + const next = { + ...last, + enabled: { ...last.enabled, [pluginId]: false }, + crashed: { ...last.crashed, [pluginId]: message }, + }; + const temporary = `${configPath}.crash-${process.pid}.tmp`; + NodeFS.writeFileSync(temporary, `${encodeConfigSync(next)}\n`); + NodeFS.renameSync(temporary, configPath); + }, + }; +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 452addfb4a667..2a8a64276f83a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -88,6 +88,11 @@ import * as PreviewBrowser from "./preview/PreviewBrowser.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; +import * as PluginUploads from "./plugins/pluginUploads.ts"; +import * as PluginMcpTools from "./plugins/PluginMcpTools.ts"; +import * as PluginSettingsStore from "./plugins/PluginSettingsStore.ts"; +import * as PluginStorage from "./plugins/PluginStorage.ts"; import * as Keybindings from "./keybindings.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerOwnership from "./serverOwnership.ts"; @@ -681,6 +686,8 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)), Layer.provideMerge(layerRepositoryIdentityResolver), Layer.provideMerge(CodexManagedTokenBroker.layer), + // /mcp and the plugin host share one MCP tool registry and one set of upload URLs. + Layer.provideMerge(Layer.mergeAll(PluginMcpTools.layer, PluginUploads.layer)), Layer.provideMerge(layerServerEnvironment), Layer.provideMerge(layerAuth), Layer.provideMerge(ServerSecretStore.layer), @@ -709,6 +716,12 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( Layer.provide(NetService.layer), ); +// The plugin host sits on top of the runtime for its theme service, secret store and registry. +const layerRuntimeDependenciesWithPlugins = PluginHost.layer.pipe( + Layer.provide(Layer.mergeAll(PluginSettingsStore.layer, PluginStorage.layer)), + Layer.provideMerge(layerRuntimeDependencies), +); + const layerCommandReadiness = HttpRouter.middleware( (httpEffect) => Effect.flatMap(ServerRuntimeStartup.ServerRuntimeStartup, (startup) => @@ -734,6 +747,8 @@ const layerMakeRoutes = Layer.mergeAll( ServerHttp.layerAssetRoute, ServerHttp.layerAttachmentUploadRoute, GuestHttp.layer, + PluginHost.layerWebFilesRoute, + PluginUploads.layerRoute, DeviceHubProxy.layer, ServerBrowserStream.routeLayer, ServerHttp.layerStaticAndDevRoute, @@ -1072,7 +1087,7 @@ const layerMakeServer = Layer.unwrap( ], { concurrency: "unbounded" }, ).pipe(Effect.asVoid), - }).pipe(Layer.provideMerge(layerRuntimeDependencies), Layer.provide(layerLauncher)); + }).pipe(Layer.provideMerge(layerRuntimeDependenciesWithPlugins), Layer.provide(layerLauncher)); const layerRoutes = HttpRouter.serve(layerMakeRoutes.pipe(Layer.provide(layerLauncher)), { disableLogger: !config.logWebSocketEvents, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 641845f322ea6..4e6c3b7a9a17c 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -79,6 +79,9 @@ import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolve import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; +import * as PluginInstaller from "./plugins/PluginInstaller.ts"; +import * as PluginUploads from "./plugins/pluginUploads.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -529,6 +532,9 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ResourceTelemetry.ResourceTelemetry)({}), Layer.mock(ReviewService.ReviewService)({}), Layer.mock(ScheduledTasks.ScheduledTaskService)({}), + Layer.mock(PluginHost.PluginHost)({}), + Layer.mock(PluginInstaller.PluginInstaller)({}), + Layer.mock(PluginUploads.PluginUploads)({}), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(SecretResolver.SecretResolver)({}), Layer.mock(GuestShareService.GuestShareService)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4781fcf78dde6..52a553cb94f43 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -121,6 +121,10 @@ import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts" import * as DocumentText from "./provider/DocumentText.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; +import * as PluginInstaller from "./plugins/PluginInstaller.ts"; +import * as PluginUploads from "./plugins/pluginUploads.ts"; +import { makePluginRpcHandlers } from "./plugins/pluginRpcHandlers.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; import { @@ -1273,6 +1277,9 @@ export const layerWsRpc = ( const commandOutputHub = yield* CommandOutputHub.CommandOutputHub; const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; + const pluginHost = yield* PluginHost.PluginHost; + const pluginInstaller = yield* PluginInstaller.PluginInstaller; + const pluginUploads = yield* PluginUploads.PluginUploads; const secretRequests = yield* SecretRequests.SecretRequests; const guestShares = yield* GuestShareService.GuestShareService; const pullRequests = yield* PullRequestService.PullRequestService; @@ -1867,6 +1874,7 @@ export const layerWsRpc = ( }); const handlers = ServerWsRpcGroup.of({ + ...makePluginRpcHandlers(pluginHost, pluginInstaller, pluginUploads, currentSession.scopes), [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, diff --git a/apps/web/index.html b/apps/web/index.html index a7a5e7450b3de..1a8644231aad1 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -15,6 +15,7 @@ const LIGHT_BACKGROUND = "#ffffff"; const DARK_BACKGROUND = "#000000"; const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1"; + const ENVIRONMENT_THEMES_STORAGE_KEY = "t3code:environment-themes:v1"; const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system"; const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode"; const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1"; @@ -197,19 +198,20 @@ (appearance === value.appearance || isRecord(colors)), ); }; + // Custom themes first, then the palettes the server last published (a plugin's theme, + // say), in the order the runtime looks them up. const findCustomTheme = (themeId) => { if (RESERVED_THEME_IDS.includes(themeId)) return null; - try { - const parsed = JSON.parse( - window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY) ?? "null", - ); - if (!Array.isArray(parsed)) return null; - return ( - parsed.find((value) => isStoredCustomTheme(value) && value.id === themeId) ?? null - ); - } catch { - return null; + for (const storageKey of [CUSTOM_THEMES_STORAGE_KEY, ENVIRONMENT_THEMES_STORAGE_KEY]) { + try { + const parsed = JSON.parse(window.localStorage.getItem(storageKey) ?? "null"); + const theme = Array.isArray(parsed) + ? parsed.find((value) => isStoredCustomTheme(value) && value.id === themeId) + : undefined; + if (theme) return theme; + } catch {} } + return null; }; try { diff --git a/apps/web/package.json b/apps/web/package.json index 4407645ea143f..351c71a0e0735 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "@pierre/trees": "1.0.0-beta.6", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/secret-provider-definitions": "workspace:*", "@t3tools/secret-provider-onepassword": "workspace:*", diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 8fe98b78da113..5186197cf05dc 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -4,7 +4,9 @@ import { ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; import { expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; +import { page, userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; import { AppRoot } from "~/AppRoot"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; @@ -62,3 +64,67 @@ it("a new draft does not load its reserved thread before the server lists it", a // A 404 here marks the thread deleted, and the view never loads it once the send creates it. expect(threadRequests).toEqual([]); }); + +it("marks the window's main areas with the region names plugin styles target", async () => { + using _environment = installFakeEnvironment([]); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const draftId = DraftId.make("draft-regions"); + useComposerDraftStore + .getState() + .setProjectDraftThreadId(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), draftId, { + threadId: ThreadId.make("thread-regions"), + }); + + const router = getRouter(createMemoryHistory({ initialEntries: [`/draft/${draftId}`] })); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + await expect.element(page.getByTestId("composer-editor")).toBeVisible(); + + // Plugins style these by name, so renaming or moving one breaks them. The sidebar, thread + // details, terminal and command palette tests check the other names the SDK README lists. + expect(document.querySelector('[data-slot="sidebar-inset"]')).toHaveAttribute( + "data-t3-region", + "main", + ); + await expect + .element(page.getByRole("region", { name: "Conversation" })) + .toHaveAttribute("data-t3-region", "timeline"); + + // A region sits on the element that paints its area, so a plugin's plain rule recolors it. + const pluginSheet = document.createElement("style"); + pluginSheet.textContent = '[data-t3-region="composer"] { background: rgb(1, 2, 3); }'; + document.head.append(pluginSheet); + using _pluginSheet = { [Symbol.dispose]: () => pluginSheet.remove() }; + const composerBox = document.querySelector("[data-chat-composer-main-surface]"); + expect(composerBox && getComputedStyle(composerBox).backgroundColor).toBe("rgb(1, 2, 3)"); + + await userEvent.click(page.getByRole("button", { name: "Toggle right panel" })); + await expect + .element(page.getByRole("region", { name: "Workspace panel" })) + .toHaveAttribute("data-t3-region", "right-panel"); + + await router.navigate({ to: "/settings/general" }); + await expect + .element(page.getByRole("region", { name: "Settings content" })) + .toHaveAttribute("data-t3-region", "settings"); + await expect + .element(page.getByRole("navigation", { name: "Settings sections" })) + .toHaveAttribute("data-t3-region", "settings-nav"); + + await router.navigate({ to: "/pull-requests", search: { involvement: "all", state: "open" } }); + await expect + .element(page.getByRole("region", { name: "Pull requests" })) + .toHaveAttribute("data-t3-region", "pull-requests"); + + // The welcome page draws the home page under its setup dialog. + await router.navigate({ to: "/welcome" }); + const homeTitle = page.getByText("What should we work on?"); + await expect.element(homeTitle).toBeInTheDocument(); + expect(homeTitle.element().closest("[data-t3-region]")).toHaveAttribute("data-t3-region", "home"); +}); diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index 3883a3e2258e9..122ede224f7e3 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -29,5 +29,9 @@ describe("CommandPalette open intents", () => { const input = page.getByPlaceholder("Search commands, projects, and threads..."); await expect.element(input).toHaveFocus(); await expect.element(input).toHaveValue("acme"); + // Plugin styles target the palette by this name. + await expect + .element(page.getByTestId("command-palette")) + .toHaveAttribute("data-t3-region", "command-palette"); }); }); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 486d80a988666..55064fb87072b 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -73,6 +73,7 @@ import { SquarePenIcon, TextSearchIcon, UserPlusIcon, + BlocksIcon, } from "lucide-react"; import { openThreadFind } from "./chat/threadFindStore"; import { @@ -86,6 +87,8 @@ import { } from "react"; import type { LegendListRef } from "@legendapp/list/react"; import { useAtomValue } from "@effect/atom-react"; +import { followedPluginEnvironmentAtom } from "../plugins/pluginFollow"; +import { pluginCommandsAtom } from "../plugins/pluginUi"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; @@ -340,6 +343,7 @@ function CommandPaletteDialog(props: { } className={cn("overflow-hidden", props.mode === "content" && "h-105")} data-command-palette="true" + data-t3-region="command-palette" data-palette-mode={props.mode} data-testid="command-palette" finalFocus={() => { @@ -441,6 +445,8 @@ function OpenCommandPaletteDialog(props: { const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const pluginEnvironmentId = useAtomValue(followedPluginEnvironmentAtom); + const pluginCommands = useAtomValue(pluginCommandsAtom(pluginEnvironmentId ?? "")); const availableSettingsSearchItems = useAvailableSettingsSearchItems(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); @@ -1647,6 +1653,19 @@ function OpenCommandPaletteDialog(props: { }); } + for (const command of pluginCommands) { + actionItems.push({ + kind: "action", + value: `action:${command.command}`, + searchTerms: [command.pluginName, ...command.keywords], + title: command.title, + description: command.pluginName, + icon: , + shortcutCommand: command.command, + run: async () => command.run(), + }); + } + const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); // Every setting goes to the palette's own matcher, so settings get the same // word starts and typo tolerance as threads. diff --git a/apps/web/src/components/NoProjectsHero.tsx b/apps/web/src/components/NoProjectsHero.tsx index e6ebe6536c519..91432dfa5355e 100644 --- a/apps/web/src/components/NoProjectsHero.tsx +++ b/apps/web/src/components/NoProjectsHero.tsx @@ -22,7 +22,10 @@ export function NoProjectsHero() { role="main" className="h-dvh min-h-0 overflow-hidden overscroll-y-none" > -
+
{/* The desktop window only moves where CSS opts in, so keep a titlebar strip. */} {isElectron ? : null} diff --git a/apps/web/src/components/RenderErrorBoundary.tsx b/apps/web/src/components/RenderErrorBoundary.tsx index 9d93f3e56069d..7a0616e2d7be6 100644 --- a/apps/web/src/components/RenderErrorBoundary.tsx +++ b/apps/web/src/components/RenderErrorBoundary.tsx @@ -4,6 +4,7 @@ interface RenderErrorBoundaryProps { readonly children: ReactNode; readonly fallback: ReactNode; readonly resetKeys?: ReadonlyArray; + readonly onError?: (error: unknown) => void; } interface RenderErrorBoundaryState { @@ -34,6 +35,10 @@ export class RenderErrorBoundary extends Component< return { failed: true }; } + override componentDidCatch(error: unknown) { + this.props.onError?.(error); + } + override render() { return this.state.failed ? this.props.fallback : this.props.children; } diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 596bdee96ad1f..4e7dcd46a385a 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -46,7 +46,10 @@ import { MessagesSquare, Plus, TerminalSquare, + Puzzle, } from "lucide-react"; +import { useAtomValue } from "@effect/atom-react"; +import { pluginUiAtom } from "../plugins/pluginUi"; import { Volume2, VolumeOff } from "lucide"; import { type KeyboardEvent as ReactKeyboardEvent, @@ -313,6 +316,7 @@ function SurfaceMenuItem(props: { } function RightPanelEmptyState(props: { + environmentId: EnvironmentId | null; onAddBrowser: () => void; onAddBrowserInProfile: (profileId: string) => void; browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; @@ -335,6 +339,7 @@ function RightPanelEmptyState(props: { sideChatDisabledReason: string | null; }) { const [highlight, setHighlight] = useState(-1); + const pluginUi = useAtomValue(pluginUiAtom); const actions = [ { @@ -561,6 +566,9 @@ function RightPanelEmptyState(props: { /> ), )} + {pluginUi === null ? null : ( + + )}
@@ -594,6 +602,8 @@ function surfaceTitle( return surface.title ?? surface.target?.name ?? "Device"; case "side-chat": return "Side chat"; + case "plugin": + return surface.title; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -692,6 +702,8 @@ function SurfaceIcon({ ) : ( ); + case "plugin": + return ; } } @@ -1026,6 +1038,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const focusedSurface = props.surfaces.find((surface) => surface.id === props.activeSurfaceId); const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const browserProfiles = useBrowserDefaults().profiles; + const pluginUi = useAtomValue(pluginUiAtom); const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); const addSurfaceTriggerRef = useRef(null); @@ -1461,6 +1474,12 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ); })} + {pluginUi === null ? null : ( + setAddSurfaceMenuOpen(false)} + /> + )} ) : null} @@ -1524,6 +1543,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
{props.activeSurfaceId === null ? ( , ); + // Plugin styles target the drawer by this name. + await expect + .element(screen.getByRole("region", { name: "Terminal" })) + .toHaveAttribute("data-t3-region", "terminal"); using _timers = fakeTimers(); // The exit is seen once the surface loads, and its close waits for the next task. await act(async () => { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c423773fb6fc6..d2f4a039b6831 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3185,6 +3185,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index a4a8993a7036e..fada51f096935 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -110,7 +110,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } : undefined; - const environmentIdentificationMode = useEnvironmentIdentificationMode(); + const environmentIdentificationMode = useEnvironmentIdentificationMode("composer"); const shortcutModifiers = useShortcutModifierState(); const isQueuing = !isEditingQueuedMessage && diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4c28163766058..88c7ec2614a1c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1283,7 +1283,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ return ( <> {activityAnnouncer} -
+
); } diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index 6ca50c824cb45..9a3cfb1fb625f 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -33,6 +33,11 @@ const DevicePanel = lazy(() => import("../device/DevicePanel").then((module) => ({ default: module.DevicePanel })), ); const FilePreviewPanel = lazy(() => import("../files/FilePreviewPanel")); +const PluginPanelSurface = lazy(() => + import("../../plugins/PluginPanelSurface").then((module) => ({ + default: module.PluginPanelSurface, + })), +); type RightPanelTerminalProps = Omit< PersistentThreadTerminalPanelProps, @@ -179,6 +184,18 @@ export function RightPanelSurfaceContent(props: { onClose={() => props.onCloseSurface(surface)} /> ); + case "plugin": + return ( + + props.onCloseSurface(surface)} + /> + + ); case "device": return ( diff --git a/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx index a8728d7879dd4..2cce1bb661c43 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.browser.test.tsx @@ -154,6 +154,11 @@ it("returns to full density when hidden items make the content fit", async () => await using screen = await render(); const card = page.getByRole("region", { name: "Thread details" }); await expect.element(card).toHaveAttribute("data-density", "compact"); + // Plugin styles target the card by this name, on the element that paints it. + expect(card.element().querySelector("[data-thread-details-card]")).toHaveAttribute( + "data-t3-region", + "thread-details", + ); await screen.rerender(); await expect.element(card).toHaveAttribute("data-density", "full"); diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index 9ffbc90de753d..0a3f8202d1db7 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -122,6 +122,7 @@ export function ThreadDetailsCard({ style={placement ? { maxHeight: height } : undefined} onContextMenu={onContextMenu} data-thread-details-card + data-t3-region="thread-details" >
{children(density)}
diff --git a/apps/web/src/components/settings/JsonSchemaSettingsForm.test.ts b/apps/web/src/components/settings/JsonSchemaSettingsForm.test.ts new file mode 100644 index 0000000000000..d98c0cb09192b --- /dev/null +++ b/apps/web/src/components/settings/JsonSchemaSettingsForm.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { toJsonPatch, toPatch } from "./JsonSchemaSettingsForm"; +import { pluginSettingsForm } from "./plugins/pluginSettingsForm"; + +const form = pluginSettingsForm({ + type: "object", + properties: { + token: { type: "string", format: "t3-secret" }, + name: { type: "string" }, + count: { type: "integer", minimum: 0 }, + }, +}); +const fields = form.kind === "fields" ? form.fields : []; + +describe("toPatch", () => { + it("sends only changed fields, removes a cleared secret and keeps an untouched one", () => { + const saved = { token: "", name: "Ash", count: "2" }; + expect(toPatch(fields, saved, { ...saved, token: null })).toEqual({ + patch: { token: null }, + }); + expect(toPatch(fields, saved, { ...saved, name: " " })).toEqual({ patch: { name: null } }); + expect(toPatch(fields, saved, { ...saved, token: "new" })).toEqual({ + patch: { token: "new" }, + }); + // A half-typed or out-of-range number is a problem to fix, not a cleared field. + expect(toPatch(fields, saved, { ...saved, count: "1e" })).toEqual({ + problem: "count must be a whole number.", + }); + expect(toPatch(fields, saved, { ...saved, count: "-1" })).toEqual({ + problem: "count must be at least 0.", + }); + }); +}); + +describe("toJsonPatch", () => { + it("removes a key deleted from the JSON", () => { + expect(toJsonPatch({ a: 1, b: { c: 2 } }, '{ "b": { "c": 3 } }')).toEqual({ + patch: { a: null, b: { c: 3 } }, + }); + expect(toJsonPatch({ a: 1 }, "[]")).toEqual({ problem: "Settings must be a JSON object." }); + }); +}); diff --git a/apps/web/src/components/settings/JsonSchemaSettingsForm.tsx b/apps/web/src/components/settings/JsonSchemaSettingsForm.tsx new file mode 100644 index 0000000000000..c0f37a59a291b --- /dev/null +++ b/apps/web/src/components/settings/JsonSchemaSettingsForm.tsx @@ -0,0 +1,394 @@ +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { XIcon } from "lucide-react"; +import { type ComponentProps, useState } from "react"; + +import { Button } from "../ui/button"; +import { DraftInput } from "../ui/draft-input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { Textarea } from "../ui/textarea"; +import { SecretInput } from "./SecretInput"; +import { type PluginSettingsField, pluginSettingsForm } from "./plugins/pluginSettingsForm"; +import { SettingResetButton, SettingsRow } from "./settingsLayout"; + +// `null` clears a saved value, so the plugin gets its default again. +type DraftValue = string | boolean | null; +export type Draft = Readonly>; + +const draftValue = (field: PluginSettingsField, value: Schema.Json | undefined) => { + switch (field.kind) { + case "boolean": + return typeof value === "boolean" ? value : (field.defaultValue ?? false); + case "secret": + return ""; + case "list": + return Array.isArray(value) ? value.join("\n") : ""; + default: + return typeof value === "string" || typeof value === "number" ? String(value) : ""; + } +}; + +/** The changed fields as a settings patch, or the first problem a user can fix before saving. */ +export const toPatch = ( + fields: ReadonlyArray, + saved: Draft, + draft: Draft, +): { readonly patch: Schema.JsonObject } | { readonly problem: string } => { + const patch: Record = {}; + for (const field of fields) { + const value = draft[field.key]; + if (value === saved[field.key] || value === undefined) continue; + if (value === null) { + patch[field.key] = null; + } else if (typeof value === "boolean") { + patch[field.key] = value; + } else if (field.kind === "secret") { + if (value !== "") patch[field.key] = value; + } else if (value.trim() === "") { + patch[field.key] = null; + } else if (field.kind === "number") { + const parsed = Number(value); + if (!Number.isFinite(parsed) || (field.integer && !Number.isInteger(parsed))) { + return { + problem: `${field.label} must be ${field.integer ? "a whole number" : "a number"}.`, + }; + } + if (field.minimum !== undefined && parsed < field.minimum) { + return { problem: `${field.label} must be at least ${field.minimum}.` }; + } + if (field.maximum !== undefined && parsed > field.maximum) { + return { problem: `${field.label} must be at most ${field.maximum}.` }; + } + patch[field.key] = parsed; + } else if (field.kind === "select") { + patch[field.key] = field.options.find((option) => String(option) === value) ?? value; + } else if (field.kind === "list") { + patch[field.key] = value + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + } else { + patch[field.key] = value; + } + } + return { patch }; +}; + +const parseJsonObject = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.JsonObject)); + +/** The edited JSON as a settings patch, with `null` for each saved key it no longer has. */ +export const toJsonPatch = ( + saved: Schema.JsonObject, + json: string, +): { readonly patch: Schema.JsonObject } | { readonly problem: string } => { + const parsed = parseJsonObject(json); + if (Option.isNone(parsed)) return { problem: "Settings must be a JSON object." }; + const removed = Object.keys(saved).filter((key) => !Object.hasOwn(parsed.value, key)); + return { patch: { ...Object.fromEntries(removed.map((key) => [key, null])), ...parsed.value } }; +}; + +const without = (record: Readonly>, key: string) => + Object.fromEntries(Object.entries(record).filter(([entry]) => entry !== key)); + +/** + * Settings rows drawn from a plugin's JSON Schema. Each field saves on its own, as T3's own + * settings rows do. `onSave` gets that field as a patch, `null` when cleared, and resolves with the + * settings as saved or the reason they were not. + */ +export function JsonSchemaSettingsForm({ + idPrefix, + label, + schema, + settings, + canSave, + onSave, +}: { + readonly idPrefix: string; + readonly label: string; + readonly schema: Schema.JsonObject | undefined; + readonly settings: Schema.JsonObject; + readonly canSave: boolean; + readonly onSave: ( + patch: Schema.JsonObject, + ) => Promise<{ readonly settings: Schema.JsonObject } | { readonly problem: string }>; +}) { + const form = pluginSettingsForm(schema); + const [saved, setSaved] = useState(settings); + // What the user just set, shown until it saves; it stays, with its problem, when it can't. + const [pending, setPending] = useState>>({}); + const [problems, setProblems] = useState>>({}); + const settle = (key: string, problem: string | null) => { + setProblems((all) => (problem === null ? without(all, key) : { ...all, [key]: problem })); + if (problem === null) setPending((all) => without(all, key)); + }; + + if (form.kind === "json") { + return ( + + ); + } + if (form.kind !== "fields") return null; + + const commit = async (field: PluginSettingsField, value: DraftValue) => { + const current = draftValue(field, saved[field.key]); + const next = toPatch([field], { [field.key]: current }, { [field.key]: value }); + setPending((all) => ({ ...all, [field.key]: value })); + if ("problem" in next) return settle(field.key, next.problem); + if (Object.keys(next.patch).length === 0) return settle(field.key, null); + const result = await onSave(next.patch); + if ("problem" in result) return settle(field.key, result.problem); + setSaved(result.settings); + settle(field.key, null); + }; + + return ( + <> + {form.fields.map((field) => ( + void commit(field, value)} + /> + ))} + + ); +} + +/** For settings whose schema doesn't fit a form: the settings as JSON, saved together. */ +function JsonSettingsRow({ + label, + settings, + canSave, + onSave, + onSaved, +}: { + readonly label: string; + readonly settings: Schema.JsonObject; + readonly canSave: boolean; + readonly onSave: ( + patch: Schema.JsonObject, + ) => Promise<{ readonly settings: Schema.JsonObject } | { readonly problem: string }>; + readonly onSaved: (settings: Schema.JsonObject) => void; +}) { + const [json, setJson] = useState(() => JSON.stringify(settings, null, 2)); + const [problem, setProblem] = useState(null); + const [busy, setBusy] = useState(false); + const save = async () => { + const next = toJsonPatch(settings, json); + if ("problem" in next) return setProblem(next.problem); + setBusy(true); + const result = await onSave(next.patch); + setBusy(false); + if ("problem" in result) return setProblem(result.problem); + setProblem(null); + onSaved(result.settings); + setJson(JSON.stringify(result.settings, null, 2)); + }; + return ( + {problem} : undefined} + > +
+