diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 07942b67356b..7b043882d6a2 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -33,6 +33,7 @@ import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; import { makeQuitShortcutHandler } from "./QuitHold.ts"; +import { shouldVetoViewFrameNavigation } from "./pluginViewNavigation.ts"; const TITLEBAR_HEIGHT = 40; // Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS @@ -647,6 +648,30 @@ export const make = Effect.gen(function* () { void runPromise(electronShell.openExternal(url)); } }); + // Plugin views may not navigate themselves; see pluginViewNavigation.ts. + window.webContents.on("will-frame-navigate", (event) => { + if (event.isMainFrame) return; + if ( + !shouldVetoViewFrameNavigation({ + url: event.url, + frame: event.frame, + initiator: event.initiator, + mainFrame: window.webContents.mainFrame, + }) + ) + return; + event.preventDefault(); + // Only where it tried to go: a path or query can carry the view's data. + const target = URL.parse(event.url); + void runPromise( + logWindowInfo( + "refused a plugin view navigation", + target === null + ? { protocol: "invalid" } + : { protocol: target.protocol, host: target.host }, + ), + ); + }); // Electron's windowMenu close role owns CmdOrCtrl+W. Holding the // close-terminal shortcut can outlive the terminal that handled its first diff --git a/apps/desktop/src/window/pluginViewNavigation.test.ts b/apps/desktop/src/window/pluginViewNavigation.test.ts new file mode 100644 index 000000000000..795f12416afe --- /dev/null +++ b/apps/desktop/src/window/pluginViewNavigation.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { type NavigationFrame, shouldVetoViewFrameNavigation } from "./pluginViewNavigation.ts"; + +const frame = ( + frameToken: string, + url: string, + origin: string, + parent: NavigationFrame | null, +): NavigationFrame => ({ frameToken, url, origin, parent }); + +const app = frame("app", "t3code-dev://app/", "t3code-dev://app", null); +const view = frame("view", "about:srcdoc", "null", app); +const wrapper = frame("wrapper", "about:srcdoc", "null", app); +const wrappedView = frame("inner", "about:srcdoc", "null", wrapper); + +describe("shouldVetoViewFrameNavigation", () => { + it("refuses a view navigating itself, including through a policy wrapper", () => { + for (const target of [view, wrappedView]) { + for (const url of ["about:blank", "http://127.0.0.1:7391/page.html", "data:text/html,x"]) { + expect( + shouldVetoViewFrameNavigation({ url, frame: target, initiator: target, mainFrame: app }), + ).toBe(true); + } + } + }); + + it("refuses a navigation with no known initiator", () => { + expect( + shouldVetoViewFrameNavigation({ + url: "http://127.0.0.1:7391/redirect", + frame: view, + initiator: null, + mainFrame: app, + }), + ).toBe(true); + }); + + it("lets the app mount, wrap and dispose views", () => { + const fresh = frame("fresh", "about:blank", "t3code-dev://app", app); + expect( + shouldVetoViewFrameNavigation({ + url: "about:srcdoc", + frame: fresh, + initiator: app, + mainFrame: app, + }), + ).toBe(false); + const freshInner = frame("fresh-inner", "about:blank", "null", wrapper); + expect( + shouldVetoViewFrameNavigation({ + url: "about:srcdoc", + frame: freshInner, + initiator: wrapper, + mainFrame: app, + }), + ).toBe(false); + expect( + shouldVetoViewFrameNavigation({ + url: "about:blank", + frame: view, + initiator: app, + mainFrame: app, + }), + ).toBe(false); + }); + + it("leaves frames outside views alone", () => { + const other = frame("other", "https://example.com/", "https://example.com", app); + expect( + shouldVetoViewFrameNavigation({ + url: "https://example.com/next", + frame: other, + initiator: other, + mainFrame: app, + }), + ).toBe(false); + }); +}); diff --git a/apps/desktop/src/window/pluginViewNavigation.ts b/apps/desktop/src/window/pluginViewNavigation.ts new file mode 100644 index 000000000000..8bcea62c9458 --- /dev/null +++ b/apps/desktop/src/window/pluginViewNavigation.ts @@ -0,0 +1,35 @@ +/** + * Main-process navigation veto for isolated plugin view frames. + * + * A plugin view runs in an opaque-origin `about:srcdoc` frame inside a + * policy wrapper frame of the same kind. A document's own CSP cannot stop its + * frame navigating, so the main process refuses any navigation of a frame in + * such a subtree unless the app's main frame started it. Loading + * `about:srcdoc` stays allowed so the wrapper can mount its view. Electron + * does not report `about:blank` or `data:` here: the wrapper's CSP refuses + * `data:`, and the host's ping liveness check ends a view that blanked itself. + */ + +export interface NavigationFrame { + readonly url: string; + readonly origin: string; + readonly frameToken: string; + readonly parent: NavigationFrame | null; +} + +const isViewDocument = (frame: NavigationFrame) => + frame.url === "about:srcdoc" && frame.origin === "null"; + +export function shouldVetoViewFrameNavigation(input: { + readonly url: string; + readonly frame: NavigationFrame | null; + readonly initiator: NavigationFrame | null | undefined; + readonly mainFrame: NavigationFrame; +}): boolean { + let insideView = false; + for (let frame = input.frame; frame !== null; frame = frame.parent) { + if (isViewDocument(frame)) insideView = true; + } + if (!insideView || input.url === "about:srcdoc") return false; + return input.initiator?.frameToken !== input.mainFrame.frameToken; +} diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index ec4b2565e174..4a7a1a1d9270 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -95,6 +95,10 @@ import { SettingsScheduledTaskNewRouteScreen, SettingsScheduledTaskEditRouteScreen, } from "./features/settings/SettingsScheduledTasksRouteScreen"; +import { + SettingsPluginRouteScreen, + SettingsPluginsRouteScreen, +} from "./features/settings/SettingsPluginsRouteScreen"; import { ScheduledTaskModelPickerRouteScreen, ScheduledTaskBranchPickerRouteScreen, @@ -326,6 +330,15 @@ const SettingsContentStack = createV5SheetStackNavigator({ headerTitleStyle: { fontSize: 16, fontWeight: "800" }, }, }), + SettingsPlugins: createNativeStackScreen({ + screen: SettingsPluginsRouteScreen, + linking: "plugins", + options: { title: "Plugins" }, + }), + SettingsPlugin: createNativeStackScreen({ + screen: SettingsPluginRouteScreen, + options: { title: "Plugin" }, + }), SettingsScheduledTaskNew: createNativeStackScreen({ screen: SettingsScheduledTaskNewRouteScreen, linking: "scheduled-tasks/new", diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 5c5126865e55..a25de5ea92fc 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -1,6 +1,6 @@ import { useNavigation } from "@react-navigation/native"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; -import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; +import { AuthOrchestrationOperateScope, THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -25,13 +25,16 @@ import { cn } from "../../lib/cn"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; import { useProjects, useThreadShell, useThreadShells } from "../../state/entities"; +import { runPluginAction, usePluginActions } from "../../state/plugin-actions"; import { useThreadSearch } from "../../state/queries"; +import { useEnvironmentScope } from "../../state/session"; import { useWorkspaceEnvironments } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadSearchMatchExcerpt } from "../threads/thread-search-match"; import { + buildPluginActionPaletteItems, filterCommandPaletteItems, nextPaletteIndex, type CommandPaletteItem, @@ -66,6 +69,7 @@ const ACTION_ICONS: Record = { function itemIcon(item: CommandPaletteItem): AppSymbolName { if (item.kind === "project") return "folder"; if (item.kind === "thread") return "text.bubble"; + if (item.key.startsWith("plugin-action:")) return "cube"; return ACTION_ICONS[item.key] ?? "ellipsis"; } @@ -146,6 +150,17 @@ export function CommandPalette(props: { const activeThreadRef = useMemo(() => parseActiveThreadPath(props.pathname), [props.pathname]); const activeThread = useThreadShell(activeThreadRef); const environments = useWorkspaceEnvironments(); + // Plugin actions belong to the open thread's environment, else the first connected one. + const pluginActionEnvironmentId = + activeThreadRef?.environmentId ?? + environments.find((environment) => environment.connectionState === "connected") + ?.environmentId ?? + null; + const pluginActions = usePluginActions(pluginActionEnvironmentId); + const canRunPluginActions = useEnvironmentScope( + pluginActionEnvironmentId, + AuthOrchestrationOperateScope, + ); const { savedConnectionsById } = useSavedRemoteConnections(); const [query, setQuery] = useState(""); const [selection, setSelection] = useState(null); @@ -300,6 +315,18 @@ export function CommandPalette(props: { })), ); } + if (pluginActionEnvironmentId !== null) { + actions.push( + ...buildPluginActionPaletteItems({ + actions: pluginActions, + canOperate: canRunPluginActions, + environmentId: pluginActionEnvironmentId, + threadId: activeThread?.id ?? null, + projectId: activeThread?.projectId ?? null, + runAction: (input) => void runPluginAction(input), + }), + ); + } const projectItems: CommandPaletteItem[] = projects.map((project) => ({ key: `project:${scopedProjectKey(project.environmentId, project.id)}`, kind: "project", @@ -346,6 +373,9 @@ export function CommandPalette(props: { activeThread, activeThreadRef, navigation, + canRunPluginActions, + pluginActionEnvironmentId, + pluginActions, projects, runCommand, savedConnectionsById, diff --git a/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts b/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts index 2a383b80369d..89c23fcccd99 100644 --- a/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts +++ b/apps/mobile/src/features/keyboard/commandPaletteItems.test.ts @@ -1,6 +1,14 @@ -import { describe, expect, it } from "vite-plus/test"; +import { + EnvironmentId, + PluginActionId, + ProjectId, + ThreadId, + type PluginAction, +} from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; import { + buildPluginActionPaletteItems, filterCommandPaletteItems, nextPaletteIndex, type CommandPaletteItem, @@ -74,3 +82,79 @@ describe("nextPaletteIndex", () => { expect(nextPaletteIndex(0, 1, 0)).toBe(0); }); }); + +describe("buildPluginActionPaletteItems", () => { + const environmentId = EnvironmentId.make("environment-1"); + const thread = { + environmentId, + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + }; + const deploy: PluginAction = { + id: PluginActionId.make("installation-1:1:deploy"), + pluginId: "acme.deploy", + pluginName: "Deploy", + name: "deploy", + title: "Deploy this branch", + target: "thread", + placements: ["command-palette"], + }; + const refresh: PluginAction = { + ...deploy, + id: PluginActionId.make("installation-1:1:refresh"), + name: "refresh", + title: "Refresh caches", + target: "environment", + }; + + it("runs an offered action in the open thread's environment", () => { + const runAction = vi.fn(); + const offered = buildPluginActionPaletteItems({ + actions: [deploy], + canOperate: true, + ...thread, + runAction, + }); + expect(offered.map((item) => item.title)).toEqual(["Deploy this branch"]); + + offered[0]?.run(); + + expect(runAction).toHaveBeenCalledWith({ + environmentId: thread.environmentId, + action: deploy, + target: { _tag: "thread", threadId: thread.threadId }, + }); + }); + + it("offers environment actions when no thread is open", () => { + const runAction = vi.fn(); + const offered = buildPluginActionPaletteItems({ + actions: [deploy, refresh], + canOperate: true, + environmentId, + threadId: null, + projectId: null, + runAction, + }); + expect(offered.map((item) => item.title)).toEqual(["Refresh caches"]); + + offered[0]?.run(); + + expect(runAction).toHaveBeenCalledWith({ + environmentId, + action: refresh, + target: { _tag: "environment" }, + }); + }); + + it("offers nothing to a connection that cannot operate the environment", () => { + expect( + buildPluginActionPaletteItems({ + actions: [deploy], + canOperate: false, + ...thread, + runAction: vi.fn(), + }), + ).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/keyboard/commandPaletteItems.ts b/apps/mobile/src/features/keyboard/commandPaletteItems.ts index 6a06fd4e3387..69f3bd7b393f 100644 --- a/apps/mobile/src/features/keyboard/commandPaletteItems.ts +++ b/apps/mobile/src/features/keyboard/commandPaletteItems.ts @@ -1,3 +1,12 @@ +import { pluginActionLabels, pluginActionsAt } from "@t3tools/client-runtime/state/pluginActions"; +import type { + EnvironmentId, + PluginAction, + PluginActionTarget, + ProjectId, + ThreadId, +} from "@t3tools/contracts"; + export interface CommandPaletteItem { readonly key: string; readonly kind: "action" | "project" | "thread"; @@ -44,3 +53,37 @@ export function filterCommandPaletteItems( export function nextPaletteIndex(index: number, direction: -1 | 1, count: number) { return count === 0 ? 0 : (index + direction + count) % count; } + +/** + * The palette plugin actions of one environment, for the open thread and its + * project when there is one. Running one needs `orchestration:operate`, so a + * connection without it is offered none. + */ +export function buildPluginActionPaletteItems(input: { + readonly actions: ReadonlyArray; + readonly canOperate: boolean; + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId | null; + readonly projectId: ProjectId | null; + readonly runAction: (input: { + readonly environmentId: EnvironmentId; + readonly action: PluginAction; + readonly target: PluginActionTarget; + }) => void; +}): CommandPaletteItem[] { + if (!input.canOperate) return []; + const { environmentId } = input; + const entries = pluginActionsAt(input.actions, "command-palette", { + threadId: input.threadId, + projectId: input.projectId, + }); + const labels = pluginActionLabels(entries.map((entry) => entry.action)); + return entries.map(({ action, target }, index) => ({ + key: `plugin-action:${action.id}`, + kind: "action", + title: labels[index] ?? action.title, + detail: action.description ?? action.pluginName, + searchTerms: [action.name, action.pluginName, "plugin"], + run: () => input.runAction({ environmentId, action, target }), + })); +} diff --git a/apps/mobile/src/features/plugins/PluginSettingsSections.tsx b/apps/mobile/src/features/plugins/PluginSettingsSections.tsx new file mode 100644 index 000000000000..6a1ead9a1e29 --- /dev/null +++ b/apps/mobile/src/features/plugins/PluginSettingsSections.tsx @@ -0,0 +1,51 @@ +import { useAtomValue } from "@effect/atom-react"; +import { supportsPluginSettings } from "@t3tools/client-runtime/state/pluginSettings"; +import type { EnvironmentId, ExecutionEnvironmentCapabilities } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/reactivity"; + +import { AppText as Text } from "../../components/AppText"; +import { pluginEnvironment } from "../../state/plugins"; +import { PluginSettingsValues } from "./PluginSettingsValues"; + +/** + * The saved settings of each installed plugin that declares any, read-only: + * mobile always pairs with standard scopes, so it cannot save them. Renders + * nothing on a server without plugin settings or when no plugin declares any. + */ +export function PluginSettingsSections({ + environmentId, + capabilities, +}: { + readonly environmentId: EnvironmentId; + readonly capabilities: ExecutionEnvironmentCapabilities | undefined; +}) { + if (!supportsPluginSettings(capabilities)) return null; + return ; +} + +function InstalledPluginSettings({ environmentId }: { readonly environmentId: EnvironmentId }) { + const catalog = Option.getOrNull( + AsyncResult.value(useAtomValue(pluginEnvironment.catalog({ environmentId, input: {} }))), + ); + if (catalog?._tag !== "available") return null; + const installations = catalog.installations.filter( + (installation) => (installation.manifest?.settings?.length ?? 0) > 0, + ); + if (installations.length === 0) return null; + return ( + <> + {installations.map((installation) => ( + + ))} + + Plugin settings are view-only on mobile. Edit them from an administrative web or desktop + connection. + + + ); +} diff --git a/apps/mobile/src/features/plugins/PluginSettingsValues.logic.test.ts b/apps/mobile/src/features/plugins/PluginSettingsValues.logic.test.ts new file mode 100644 index 000000000000..e3051a18f0c1 --- /dev/null +++ b/apps/mobile/src/features/plugins/PluginSettingsValues.logic.test.ts @@ -0,0 +1,58 @@ +import { pluginSettingRows } from "@t3tools/client-runtime/state/pluginSettings"; +import { + type PluginSettingField, + PluginInstallationId, + type PluginSettingsValues, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { describePluginSettingValue } from "./PluginSettingsValues.logic"; + +const fields: ReadonlyArray = [ + { type: "number", key: "retries", label: "Retries", min: 0, max: 5, integer: true, default: 2 }, + { + type: "select", + key: "mode", + label: "Mode", + options: [ + { value: "safe", label: "Safe" }, + { value: "fast", label: "Fast" }, + ], + default: "safe", + }, + { type: "secret", key: "token", label: "Token" }, +]; +const shown = (saved: PluginSettingsValues["values"], secrets: ReadonlyArray = []) => + pluginSettingRows(fields, { + installationId: PluginInstallationId.make("installation-1"), + values: saved, + secrets, + }).map(describePluginSettingValue); + +describe("describePluginSettingValue", () => { + it("marks defaults when nothing is saved", () => { + expect(shown([])).toEqual(["2 (default)", "Safe (default)", "Not set"]); + }); + + it("shows saved values that still fit without a default label", () => { + expect( + shown( + [ + { key: "retries", value: 4 }, + { key: "mode", value: "fast" }, + ], + ["token"], + ), + ).toEqual(["4", "Fast", "Saved"]); + }); + + it("marks the default a saved value fell back to once it no longer fits", () => { + // Saved before an update narrowed the range and removed the option. + expect( + shown([ + { key: "retries", value: 9 }, + { key: "mode", value: "turbo" }, + ]), + ).toEqual(["2 (default)", "Safe (default)", "Not set"]); + }); +}); diff --git a/apps/mobile/src/features/plugins/PluginSettingsValues.logic.ts b/apps/mobile/src/features/plugins/PluginSettingsValues.logic.ts new file mode 100644 index 000000000000..c515c1a88dc6 --- /dev/null +++ b/apps/mobile/src/features/plugins/PluginSettingsValues.logic.ts @@ -0,0 +1,17 @@ +import type { PluginSettingRow } from "@t3tools/client-runtime/state/pluginSettings"; + +/** A field's value as text; a secret shows only whether one is saved. */ +export function describePluginSettingValue({ field, value, saved, isDefault }: PluginSettingRow) { + if (field.type === "secret") return saved ? "Saved" : "Not set"; + if (value === undefined) return "Not set"; + const text = + field.type === "boolean" + ? value === true + ? "On" + : "Off" + : field.type === "select" + ? (field.options.find((option) => option.value === value)?.label ?? String(value)) + : String(value); + // A saved value that no longer fits falls back to the default, and says so. + return isDefault ? `${text} (default)` : text; +} diff --git a/apps/mobile/src/features/plugins/PluginSettingsValues.tsx b/apps/mobile/src/features/plugins/PluginSettingsValues.tsx new file mode 100644 index 000000000000..d16758c99832 --- /dev/null +++ b/apps/mobile/src/features/plugins/PluginSettingsValues.tsx @@ -0,0 +1,50 @@ +import { useAtomValue } from "@effect/atom-react"; +import { pluginSettingRows } from "@t3tools/client-runtime/state/pluginSettings"; +import type { EnvironmentId, PluginInstallation } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/reactivity"; +import { View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { pluginSettingsEnvironment } from "../../state/plugins"; +import { SettingsSection } from "../settings/components/SettingsSection"; +import { describePluginSettingValue } from "./PluginSettingsValues.logic"; + +/** + * The settings one installation declares and what is saved for them, read-only. + * Renders nothing when the plugin declares none or the server cannot store them. + */ +export function PluginSettingsValues({ + environmentId, + installation, +}: { + readonly environmentId: EnvironmentId; + readonly installation: PluginInstallation; +}) { + const fields = installation.manifest?.settings ?? []; + const result = useAtomValue( + pluginSettingsEnvironment.values({ + environmentId, + input: { installationId: installation.installationId }, + }), + ); + const view = Option.getOrNull(AsyncResult.value(result)); + if (fields.length === 0 || view === null || view._tag === "unsupported") return null; + return ( + + {pluginSettingRows(fields, view.values).map((row, index) => ( + 0 ? "gap-1 border-t border-border-subtle px-4 py-3" : "gap-1 px-4 py-3" + } + > + {row.field.label} + + {describePluginSettingValue(row)} + + + ))} + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx index 1065a860a5e4..e094d8000f2a 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx @@ -22,6 +22,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; import { EnvironmentRoutesSection } from "./EnvironmentRoutesSection"; +import { PluginSettingsSections } from "../plugins/PluginSettingsSections"; import { SettingsActionRow } from "./components/SettingsActionRow"; import { SettingsScreen } from "./components/SettingsScreen"; import { SettingsSection } from "./components/SettingsSection"; @@ -372,6 +373,7 @@ function EnvironmentDetail({ environmentId }: { readonly environmentId: Environm ))} + ) : null} diff --git a/apps/mobile/src/features/settings/SettingsPluginsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsPluginsRouteScreen.tsx new file mode 100644 index 000000000000..1f2bf4a47bf2 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsPluginsRouteScreen.tsx @@ -0,0 +1,586 @@ +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { + type EnvironmentId, + type PluginInstallation, + type PluginInstallationId, + type PluginInstallationManifest, + type PluginNpmPackage, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; +import { + describePluginCapabilities, + describePluginContributions, + type PluginOfferedViews, +} from "@t3tools/client-runtime/state/pluginContributions"; +import { + describePluginNpmSource, + PLUGIN_NPM_INTEGRITY_STATEMENT, + pluginNpmListKey, + pluginNpmRowLabel, + resolvePluginNpmPackagesState, + resolvePluginNpmProvenance, + supportsPluginNpm, +} from "@t3tools/client-runtime/state/pluginNpmPresentation"; +import { + describePluginSource, + presentPluginInstallation, + resolvePluginCatalogState, + resolvePluginDetail, + type PluginStateTone, +} from "@t3tools/client-runtime/state/pluginPresentation"; +import { useEffect, useRef } from "react"; +import { Platform, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; +import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; +import { usePluginActionsSnapshot } from "../../state/plugin-actions"; +import { + pluginEnvironment, + pluginNpmEnvironment, + pluginViewEnvironment, +} from "../../state/plugins"; +import { useEnvironmentQuery } from "../../state/query"; +import { SettingsActionRow } from "./components/SettingsActionRow"; +import { + AndroidSettingsEnvironmentFilter, + SettingsEnvironmentFilterHeader, +} from "./components/SettingsEnvironmentFilterHeader"; +import { SettingsScreen } from "./components/SettingsScreen"; +import { SettingsSection } from "./components/SettingsSection"; +import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter"; + +type PluginRoutes = { + SettingsPlugin: { + readonly environmentId: EnvironmentId; + readonly installationId: PluginInstallationId; + }; +}; + +const TONE_TEXT: Record = { + neutral: "text-foreground-muted", + success: "text-foreground", + info: "text-foreground", + warning: "text-warning-foreground", + error: "text-danger-foreground", +}; + +/** Whether this environment's server has the plugin catalogue; older servers never get a plugin call. */ +export function supportsPlugins(target: SettingsTarget): boolean { + return target.serverConfig.environment.capabilities.plugins === true; +} + +/** + * Mobile always pairs with standard scopes, so it never has the access:write that + * plugin management needs; it shows plugins without controls. + */ +const PLUGINS_VIEW_ONLY = + "Plugins are view-only on mobile. Add, install, approve, enable, update, or remove them from an administrative web or desktop connection."; + +function ViewOnlyNotice() { + return {PLUGINS_VIEW_ONLY}; +} + +/** The catalogue of a connected environment that supports plugins; anything else gets no call. */ +function usePluginCatalog(environmentId: EnvironmentId, environment: SettingsTarget | undefined) { + const supported = environment !== undefined && supportsPlugins(environment); + const catalog = useEnvironmentQuery( + supported ? pluginEnvironment.catalog({ environmentId, input: {} }) : null, + ); + const state = resolvePluginCatalogState({ + connected: environment !== undefined, + data: supported ? catalog.data : { _tag: "unsupported" }, + error: catalog.error, + }); + return { state, retry: catalog.refresh }; +} + +/** Calls `refresh` when `key` changes from one known value to another; never on a timer. */ +function useRefreshOnChange(key: string | null, refresh: () => void) { + const last = useRef(key); + useEffect(() => { + const previous = last.current; + last.current = key; + if (previous !== null && key !== null && previous !== key) refresh(); + }, [key, refresh]); +} + +/** + * Where an environment's plugins came from on npm. An older server gets no call. + * The list is a query, so it is read again when installations or their files change. + */ +function usePluginNpmPackages( + environmentId: EnvironmentId, + environment: SettingsTarget | undefined, + installations: ReadonlyArray | null, +) { + const supported = + environment !== undefined && + supportsPluginNpm(environment.serverConfig.environment.capabilities); + const query = useEnvironmentQuery( + supported ? pluginNpmEnvironment.packages({ environmentId, input: {} }) : null, + ); + useRefreshOnChange( + supported && installations !== null ? pluginNpmListKey(installations) : null, + query.refresh, + ); + return { + state: resolvePluginNpmPackagesState({ supported, data: query.data, error: query.error }), + refresh: query.refresh, + }; +} + +const SCROLL_PROPS = { + keyboardShouldPersistTaps: "handled", + contentInsetAdjustmentBehavior: "automatic", + showsVerticalScrollIndicator: false, + className: "flex-1", + contentContainerClassName: "gap-5 px-5 pt-4", +} as const; + +export function SettingsPluginsRouteScreen() { + const insets = useSafeAreaInsets(); + const { availableTargets, selectedTargets } = useSettingsEnvironmentFilter(); + const targets = selectedTargets.filter(supportsPlugins); + return ( + <> + + }> + + {targets.length > 0 ? ( + targets.map((target) => ( + + )) + ) : ( + + {availableTargets.length === 0 + ? "Connect an environment to see its plugins." + : selectedTargets.length === 0 + ? "Select an environment to see its plugins." + : "Update T3 Code on the selected environments to see their plugins."} + + )} + + + + ); +} + +function EnvironmentPlugins({ environment }: { readonly environment: SettingsTarget }) { + const navigation = useNavigation>(); + const environmentId = environment.environmentId; + const catalog = usePluginCatalog(environmentId, environment); + const installations = + catalog.state._tag === "available" ? catalog.state.view.installations : null; + const npm = usePluginNpmPackages(environmentId, environment, installations); + if (catalog.state._tag === "unsupported") return null; + return ( + + + } + > + {catalog.state._tag === "failed" ? ( + <> + {catalog.state.message} + + + + + ) : installations === null ? ( + Loading plugins… + ) : installations.length === 0 ? ( + No plugins yet. + ) : ( + installations.map((installation, index) => ( + + navigation.navigate("SettingsPlugin", { + environmentId, + installationId: installation.installationId, + }) + } + /> + )) + )} + + {installations !== null ? : null} + + ); +} + +/** The npm package behind a listed installation, from the list alone. */ +function npmPackageOf( + state: ReturnType, + installationId: PluginInstallationId, +): PluginNpmPackage | null { + const provenance = resolvePluginNpmProvenance({ state, installationId, step: null }); + return provenance._tag === "found" ? provenance.package : null; +} + +function PluginListRow({ + installation, + npmPackage, + first, + onPress, +}: { + readonly installation: PluginInstallation; + readonly npmPackage: PluginNpmPackage | null; + readonly first: boolean; + readonly onPress: () => void; +}) { + const view = presentPluginInstallation(installation); + return ( + + + + {view.title} + {installation.manifest ? ( + {installation.manifest.version} + ) : null} + + + {view.stateLabel} + {view.detail ? ` · ${view.detail}` : ""} + + {view.delivery ? ( + + {view.delivery.label} + {view.delivery.detail ? ` · ${view.delivery.detail}` : ""} + + ) : null} + {npmPackage ? ( + + {pluginNpmRowLabel(npmPackage)} + + ) : null} + + {installation.directory} + + + + + ); +} + +/** Each declared capability on its own line, with what it lets the plugin do. */ +function capabilityLines(capabilities: ReadonlyArray): string { + if (capabilities.length === 0) return "None declared"; + return describePluginCapabilities(capabilities) + .map((capability) => + capability.meaning ? `${capability.name}: ${capability.meaning}` : capability.name, + ) + .join("\n"); +} + +function DetailField({ + label, + value, + mono = false, + first = false, +}: { + readonly label: string; + readonly value: string; + readonly mono?: boolean; + readonly first?: boolean; +}) { + return ( + + {label} + + {value} + + + ); +} + +export function SettingsPluginRouteScreen({ + route, +}: StaticScreenProps) { + return ( + + ); +} + +function PluginDetail({ + environmentId, + installationId, +}: { + readonly environmentId: EnvironmentId; + readonly installationId: PluginInstallationId; +}) { + const insets = useSafeAreaInsets(); + const { availableTargets } = useSettingsEnvironmentFilter(); + const environment = availableTargets.find((target) => target.environmentId === environmentId); + const label = environment?.label ?? "this environment"; + const catalog = usePluginCatalog(environmentId, environment); + const detail = resolvePluginDetail({ catalog: catalog.state, installationId, added: null }); + const npm = usePluginNpmPackages( + environmentId, + environment, + catalog.state._tag === "available" ? catalog.state.view.installations : null, + ); + + if (detail._tag !== "found") { + return ( + + + {detail._tag === "failed" ? ( + + + {detail.message} + + + + + + ) : ( + + {detail._tag === "missing" + ? `This plugin is no longer installed on ${label}.` + : detail._tag === "loading" + ? "Loading plugin…" + : detail._tag === "disconnected" + ? `Reconnect ${label} to see its plugins.` + : `${label} does not support plugins. Update T3 Code there.`} + + )} + + + ); + } + + const installation = detail.installation; + const view = presentPluginInstallation(installation); + const manifest = installation.manifest; + const digest = installation.source?.digest ?? null; + const provenance = resolvePluginNpmProvenance({ state: npm.state, installationId, step: null }); + const npmPackage = provenance._tag === "found" ? provenance.package : null; + + return ( + + + + {view.stateLabel} + {view.detail ? ( + + {view.detail} + + ) : null} + {view.delivery ? ( + <> + + {view.delivery.label} + + {view.delivery.detail ? ( + + {view.delivery.detail} + + ) : null} + + ) : null} + + + {manifest ? ( + <> + + + {manifest.description ? ( + + ) : null} + + ) : null} + {npmPackage ? ( + <> + + + + ) : provenance._tag === "unknown" ? ( + + ) : null} + + + {digest !== null ? : null} + {manifest ? ( + + ) : null} + {installation.problem ? ( + + ) : null} + + {manifest ? ( + + ) : null} + + {npm.state._tag === "failed" ? ( + + + {npm.state.message} + + + + + + ) : null} + + + + + ); +} + +/** + * What a plugin adds to T3 Code, from its manifest summary. For an enabled + * installation, the environment's action and view snapshots say what is offered + * now; listing them never starts the plugin. + */ +function PluginContributionsSection({ + environmentId, + environment, + manifest, + installation, +}: { + readonly environmentId: EnvironmentId; + readonly environment: SettingsTarget | undefined; + readonly manifest: PluginInstallationManifest; + readonly installation: PluginInstallation; +}) { + const capabilities = environment?.serverConfig.environment.capabilities; + const offered = installation.enabled; + const actions = usePluginActionsSnapshot( + offered && capabilities?.pluginActions === true ? environmentId : null, + ); + const views = useEnvironmentQuery( + offered && capabilities?.pluginViews === true + ? pluginViewEnvironment.views({ environmentId, input: {} }) + : null, + ).data; + const offeredViews: PluginOfferedViews | null = + views?._tag === "available" + ? { + views: views.views.filter( + (view) => + view.installationId === installation.installationId && + view.generation === installation.generation, + ), + problems: views.problems.filter( + (problem) => + problem.installationId === installation.installationId && + problem.generation === installation.generation, + ), + } + : null; + const groups = describePluginContributions({ + manifest, + installation, + actions: actions ?? null, + views: offeredViews, + }); + return ( + + {groups.length === 0 ? ( + Nothing declared + ) : ( + groups.map((group, index) => ( + + {group.label} + {group.items.map((item) => ( + + + {item.title} + + {item.detail ? ( + + {item.detail} + + ) : null} + + ))} + {group.notice ? ( + {group.notice} + ) : null} + + )) + )} + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 1f474a9d56b8..536536564f12 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -17,6 +17,7 @@ import { SettingsEnvironmentFilterHeader, } from "./components/SettingsEnvironmentFilterHeader"; import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; +import { supportsPlugins } from "./SettingsPluginsRouteScreen"; export function SettingsRouteScreen() { const navigation = useNavigation(); @@ -208,6 +209,9 @@ function SettingsIndexSections() { target="SettingsEnvironmentMaintenance" disabled={noServerTargets} /> + {selectedTargets.some(supportsPlugins) ? ( + + ) : null} diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index ad0197d634d8..5cdce284b468 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -15,6 +15,7 @@ export type SettingsSheetTarget = | "SettingsKeyboard" | "SettingsFollowUp" | "SettingsScheduledTasks" + | "SettingsPlugins" | "SettingsProjectGrouping" | "SettingsClientStorage" | "SettingsDiagnostics" diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 7b85e8e52273..b88d99187e54 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -3,6 +3,8 @@ import { type ProviderSkillSourceKind, } from "@t3tools/client-runtime/providerSkills"; import type { + PluginAction, + PluginActionTarget, PullRequestContextMetadata, ScopedThreadRef, ServerProviderSkill, @@ -59,6 +61,14 @@ export type ComposerCommandItem = readonly skill: ServerProviderSkill; readonly label: string; readonly description: string; + } + | { + readonly id: string; + readonly type: "plugin-action"; + readonly action: PluginAction; + readonly target: PluginActionTarget; + readonly label: string; + readonly description: string; }; interface ComposerCommandPopoverProps { @@ -109,6 +119,8 @@ function itemIcon(item: ComposerCommandItem): AppSymbolName | null { return null; case "thread": return "text.bubble"; + case "plugin-action": + return "cube"; } } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 181c257cbc17..6d431d92cd0e 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -490,6 +490,8 @@ export function NewTaskDraftScreen(props: { draftMessage: flow.prompt, ownerKey: flow.draftKey, environmentId: selectedProject?.environmentId ?? null, + // Project actions run on the selected project; a draft has no thread yet. + projectId: selectedProject?.id ?? null, threadShells: useThreadShells(), pullRequestProjectId: selectedEnvironmentServerConfig?.environment.capabilities.pullRequests ? (selectedProject?.id ?? null) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 6873926fd5dd..529e04dff80a 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -491,6 +491,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer environmentId: props.environmentId, threadShells: useThreadShells(), currentThreadId: props.selectedThread.id, + projectId: props.selectedThread.projectId, projectCwd: props.projectCwd, pullRequestProjectId: props.serverConfig?.environment.capabilities.pullRequests ? (project?.id ?? null) diff --git a/apps/mobile/src/features/threads/ThreadContributionStatusStrip.tsx b/apps/mobile/src/features/threads/ThreadContributionStatusStrip.tsx new file mode 100644 index 000000000000..89fc0a246a16 --- /dev/null +++ b/apps/mobile/src/features/threads/ThreadContributionStatusStrip.tsx @@ -0,0 +1,120 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { + ContributionStatusTone, + EnvironmentId, + ProviderDriverKind, + ThreadId, +} from "@t3tools/contracts"; +import { useMemo } from "react"; +import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { cn } from "../../lib/cn"; +import { contributionStatusEnvironment } from "../../state/contribution-status"; +import { + reservesContributionStatusBand, + threadContributionStatusChips, + threadHasContributionStatus, +} from "./thread-contribution-status-presentation"; + +/** The strip's height: one row of chip touch targets (44pt iOS, 48dp Android). */ +const STRIP_HEIGHT = Platform.OS === "android" ? 48 : 44; + +// Neutral, the default, has no dot: Pi status text often brings its own glyph. +const TONE_DOT_CLASS = { + neutral: null, + info: "bg-adaptive-sky-600-400", + success: "bg-adaptive-emerald-600-400", + warning: "bg-adaptive-amber-700-400", + error: "bg-adaptive-rose-600-400", +} as const satisfies Record; + +/** + * The band the feed reserves above its first row for the status strip, passed + * to ThreadFeed's `topOverlayInset`. See `reservesContributionStatusBand`. + */ +export function useThreadContributionStatusStripInset(input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly supported: boolean; + readonly driver: ProviderDriverKind | undefined; +}): number { + const hasStatus = useAtomValue( + contributionStatusEnvironment.threadStatus(input.environmentId, input.threadId), + threadHasContributionStatus, + ); + return reservesContributionStatusBand({ ...input, hasStatus }) ? STRIP_HEIGHT : 0; +} + +/** + * Advisory statuses the thread's provider set, such as Pi extension + * `setStatus` text, as one row of chips floating just under the navigation + * header. It overlays the feed like the header does, so a status changing + * never resizes the composer; the feed reserves its band through + * `useThreadContributionStatusStripInset`. Renders nothing when the server + * lacks the capability or the thread has no statuses. Pressing a chip shows + * its tooltip and where it came from. + */ +export function ThreadContributionStatusStrip(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + /** Distance from the screen's top edge to the bottom of the navigation header. */ + readonly top: number; + readonly contentMaxWidth: number | undefined; +}) { + const entries = useAtomValue( + contributionStatusEnvironment.threadStatus(props.environmentId, props.threadId), + ); + const chips = useMemo(() => threadContributionStatusChips(entries), [entries]); + if (chips.length === 0) return null; + + return ( + + + {/* Sized to its chips, so the feed under the empty rest of the row + still takes touches. */} + + {chips.map((chip) => { + const dotClass = TONE_DOT_CLASS[chip.tone]; + // The compact pill sits inside a full-size touch target (44pt + // iOS, 48dp Android); neighbours abut without overlapping. + return ( + Alert.alert(chip.details.title, chip.details.message)} + > + + {chip.leadsSource ? : null} + {dotClass === null ? null : ( + + )} + + {chip.text} + + + + ); + })} + + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index a3d7a6f4e0c6..bc20dd883689 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -123,6 +123,10 @@ import { ComposerFeedback } from "./ComposerFeedback"; import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; import { ProviderSubagentBar } from "./ProviderSubagentBar"; +import { + ThreadContributionStatusStrip, + useThreadContributionStatusStripInset, +} from "./ThreadContributionStatusStrip"; import { ThreadCreationFailedCard } from "./ThreadCreationFailedCard"; import { FLOATING_WORKING_CONTROL_COVERAGE, @@ -849,6 +853,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread providerSubagentProvider.models, ) : null; + const statusStripInset = useThreadContributionStatusStripInset({ + environmentId: props.environmentId, + threadId: props.selectedThread.id, + supported: props.serverConfig?.environment.capabilities.contributionStatus === true, + driver: providerSubagentProvider?.driver, + }); const providerSubagentCatalogModel = providerSubagentProvider?.models.find( (model) => model.slug === providerSubagentModelSlug, ); @@ -1172,6 +1182,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread submittedMessageId={submittedMessageId} contentInsetEndAdjustment={combinedContentInsetEndAdjustment} contentTopInset={0} + topOverlayInset={statusStripInset} contentBottomInset={ estimatedOverlayHeight + (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0) @@ -1204,6 +1215,21 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread /> ) : null} + {showContent ? ( + + ) : null} + {/* Floating composer — sticks to keyboard via KeyboardStickyView */} {showContent ? ( ; readonly contentTopInset?: number; + /** Height of UI floating over the feed's top edge below the header (the status strip). */ + readonly topOverlayInset?: number; readonly contentBottomInset?: number; readonly historyControls?: ThreadFeedHistoryControls; readonly contentMaxWidth?: number; @@ -2306,6 +2309,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const anchorTopInset = usesNativeAutomaticInsets ? (navigationHeaderHeight ?? insets.top + IOS_NAV_BAR_HEIGHT) : topContentInset; + const feedTop = deriveThreadFeedTopGeometry({ + usesNativeAutomaticInsets, + headerInset: anchorTopInset, + topOverlayInset: props.topOverlayInset ?? 0, + }); const theme = useUniwindTheme(); const iconSubtleColor = theme["--color-icon-subtle"]; @@ -2753,9 +2761,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { presentedFeed, props.anchorMessageId, (entry) => (entry.type === "message" ? entry.id : null), - { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, + { anchorOffset: feedTop.anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, ), - [presentedFeed, props.anchorMessageId, anchorTopInset], + [presentedFeed, props.anchorMessageId, feedTop.anchorTopInset], ); const failedRunIds = useMemo( () => failedFeedRunIds(props.feed, props.latestRun), @@ -3252,7 +3260,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { scrollEventThrottle={16} ListHeaderComponent={ <> - {usesNativeAutomaticInsets ? null : } + {feedTop.spacerHeight > 0 ? ( + + ) : null} {setupAnchorIndex < 0 && props.worktreeSetup ? ( ) : null} diff --git a/apps/mobile/src/features/threads/thread-contribution-status-presentation.test.ts b/apps/mobile/src/features/threads/thread-contribution-status-presentation.test.ts new file mode 100644 index 000000000000..3f09c12308fb --- /dev/null +++ b/apps/mobile/src/features/threads/thread-contribution-status-presentation.test.ts @@ -0,0 +1,121 @@ +import { + type ContributionStatusEntry, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ThreadId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + reservesContributionStatusBand, + threadContributionStatusChips, + threadHasContributionStatus, +} from "./thread-contribution-status-presentation"; + +const entry = ( + session: string, + items: ContributionStatusEntry["items"], + driver = "pi", +): ContributionStatusEntry => ({ + threadId: ThreadId.make("thread-1"), + source: { + kind: "provider-session", + providerSessionId: ProviderSessionId.make(session), + providerInstanceId: ProviderInstanceId.make(driver), + driver: ProviderDriverKind.make(driver), + }, + items, +}); + +describe("threadContributionStatusChips", () => { + it("shows nothing for a thread without statuses", () => { + expect(threadContributionStatusChips([])).toEqual([]); + }); + + it("keys chips by source and item key, in the server's order", () => { + const chips = threadContributionStatusChips([ + entry("session-a", [ + { key: "mode", text: "● plan" }, + { key: "tokens", text: "12k", tone: "warning", tooltip: "Context is filling up" }, + ]), + entry("session-b", [{ key: "mode", text: "● plan" }], "codex"), + ]); + + expect(chips.map((chip) => [chip.text, chip.leadsSource, chip.tone, chip.tooltip])).toEqual([ + ["● plan", true, "neutral", null], + ["12k", false, "warning", "Context is filling up"], + ["● plan", true, "neutral", null], + ]); + // The same item key under two sources must stay two distinct rows. + expect(new Set(chips.map((chip) => chip.id)).size).toBe(3); + }); + + it("re-keys a chip when another provider session takes the thread over", () => { + const [before] = threadContributionStatusChips([entry("old", [{ key: "mode", text: "x" }])]); + const [after] = threadContributionStatusChips([entry("new", [{ key: "mode", text: "x" }])]); + + expect(after?.id).not.toBe(before?.id); + }); + + it("attributes the status to its producer without claiming freshness", () => { + const [pi] = threadContributionStatusChips([entry("s", [{ key: "mode", text: "● startup" }])]); + const [codex] = threadContributionStatusChips([ + entry("s", [{ key: "mode", text: "busy" }], "codex"), + ]); + + expect(pi?.accessibilityLabel).toBe("Pi status: ● startup"); + expect(pi?.help).toBe("Set by a Pi extension. It can lag a session change."); + expect(codex?.help).toBe("Set by Codex. It can lag a session change."); + }); + + it("puts the full status text in the details body, where it is never truncated", () => { + const text = "● build — indexing 1,284 files in packages/client-runtime before the next turn"; + const [plain, withTooltip] = threadContributionStatusChips([ + entry("s", [ + { key: "mode", text }, + { key: "tokens", text: "12k", tooltip: "Context is filling up" }, + ]), + ]); + + expect(plain?.details).toEqual({ + title: "Pi status", + message: `${text}\n\nSet by a Pi extension. It can lag a session change.`, + }); + expect(withTooltip?.details.message).toBe( + "12k\n\nContext is filling up\n\nSet by a Pi extension. It can lag a session change.", + ); + }); +}); + +describe("status strip band", () => { + it("counts a thread as having statuses only when an entry has items", () => { + expect(threadHasContributionStatus([])).toBe(false); + expect(threadHasContributionStatus([entry("s", [])])).toBe(false); + expect(threadHasContributionStatus([entry("s", [{ key: "mode", text: "x" }])])).toBe(true); + }); + + it("keeps a Pi thread's band through set, replace and clear", () => { + const pi = ProviderDriverKind.make("pi"); + const band = [false, true, true, false].map((hasStatus) => + reservesContributionStatusBand({ supported: true, driver: pi, hasStatus }), + ); + + expect(band).toEqual([true, true, true, true]); + }); + + it("reserves nothing for other providers without statuses or on servers without the stream", () => { + const codex = ProviderDriverKind.make("codex"); + const pi = ProviderDriverKind.make("pi"); + + expect( + reservesContributionStatusBand({ supported: true, driver: codex, hasStatus: false }), + ).toBe(false); + expect( + reservesContributionStatusBand({ supported: true, driver: codex, hasStatus: true }), + ).toBe(true); + expect(reservesContributionStatusBand({ supported: false, driver: pi, hasStatus: false })).toBe( + false, + ); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-contribution-status-presentation.ts b/apps/mobile/src/features/threads/thread-contribution-status-presentation.ts new file mode 100644 index 000000000000..647edaff8371 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-contribution-status-presentation.ts @@ -0,0 +1,85 @@ +import { + type ContributionStatusEntry, + type ContributionStatusTone, + contributionStatusSourceKey, + PROVIDER_DISPLAY_NAMES, + type ProviderDriverKind, +} from "@t3tools/contracts"; + +export interface ThreadContributionStatusChip { + /** Source key plus item key, so a provider session taking over re-keys the chip. */ + readonly id: string; + readonly driver: ProviderDriverKind; + /** The first chip of each source carries that source's provider icon. */ + readonly leadsSource: boolean; + readonly text: string; + readonly tone: ContributionStatusTone; + readonly tooltip: string | null; + readonly accessibilityLabel: string; + /** Says where the status came from and that it can lag; never "current session". */ + readonly help: string; + /** The details alert. The full text goes in the body: native alert titles truncate. */ + readonly details: { readonly title: string; readonly message: string }; +} + +function providerLabel(driver: ProviderDriverKind): string { + return PROVIDER_DISPLAY_NAMES[driver] ?? driver; +} + +function statusHelp(driver: ProviderDriverKind): string { + const origin = driver === "pi" ? "a Pi extension" : providerLabel(driver); + return `Set by ${origin}. It can lag a session change.`; +} + +/** Flattens a thread's status entries into chips, in the server's order. */ +export function threadContributionStatusChips( + entries: ReadonlyArray, +): ReadonlyArray { + return entries.flatMap((entry) => { + const sourceKey = contributionStatusSourceKey(entry.source); + const { driver } = entry.source; + return entry.items.map((item, index) => { + const help = statusHelp(driver); + return { + id: JSON.stringify([sourceKey, item.key]), + driver, + leadsSource: index === 0, + text: item.text, + tone: item.tone ?? "neutral", + tooltip: item.tooltip ?? null, + accessibilityLabel: `${providerLabel(driver)} status: ${item.text}`, + help, + details: { + title: `${providerLabel(driver)} status`, + message: [item.text, item.tooltip, help].filter(Boolean).join("\n\n"), + }, + }; + }); + }); +} + +export function threadHasContributionStatus( + entries: ReadonlyArray, +): boolean { + return entries.some((entry) => entry.items.length > 0); +} + +/** Drivers whose sessions publish statuses (Pi's `setStatus`). */ +const STATUS_PUBLISHING_DRIVERS: ReadonlySet = new Set(["pi"]); + +/** + * Whether the feed reserves the status strip's band above its first row. + * Threads of a publishing provider keep it from the start, so a status + * appearing, changing or clearing never shifts the feed; other threads only + * get it while they have a status. + */ +export function reservesContributionStatusBand(input: { + readonly supported: boolean; + readonly driver: ProviderDriverKind | undefined; + readonly hasStatus: boolean; +}): boolean { + if (!input.supported) return false; + return ( + input.hasStatus || (input.driver !== undefined && STATUS_PUBLISHING_DRIVERS.has(input.driver)) + ); +} diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index d7596305cff2..1e76f5040cd1 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -10,6 +10,8 @@ import { import { RowPressable } from "../../components/RowPressable"; import { CustomSnoozeSheet } from "./CustomSnoozeSheet"; import { appAtomRegistry } from "../../state/atom-registry"; +import { runPluginAction, usePluginActions } from "../../state/plugin-actions"; +import { pluginActionLabels, pluginActionsAt } from "@t3tools/client-runtime/state/pluginActions"; import { threadArrangementOpenAtom } from "../../state/thread-order"; import type { ThreadMoveDestination } from "./threadOrder"; import type { @@ -791,6 +793,37 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ], [props.titleRegenerationSupported, thread.titleRegeneration], ); + const pluginActions = usePluginActions(thread.environmentId); + const pluginMenuEntries = useMemo(() => { + const entries = pluginActionsAt(pluginActions, "thread-menu", { + threadId: thread.id, + projectId: thread.projectId, + }); + const labels = pluginActionLabels(entries.map((entry) => entry.action)); + return entries.map((entry, index) => ({ + ...entry, + menuId: `plugin-action:${entry.action.id}`, + title: labels[index] ?? entry.action.title, + })); + }, [pluginActions, thread.id, thread.projectId]); + // One submenu keeps the long-press menu short however many plugins add actions. + const pluginMenuActions = useMemo( + () => + pluginMenuEntries.length === 0 + ? [] + : [ + { + id: "plugin-actions", + title: "Plugin actions", + image: "puzzlepiece.extension", + subactions: pluginMenuEntries.map((entry) => ({ + id: entry.menuId, + title: entry.title, + })), + }, + ], + [pluginMenuEntries], + ); const snoozableCardMenuActions = useMemo( () => [ { id: "settle", title: "Settle", image: "checkmark" }, @@ -851,6 +884,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + const pluginEntry = pluginMenuEntries.find((entry) => entry.menuId === nativeEvent.event); + if (pluginEntry) { + void runPluginAction({ + environmentId: thread.environmentId, + action: pluginEntry.action, + target: pluginEntry.target, + }); + return; + } if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread); if (nativeEvent.event === "settle") handleSettle(); if (nativeEvent.event === "unsettle") handleUnsettle(); @@ -900,6 +942,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleUnpin, handleUnsettle, handleUnsnooze, + pluginMenuEntries, setCustomSnoozeOpen, snoozePresets, ], @@ -1341,6 +1384,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ] : []), { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" }, + ...pluginMenuActions, ...(snoozedRow ? snoozedMenuActions : !props.settlementSupported diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.plugin-actions.test.tsx b/apps/mobile/src/features/threads/use-composer-command-menu.plugin-actions.test.tsx new file mode 100644 index 000000000000..4b76a3dd3dec --- /dev/null +++ b/apps/mobile/src/features/threads/use-composer-command-menu.plugin-actions.test.tsx @@ -0,0 +1,254 @@ +// @vitest-environment jsdom +import { + EnvironmentId, + PluginActionId, + ProjectId, + ThreadId, + type PluginAction, +} from "@t3tools/contracts"; +import { act, createElement, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const fixture = vi.hoisted(() => ({ + actions: [] as ReadonlyArray, + canOperate: true, + // The live operate grant, which can change after the menu was offered. + canRunNow: true, + runPluginAction: vi.fn(async (_input: unknown) => true), + onChangeDraftMessage: vi.fn((_draft: string) => {}), +})); +vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); +vi.mock("../../state/queries", () => ({ + useComposerPathSearch: () => ({ entries: [], isPending: false }), + useComposerPullRequestSearch: () => ({ entries: [], isPending: false, error: null }), +})); +vi.mock("../../state/use-composer-drafts", () => ({ + getComposerDraftSnapshot: vi.fn(), + readComposerDraftSelection: () => null, + setComposerDraftContext: vi.fn(), +})); +vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" })); +vi.mock("../../state/server", () => ({ + serverEnvironment: { refreshProviders: Symbol("refreshProviders") }, +})); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../../state/session", () => ({ + useEnvironmentScope: () => fixture.canOperate, +})); +vi.mock("../../state/plugin-actions", () => ({ + usePluginActions: () => fixture.actions, + runPluginAction: fixture.runPluginAction, + canRunPluginActionsNow: () => fixture.canRunNow, +})); + +import { useComposerCommandMenu } from "./use-composer-command-menu"; + +const action = (name: string, title: string, target: PluginAction["target"]) => + ({ + id: PluginActionId.make(`installation-1:1:${name}`), + pluginId: "acme.deploy", + pluginName: "Deploy", + name, + title, + target, + placements: ["composer-slash"], + }) satisfies PluginAction; +const deploy = action("deploy", "Deploy this branch", "thread"); +const dashboard = action("open-dashboard", "Open dashboard", "project"); +const environmentId = EnvironmentId.make("environment-1"); +const threadId = ThreadId.make("thread-1"); +const projectId = ProjectId.make("project-1"); + +type Menu = ReturnType; +const latest: { menu: Menu | null; draft: string } = { menu: null, draft: "" }; +const report = (menu: Menu, draft: string) => { + latest.menu = menu; + latest.draft = draft; +}; +const onUpdateInteractionMode = vi.fn(); +const onUsageLimits = vi.fn(); + +/** A composer: the draft lives in state and the menu edits it, as in ThreadComposer and New Task. */ +function Composer(props: { + initialDraft: string; + currentThreadId: ThreadId | null; + report: (menu: Menu, draft: string) => void; +}) { + const [draft, setDraft] = useState(props.initialDraft); + const menu = useComposerCommandMenu({ + draftMessage: draft, + ownerKey: "draft-1", + environmentId, + currentThreadId: props.currentThreadId, + projectId, + projectCwd: null, + selectedProviderStatus: null, + hasThread: props.currentThreadId !== null, + hasCompactableConversation: false, + onChangeDraftMessage: (next) => { + fixture.onChangeDraftMessage(next); + setDraft(next); + }, + onUpdateInteractionMode, + onUsageLimits, + }); + props.report(menu, draft); + return null; +} + +let root: Root; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + fixture.actions = [deploy, dashboard]; + fixture.canOperate = true; + fixture.canRunNow = true; + fixture.runPluginAction.mockReset().mockResolvedValue(true); + fixture.onChangeDraftMessage.mockClear(); + onUpdateInteractionMode.mockClear(); + onUsageLimits.mockClear(); + root = createRoot(document.createElement("div")); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + vi.unstubAllGlobals(); +}); + +/** Types `draft` with the caret after `/`, then returns the plugin entries offered. */ +async function openMenu(draft: string, caret: number, currentThreadId: ThreadId | null) { + await act(async () => + root.render(createElement(Composer, { initialDraft: draft, currentThreadId, report })), + ); + await act(async () => latest.menu!.onSelectionChange({ start: caret, end: caret })); + return latest.menu!.items.filter((item) => item.type === "plugin-action"); +} + +function offered(label: string) { + const item = latest.menu!.items.find((candidate) => candidate.label === label); + if (!item) throw new Error(`Expected ${label} in the menu`); + return item; +} + +async function pick(label: string) { + const item = offered(label); + await act(async () => latest.menu!.onSelect(item)); +} + +describe("picking a plugin action from the slash menu", () => { + it("runs it on the open thread and keeps the rest of the message", async () => { + const draft = "ship it\n/depl\nthen tell me"; + const offered = await openMenu(draft, "ship it\n/depl".length, threadId); + expect(offered.map((item) => item.label)).toContain("/deploy"); + + await pick("/deploy"); + + expect(latest.draft).toBe("ship it\n\nthen tell me"); + expect(fixture.runPluginAction).toHaveBeenCalledExactlyOnceWith({ + environmentId, + action: deploy, + target: { _tag: "thread", threadId }, + }); + // The pick is the action itself: nothing else changes the message or the turn. + expect(onUpdateInteractionMode).not.toHaveBeenCalled(); + expect(onUsageLimits).not.toHaveBeenCalled(); + }); + + it("in a New Task draft offers project actions on the selected project, not thread actions", async () => { + const offered = await openMenu("check this\n/", "check this\n/".length, null); + expect(offered.map((item) => item.label)).toEqual(["/open-dashboard"]); + + await pick("/open-dashboard"); + + expect(latest.draft).toBe("check this\n"); + expect(fixture.runPluginAction).toHaveBeenCalledExactlyOnceWith({ + environmentId, + action: dashboard, + target: { _tag: "project", projectId }, + }); + }); + + it("offers no plugin actions to a read-only connection and keeps the typed command", async () => { + fixture.canOperate = false; + const draft = "ship it\n/depl"; + const offered = await openMenu(draft, draft.length, threadId); + expect(offered).toEqual([]); + + // A stale entry picked after the grant changed is refused before the draft is touched. + await act(async () => + latest.menu!.onSelect({ + id: `plugin-action:${deploy.id}`, + type: "plugin-action", + action: deploy, + target: { _tag: "thread", threadId }, + label: "/deploy", + description: deploy.title, + }), + ); + + expect(latest.draft).toBe(draft); + expect(fixture.runPluginAction).not.toHaveBeenCalled(); + }); + + it("keeps the typed command when the grant is lost while the menu is open", async () => { + const draft = "ship it\n/depl"; + await openMenu(draft, draft.length, threadId); + const stale = offered("/deploy"); + + fixture.canOperate = false; + await act(async () => + root.render( + createElement(Composer, { initialDraft: draft, currentThreadId: threadId, report }), + ), + ); + await act(async () => latest.menu!.onSelect(stale)); + + expect(fixture.runPluginAction).not.toHaveBeenCalled(); + expect(latest.draft).toBe(draft); + }); + + it("reads the live grant when the action is picked and keeps the draft if it is gone", async () => { + const draft = "ship it\n/depl"; + await openMenu(draft, draft.length, threadId); + + // The cached grant still offers the action, but the connection lost it. + fixture.canRunNow = false; + await pick("/deploy"); + + expect(fixture.runPluginAction).not.toHaveBeenCalled(); + expect(latest.draft).toBe(draft); + }); + + it("removes the command when it is picked, before the action settles, and runs it once", async () => { + let finish: (ran: boolean) => void = () => {}; + fixture.runPluginAction.mockReturnValue(new Promise((resolve) => (finish = resolve))); + await openMenu("ship it\n/depl", "ship it\n/depl".length, threadId); + const stale = offered("/deploy"); + const staleMenu = latest.menu!; + + // Two taps land on the same render before the draft updates. + await act(async () => { + staleMenu.onSelect(stale); + staleMenu.onSelect(stale); + }); + expect(latest.draft).toBe("ship it\n"); + await act(async () => finish(true)); + + expect(fixture.runPluginAction).toHaveBeenCalledOnce(); + }); + + it("writes no draft when the action settles after the composer is gone", async () => { + let finish: (ran: boolean) => void = () => {}; + fixture.runPluginAction.mockReturnValue(new Promise((resolve) => (finish = resolve))); + await openMenu("ship it\n/depl", "ship it\n/depl".length, threadId); + await pick("/deploy"); + expect(fixture.onChangeDraftMessage).toHaveBeenCalledExactlyOnceWith("ship it\n"); + + await act(async () => root.unmount()); + await act(async () => finish(true)); + + expect(fixture.onChangeDraftMessage).toHaveBeenCalledOnce(); + root = createRoot(document.createElement("div")); + }); +}); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 79965f76e924..51e87732f0ed 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -1,8 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, + PluginActionId, + ProjectId, ProviderDriverKind, ProviderInstanceId, + ThreadId, + type PluginAction, type ServerProvider, } from "@t3tools/contracts"; import { act, createElement } from "react"; @@ -27,9 +31,15 @@ vi.mock("../../state/server", () => ({ vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => refreshProviders, })); +vi.mock("../../state/session", () => ({ useEnvironmentScope: () => true })); +vi.mock("../../state/plugin-actions", () => ({ + usePluginActions: () => [], + runPluginAction: vi.fn(), +})); import { buildComposerSlashCommandItems, + buildPluginActionSlashItems, resolveComposerCommandSelection, useComposerCommandMenu, } from "./use-composer-command-menu"; @@ -145,6 +155,7 @@ describe("workspace command discovery retry", () => { draftMessage: "/project", ownerKey: null, environmentId, + projectId: null, projectCwd: cwd, selectedProviderStatus: status, hasThread: false, @@ -322,3 +333,43 @@ describe("workspace command discovery retry", () => { expect(vi.getTimerCount()).toBe(0); }); }); + +describe("mobile plugin action slash entries", () => { + const action = (name: string, title: string, target: PluginAction["target"]) => + ({ + id: PluginActionId.make(`installation-1:1:${name}`), + pluginId: "acme.deploy", + pluginName: "Deploy", + name, + title, + target, + placements: ["composer-slash"], + }) satisfies PluginAction; + const actions = [ + action("deploy", "Deploy this branch", "thread"), + action("open-dashboard", "Open dashboard", "project"), + ]; + const threadId = ThreadId.make("thread-1"); + const projectId = ProjectId.make("project-1"); + + it("match by name or title and run on the thread they were picked on", () => { + expect( + buildPluginActionSlashItems(actions, "branch", { threadId, projectId }).map((item) => + item.type === "plugin-action" ? [item.label, item.target] : null, + ), + ).toEqual([["/deploy", { _tag: "thread", threadId }]]); + expect( + buildPluginActionSlashItems(actions, "dash", { threadId, projectId }).map( + (item) => item.label, + ), + ).toEqual(["/open-dashboard"]); + }); + + it("leave out actions whose target the composer cannot supply", () => { + expect( + buildPluginActionSlashItems(actions, "", { threadId: null, projectId }).map( + (item) => item.label, + ), + ).toEqual(["/open-dashboard"]); + }); +}); diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index a02b95b50666..6d83830a5228 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -1,9 +1,11 @@ -import type { - EnvironmentId, - ProjectId, - ProviderInteractionMode, - ServerProvider, - ThreadId, +import { + AuthOrchestrationOperateScope, + type EnvironmentId, + type PluginAction, + type ProjectId, + type ProviderInteractionMode, + type ServerProvider, + type ThreadId, } from "@t3tools/contracts"; import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; @@ -48,6 +50,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ComposerEditorSelection } from "../../components/ComposerEditor"; import { serverEnvironment } from "../../state/server"; +import { useEnvironmentScope } from "../../state/session"; +import { + canRunPluginActionsNow, + runPluginAction, + usePluginActions, +} from "../../state/plugin-actions"; +import { + type PluginActionContext, + pluginActionsAt, +} from "@t3tools/client-runtime/state/pluginActions"; import { useAtomCommand } from "../../state/use-atom-command"; import { useComposerPathSearch, useComposerPullRequestSearch } from "../../state/queries"; import type { ComposerCommandItem } from "./ComposerCommandPopover"; @@ -169,6 +181,31 @@ export function resolveComposerCommandSelection(input: { }; } +/** + * Slash entries for the plugin actions matching `query` (lowercase) by name or title. + * They run when picked, so they are offered anywhere in the message. + */ +export function buildPluginActionSlashItems( + actions: ReadonlyArray, + query: string, + context: PluginActionContext, +): ComposerCommandItem[] { + return pluginActionsAt(actions, "composer-slash", context) + .filter( + // Names are lowercase today, but the wire accepts any string from a newer server. + ({ action }) => + action.name.toLowerCase().includes(query) || action.title.toLowerCase().includes(query), + ) + .map(({ action, target }) => ({ + id: `plugin-action:${action.id}`, + type: "plugin-action" as const, + action, + target, + label: `/${action.name}`, + description: `${action.title} · ${action.pluginName}`, + })); +} + /** Shared autocomplete for thread composers and unsent new-task drafts. */ export function useComposerCommandMenu({ draftMessage, @@ -176,6 +213,7 @@ export function useComposerCommandMenu({ environmentId, threadShells = EMPTY_THREAD_SHELLS, currentThreadId = null, + projectId, projectCwd, pullRequestProjectId = null, pullRequestRepository = null, @@ -195,6 +233,8 @@ export function useComposerCommandMenu({ readonly threadShells?: ReadonlyArray; /** Left out of `@` thread suggestions: a thread is never context for itself. */ readonly currentThreadId?: ThreadId | null; + /** The project plugin actions with a project target run on; required so no composer drops them. */ + readonly projectId: ProjectId | null; readonly projectCwd: string | null; readonly pullRequestProjectId?: ProjectId | null; readonly pullRequestRepository?: string | null; @@ -211,6 +251,7 @@ export function useComposerCommandMenu({ }) { const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage)); const previousOwnerKeyRef = useRef(ownerKey); + const pluginActionRunningRef = useRef(false); const onSelectionChange = useCallback((nextSelection: ComposerEditorSelection) => { setSelection(nextSelection); }, []); @@ -360,6 +401,9 @@ export function useComposerCommandMenu({ query: trigger?.kind === "pull-request" ? trigger.query : null, }); + const pluginActions = usePluginActions(environmentId); + // Running a plugin action needs `orchestration:operate`. + const canRunPluginActions = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const items = useMemo(() => { if (!trigger) return []; @@ -412,7 +456,16 @@ export function useComposerCommandMenu({ description: skill.shortDescription ?? skill.description ?? "", })); - return [...commandItems, ...skillItems]; + return [ + ...commandItems, + ...skillItems, + ...(canRunPluginActions + ? buildPluginActionSlashItems(pluginActions, q, { + threadId: currentThreadId, + projectId, + }) + : []), + ]; } if (trigger.kind === "skill") { @@ -531,7 +584,10 @@ export function useComposerCommandMenu({ hasThread, hasCompactableConversation, onUpdateInteractionMode, + canRunPluginActions, pathSearch.entries, + pluginActions, + projectId, pullRequestSearch.entries, projectCwd, selectedProviderStatus, @@ -612,6 +668,29 @@ export function useComposerCommandMenu({ return; } + if (item.type === "plugin-action") { + // Keep the typed command when this connection may no longer run actions. + // The live grant is read because the menu may predate a permission change. + if ( + environmentId === null || + !canRunPluginActions || + !canRunPluginActionsNow(environmentId) + ) { + return; + } + // A second tap before this render's draft updates must not run it twice. + if (pluginActionRunningRef.current) return; + pluginActionRunningRef.current = true; + const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""); + setSelection({ start: cleared.cursor, end: cleared.cursor }); + onChangeDraftMessage(cleared.text); + void runPluginAction({ environmentId, action: item.action, target: item.target }).finally( + () => { + pluginActionRunningRef.current = false; + }, + ); + return; + } if ( item.type === "provider-slash-command" && item.command.name === USAGE_LIMITS_COMMAND.name && @@ -639,7 +718,9 @@ export function useComposerCommandMenu({ } }, [ + canRunPluginActions, draftMessage, + environmentId, ownerKey, items, onChangeDraftMessage, diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 7d288b1352a5..d655781a8f42 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -6,6 +6,7 @@ import { deriveFileInspectorPaneLayout, deriveLayout, deriveThreadFeedInitialContentInset, + deriveThreadFeedTopGeometry, deriveThreadWorkLogSizing, deriveWorkspacePaneLayout, SPLIT_LAYOUT_MIN_HEIGHT, @@ -72,6 +73,45 @@ describe("deriveThreadFeedInitialContentInset", () => { }); }); +describe("deriveThreadFeedTopGeometry", () => { + it("keeps the first row and an anchored message below a strip under an opaque header", () => { + expect( + deriveThreadFeedTopGeometry({ + usesNativeAutomaticInsets: false, + headerInset: 0, + topOverlayInset: 48, + }), + ).toEqual({ spacerHeight: 48, anchorTopInset: 48 }); + }); + + it("adds only the strip to content under a glass header, whose inset UIKit applies", () => { + expect( + deriveThreadFeedTopGeometry({ + usesNativeAutomaticInsets: true, + headerInset: 106, + topOverlayInset: 44, + }), + ).toEqual({ spacerHeight: 44, anchorTopInset: 150 }); + }); + + it("leaves the feed where it was when nothing floats over it", () => { + expect( + deriveThreadFeedTopGeometry({ + usesNativeAutomaticInsets: true, + headerInset: 106, + topOverlayInset: 0, + }), + ).toEqual({ spacerHeight: 0, anchorTopInset: 106 }); + expect( + deriveThreadFeedTopGeometry({ + usesNativeAutomaticInsets: false, + headerInset: 0, + topOverlayInset: 0, + }), + ).toEqual({ spacerHeight: 0, anchorTopInset: 0 }); + }); +}); + describe("resizable pane constraints", () => { it("preserves a useful main pane while constraining a trailing pane", () => { expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 1_100 })).toBe(440); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index ac4cde7789b0..53afe9e51471 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -87,6 +87,24 @@ export function deriveThreadFeedInitialContentInset(input: { return { bottom: Math.max(0, input.bottomContentInset) }; } +/** + * Where the thread feed's first row and a just-sent message rest. The header + * inset is UIKit's under native automatic insets and a content spacer + * otherwise. UI floating over the feed's top edge below the header, such as + * the status strip, adds to both, so it never covers the oldest loaded row, + * the load-earlier control or an anchored message. + */ +export function deriveThreadFeedTopGeometry(input: { + readonly usesNativeAutomaticInsets: boolean; + readonly headerInset: number; + readonly topOverlayInset: number; +}): { readonly spacerHeight: number; readonly anchorTopInset: number } { + return { + spacerHeight: (input.usesNativeAutomaticInsets ? 0 : input.headerInset) + input.topOverlayInset, + anchorTopInset: input.headerInset + input.topOverlayInset, + }; +} + export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector"; export function deriveLayout(input: { diff --git a/apps/mobile/src/state/contribution-status.ts b/apps/mobile/src/state/contribution-status.ts new file mode 100644 index 000000000000..8a83addc96ab --- /dev/null +++ b/apps/mobile/src/state/contribution-status.ts @@ -0,0 +1,9 @@ +import { createContributionStatusEnvironmentAtoms } from "@t3tools/client-runtime/state/contribution-status"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { serverEnvironment } from "./server"; + +export const contributionStatusEnvironment = createContributionStatusEnvironmentAtoms( + connectionAtomRuntime, + { configValueAtom: serverEnvironment.configValueAtom }, +); diff --git a/apps/mobile/src/state/plugin-actions.test.ts b/apps/mobile/src/state/plugin-actions.test.ts new file mode 100644 index 000000000000..542003f432e9 --- /dev/null +++ b/apps/mobile/src/state/plugin-actions.test.ts @@ -0,0 +1,100 @@ +import { + AuthOrchestrationOperateScope, + EnvironmentId, + PluginActionId, + ProjectId, + ThreadId, + type PluginAction, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + canOperate: true, + invoke: vi.fn(), + alert: vi.fn(), +})); + +// The session grant, the invoke RPC and the alert are the boundaries. +vi.mock("react-native", () => ({ Alert: { alert: state.alert } })); +vi.mock("expo-haptics", () => ({ + notificationAsync: async () => {}, + NotificationFeedbackType: { Success: "success" }, +})); +vi.mock("./session", () => ({ + readEnvironmentScope: (_environmentId: string, scope: string) => + scope === AuthOrchestrationOperateScope && state.canOperate, +})); +vi.mock("@t3tools/client-runtime/state/runtime", async (importOriginal) => ({ + ...(await importOriginal()), + runAtomCommand: state.invoke, +})); +vi.mock("@t3tools/client-runtime/state/pluginActions", async (importOriginal) => ({ + ...(await importOriginal()), + createPluginActionEnvironmentAtoms: () => ({ invoke: "invoke", snapshot: () => null }), +})); +vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} })); +vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); +vi.mock("./query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); + +import { buildPluginActionPaletteItems } from "../features/keyboard/commandPaletteItems"; +import { runPluginAction } from "./plugin-actions"; + +const environmentId = EnvironmentId.make("environment-1"); +const threadId = ThreadId.make("thread-1"); +const deploy: PluginAction = { + id: PluginActionId.make("installation-1:1:deploy"), + pluginId: "acme.deploy", + pluginName: "Deploy", + name: "deploy", + title: "Deploy this branch", + target: "thread", + placements: ["command-palette"], +}; +const run = () => + runPluginAction({ environmentId, action: deploy, target: { _tag: "thread", threadId } }); + +beforeEach(() => { + state.canOperate = true; + state.invoke.mockReset().mockResolvedValue(AsyncResult.success({ message: "Deployed" })); + state.alert.mockReset(); +}); + +describe("runPluginAction", () => { + it("reports that the plugin ran the action", async () => { + await expect(run()).resolves.toBe(true); + expect(state.invoke).toHaveBeenCalledOnce(); + expect(state.alert).toHaveBeenCalledWith("Deploy this branch", "Deployed"); + }); + + it("reports a refused action as not run", async () => { + state.invoke.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("Forbidden")))); + await expect(run()).resolves.toBe(false); + expect(state.alert).toHaveBeenCalledWith("Deploy this branch failed", "Forbidden"); + }); + + it("does not invoke once the connection has lost its operate grant", async () => { + state.canOperate = false; + await expect(run()).resolves.toBe(false); + expect(state.invoke).not.toHaveBeenCalled(); + }); + + it("rechecks the grant when a palette entry runs after the palette closes", async () => { + // The palette stores the picked entry and runs it once its modal is dismissed. + const [entry] = buildPluginActionPaletteItems({ + actions: [deploy], + canOperate: true, + environmentId, + threadId, + projectId: ProjectId.make("project-1"), + runAction: (input) => void runPluginAction(input), + }); + state.canOperate = false; + + entry?.run(); + await Promise.resolve(); + + expect(state.invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/state/plugin-actions.ts b/apps/mobile/src/state/plugin-actions.ts new file mode 100644 index 000000000000..88fc243f315b --- /dev/null +++ b/apps/mobile/src/state/plugin-actions.ts @@ -0,0 +1,86 @@ +import { createPluginActionEnvironmentAtoms } from "@t3tools/client-runtime/state/pluginActions"; +import { + isAtomCommandInterrupted, + runAtomCommand, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + AuthOrchestrationOperateScope, + type EnvironmentId, + type PluginAction, + type PluginActionTarget, +} from "@t3tools/contracts"; +import * as Haptics from "expo-haptics"; +import { Alert } from "react-native"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "./atom-registry"; +import { useEnvironmentQuery } from "./query"; +import { readEnvironmentScope } from "./session"; + +const pluginActionEnvironment = createPluginActionEnvironmentAtoms(connectionAtomRuntime); + +const NO_ACTIONS: ReadonlyArray = []; + +/** The environment's plugin actions; none on servers without them. */ +export function usePluginActions(environmentId: EnvironmentId | null): ReadonlyArray { + return ( + useEnvironmentQuery( + environmentId === null + ? null + : pluginActionEnvironment.snapshot({ environmentId, input: {} }), + ).data?.actions ?? NO_ACTIONS + ); +} + +/** The environment's plugin actions snapshot, including what its limit left out; null until it arrives. */ +export function usePluginActionsSnapshot(environmentId: EnvironmentId | null) { + return useEnvironmentQuery( + environmentId === null ? null : pluginActionEnvironment.snapshot({ environmentId, input: {} }), + ).data; +} + +/** Whether this connection may run plugin actions now, read from the live grant. */ +export function canRunPluginActionsNow(environmentId: EnvironmentId): boolean { + return readEnvironmentScope(environmentId, AuthOrchestrationOperateScope); +} + +/** + * Runs a plugin action in the environment that listed it. A message from the + * plugin or a failure is shown in an alert; a silent success taps a haptic. + * The grant is read when the action runs, not when it was offered, so a + * palette entry picked after the grant changed is refused. Resolves `true` + * only when the plugin ran the action. + */ +export async function runPluginAction(input: { + readonly environmentId: EnvironmentId; + readonly action: PluginAction; + readonly target: PluginActionTarget; +}): Promise { + const { action } = input; + if (!canRunPluginActionsNow(input.environmentId)) { + Alert.alert(`${action.title} unavailable`, "This connection cannot run plugin actions."); + return false; + } + const result = await runAtomCommand( + appAtomRegistry, + pluginActionEnvironment.invoke, + { environmentId: input.environmentId, input: { actionId: action.id, target: input.target } }, + { reportFailure: false }, + ); + if (result._tag === "Success") { + if (result.value.message === null) { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); + } else { + Alert.alert(action.title, result.value.message); + } + return true; + } + if (isAtomCommandInterrupted(result)) return false; + const error = squashAtomCommandFailure(result); + Alert.alert( + `${action.title} failed`, + error instanceof Error ? error.message : "The plugin action failed.", + ); + return false; +} diff --git a/apps/mobile/src/state/plugins.ts b/apps/mobile/src/state/plugins.ts new file mode 100644 index 000000000000..288261924ee7 --- /dev/null +++ b/apps/mobile/src/state/plugins.ts @@ -0,0 +1,13 @@ +import { createPluginEnvironmentAtoms } from "@t3tools/client-runtime/state/plugins"; +import { createPluginNpmEnvironmentAtoms } from "@t3tools/client-runtime/state/pluginNpm"; +import { createPluginSettingsEnvironmentAtoms } from "@t3tools/client-runtime/state/pluginSettings"; +import { createPluginViewEnvironmentAtoms } from "@t3tools/client-runtime/state/pluginViews"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const pluginEnvironment = createPluginEnvironmentAtoms(connectionAtomRuntime); +export const pluginSettingsEnvironment = + createPluginSettingsEnvironmentAtoms(connectionAtomRuntime); +export const pluginNpmEnvironment = createPluginNpmEnvironmentAtoms(connectionAtomRuntime); +/** Mobile shows no views; it reads which views a plugin offers for its details. */ +export const pluginViewEnvironment = createPluginViewEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 511fd77cd147..26d5704ce98b 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -5,6 +5,7 @@ import { authScopeRequiredResponse, AssetCreateUrlInput, AuthAccessReadScope, + AuthAccessWriteScope, ServerSettingsPatch, ProviderInstanceMutation, requiredScopesForServerSettingsPatch, @@ -116,6 +117,35 @@ export const RPC_REQUIRED_SCOPES = { // Delivery logs hold request bodies, so they need the same scope as the URL. [WS_METHODS.scheduledTasksListWebhookDeliveries]: AuthOrchestrationOperateScope, [WS_METHODS.scheduledTasksGetWebhookDelivery]: AuthOrchestrationOperateScope, + // Plugins run as the server's OS user, so changing what runs takes the administrative scope + // that also manages pairing and sessions; standard pairing never grants it. + [WS_METHODS.pluginsList]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSubscribe]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsAdd]: AuthAccessWriteScope, + [WS_METHODS.pluginsRefresh]: AuthAccessWriteScope, + [WS_METHODS.pluginsConsent]: AuthAccessWriteScope, + [WS_METHODS.pluginsEnable]: AuthAccessWriteScope, + [WS_METHODS.pluginsDisable]: AuthAccessWriteScope, + [WS_METHODS.pluginsRemove]: AuthAccessWriteScope, + [WS_METHODS.pluginsResume]: AuthAccessWriteScope, + // Saved setting values are readable like the catalogue; secrets are never sent. Saving one + // configures code that runs as the server's OS user, so it takes the administrative scope. + [WS_METHODS.pluginsSettingsSubscribe]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSettingsUpdate]: AuthAccessWriteScope, + // Running an action the administrator already enabled is ordinary operation, like starting a + // turn; it cannot change what code runs. + [WS_METHODS.pluginActionsSubscribe]: AuthOrchestrationReadScope, + [WS_METHODS.pluginActionsInvoke]: AuthOrchestrationOperateScope, + // A view call runs the plugin's own `view:` handlers, like an action a client takes. + [WS_METHODS.pluginViewsSubscribe]: AuthOrchestrationReadScope, + [WS_METHODS.pluginViewsReadBundle]: AuthOrchestrationReadScope, + [WS_METHODS.pluginViewsCall]: AuthOrchestrationOperateScope, + // Installing or updating from npm changes which code the server runs, so it is administrative. + [WS_METHODS.pluginsNpmList]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsNpmAdd]: AuthAccessWriteScope, + [WS_METHODS.pluginsNpmStageUpdate]: AuthAccessWriteScope, + [WS_METHODS.pluginsNpmApplyUpdate]: AuthAccessWriteScope, + [WS_METHODS.pluginsNpmDiscardUpdate]: AuthAccessWriteScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, @@ -167,6 +197,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeResourceTelemetry]: AuthDiagnosticsReadScope, + [WS_METHODS.subscribeContributionStatus]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationReadScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 063093874503..57421c3e99d4 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -4,8 +4,9 @@ * Every ACP agent spawns `t3 acp-mcp-bridge` while opening its session, and * terminal-fallback agents run `t3 acp-mcp-call` per tool call, so their * startup sits on first-message latency. Both dispatch here before the full - * CLI module graph (seconds of evaluation) loads; everything else defers to - * the real CLI in ./binCli.ts. + * CLI module graph (seconds of evaluation) loads. Plugin children + * (`__plugin-host`) take the same shortcut. Everything else defers to the + * real CLI in ./binCli.ts. */ import { isEntrypoint } from "./entrypoint.ts"; @@ -20,6 +21,10 @@ if ( if (command === "acp-mcp-bridge" || command === "acp-mcp-call") { const { runAcpMcpCliFastPath } = await import("./mcp/AcpMcpStdioBridge.ts"); await runAcpMcpCliFastPath(command, process.argv.slice(3)); + } else if (command === "__plugin-host") { + // One process per enabled plugin: keep its footprint to the child runtime. + const { runPluginHostChild } = await import("./plugins/pluginHostChild.ts"); + runPluginHostChild(); } else { const { runCli } = await import("./binCli.ts"); runCli(); diff --git a/apps/server/src/contributions/ContributionStatusRpc.test.ts b/apps/server/src/contributions/ContributionStatusRpc.test.ts new file mode 100644 index 000000000000..34d999adf280 --- /dev/null +++ b/apps/server/src/contributions/ContributionStatusRpc.test.ts @@ -0,0 +1,206 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + AuthOrchestrationReadScope, + AuthRelayReadScope, + type AuthEnvironmentScope, + ProviderDriverKind, + ProviderInstanceId, + ProviderSessionId, + ThreadId, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as TestClock from "effect/testing/TestClock"; +import { RpcMessage, RpcSerialization, RpcServer } from "effect/rpc"; + +import * as RpcAuthorization from "../auth/RpcAuthorization.ts"; +import { RPC_REQUIRED_SCOPES } from "../auth/RpcAuthorization.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import * as ProviderOrchestrationAdapterInfrastructure from "../provider/ProviderOrchestrationAdapterInfrastructure.ts"; +import * as ContributionStatusStore from "@t3tools/provider-core/server/ContributionStatusStore"; + +const TAG = WS_METHODS.subscribeContributionStatus; +const THREAD = ThreadId.make("thread-1"); +const SOURCE = { + kind: "provider-session", + providerSessionId: ProviderSessionId.make("session-1"), + providerInstanceId: ProviderInstanceId.make("pi"), + driver: ProviderDriverKind.make("pi"), +} as const; +const encodedEntry = (texts: ReadonlyArray) => ({ + threadId: THREAD, + source: SOURCE, + items: texts.map((text, index) => ({ key: `k${index}`, text })), +}); + +// The server group narrowed to this RPC; it keeps the group's scope middleware. +const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => tag !== TAG, + ), +); + +/** + * Serves `subscribeContributionStatus` as ws.ts does (the store's subscription + * stream behind the connection's scope middleware) over an in-memory RPC protocol. + */ +const serveStatus = Effect.fn("serveStatus")(function* ( + store: ContributionStatusStore.ContributionStatusStoreShape, + scopes: ReadonlyArray, +) { + const responses = yield* Queue.unbounded(); + const receive = yield* Deferred.make[0]>(); + const protocol = yield* RpcServer.Protocol.make((write) => + Effect.gen(function* () { + yield* Deferred.succeed(receive, write); + const serialization = yield* RpcSerialization.RpcSerialization; + return { + disconnects: yield* Queue.unbounded(), + send: (_clientId, response) => Queue.offer(responses, response), + end: () => Effect.void, + clientIds: Effect.succeed(new Set([0])), + initialMessage: Effect.succeedNone, + supportsAck: false, + supportsTransferables: false, + supportsSpanPropagation: false, + supportsNotifications: true, + codecFor: serialization.codecFor, + }; + }), + ); + yield* RpcServer.make(group).pipe( + Effect.provide( + Layer.merge( + group.toLayerHandler(TAG, () => ContributionStatusStore.subscriptionStream(store)), + RpcAuthorization.layer(scopes), + ), + ), + Effect.provideService(RpcServer.Protocol, protocol), + Effect.forkScoped, + ); + const write = yield* Deferred.await(receive); + return { + subscribe: (id: string) => + write(0, { _tag: "Request", id, tag: TAG, payload: {}, headers: [] }), + interrupt: (id: string) => write(0, { _tag: "Interrupt", requestId: id }), + /** The next frame the client receives; the store publishes one per visible change. */ + next: Queue.take(responses), + /** Lets server fibers run so a frame that would be sent is in the queue. */ + pending: TestClock.adjust(0).pipe(Effect.andThen(Queue.size(responses))), + }; +}); + +const chunk = ( + requestId: string, + entries: ReadonlyArray>, +): RpcMessage.FromServerEncoded => ({ _tag: "Chunk", requestId, values: [{ entries }] }); + +describe("subscribeContributionStatus", () => { + it.effect("streams the current snapshot, replacements, and a fresh snapshot on resubscribe", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const handle = yield* store.openSource(SOURCE); + yield* handle.bindThread(THREAD); + yield* handle.set({ key: "k0", text: "plan" }); + const client = yield* serveStatus(store, [AuthOrchestrationReadScope]); + + yield* client.subscribe("1"); + assert.deepStrictEqual(yield* client.next, chunk("1", [encodedEntry(["plan"])])); + + yield* handle.set({ key: "k0", text: "build" }); + assert.deepStrictEqual(yield* client.next, chunk("1", [encodedEntry(["build"])])); + yield* handle.clear("k0"); + assert.deepStrictEqual(yield* client.next, chunk("1", [])); + + // A reconnect is a new subscription whose first frame is the current state. + yield* handle.set({ key: "k0", text: "review" }); + assert.deepStrictEqual(yield* client.next, chunk("1", [encodedEntry(["review"])])); + yield* client.subscribe("2"); + assert.deepStrictEqual(yield* client.next, chunk("2", [encodedEntry(["review"])])); + + // An interrupted subscription stops receiving frames; the other keeps going. + yield* client.interrupt("1"); + assert.strictEqual((yield* client.next)._tag, "Exit"); + yield* handle.set({ key: "k0", text: "done" }); + assert.deepStrictEqual(yield* client.next, chunk("2", [encodedEntry(["done"])])); + assert.strictEqual(yield* client.pending, 0); + }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), + ); + + it.effect("refuses a client without the orchestration read scope", () => + Effect.gen(function* () { + const store = yield* ContributionStatusStore.make(); + const handle = yield* store.openSource(SOURCE); + yield* handle.bindThread(THREAD); + yield* handle.set({ key: "k0", text: "plan" }); + const client = yield* serveStatus(store, [AuthRelayReadScope]); + + yield* client.subscribe("1"); + assert.deepStrictEqual(yield* client.next, { + _tag: "Exit", + requestId: "1", + exit: { + _tag: "Failure", + cause: [ + { + _tag: "Fail", + error: { + _tag: "EnvironmentAuthorizationError", + message: `The authenticated token is missing required scope: ${AuthOrchestrationReadScope}.`, + requiredPermission: AuthOrchestrationReadScope, + requiredScope: AuthOrchestrationReadScope, + }, + }, + ], + }, + }); + }).pipe(Effect.provide(RpcSerialization.layerJson), Effect.scoped), + ); + + it.effect("shares one store between provider adapters and the WebSocket stream", () => + Effect.gen(function* () { + // Mirrors server.ts: adapters get the store through the provider + // infrastructure inside an unwrapped instance-registry layer, while the + // WebSocket layer reads it from the runtime's own reference. + const producer = Layer.unwrap( + Effect.succeed( + Layer.effectDiscard( + Effect.gen(function* () { + const store = yield* ContributionStatusStore.ContributionStatusStore; + const handle = yield* store.openSource(SOURCE); + yield* handle.bindThread(THREAD); + yield* handle.set({ key: "k0", text: "from adapter" }); + }), + ).pipe(Layer.provide(ProviderOrchestrationAdapterInfrastructure.layer)), + ), + ); + // As in server.ts, the registry layer sits below the store reference, so + // the producer only sees the store its own infrastructure provides. + const runtime = ContributionStatusStore.layer.pipe( + Layer.provideMerge(producer), + Layer.provide( + Layer.merge( + NodeServices.layer, + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ), + ); + + const snapshot = yield* Effect.gen(function* () { + const store = yield* ContributionStatusStore.ContributionStatusStore; + return yield* store.snapshot; + }).pipe(Effect.provide(runtime)); + assert.deepStrictEqual(snapshot.entries, [ + { threadId: THREAD, source: SOURCE, items: [{ key: "k0", text: "from adapter" }] }, + ]); + }), + ); +}); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 60589ac9106f..abe82953d158 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -251,6 +251,12 @@ export const make = Effect.gen(function* () { serverResolvedCommandContext: true, environmentIcon: true, projectCloneTracking: true, + contributionStatus: true, + plugins: true, + pluginSettings: true, + pluginActions: true, + pluginViews: true, + pluginNpm: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverInstallation === null ? {} : { serverInstallation }), // V2 restart recovery uses the environment-owned opt-in. The old diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 0e2b52696712..47a746b3e89f 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -50,6 +50,8 @@ import { WorktreeToolkit } from "./toolkits/worktree/tools.ts"; import * as WorktreeMcpService from "./WorktreeMcpService.ts"; import * as PullRequestsHandlers from "./toolkits/pullRequests/handlers.ts"; import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; +import * as PluginToolsHandlers from "./toolkits/pluginTools/handlers.ts"; +import { PluginToolsToolkit } from "./toolkits/pluginTools/tools.ts"; import * as DeviceHandlers from "./toolkits/device/handlers.ts"; import { DeviceScreenshotTool, @@ -843,6 +845,11 @@ export const layerPullRequestsToolkit = toolkitRegistration( PullRequestsHandlers.layer, ); +export const layerPluginToolsToolkit = toolkitRegistration( + PluginToolsToolkit, + PluginToolsHandlers.layer, +); + const layerDeviceStandardToolkitRegistration = toolkitRegistration( DeviceStandardToolkit, DeviceHandlers.layerStandard, @@ -878,4 +885,5 @@ export const layer = Layer.mergeAll( layerPullRequestsToolkit, layerDeviceToolkit, layerHtmlToolkit, + layerPluginToolsToolkit, ).pipe(Layer.provideMerge(layerMcpTransport)); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index c53dac6695c0..db29890b2758 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -11,6 +11,8 @@ import { import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import type { PluginToolGrant } from "../plugins/PluginTools.ts"; + const ALL_MCP_CAPABILITIES = [ "preview", "orchestration", @@ -58,6 +60,8 @@ export interface McpInvocationScope { readonly requestNamespace: string; readonly thread: McpThreadCaller | undefined; readonly client: McpClientCaller | undefined; + /** Tool plugins enabled when the session was prepared; see PluginTools.ts. */ + readonly pluginToolGrants?: ReadonlyArray; } export class McpInvocationContext extends Context.Service< diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 6b6207dc2660..b2c1f8eda6e0 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -1,6 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + PluginInstallationId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/http"; import * as NetAddress from "effect/net/NetAddress"; @@ -180,3 +185,30 @@ it.effect("does not keep credentials of other threads alive", () => expect(yield* registry.resolve(token)).toBeUndefined(); }), ); + +it.effect("keeps a credential's plugin tool grants and replaces them without rotating it", () => + Effect.gen(function* () { + const registry = yield* makeRegistry(() => 1_000); + const grant = (generation: number) => ({ + installationId: PluginInstallationId.make("installation-1"), + generation, + }); + const issued = yield* registry.issue({ + threadId: ThreadId.make("thread-plugin-tools"), + providerInstanceId: ProviderInstanceId.make("codex"), + pluginToolGrants: [grant(1)], + }); + const other = yield* registry.issue({ + threadId: ThreadId.make("thread-other"), + providerInstanceId: ProviderInstanceId.make("codex"), + }); + const tokenOf = (credential: typeof issued) => + credential.config.authorizationHeader.replace(/^Bearer\s+/, ""); + expect((yield* registry.resolve(tokenOf(issued)))?.pluginToolGrants).toEqual([grant(1)]); + expect((yield* registry.resolve(tokenOf(other)))?.pluginToolGrants).toBeUndefined(); + + yield* registry.setPluginToolGrants(issued.config.providerSessionId, [grant(2)]); + expect((yield* registry.resolve(tokenOf(issued)))?.pluginToolGrants).toEqual([grant(2)]); + expect((yield* registry.resolve(tokenOf(other)))?.pluginToolGrants).toBeUndefined(); + }), +); diff --git a/apps/server/src/mcp/McpSessionRegistry.testkit.ts b/apps/server/src/mcp/McpSessionRegistry.testkit.ts index 1ac02357a878..b11d7e7440fc 100644 --- a/apps/server/src/mcp/McpSessionRegistry.testkit.ts +++ b/apps/server/src/mcp/McpSessionRegistry.testkit.ts @@ -21,6 +21,7 @@ export const layer = Layer.succeed( }), resolve: () => Effect.succeed(undefined), touch: () => Effect.void, + setPluginToolGrants: () => Effect.void, revokeProviderSession: () => Effect.void, revokeThread: () => Effect.void, revokeAll: Effect.void, diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index f1e027f9eb85..3088c9257542 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -9,6 +9,7 @@ import { HttpServer } from "effect/http"; import * as NetAddress from "effect/net/NetAddress"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import type { PluginToolGrant } from "../plugins/PluginTools.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; @@ -22,6 +23,8 @@ export interface McpCredentialRequest { */ readonly browserToolsAvailable?: boolean; readonly capabilities?: ReadonlySet; + /** Tool plugins the session may use; none when omitted. */ + readonly pluginToolGrants?: ReadonlyArray; } export interface McpIssuedCredential { @@ -39,6 +42,14 @@ export interface McpSessionRegistryShape { * credential even when it goes a long time without touching an MCP tool. */ readonly touch: (threadId: ThreadId) => Effect.Effect; + /** + * Replaces the tool plugin grants of a live credential without rotating it, + * for a session prepared again on a credential its provider still holds. + */ + readonly setPluginToolGrants: ( + providerSessionId: string, + grants: ReadonlyArray, + ) => Effect.Effect; readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect; readonly revokeThread: (threadId: ThreadId) => Effect.Effect; readonly revokeAll: Effect.Effect; @@ -144,6 +155,9 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( ...(request.capabilities ?? (browserToolsAvailable ? (["preview"] as const) : [])), ]), issuedAt, + ...(request.pluginToolGrants === undefined + ? {} + : { pluginToolGrants: request.pluginToolGrants }), }; yield* SynchronizedRef.update(state, ({ records }) => { const next = new Map(pruneDead(records, issuedAt)); @@ -206,6 +220,22 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( issue, resolve, touch, + setPluginToolGrants: Effect.fn("McpSessionRegistry.setPluginToolGrants")( + function* (providerSessionId, grants) { + yield* SynchronizedRef.update(state, ({ records }) => { + const next = new Map(records); + for (const [tokenHash, record] of records) { + if (record.scope.thread.providerSessionId === providerSessionId) { + next.set(tokenHash, { + ...record, + scope: { ...record.scope, pluginToolGrants: grants }, + }); + } + } + return { records: next }; + }); + }, + ), revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")( function* (providerSessionId) { yield* revokeWhere((record) => record.scope.thread.providerSessionId === providerSessionId); diff --git a/apps/server/src/mcp/toolkits/pluginTools/handlers.test.ts b/apps/server/src/mcp/toolkits/pluginTools/handlers.test.ts new file mode 100644 index 000000000000..4343a95f596d --- /dev/null +++ b/apps/server/src/mcp/toolkits/pluginTools/handlers.test.ts @@ -0,0 +1,165 @@ +import { expect, it } from "@effect/vitest"; +import { + EnvironmentId, + PluginInstallationId, + PluginToolError, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { McpSchema, McpServer } from "effect/ai"; + +import * as PluginTools from "../../../plugins/PluginTools.ts"; +import * as McpHttpServer from "../../McpHttpServer.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { liveThreadsLayer } from "../../McpToolAccess.testkit.ts"; + +const grants = [{ installationId: PluginInstallationId.make("installation-1"), generation: 3 }]; +const invocation = { + environmentId: EnvironmentId.make("environment-plugin-tools"), + capabilities: new Set(), + issuedAt: 1, + requestNamespace: "thread:thread-plugin-tools", + thread: { + threadId: ThreadId.make("thread-plugin-tools"), + providerSessionId: "provider-session-plugin-tools", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + pluginToolGrants: grants, +} satisfies McpInvocationContext.McpInvocationScope; +// An MCP OAuth client signed in from outside T3 Code: no thread and no grants. +const outsideClient = { + environmentId: invocation.environmentId, + capabilities: new Set(), + issuedAt: 1, + requestNamespace: "client:session-outside", + thread: undefined, + client: { sessionId: "session-outside", label: "outside", access: "full-access" }, +} satisfies McpInvocationContext.McpInvocationScope; +const client = McpSchema.McpServerClient.of({ + clientId: 1, + clientCapabilities: {}, + clientInfo: { name: "mcp-test", version: "1.0.0" }, + protocolVersion: "2025-06-18", + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "mcp-test", version: "1.0.0" }, + }, + getClient: Effect.die("unused"), +}); + +it.effect( + "takes grants and thread from the credential and marks the call tool conservatively", + () => { + const seen: Array = []; + const tools = Layer.mock(PluginTools.PluginTools)({ + list: (granted, options) => + Effect.sync(() => { + seen.push({ granted, options }); + return { tools: [], notInThisSession: [] }; + }), + call: (granted, request) => + Effect.suspend(() => { + seen.push({ granted, request }); + return request.tool === "acme.search/lookup" + ? Effect.succeed({ hits: 1 }) + : Effect.fail( + new PluginToolError({ reason: "unknown-tool", message: "No such tool." }), + ); + }), + }); + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const registered = Object.fromEntries(server.tools.map(({ tool }) => [tool.name, tool])); + expect(registered.plugin_tools_list?.annotations).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + }); + expect(registered.plugin_tool_call?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }); + const callTool = ( + name: string, + args: Record, + scope: McpInvocationContext.McpInvocationScope = invocation, + ) => + server + .callTool({ name, arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + const listed = yield* callTool("plugin_tools_list", { plugin: "acme.search", cursor: "a" }); + expect(listed.isError).toBe(false); + // A context the agent passes is only input; the plugin gets the credential's. + const called = yield* callTool("plugin_tool_call", { + tool: "acme.search/lookup", + input: { q: "x", context: { threadId: "spoofed" } }, + }); + expect(called).toMatchObject({ isError: false, structuredContent: { result: { hits: 1 } } }); + const failed = yield* callTool("plugin_tool_call", { tool: "acme.search/nope" }); + expect(failed.isError).toBe(true); + expect(failed.content).toEqual([{ type: "text", text: "No such tool." }]); + + expect(seen).toEqual([ + { granted: grants, options: { plugin: "acme.search", cursor: "a" } }, + { + granted: grants, + request: { + tool: "acme.search/lookup", + input: { q: "x", context: { threadId: "spoofed" } }, + context: { + environmentId: invocation.environmentId, + threadId: invocation.thread.threadId, + }, + }, + }, + { + granted: grants, + request: { + tool: "acme.search/nope", + input: {}, + context: { + environmentId: invocation.environmentId, + threadId: invocation.thread.threadId, + }, + }, + }, + ]); + + // Outside clients see no plugin tools and cannot call one; the plugin is never reached. + seen.length = 0; + const outsideList = yield* callTool("plugin_tools_list", {}, outsideClient); + expect(outsideList.isError).toBe(false); + const outsideCall = yield* callTool( + "plugin_tool_call", + { tool: "acme.search/lookup" }, + outsideClient, + ); + expect(outsideCall.isError).toBe(true); + expect(outsideCall.content).toEqual([ + { + type: "text", + text: "This tool acts as the calling T3 thread, so it needs an agent running inside T3 Code. This MCP client signed in from outside a thread.", + }, + ]); + expect(outsideList.structuredContent).toEqual({ tools: [], notInThisSession: [] }); + expect(seen).toEqual([]); + }).pipe( + Effect.scoped, + Effect.provide( + McpHttpServer.layerPluginToolsToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(tools), + Layer.provide(liveThreadsLayer), + ), + ), + ); + }, +); diff --git a/apps/server/src/mcp/toolkits/pluginTools/handlers.ts b/apps/server/src/mcp/toolkits/pluginTools/handlers.ts new file mode 100644 index 000000000000..9d2b896c69d9 --- /dev/null +++ b/apps/server/src/mcp/toolkits/pluginTools/handlers.ts @@ -0,0 +1,45 @@ +import * as Effect from "effect/Effect"; + +import * as PluginTools from "../../../plugins/PluginTools.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import * as McpToolAccess from "../../McpToolAccess.ts"; +import { PluginToolsToolkit } from "./tools.ts"; + +const make = Effect.gen(function* () { + const tools = yield* PluginTools.PluginTools; + return { + // Scope and grants come from the session's credential, never from the agent. + // A caller without a thread can call no plugin tool, so it learns of no plugins either. + plugin_tools_list: McpToolAccess.reads((input) => + McpInvocationContext.McpInvocationContext.pipe( + Effect.flatMap((scope) => + scope.thread === undefined + ? Effect.succeed({ tools: [], notInThisSession: [] }) + : tools.list(scope.pluginToolGrants ?? [], { + ...(input.plugin === undefined ? {} : { plugin: input.plugin }), + ...(input.cursor === undefined ? {} : { cursor: input.cursor }), + }), + ), + ), + ), + // A plugin tool runs on behalf of the calling thread while its run is live, so a client + // signed in from outside T3 Code (no thread, no grants) cannot call one. + plugin_tool_call: McpToolAccess.actsAsCaller((input) => + McpInvocationContext.McpInvocationContext.pipe( + Effect.flatMap((scope) => + McpInvocationContext.requireThreadScope(scope, "plugin_tool_call"), + ), + Effect.flatMap((scope) => + tools.call(scope.pluginToolGrants ?? [], { + tool: input.tool, + input: input.input ?? {}, + context: { environmentId: scope.environmentId, threadId: scope.thread.threadId }, + }), + ), + Effect.map((result) => ({ result })), + ), + ), + } satisfies McpToolAccess.Handlers; +}); + +export const layer = McpToolAccess.toLayer(PluginToolsToolkit, make); diff --git a/apps/server/src/mcp/toolkits/pluginTools/tools.ts b/apps/server/src/mcp/toolkits/pluginTools/tools.ts new file mode 100644 index 000000000000..8cfbdc395c09 --- /dev/null +++ b/apps/server/src/mcp/toolkits/pluginTools/tools.ts @@ -0,0 +1,64 @@ +import { OrchestratorMcpFailure, PluginToolError, PluginToolsListResult } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Tool from "effect/ai/Tool"; +import * as Toolkit from "effect/ai/Toolkit"; + +import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const dependencies = [McpInvocationContext.McpInvocationContext]; + +const PluginToolsListTool = Tool.make("plugin_tools_list", { + description: + "List the tools offered by the user's trusted local T3 Code plugins that this session may use. Each entry has the tool name to pass to plugin_tool_call, its inputSchema, and its sideEffect (read, write or destructive). Listing starts no plugin. Results come in pages ordered by plugin id: when nextCursor is present, pass it as cursor for more. Pass plugin to list one plugin's tools. Plugins enabled after this session started appear under notInThisSession and need a new session.", + parameters: Schema.Struct({ + plugin: Schema.optionalKey( + Schema.String.check(Schema.isMaxLength(128)).annotate({ + description: "A plugin id, such as acme.search, to list only that plugin's tools.", + }), + ), + cursor: Schema.optionalKey( + Schema.String.check(Schema.isMaxLength(128)).annotate({ + description: "The nextCursor of the previous page.", + }), + ), + }), + success: PluginToolsListResult, + failure: PluginToolError, + dependencies, +}) + .annotate(Tool.Title, "List plugin tools") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +/** + * One fixed tool calls every plugin tool, so its hints describe the most a + * plugin tool may do. Each tool's own sideEffect is in plugin_tools_list. + */ +const PluginToolCallTool = Tool.make("plugin_tool_call", { + description: + "Call a tool from plugin_tools_list. Pass its tool name and an input object that matches its inputSchema. Check the tool's sideEffect first: write and destructive tools change things, so follow the user's instructions about such changes.", + parameters: Schema.Struct({ + tool: Schema.String.check(Schema.isMaxLength(200)).annotate({ + description: "The tool value from plugin_tools_list, such as acme.search/lookup.", + }), + input: Schema.optionalKey( + Schema.Record(Schema.String, Schema.Unknown).annotate({ + description: "Arguments matching the tool's inputSchema. Defaults to {}.", + }), + ), + }), + success: Schema.Struct({ result: Schema.Unknown }), + // The caller check refuses an outside client or a thread whose run has ended. + failure: Schema.Union([PluginToolError, OrchestratorMcpFailure]), + dependencies: [...dependencies, ThreadManagementService.ThreadManagementService], +}) + .annotate(Tool.Title, "Call plugin tool") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, true); + +export const PluginToolsToolkit = Toolkit.make(PluginToolsListTool, PluginToolCallTool); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 91257c2fdb80..65fa2121cec5 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -15,6 +15,7 @@ import * as ServerEnvironment from "../../../environment/ServerEnvironment.ts"; import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; import * as ProviderAdapterRegistry from "../../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as PluginTools from "../../../plugins/PluginTools.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts"; import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; @@ -50,6 +51,7 @@ const layerStubServices = Layer.mergeAll( Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({}), Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/unused" }), + Layer.mock(PluginTools.PluginTools)({}), Layer.mock(PreviewManager.PreviewManager)({}), Layer.mock(ServerSecretStore.ServerSecretStore)({}), Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({}), diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 2ad903ff792a..607ac43bf9ef 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -88,6 +88,27 @@ const RPC_AGGREGATES = { [WS_METHODS.scheduledTasksSetEnabled]: "scheduledTasks", [WS_METHODS.scheduledTasksDelete]: "scheduledTasks", [WS_METHODS.scheduledTasksRunNow]: "scheduledTasks", + [WS_METHODS.pluginsList]: "plugins", + [WS_METHODS.pluginsSubscribe]: "plugins", + [WS_METHODS.pluginsAdd]: "plugins", + [WS_METHODS.pluginsRefresh]: "plugins", + [WS_METHODS.pluginsConsent]: "plugins", + [WS_METHODS.pluginsEnable]: "plugins", + [WS_METHODS.pluginsDisable]: "plugins", + [WS_METHODS.pluginsRemove]: "plugins", + [WS_METHODS.pluginsResume]: "plugins", + [WS_METHODS.pluginsSettingsSubscribe]: "plugins", + [WS_METHODS.pluginsSettingsUpdate]: "plugins", + [WS_METHODS.pluginActionsSubscribe]: "plugins", + [WS_METHODS.pluginActionsInvoke]: "plugins", + [WS_METHODS.pluginsNpmList]: "plugins", + [WS_METHODS.pluginsNpmAdd]: "plugins", + [WS_METHODS.pluginsNpmStageUpdate]: "plugins", + [WS_METHODS.pluginsNpmApplyUpdate]: "plugins", + [WS_METHODS.pluginsNpmDiscardUpdate]: "plugins", + [WS_METHODS.pluginViewsSubscribe]: "pluginViews", + [WS_METHODS.pluginViewsReadBundle]: "pluginViews", + [WS_METHODS.pluginViewsCall]: "pluginViews", [WS_METHODS.scheduledTasksRotateWebhookToken]: "scheduledTasks", [WS_METHODS.scheduledTasksListWebhookDeliveries]: "scheduledTasks", [WS_METHODS.scheduledTasksGetWebhookDelivery]: "scheduledTasks", @@ -154,6 +175,7 @@ const RPC_AGGREGATES = { [WS_METHODS.subscribeWorktreeSetup]: "vcs", [WS_METHODS.worktreeSetupCancel]: "vcs", [WS_METHODS.subscribeResourceTelemetry]: "server", + [WS_METHODS.subscribeContributionStatus]: "server", [WS_METHODS.vcsRefreshStatus]: "vcs", [WS_METHODS.vcsPull]: "git", [WS_METHODS.gitRunStackedAction]: "vcs", diff --git a/apps/server/src/orchestration-v2/EffectOutbox.ts b/apps/server/src/orchestration-v2/EffectOutbox.ts index d9b21038cf71..6ed5a0000e36 100644 --- a/apps/server/src/orchestration-v2/EffectOutbox.ts +++ b/apps/server/src/orchestration-v2/EffectOutbox.ts @@ -214,6 +214,10 @@ export interface EffectOutboxV2Shape { readonly listByCommandId: ( commandId: CommandId, ) => Effect.Effect, EffectOutboxError>; + /** + * Cancelling a checkpoint capture abandons its run's finalization. Do that + * through `EventSink.commitCommand`, which records the failure. + */ readonly cancelUnsettled: (input: { readonly threadId: ThreadId; readonly effectTypes: ReadonlyArray; diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index d9bd8689f092..8bceed22e008 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -130,7 +130,10 @@ function layerExecutorFor(input: { ), Layer.succeed( RunFinalizationService.RunFinalizationService, - RunFinalizationService.RunFinalizationService.of({ finalize: () => Effect.void }), + RunFinalizationService.RunFinalizationService.of({ + finalize: () => Effect.void, + abandon: () => Effect.succeed(true), + }), ), Layer.succeed( CheckpointRollbackService.CheckpointRollbackServiceV2, diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 9ae94910b7c8..19d6a263b484 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -71,6 +71,18 @@ export interface OrchestrationEffectExecutorV2Shape { effect: EffectOutbox.OrchestrationEffectV2, options?: { readonly willRetry: boolean }, ) => Effect.Effect; + /** + * Settles an effect whose last attempt failed, in place of `outbox.fail`, so + * its failure record commits with the terminal status. Returns undefined + * for effects that keep no record, and false when the worker no longer + * owns the lease. + */ + readonly fail?: (input: { + readonly effect: EffectOutbox.OrchestrationEffectV2; + readonly workerId: string; + readonly error: string; + readonly cause: Cause.Cause; + }) => Effect.Effect | undefined; } export class OrchestrationEffectExecutorV2 extends Context.Service< @@ -497,6 +509,36 @@ export const layerExecutor: Layer.Layer< ); } }, + fail: ({ effect, workerId, error, cause }) => { + if (effect.request.type !== "checkpoint.capture") return undefined; + const failure = Cause.findErrorOption(cause).pipe( + Option.map((executionError) => executionError.cause), + Option.filter(RunFinalizationService.isRunFinalizationError), + ); + return runFinalization + .abandon({ + threadId: effect.threadId, + runId: effect.request.runId, + scopeId: effect.request.scopeId, + operation: Option.match(failure, { + onNone: () => "capture-checkpoint" as const, + onSome: (finalizationError) => finalizationError.operation, + }), + effectId: effect.id, + workerId, + error, + }) + .pipe( + Effect.mapError( + (abandonCause) => + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause: abandonCause, + }), + ), + ); + }, }); }), ); @@ -558,9 +600,12 @@ export const layerWithOptions = ( }), ), ); + const retryDelayMs = (effect: EffectOutbox.OrchestrationEffectV2) => + Math.min(30_000, 100 * 2 ** Math.max(0, effect.attemptCount - 1)); const requeueClaim = ( effect: EffectOutbox.OrchestrationEffectV2, cause: Cause.Cause, + delayMs = 0, ) => Cause.hasInterruptsOnly(cause) ? Effect.void @@ -569,7 +614,7 @@ export const layerWithOptions = ( effectId: effect.id, workerId, error: `Worker failed before settling the claimed effect: ${Cause.pretty(cause)}`, - delayMs: 0, + delayMs, }) .pipe( Effect.flatMap((requeued) => @@ -722,24 +767,36 @@ export const layerWithOptions = ( nonRetryable, error, }); + const retry = outbox + .retry({ + effectId: effect.id, + workerId, + error, + delayMs: retryDelayMs(effect), + }) + .pipe(Effect.onError((cause) => requeueClaim(effect, cause))); + const recordedFailure = executor.fail?.({ effect, workerId, error, cause: exit.cause }); // Prefer succeed for terminal interrupt races so the outbox does not // keep a failed interrupt around; fail only when we must not retry. + // An effect that records its failure stays recoverable until that + // record commits, retried with backoff so a record that keeps failing + // does not rerun the effect at once; an interruption is replayed, not + // recorded. const updated = nonRetryable ? yield* outbox .succeed({ effectId: effect.id, workerId }) .pipe(Effect.onError((cause) => terminalizeClaim(effect, cause))) - : effect.attemptCount >= maxAttempts - ? yield* outbox - .fail({ effectId: effect.id, workerId, error }) - .pipe(Effect.onError((cause) => terminalizeClaim(effect, cause))) - : yield* outbox - .retry({ - effectId: effect.id, - workerId, - error, - delayMs: Math.min(30_000, 100 * 2 ** Math.max(0, effect.attemptCount - 1)), - }) - .pipe(Effect.onError((cause) => requeueClaim(effect, cause))); + : effect.attemptCount < maxAttempts + ? yield* retry + : recordedFailure === undefined + ? yield* outbox + .fail({ effectId: effect.id, workerId, error }) + .pipe(Effect.onError((cause) => terminalizeClaim(effect, cause))) + : Cause.hasInterruptsOnly(exit.cause) + ? yield* retry + : yield* recordedFailure.pipe( + Effect.onError((cause) => requeueClaim(effect, cause, retryDelayMs(effect))), + ); if (!updated) { if (yield* wasCancelled(effect.id)) return true; return yield* new OrchestrationEffectWorkerError({ diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 039d1872da6f..e3368a4064f7 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -31,6 +31,7 @@ import * as EffectOutbox from "./EffectOutbox.ts"; import * as EventStore from "./EventStore.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ProjectStore from "./ProjectStore.ts"; +import * as RunFinalized from "./RunFinalized.ts"; import * as TurnItemPositionStore from "./TurnItemPositionStore.ts"; /** @@ -139,6 +140,25 @@ export interface EventSinkV2Shape { }, EventSinkV2Error >; + /** + * Fails a claimed effect and records `events` in one transaction. Commits + * nothing when `workerId` no longer holds the effect's lease. + */ + readonly failEffect: (input: { + readonly commandId: CommandId; + readonly effectId: string; + readonly workerId: string; + readonly error: string; + readonly events: ReadonlyArray; + }) => Effect.Effect< + { + readonly committed: boolean; + readonly storedEvents: ReadonlyArray; + }, + EventSinkV2Error + >; + /** Whether the run recorded `run.finalized` or `run.finalization-failed`. */ + readonly hasRunFinalization: (runId: RunId) => Effect.Effect; readonly commitRejectedCommand: (input: { readonly commandId: CommandId; readonly threadId: ThreadId; @@ -306,7 +326,110 @@ const layerBase: Layer.Layer< }); }); - const normalizeEvents = (events: ReadonlyArray) => { + const isRunFinalizationRecorded = (runId: RunId) => + sql<{ readonly found: number }>` + SELECT 1 AS found + FROM orchestration_events + WHERE event_id = ${RunFinalized.runFinalizedEventId(runId)} + LIMIT 1 + `.pipe(Effect.map((rows) => rows.length > 0)); + + // A run records one finalization. A run whose checkpoint capture is due, + // running or done finalizes through RunFinalizationService. A run that + // never enqueued one finalizes in the commit that writes its terminal + // status, and one whose capture was abandoned without a record reports + // that failure there instead. Events are checked against stored state so + // a repeated write cannot record twice. + const withRunFinalizedEvents = ( + events: ReadonlyArray, + effects: ReadonlyArray, + ) => + Effect.gen(function* () { + const isFinalizationRecord = (event: OrchestrationV2DomainEvent) => + event.type === "run.finalized" || event.type === "run.finalization-failed"; + if (!events.some((event) => event.type === "run.updated" || isFinalizationRecord(event))) { + return events; + } + const captureStatus = (runId: RunId) => + effects.some( + (effect) => + effect.request.type === "checkpoint.capture" && effect.request.runId === runId, + ) + ? Effect.succeed(Option.some("pending")) + : effectOutbox + .get(RunFinalized.checkpointCaptureEffectId(runId)) + .pipe(Effect.map(Option.map((effect) => effect.status))); + const statuses = new Map(); + const previousStatus = (runId: RunId) => + statuses.has(runId) + ? Effect.succeed(statuses.get(runId)) + : sql<{ readonly status: string }>` + SELECT status + FROM orchestration_v2_projection_runs + WHERE run_id = ${runId} + LIMIT 1 + `.pipe(Effect.map((rows) => rows[0]?.status)); + const finalized = new Set(); + const result: Array = []; + // Appended last so the milestone follows every write in its commit. + const milestones: Array = []; + for (const event of events) { + if (event.type === "run.finalized" || event.type === "run.finalization-failed") { + const runId = event.payload.runId; + if (!finalized.has(runId) && !(yield* isRunFinalizationRecorded(runId))) { + finalized.add(runId); + result.push(event); + } + continue; + } + result.push(event); + if (event.type === "run.created") { + statuses.set(event.payload.id, event.payload.status); + continue; + } + if (event.type !== "run.updated") continue; + const run = event.payload; + const previous = yield* previousStatus(run.id); + statuses.set(run.id, run.status); + const outcome = RunFinalized.runFinalizedOutcome(run.status); + // Only the transition into a final status finalizes, so later + // updates to old runs never produce a late milestone. + if ( + outcome === null || + finalized.has(run.id) || + (previous !== undefined && RunFinalized.isSettledRunStatus(previous)) || + (yield* isRunFinalizationRecorded(run.id)) + ) { + continue; + } + const capture = yield* captureStatus(run.id); + const abandoned = + Option.isSome(capture) && (capture.value === "failed" || capture.value === "cancelled"); + if (Option.isSome(capture) && !abandoned) continue; + finalized.add(run.id); + milestones.push( + abandoned + ? RunFinalized.makeRunFinalizationFailedEvent({ + run, + operation: RunFinalized.abandonedOperation(run), + occurredAt: event.occurredAt, + }) + : RunFinalized.makeRunFinalizedEvent({ run, outcome, occurredAt: event.occurredAt }), + ); + } + return [...result, ...milestones]; + }); + + const normalizeEvents = ( + input: ReadonlyArray, + effects: ReadonlyArray = [], + ) => + Effect.gen(function* () { + const events = yield* withRunFinalizedEvents(input, effects); + return yield* normalizeTurnItemPositions(events); + }); + + const normalizeTurnItemPositions = (events: ReadonlyArray) => { const runOrdinals = new Map( events.flatMap((event) => event.type === "run.created" || event.type === "run.updated" @@ -374,6 +497,7 @@ const layerBase: Layer.Layer< input.guardPendingUserInputCancellations === true ? yield* guardUserInputCancellations(input.events) : input.events, + input.effects, ); const committed = yield* eventStore.append({ ...(input.commandId === undefined ? {} : { commandId: input.commandId }), @@ -428,10 +552,13 @@ const layerBase: Layer.Layer< }; } + // The checkpoint capture enqueued below decides how a run that + // settles here finalizes, so it must be visible to normalization. const normalized = yield* normalizeEvents( input.guardPendingUserInputCancellations === true ? yield* guardUserInputCancellations(input.events) : input.events, + input.effects, ); const storedEvents = yield* eventStore.append({ ...(input.commandId === undefined ? {} : { commandId: input.commandId }), @@ -524,6 +651,79 @@ const layerBase: Layer.Layer< return { receipt: existing.value, storedEvents }; }); + // Cancelling a checkpoint capture abandons its run's finalization, so the + // cancelling commit records `run.finalization-failed` for that run. + const recordAbandonedCaptures = ( + commandId: CommandId, + cancelledEffectIds: ReadonlyArray, + occurredAt: DateTime.Utc, + ) => + Effect.gen(function* () { + const events: Array = []; + for (const effectId of cancelledEffectIds) { + const effect = yield* effectOutbox.get(effectId); + if (Option.isNone(effect) || effect.value.request.type !== "checkpoint.capture") continue; + const { run } = yield* projectionStore.getCheckpointCaptureContext( + effect.value.threadId, + effect.value.request, + ); + if (run === undefined || run.status === "rolled_back") continue; + events.push( + RunFinalized.makeRunFinalizationFailedEvent({ + run, + operation: RunFinalized.abandonedOperation(run), + occurredAt, + }), + ); + } + if (events.length === 0) return []; + const storedEvents = yield* eventStore.append({ + commandId, + events: yield* normalizeEvents(events), + }); + yield* applyStoredEvents(storedEvents); + return storedEvents; + }); + + const failEffectEffect = Effect.fn("orchestrationV2.EventSink.failEffect")(function* ( + input: Parameters[0], + ) { + yield* Effect.annotateCurrentSpan({ + "orchestration_v2.command_id": input.commandId, + "orchestration_v2.effect_id": input.effectId, + "orchestration_v2.event_count": input.events.length, + }); + + return yield* commitThenPublish( + Effect.gen(function* () { + const failed = yield* effectOutbox.fail({ + effectId: input.effectId, + workerId: input.workerId, + error: input.error, + }); + if (!failed) { + return { + committed: false as const, + storedEvents: [] as ReadonlyArray, + }; + } + const normalized = yield* normalizeEvents(input.events); + const storedEvents = + normalized.length === 0 + ? [] + : yield* eventStore.append({ commandId: input.commandId, events: normalized }); + yield* applyStoredEvents(storedEvents); + return { committed: true as const, storedEvents }; + }), + (result) => + result.committed + ? effectOutbox + .notifyAvailable() + .pipe(Effect.andThen(publishStoredEvents(result.storedEvents))) + : Effect.void, + ); + }); + const commitCommandEffect = Effect.fn("orchestrationV2.EventSink.commitCommand")(function* ( input: Parameters[0], ) { @@ -543,36 +743,47 @@ const layerBase: Layer.Layer< return { ...existing, committed: false as const, cancelledEffectIds: [] }; } - const normalized = yield* normalizeEvents(input.events); - const storedEvents = yield* eventStore.append({ + const normalized = yield* normalizeEvents(input.events, input.effects); + const appended = yield* eventStore.append({ commandId: input.commandId, events: normalized, }); - const sequence = storedEvents.at(-1)?.sequence; - if (sequence === undefined) { + if (appended.length === 0) { return yield* Effect.die( new Error(`Command ${input.commandId} produced no orchestration events.`), ); } - yield* applyStoredEvents(storedEvents); + yield* applyStoredEvents(appended); yield* effectOutbox.enqueue(input.effects); + const cancelledEffectIds = + input.cancelUnsettledEffects === undefined + ? [] + : yield* effectOutbox.cancelUnsettled({ + threadId: input.threadId, + ...input.cancelUnsettledEffects, + }); + const storedEvents = input.cancelUnsettledEffects?.effectTypes.includes( + "checkpoint.capture", + ) + ? [ + ...appended, + ...(yield* recordAbandonedCaptures( + input.commandId, + cancelledEffectIds, + input.acceptedAt, + )), + ] + : appended; const receipt: CommandReceiptStore.CommandReceiptV2 = { commandId: input.commandId, threadId: input.threadId, commandType: input.commandType, acceptedAt: input.acceptedAt, - resultSequence: sequence, + resultSequence: storedEvents.at(-1)?.sequence ?? 0, status: "accepted", error: null, }; yield* commandReceipts.upsert(receipt); - const cancelledEffectIds = - input.cancelUnsettledEffects === undefined - ? [] - : yield* effectOutbox.cancelUnsettled({ - threadId: input.threadId, - ...input.cancelUnsettledEffects, - }); return { receipt, storedEvents, committed: true as const, cancelledEffectIds }; }), (result) => @@ -815,6 +1026,21 @@ const layerBase: Layer.Layer< }), ), ), + failEffect: (input) => + failEffectEffect(input).pipe( + Effect.mapError( + (cause) => + new EventSinkWriteError({ + commandId: input.commandId, + eventCount: input.events.length, + cause, + }), + ), + ), + hasRunFinalization: (runId) => + isRunFinalizationRecorded(runId).pipe( + Effect.mapError((cause) => new EventSinkStreamError({ cause })), + ), commitRejectedCommand: (input) => commitRejectedCommandEffect(input).pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts index 0a39c5cf374a..790b56115629 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -119,6 +119,7 @@ const layerMcpRegistry = Layer.succeed( }), resolve: () => Effect.succeed(undefined), touch: () => Effect.void, + setPluginToolGrants: () => Effect.void, revokeProviderSession: () => Effect.void, revokeThread: () => Effect.void, revokeAll: Effect.void, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 51343fd8af91..0a431bc5738b 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -750,6 +750,10 @@ export function applyToProjection( ), ), }); + // Milestones over state the run rows already hold. + case "run.finalized": + case "run.finalization-failed": + return projection; case "run.background-work-cancelled": return { ...base, @@ -1929,6 +1933,9 @@ export const layer: Layer.Layer = `; break; } + case "run.finalized": + case "run.finalization-failed": + break; case "run.background-work-cancelled": { // Only this field changes, so a concurrent lifecycle write is never regressed. const workJson = yield* encodeRestartCancelledBackgroundWork( @@ -2662,7 +2669,9 @@ export const layer: Layer.Layer = event.type !== "thread.runtime-mode-updated" && event.type !== "thread.interaction-mode-updated" && event.type !== "thread.model-selection-updated" && - event.type !== "thread.provider-switched" + event.type !== "thread.provider-switched" && + event.type !== "run.finalized" && + event.type !== "run.finalization-failed" ) { const rows = yield* sql` SELECT payload_json diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 04310257a71e..a2faafd5db6e 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -10,6 +10,7 @@ import { type OrchestrationV2ProviderCapabilities, type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, + PluginInstallationId, type Project, ProjectId, ProviderDriverKind, @@ -35,6 +36,7 @@ import { HttpServer } from "effect/http"; import { ProviderWorkspaceMissingError } from "../provider/Errors.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as PluginTools from "../plugins/PluginTools.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; @@ -458,11 +460,8 @@ function layerTest(input: { readonly failReleaseEventWrites?: boolean; readonly flakyReleaseWrites?: FlakyReleaseWrites; readonly pauseAttachWrite?: Parameters[0]; - /** Once armed, holds the next credential lookup until it is interrupted. */ - readonly pauseResolve?: { - readonly armed: Ref.Ref; - readonly paused: Deferred.Deferred; - }; + /** Once armed, the next call of that registry step hangs until interrupted or crashes. */ + readonly pauseMcpRegistry?: PauseMcpRegistry; readonly hasPendingBackgroundWork?: Effect.Effect; readonly hasPendingBackgroundWorkForThread?: Effect.Effect; readonly hangSessionScopeClose?: boolean; @@ -472,6 +471,7 @@ function layerTest(input: { readonly scopeCloseReached?: Deferred.Deferred; readonly serverSettingsLayer?: ReturnType; readonly projectServiceLayer?: Layer.Layer; + readonly pluginToolsLayer?: Layer.Layer; }) { const layerConfiguredEventSink = input.flakyReleaseWrites !== undefined @@ -505,9 +505,9 @@ function layerTest(input: { }).pipe(Effect.map(ProviderAdapterRegistry.layerSingle)), ); const layerConfiguredMcpRegistry = - input.pauseResolve === undefined + input.pauseMcpRegistry === undefined ? layerTestMcpRegistry - : layerPausingMcpRegistry(input.pauseResolve); + : layerPausingMcpRegistry(input.pauseMcpRegistry); const layerProviderEventIngestorTest = ProviderEventIngestor.layer.pipe( Layer.provide( Layer.mergeAll( @@ -538,6 +538,7 @@ function layerTest(input: { layerTestStores, ...(input.serverSettingsLayer === undefined ? [] : [input.serverSettingsLayer]), ...(input.projectServiceLayer === undefined ? [] : [input.projectServiceLayer]), + ...(input.pluginToolsLayer === undefined ? [] : [input.pluginToolsLayer]), ), ), ), @@ -563,23 +564,41 @@ const layerTestMcpRegistry = Layer.effect( Layer.provide(NodeServices.layer), ); -const layerPausingMcpRegistry = (pause: { +interface PauseMcpRegistry { + readonly step: "resolve" | "setPluginToolGrants"; + readonly outcome: "hang" | "crash"; readonly armed: Ref.Ref; readonly paused: Deferred.Deferred; -}) => +} + +const layerPausingMcpRegistry = (pause: PauseMcpRegistry) => Layer.effect( McpSessionRegistry.McpSessionRegistry, Effect.gen(function* () { const delegate = yield* McpSessionRegistry.McpSessionRegistry; + const holdIfArmed = (step: PauseMcpRegistry["step"], run: Effect.Effect) => + step !== pause.step + ? run + : Ref.getAndSet(pause.armed, false).pipe( + Effect.flatMap((armed) => + armed + ? Deferred.succeed(pause.paused, undefined).pipe( + Effect.andThen( + pause.outcome === "hang" + ? Effect.never + : Effect.die(new Error(`${step} crashed`)), + ), + ) + : run, + ), + ); return McpSessionRegistry.McpSessionRegistry.of({ ...delegate, - resolve: (rawToken) => - Ref.getAndSet(pause.armed, false).pipe( - Effect.flatMap((armed) => - armed - ? Deferred.succeed(pause.paused, undefined).pipe(Effect.andThen(Effect.never)) - : delegate.resolve(rawToken), - ), + resolve: (rawToken) => holdIfArmed("resolve", delegate.resolve(rawToken)), + setPluginToolGrants: (providerSessionId, grants) => + holdIfArmed( + "setPluginToolGrants", + delegate.setPluginToolGrants(providerSessionId, grants), ), }); }), @@ -1913,6 +1932,49 @@ it.effect( }), ); +it.effect("ProviderSessionManagerV2 snapshots the enabled tool plugins into the credential", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + const enabled = yield* Ref.make([ + { installationId: PluginInstallationId.make("installation-1"), generation: 1 }, + ]); + const pluginToolsLayer = Layer.mock(PluginTools.PluginTools)({ grants: Ref.get(enabled) }); + yield* Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-plugin-tools"); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const openAndResolve = Effect.gen(function* () { + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + const config = (yield* Ref.get(mcpConfigs)).at(-1); + const token = config!.authorizationHeader.replace(/^Bearer\s+/, ""); + const resolved = yield* registry.resolve(token); + yield* manager.close(providerSessionId); + return resolved?.pluginToolGrants; + }); + + assert.deepEqual(yield* openAndResolve, yield* Ref.get(enabled)); + // A plugin enabled later reaches the next session, not the one already prepared. + yield* Ref.set(enabled, []); + assert.deepEqual(yield* openAndResolve, []); + }).pipe( + Effect.provide(layerTest({ state, idleTimeoutMs: 1_000, mcpConfigs, pluginToolsLayer })), + ); + }), +); + it.effect("ProviderSessionManagerV2 honors a project browser-access opt-out", () => Effect.gen(function* () { const captured = yield* runBrowserAccessScenario({ @@ -2172,9 +2234,14 @@ it.effect( }), ); -it.effect( - "ProviderSessionManagerV2 revokes a reused credential after a resume stopped while checking it", - () => +it.effect.each([ + ["stopped while checking it", "resolve", "hang"], + ["stopped while updating its plugin tool grants", "setPluginToolGrants", "hang"], + ["crashed while checking it", "resolve", "crash"], + ["crashed while updating its plugin tool grants", "setPluginToolGrants", "crash"], +] as const)( + "ProviderSessionManagerV2 revokes a reused credential after a resume %s", + ([, step, outcome]) => Effect.gen(function* () { const state = yield* Ref.make(emptyState); const armed = yield* Ref.make(false); @@ -2186,8 +2253,8 @@ it.effect( const registry = yield* McpSessionRegistry.McpSessionRegistry; const mcpSessions = yield* McpProviderSessions.McpProviderSessions; const now = yield* DateTime.now; - const owner = ThreadId.make("thread-provider-session-manager-resolve-stop-owner"); - const threadId = ThreadId.make("thread-provider-session-manager-resolve-stop"); + const owner = ThreadId.make(`thread-provider-session-manager-${step}-${outcome}-owner`); + const threadId = ThreadId.make(`thread-provider-session-manager-${step}-${outcome}`); const providerSessionId = idAllocator.derive.providerSession({ providerInstanceId: modelSelection.instanceId, }); @@ -2210,7 +2277,7 @@ it.effect( threadId, providerSessionId, now, - nativeThreadId: "native-resolve-stop", + nativeThreadId: `native-${step}-${outcome}`, }), }); // The thread gets a credential, then detaches and keeps it for a re-attach. @@ -2220,18 +2287,23 @@ it.effect( assert.isDefined(config); const token = config!.authorizationHeader.replace(/^Bearer\s+/, ""); - // A re-attach is stopped while it checks whether that credential is reusable. + // A re-attach is stopped, or crashes, while it reuses that credential. yield* Ref.set(armed, true); const stopped = yield* resume.pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(paused); - yield* Fiber.interrupt(stopped); + // A crashed attach is logged and rolled back rather than failing the resume. + yield* outcome === "hang" ? Fiber.interrupt(stopped) : Fiber.await(stopped); // Nothing holds the credential now, so a terminal release revokes it. yield* manager.release({ providerSessionId, reason: "manual_shutdown" }); assert.isUndefined(yield* registry.resolve(token)); }).pipe( Effect.provide( - layerTest({ state, idleTimeoutMs: 60_000, pauseResolve: { armed, paused } }), + layerTest({ + state, + idleTimeoutMs: 60_000, + pauseMcpRegistry: { step, outcome, armed, paused }, + }), ), ); }), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 0dec68dc1ec9..fbf5e8638315 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -44,6 +44,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as ServerSettings from "../serverSettings.ts"; import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; +import * as PluginTools from "../plugins/PluginTools.ts"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderEventIngestor from "./ProviderEventIngestor.ts"; @@ -355,6 +356,8 @@ export const layerWithOptions = ( */ const serverSettings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService); const projectService = yield* Effect.serviceOption(ProjectService.ProjectService); + // Optional for the same reason; without it a session gets no plugin tools. + const pluginTools = yield* Effect.serviceOption(PluginTools.PluginTools); const eventSink = yield* EventSink.EventSinkV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; const providerEventIngestor = yield* ProviderEventIngestor.ProviderEventIngestorV2; @@ -495,6 +498,10 @@ export const layerWithOptions = ( >(["orchestration", "worktree", "pull-requests"]); if (browserToolsAvailable) capabilities.add("preview"); if (deviceToolsAvailable) capabilities.add("device"); + // Taken at every preparation; each call still checks the plugin is enabled now. + const pluginToolGrants = Option.isSome(pluginTools) + ? yield* pluginTools.value.grants + : []; const existing = yield* mcpSessions.read(threadId); if (existing !== undefined) { // Reserve before the async resolve so a release cannot @@ -502,25 +509,35 @@ export const layerWithOptions = ( reserveMcpCredential(threadId, existing.providerSessionId); const rawToken = existing.authorizationHeader.replace(/^Bearer\s+/, ""); // The caller only learns of the reservation once this returns, - // so a stop while resolving must drop it here. - const resolved = yield* mcpSessionRegistry - .resolve(rawToken) - .pipe( - Effect.onInterrupt(() => - Effect.sync(() => - dropMcpCredentialReservation(threadId, existing.providerSessionId), - ), - ), + // so a stop or crash while resolving or updating grants must drop it here. + const reused = yield* Effect.gen(function* () { + const resolved = yield* mcpSessionRegistry.resolve(rawToken); + if ( + resolved === undefined || + resolved.thread.threadId !== threadId || + resolved.thread.providerInstanceId !== providerInstanceId || + // A flipped browser-access setting must not survive through + // credential reuse: rotate so the new scope reflects it. + resolved.capabilities.has("preview") !== browserToolsAvailable || + resolved.capabilities.has("device") !== deviceToolsAvailable + ) { + return false; + } + // The provider keeps this credential, and the plugin tools are fixed meta-tools, + // so new grants apply to it without rotating the token. + yield* mcpSessionRegistry.setPluginToolGrants( + existing.providerSessionId, + pluginToolGrants, ); - if ( - resolved !== undefined && - resolved.thread.threadId === threadId && - resolved.thread.providerInstanceId === providerInstanceId && - // A flipped browser-access setting must not survive through - // credential reuse: rotate so the new scope reflects it. - resolved.capabilities.has("preview") === browserToolsAvailable && - resolved.capabilities.has("device") === deviceToolsAvailable - ) { + return true; + }).pipe( + Effect.onError(() => + Effect.sync(() => + dropMcpCredentialReservation(threadId, existing.providerSessionId), + ), + ), + ); + if (reused) { return { mcpCredentialId: existing.providerSessionId, issued: false }; } dropMcpCredentialReservation(threadId, existing.providerSessionId); @@ -531,6 +548,7 @@ export const layerWithOptions = ( providerInstanceId, browserToolsAvailable, capabilities, + pluginToolGrants, }); yield* mcpSessions.set(credential.config); reserveMcpCredential(threadId, credential.config.providerSessionId); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index a316ce0ccd62..0d65e4f75f20 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -49,6 +49,7 @@ import { makeProviderFailureTurnItem, } from "@t3tools/provider-core/server/failure"; import * as RunFinalizationService from "./RunFinalizationService.ts"; +import { checkpointCaptureEffectId } from "./RunFinalized.ts"; export interface ProviderEventRoutingState { readonly ownedThreadIds: ReadonlySet; @@ -675,7 +676,7 @@ export const layer: Layer.Layer< input.terminal.status === "cancelled" ? [ { - id: `effect:checkpoint.capture:${input.run.id}`, + id: checkpointCaptureEffectId(input.run.id), commandId: checkpointCaptureCommandId, threadId: input.run.threadId, request: { diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts index 9e43c7ddd30a..9c2aea2838ad 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.test.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.test.ts @@ -1,8 +1,10 @@ import { assert, it, vi } from "@effect/vitest"; import { CheckpointScopeId, + ProviderInstanceId, RunId, ThreadId, + type OrchestrationV2Run, type OrchestrationV2ThreadShell, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -12,15 +14,18 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; import * as CheckpointCapture from "./CheckpointCaptureService.ts"; +import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as RunFinalization from "./RunFinalizationService.ts"; -it.effect("refreshes workspace after checkpoint capture without reading history", () => { +it.effect("refreshes workspace after checkpoint capture, then records finalization", () => { const threadId = ThreadId.make("thread_finalize"); const runId = RunId.make("run_finalize"); const scopeId = CheckpointScopeId.make("scope_finalize"); - const capture = vi.fn(() => Effect.void); - const refresh = vi.fn(() => Effect.void); + const steps: Array = []; + const capture = vi.fn(() => Effect.sync(() => steps.push("capture"))); + const refresh = vi.fn(() => Effect.sync(() => steps.push("refresh"))); + const write = vi.fn(() => Effect.sync(() => (steps.push("record"), []))); const checkpointContext = { runs: [], checkpointScopes: [{ id: scopeId, runId, kind: "root_run" as const, cwd: "/repo" }], @@ -34,6 +39,25 @@ it.effect("refreshes workspace after checkpoint capture without reading history" getThreadProjection: () => Effect.die("workspace refresh must not load transcript history"), getCheckpointContext: () => Effect.succeed(checkpointContext), + getCheckpointCaptureContext: () => + Effect.succeed({ + run: { + id: runId, + threadId, + rootNodeId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + status: "completed", + checkpointId: null, + } as OrchestrationV2Run, + rootNode: undefined, + scope: undefined, + providerThread: undefined, + readyCheckpointOrdinals: [], + }), + }), + Layer.mock(EventSink.EventSinkV2)({ + write, + hasRunFinalization: () => Effect.succeed(false), }), Layer.succeed(RunFinalization.RunFinalizationObserver, { refresh, @@ -47,6 +71,7 @@ it.effect("refreshes workspace after checkpoint capture without reading history" yield* service.finalize({ threadId, runId, scopeId }); assert.equal(capture.mock.calls.length, 1); assert.deepEqual(refresh.mock.calls[0], [{ cwd: "/repo", threadId, runId }]); + assert.deepEqual(steps, ["capture", "refresh", "record"]); }).pipe(Effect.provide(layer)); }); diff --git a/apps/server/src/orchestration-v2/RunFinalizationService.ts b/apps/server/src/orchestration-v2/RunFinalizationService.ts index d56ae20dc9ef..5e36c81f61a7 100644 --- a/apps/server/src/orchestration-v2/RunFinalizationService.ts +++ b/apps/server/src/orchestration-v2/RunFinalizationService.ts @@ -1,5 +1,14 @@ -import { CheckpointScopeId, ProjectId, RunId, ThreadId } from "@t3tools/contracts"; +import { + CheckpointScopeId, + CommandId, + OrchestrationV2RunFinalizationOperation, + ProjectId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -8,7 +17,9 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; import * as WorkspaceEntries from "../workspace/WorkspaceEntries.ts"; import * as CheckpointCapture from "./CheckpointCaptureService.ts"; +import * as EventSink from "./EventSink.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; +import * as RunFinalized from "./RunFinalized.ts"; export class RunFinalizationError extends Schema.TaggedError()( "RunFinalizationError", @@ -16,11 +27,13 @@ export class RunFinalizationError extends Schema.TaggedError()( "RunFinalizationRefreshError", { cwd: Schema.String, cause: Schema.Defect() }, @@ -40,49 +53,120 @@ export class RunFinalizationObserver extends Context.Reference<{ export class RunFinalizationService extends Context.Service< RunFinalizationService, { + /** + * Captures the run's checkpoint, refreshes its workspace, then records + * `run.finalized`. At-least-once: every step is safe to repeat, and a run + * that already recorded its finalization is left as recorded. Every + * failure that is not an interruption names the step that failed. + */ readonly finalize: (input: { readonly threadId: ThreadId; readonly runId: RunId; readonly scopeId: CheckpointScopeId; }) => Effect.Effect; + /** + * Gives up on a run's finalization after the worker's last attempt failed + * at `operation`: fails the claimed capture effect and records + * `run.finalization-failed` in one transaction. Returns false, recording + * nothing, when `workerId` no longer holds the effect's lease. + */ + readonly abandon: (input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly scopeId: CheckpointScopeId; + readonly operation: OrchestrationV2RunFinalizationOperation; + readonly effectId: string; + readonly workerId: string; + readonly error: string; + }) => Effect.Effect; } >()("t3/orchestration-v2/RunFinalizationService") {} const make = Effect.gen(function* () { const checkpointCapture = yield* CheckpointCapture.CheckpointCaptureServiceV2; const projections = yield* ProjectionStore.ProjectionStoreV2; + const eventSink = yield* EventSink.EventSinkV2; const observer = yield* RunFinalizationObserver; - const finalize: RunFinalizationService["Service"]["finalize"] = Effect.fn( - "RunFinalizationService.finalize", - )(function* (input) { - yield* checkpointCapture - .execute(input) - .pipe( - Effect.mapError( - (cause) => new RunFinalizationError({ ...input, operation: "capture-checkpoint", cause }), - ), - ); - const projection = yield* projections - .getCheckpointContext(input.threadId) - .pipe( - Effect.mapError( - (cause) => new RunFinalizationError({ ...input, operation: "refresh-workspace", cause }), - ), - ); - const cwd = projection.checkpointScopes.find((scope) => scope.id === input.scopeId)?.cwd; - if (cwd !== undefined) { - yield* observer - .refresh({ cwd, threadId: input.threadId, runId: input.runId }) - .pipe( - Effect.mapError( - (cause) => - new RunFinalizationError({ ...input, operation: "refresh-workspace", cause }), - ), - ); - } + const finalize = Effect.fn("RunFinalizationService.finalize")(function* (input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly scopeId: CheckpointScopeId; + }) { + // Unexpected defects fail the step too; an interruption is replayed. + const failStep = + (operation: OrchestrationV2RunFinalizationOperation) => (cause: Cause.Cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.fail( + new RunFinalizationError({ ...input, operation, cause: Cause.squash(cause) }), + ); + + // A replay after a crash honours the disposition already recorded. + if ( + yield* eventSink + .hasRunFinalization(input.runId) + .pipe(Effect.catchCause(failStep("capture-checkpoint"))) + ) + return; + yield* checkpointCapture.execute(input).pipe(Effect.catchCause(failStep("capture-checkpoint"))); + yield* Effect.gen(function* () { + const projection = yield* projections.getCheckpointContext(input.threadId); + const cwd = projection.checkpointScopes.find((scope) => scope.id === input.scopeId)?.cwd; + if (cwd !== undefined) { + yield* observer.refresh({ cwd, threadId: input.threadId, runId: input.runId }); + } + }).pipe(Effect.catchCause(failStep("refresh-workspace"))); + yield* Effect.gen(function* () { + const { run } = yield* projections.getCheckpointCaptureContext(input.threadId, input); + // A rolled-back run was discarded before its capture ran. + const outcome = run === undefined ? null : RunFinalized.runFinalizedOutcome(run.status); + if (run === undefined || outcome === null) return; + // EventSink drops the event when this run already recorded its finalization. + yield* eventSink.write({ + commandId: CommandId.make(`command:effect:run.finalized:${run.id}`), + events: [ + RunFinalized.makeRunFinalizedEvent({ run, outcome, occurredAt: yield* DateTime.now }), + ], + }); + }).pipe(Effect.catchCause(failStep("record-finalized"))); }); - return RunFinalizationService.of({ finalize }); + + const abandon: RunFinalizationService["Service"]["abandon"] = (input) => + Effect.gen(function* () { + const { run } = yield* projections.getCheckpointCaptureContext(input.threadId, input); + const { committed } = yield* eventSink.failEffect({ + commandId: CommandId.make(`command:effect:run.finalization-failed:${input.runId}`), + effectId: input.effectId, + workerId: input.workerId, + error: input.error, + // A rolled-back run was discarded; there is nothing to report. + events: + run === undefined || run.status === "rolled_back" + ? [] + : [ + RunFinalized.makeRunFinalizationFailedEvent({ + run, + operation: input.operation, + occurredAt: yield* DateTime.now, + }), + ], + }); + return committed; + }).pipe( + Effect.mapError( + (cause) => + new RunFinalizationError({ + threadId: input.threadId, + runId: input.runId, + scopeId: input.scopeId, + operation: input.operation, + cause, + }), + ), + ); + + return RunFinalizationService.of({ finalize, abandon }); }); export const layer = Layer.effect(RunFinalizationService, make); diff --git a/apps/server/src/orchestration-v2/RunFinalized.test.ts b/apps/server/src/orchestration-v2/RunFinalized.test.ts new file mode 100644 index 000000000000..de0fed312be3 --- /dev/null +++ b/apps/server/src/orchestration-v2/RunFinalized.test.ts @@ -0,0 +1,924 @@ +import { assert, it } from "@effect/vitest"; +import { + CheckpointId, + CheckpointScopeId, + CommandId, + EventId, + MessageId, + NodeId, + type OrchestrationV2DomainEvent, + type OrchestrationV2Run, + type OrchestrationV2StoredEvent, + ProjectId, + ProviderInstanceId, + ProviderThreadId, + RunAttemptId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +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 * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as CheckpointCapture from "./CheckpointCaptureService.ts"; +import * as CheckpointRollbackService from "./CheckpointRollbackService.ts"; +import * as EffectOutbox from "./EffectOutbox.ts"; +import * as EffectWorker from "./EffectWorker.ts"; +import * as EventSink from "./EventSink.ts"; +import * as EventStore from "./EventStore.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProviderRuntimeRecovery from "./ProviderRuntimeRecoveryService.ts"; +import * as ProviderSessionManager from "./ProviderSessionManager.ts"; +import * as ProviderTurnControlService from "./ProviderTurnControlService.ts"; +import * as ProviderTurnStartService from "./ProviderTurnStartService.ts"; +import * as RunFinalization from "./RunFinalizationService.ts"; +import { checkpointCaptureEffectId, makeRunFinalizationFailedEvent } from "./RunFinalized.ts"; +import * as RuntimeRequestService from "./RuntimeRequestService.ts"; +import * as ThreadManagementService from "./ThreadManagementService.ts"; +import * as ThreadTitleRegenerationService from "./ThreadTitleRegenerationService.ts"; + +const threadId = ThreadId.make("thread:run-finalized"); +const runId = RunId.make("run:run-finalized"); +const scopeId = CheckpointScopeId.make("scope:run-finalized"); +const rootNodeId = NodeId.make("node:run-finalized-root"); +const providerThreadId = ProviderThreadId.make("provider-thread:run-finalized"); +const providerInstanceId = ProviderInstanceId.make("codex"); +const checkpointId = CheckpointId.make("checkpoint:run-finalized"); +const attemptId = RunAttemptId.make("attempt:run-finalized"); + +const maxAttempts = 2; +/** The worker's backoff after a capture's first failed attempt. */ +const firstRetryDelay = "100 millis"; +/** The worker's backoff after a capture's last attempt, when its failure record cannot commit. */ +const lastRetryDelay = "200 millis"; + +/** + * Real stores, outbox, worker, finalization and restart recovery. Checkpoint + * capture and workspace refresh are stubs. `finalizationSink` can inject + * faults into the event sink the finalization service writes through. + */ +const makeLayer = ( + capture: Effect.Effect< + void, + CheckpointCapture.CheckpointCaptureExecutionError, + EventSink.EventSinkV2 + >, + refresh: () => Effect.Effect = () => + Effect.void, + finalizationSink: (sink: EventSink.EventSinkV2Shape) => EventSink.EventSinkV2Shape = (sink) => + sink, +) => { + const stores = Layer.mergeAll( + SqlitePersistence.layerMemory, + EventStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)), + ProjectionStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemory)), + ); + const eventSink = EventSink.layer.pipe(Layer.provide(stores)); + const outbox = EffectOutbox.layer.pipe(Layer.provide(SqlitePersistence.layerMemory)); + const finalization = RunFinalization.layer.pipe( + Layer.provide( + Layer.mergeAll( + stores, + Layer.effect( + EventSink.EventSinkV2, + EventSink.EventSinkV2.pipe(Effect.map(finalizationSink)), + ).pipe(Layer.provide(eventSink)), + Layer.effect( + CheckpointCapture.CheckpointCaptureServiceV2, + Effect.gen(function* () { + const sink = yield* EventSink.EventSinkV2; + return { execute: () => Effect.provideService(capture, EventSink.EventSinkV2, sink) }; + }), + ).pipe(Layer.provide(eventSink)), + Layer.succeed(RunFinalization.RunFinalizationObserver, { + refresh, + refreshAfterTurn: () => Effect.void, + }), + ), + ), + ); + const executor = EffectWorker.layerExecutor.pipe( + Layer.provide( + Layer.mergeAll( + finalization, + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({}), + Layer.mock(CheckpointRollbackService.CheckpointRollbackServiceV2)({}), + Layer.mock(ProviderTurnControlService.ProviderTurnControlServiceV2)({}), + Layer.mock(ProviderTurnStartService.ProviderTurnStartServiceV2)({}), + Layer.mock(RuntimeRequestService.RuntimeRequestServiceV2)({}), + Layer.mock(ThreadTitleRegenerationService.ThreadTitleRegenerationService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + ServerSettings.layerTest(), + ), + ), + ); + const worker = EffectWorker.layerWithOptions({ + workerId: "worker:run-finalized", + maxAttempts, + }).pipe(Layer.provide(Layer.merge(outbox, executor))); + const recovery = ProviderRuntimeRecovery.layer.pipe( + Layer.provide( + Layer.mergeAll( + stores, + eventSink, + outbox, + worker, + IdAllocator.layer, + ServerSettings.layerTest(), + ), + ), + ); + return Layer.mergeAll(stores, eventSink, outbox, finalization, worker, recovery); +}; + +const makeRun = ( + now: DateTime.Utc, + status: OrchestrationV2Run["status"], + overrides: Partial = {}, +): OrchestrationV2Run => ({ + id: runId, + threadId, + ordinal: 1, + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + providerThreadId, + userMessageId: MessageId.make("message:run-finalized"), + rootNodeId, + activeAttemptId: null, + status, + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + ...overrides, +}); + +let eventCounter = 0; +const runEvent = ( + type: "run.created" | "run.updated", + run: OrchestrationV2Run, + occurredAt: DateTime.Utc, +): OrchestrationV2DomainEvent => ({ + id: EventId.make(`event:run-finalized-test:${(eventCounter += 1)}`), + type, + threadId, + runId: run.id, + providerInstanceId, + occurredAt, + payload: run, +}); + +const captureEffect = { + id: checkpointCaptureEffectId(runId), + commandId: CommandId.make(`command:effect:checkpoint.capture:${runId}`), + threadId, + request: { type: "checkpoint.capture" as const, runId, scopeId }, +}; + +const seedThread = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:run-finalized-test:thread"), + type: "thread.created", + threadId, + providerInstanceId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("project:run-finalized"), + title: "Run finalized", + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }, + { + id: EventId.make("event:run-finalized-test:scope"), + type: "checkpoint-scope.created", + threadId, + occurredAt: now, + payload: { + id: scopeId, + threadId, + runId, + nodeId: rootNodeId, + parentScopeId: null, + providerThreadId, + kind: "root_run", + ordinalWithinParent: 0, + advancesAppRunCount: true, + cwd: "/repo", + createdAt: now, + }, + }, + runEvent("run.created", makeRun(now, "running"), now), + ], + }); +}); + +const storedEvents = Effect.gen(function* () { + const eventStore = yield* EventStore.EventStoreV2; + return Array.from(yield* eventStore.read({ threadId }).pipe(Stream.runCollect)); +}); + +const finalizedEvents = (events: ReadonlyArray) => + events.flatMap((stored) => (stored.event.type === "run.finalized" ? [stored] : [])); + +/** Every finalization record for the thread, success or failure. */ +const finalizationRecords = storedEvents.pipe( + Effect.map((events) => + events.flatMap((stored) => + stored.event.type === "run.finalized" || stored.event.type === "run.finalization-failed" + ? [{ type: stored.event.type, payload: stored.event.payload }] + : [], + ), + ), +); + +/** Runs every claimable effect on the real worker. */ +const drainWorker = EffectWorker.OrchestrationEffectWorkerV2.pipe( + Effect.flatMap((worker) => worker.drain()), +); + +const captureStatus = EffectOutbox.EffectOutboxV2.pipe( + Effect.flatMap((outbox) => outbox.get(captureEffect.id)), + Effect.map(Option.map((effect) => effect.status)), +); + +const runStatus = ProjectionStore.ProjectionStoreV2.pipe( + Effect.flatMap((projections) => + projections.getCheckpointCaptureContext(threadId, { runId, scopeId }), + ), + Effect.map(({ run }) => run?.status), +); + +/** The startup recovery a restarted server runs before its worker resumes. */ +const restartServer = ProviderRuntimeRecovery.ProviderRuntimeRecoveryService.pipe( + Effect.flatMap((recovery) => recovery.recover), +); + +/** Stands in for CheckpointCaptureService: commits the checkpoint once, like the real one. */ +const commitCapture = (status: "completed" | "interrupted" | "cancelled") => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + yield* eventSink.commitCommand({ + commandId: captureEffect.commandId, + threadId, + commandType: "checkpoint.capture", + acceptedAt: now, + effects: [], + events: [ + runEvent("run.updated", makeRun(now, status, { checkpointId, completedAt: now }), now), + ], + }); + }).pipe(Effect.orDie); + +const failCapture = Effect.fail( + new CheckpointCapture.CheckpointCaptureExecutionError({ + threadId, + runId, + scopeId, + cause: "simulated capture failure", + }), +); + +const failRefresh = () => + Effect.fail( + new RunFinalization.RunFinalizationRefreshError({ + cwd: "/repo", + cause: "simulated refresh failure", + }), + ); + +/** Ends the run waiting on its capture, as RunExecutionService does. */ +const finishProviderTurn = (status: "waiting" | "interrupted" | "cancelled") => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + yield* eventSink.writeWithEffects({ + events: [ + runEvent( + "run.updated", + makeRun(now, status, status === "waiting" ? {} : { completedAt: now }), + now, + ), + ], + effects: [captureEffect], + }); + }); + +it.effect("finalizes a completed run once, after its checkpoint and workspace refresh", () => { + const refreshedAtSequence: Array = []; + let probe: Effect.Effect = Effect.void; + return Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + yield* seedThread; + yield* finishProviderTurn("waiting"); + assert.lengthOf(yield* finalizationRecords, 0); + + probe = eventSink.latestSequence({ threadId }).pipe( + Effect.map((sequence) => { + refreshedAtSequence.push(sequence); + }), + Effect.orDie, + ); + yield* drainWorker; + + const events = yield* storedEvents; + const finalized = finalizedEvents(events); + assert.lengthOf(finalized, 1); + const [stored] = finalized; + assert.deepEqual(stored?.event.payload, { runId, outcome: "completed", checkpointId }); + assert.equal(stored?.event.id, EventId.make(`event:run-finalized:${runId}`)); + const completedAt = events.find( + (event) => event.event.type === "run.updated" && event.event.payload.status === "completed", + )?.sequence; + assert.isDefined(completedAt); + assert.deepEqual(refreshedAtSequence, [completedAt!]); + assert.isTrue(stored!.sequence > completedAt!); + assert.deepEqual(yield* captureStatus, Option.some("succeeded")); + }).pipe(Effect.provide(makeLayer(commitCapture("completed"), () => probe))); +}); + +it.effect("recording the milestone leaves thread activity where the run left it", () => + Effect.gen(function* () { + const projections = yield* ProjectionStore.ProjectionStoreV2; + yield* seedThread; + yield* finishProviderTurn("waiting"); + yield* drainWorker; + + const events = yield* storedEvents; + const completed = events.find( + (event) => event.event.type === "run.updated" && event.event.payload.status === "completed", + ); + const [milestone] = finalizedEvents(events); + assert.isDefined(completed); + assert.isDefined(milestone); + // The slow refresh put the milestone a minute after the run's last write. + assert.isTrue(DateTime.isGreaterThan(milestone.event.occurredAt, completed.event.occurredAt)); + const shell = yield* projections.getThreadShell(threadId); + assert.deepEqual(shell?.updatedAt, completed.event.occurredAt); + }).pipe( + Effect.provide(makeLayer(commitCapture("completed"), () => TestClock.adjust("60 seconds"))), + ), +); + +it.effect("a restart after finalizing but before settling records one milestone", () => { + let captures = 0; + return Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const finalization = yield* RunFinalization.RunFinalizationService; + yield* seedThread; + yield* finishProviderTurn("waiting"); + // A worker finalized, then its process died before settling the effect. + yield* outbox.claimNext({ workerId: "worker:crashed", leaseDurationMs: 60_000 }); + yield* finalization.finalize({ threadId, runId, scopeId }); + assert.lengthOf(finalizedEvents(yield* storedEvents), 1); + + // Restart requeues the capture; the worker settles it without capturing again. + const summary = yield* restartServer; + assert.equal(summary.requeuedEffects, 1); + yield* drainWorker; + assert.deepEqual(yield* captureStatus, Option.some("succeeded")); + assert.equal(captures, 1); + // A later write of the finished run adds nothing either. + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + runEvent("run.updated", makeRun(now, "completed", { checkpointId, completedAt: now }), now), + ], + }); + assert.deepEqual(yield* finalizationRecords, [ + { type: "run.finalized", payload: { runId, outcome: "completed", checkpointId } }, + ]); + }).pipe( + Effect.provide( + makeLayer( + Effect.suspend(() => { + captures += 1; + return commitCapture("completed"); + }), + ), + ), + ); +}); + +it.effect.each(["interrupted", "cancelled"] as const)( + "a stopped run with a capture finalizes as %s after the capture", + (status) => + Effect.gen(function* () { + yield* seedThread; + yield* finishProviderTurn(status); + assert.lengthOf(yield* finalizationRecords, 0); + yield* drainWorker; + assert.deepEqual(yield* finalizationRecords, [ + { type: "run.finalized", payload: { runId, outcome: status, checkpointId } }, + ]); + }).pipe(Effect.provide(makeLayer(commitCapture(status)))), +); + +/** Ends a running attempt through the ownership guard, as RunExecutionService does. */ +const finishGuardedTurn = ( + status: "interrupted" | "cancelled", + guard: { readonly activeAttemptId: RunAttemptId; readonly expectedStatus: "running" | "waiting" }, +) => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + runEvent("run.updated", makeRun(now, "running", { activeAttemptId: attemptId }), now), + ], + }); + return yield* eventSink.writeIfRunCurrent({ + threadId, + runId, + ...guard, + events: [ + runEvent( + "run.updated", + makeRun(now, status, { activeAttemptId: attemptId, completedAt: now }), + now, + ), + ], + effects: [captureEffect], + }); + }); + +it.effect.each(["interrupted", "cancelled"] as const)( + "a guarded stop that queues a capture finalizes as %s after the capture", + (status) => + Effect.gen(function* () { + yield* seedThread; + const result = yield* finishGuardedTurn(status, { + activeAttemptId: attemptId, + expectedStatus: "running", + }); + assert.isTrue(result.committed); + assert.lengthOf(yield* finalizationRecords, 0); + yield* drainWorker; + assert.deepEqual(yield* finalizationRecords, [ + { type: "run.finalized", payload: { runId, outcome: status, checkpointId } }, + ]); + }).pipe(Effect.provide(makeLayer(commitCapture(status)))), +); + +it.effect("a guarded stop that no longer owns the run commits and queues nothing", () => + Effect.gen(function* () { + yield* seedThread; + const result = yield* finishGuardedTurn("interrupted", { + activeAttemptId: RunAttemptId.make("attempt:run-finalized-stale"), + expectedStatus: "running", + }); + assert.isFalse(result.committed); + assert.deepEqual(yield* captureStatus, Option.none()); + assert.equal(yield* drainWorker, 0); + assert.equal(yield* runStatus, "running"); + assert.lengthOf(yield* finalizationRecords, 0); + }).pipe(Effect.provide(makeLayer(commitCapture("interrupted")))), +); + +it.effect("a run that ends without a capture finalizes in its terminal commit", () => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + yield* seedThread; + const now = yield* DateTime.now; + const failed = makeRun(now, "failed", { completedAt: now }); + const [terminal, milestone] = yield* eventSink.write({ + events: [runEvent("run.updated", failed, now)], + }); + assert.equal(terminal?.event.type, "run.updated"); + assert.equal(milestone?.event.type, "run.finalized"); + assert.equal(milestone?.commandId, terminal?.commandId); + // A repeated terminal write and a later update to the finished run add nothing. + yield* eventSink.write({ events: [runEvent("run.updated", failed, now)] }); + yield* eventSink.write({ + events: [runEvent("run.updated", { ...failed, status: "cancelled" }, now)], + }); + assert.deepEqual( + finalizedEvents(yield* storedEvents).map((stored) => stored.event.payload), + [{ runId, outcome: "failed", checkpointId: null }], + ); + }).pipe(Effect.provide(makeLayer(Effect.void))), +); + +it.effect("unfinished, discarded, and previously finished runs never finalize", () => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const finalization = yield* RunFinalization.RunFinalizationService; + yield* seedThread; + const now = yield* DateTime.now; + // Waiting on a capture that never ran. + yield* finishProviderTurn("waiting"); + assert.lengthOf(yield* finalizationRecords, 0); + // Rolled back before the capture ran: discarded, and the capture skips it. + yield* eventSink.write({ + events: [runEvent("run.updated", makeRun(now, "rolled_back", { completedAt: now }), now)], + }); + yield* finalization.finalize({ threadId, runId, scopeId }); + assert.lengthOf(yield* finalizationRecords, 0); + + // A run that finished before this milestone existed has no event. A later + // update to it must not invent one. + const historical = makeRun(now, "completed", { + id: RunId.make("run:run-finalized-historical"), + ordinal: 2, + completedAt: now, + }); + yield* eventSink.write({ events: [runEvent("run.created", historical, now)] }); + yield* eventSink.write({ events: [runEvent("run.updated", historical, now)] }); + assert.lengthOf(yield* finalizationRecords, 0); + }).pipe(Effect.provide(makeLayer(Effect.void))), +); + +it.effect.each(["waiting", "interrupted"] as const)( + "a capture that gives up after a %s turn records the failure, never run.finalized", + (status) => + Effect.gen(function* () { + yield* seedThread; + yield* finishProviderTurn(status); + // The first failure will be retried, so nothing is recorded yet. + yield* drainWorker; + assert.deepEqual(yield* captureStatus, Option.some("pending")); + assert.lengthOf(yield* finalizationRecords, 0); + + yield* TestClock.adjust(firstRetryDelay); + yield* drainWorker; + assert.deepEqual(yield* captureStatus, Option.some("failed")); + const failure = [ + { + type: "run.finalization-failed" as const, + payload: { runId, operation: "capture-checkpoint" as const }, + }, + ]; + assert.deepEqual(yield* finalizationRecords, failure); + + // A restart cancels a run still waiting on the failed capture. That is + // not a finalization either. + yield* restartServer; + assert.equal(yield* runStatus, status === "waiting" ? "cancelled" : "interrupted"); + assert.deepEqual(yield* finalizationRecords, failure); + }).pipe(Effect.provide(makeLayer(failCapture))), +); + +it.effect("a refresh that gives up after the checkpoint commit records the failure", () => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + yield* seedThread; + yield* finishProviderTurn("waiting"); + yield* drainWorker; + yield* TestClock.adjust(firstRetryDelay); + yield* drainWorker; + + assert.deepEqual(yield* captureStatus, Option.some("failed")); + assert.equal(yield* runStatus, "completed"); + const failure = [ + { + type: "run.finalization-failed" as const, + payload: { runId, operation: "refresh-workspace" as const }, + }, + ]; + assert.deepEqual(yield* finalizationRecords, failure); + // Neither a restart nor a later write of the completed run finalizes it. + yield* restartServer; + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + runEvent("run.updated", makeRun(now, "completed", { checkpointId, completedAt: now }), now), + ], + }); + assert.deepEqual(yield* finalizationRecords, failure); + }).pipe(Effect.provide(makeLayer(commitCapture("completed"), failRefresh))), +); + +it.effect("a refresh that fails once and then succeeds records only run.finalized", () => { + let refreshes = 0; + return Effect.gen(function* () { + yield* seedThread; + yield* finishProviderTurn("waiting"); + yield* drainWorker; + yield* TestClock.adjust(firstRetryDelay); + yield* drainWorker; + assert.equal(refreshes, 2); + assert.deepEqual(yield* finalizationRecords, [ + { type: "run.finalized", payload: { runId, outcome: "completed", checkpointId } }, + ]); + }).pipe( + Effect.provide( + makeLayer(commitCapture("completed"), () => + Effect.suspend(() => ((refreshes += 1) === 1 ? failRefresh() : Effect.void)), + ), + ), + ); +}); + +it.effect( + "a capture cancelled outside a command records the failure when restart ends the run", + () => + Effect.gen(function* () { + const outbox = yield* EffectOutbox.EffectOutboxV2; + yield* seedThread; + yield* finishProviderTurn("waiting"); + yield* outbox.cancelUnsettled({ + threadId, + effectTypes: ["checkpoint.capture"], + reason: "test", + }); + assert.deepEqual(yield* captureStatus, Option.some("cancelled")); + assert.lengthOf(yield* finalizationRecords, 0); + yield* restartServer; + assert.equal(yield* runStatus, "cancelled"); + assert.deepEqual(yield* finalizationRecords, [ + { + type: "run.finalization-failed", + payload: { runId, operation: "capture-checkpoint" }, + }, + ]); + }).pipe(Effect.provide(makeLayer(Effect.void))), +); + +it.effect("a command that cancels a capture records the failure in its commit", () => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + yield* seedThread; + yield* finishProviderTurn("interrupted"); + const now = yield* DateTime.now; + const commandId = CommandId.make("command:run-finalized-test:cancel-capture"); + const { storedEvents, receipt } = yield* eventSink.commitCommand({ + commandId, + threadId, + commandType: "test.cancel-capture", + acceptedAt: now, + events: [runEvent("run.updated", makeRun(now, "interrupted", { completedAt: now }), now)], + effects: [], + cancelUnsettledEffects: { effectTypes: ["checkpoint.capture"], reason: "test" }, + }); + const failure = [ + { + type: "run.finalization-failed" as const, + payload: { runId, operation: "capture-checkpoint" as const }, + }, + ]; + assert.deepEqual(yield* captureStatus, Option.some("cancelled")); + assert.deepEqual(yield* finalizationRecords, failure); + const recorded = storedEvents.at(-1); + assert.equal(recorded?.event.type, "run.finalization-failed"); + assert.equal(recorded?.commandId, commandId); + assert.equal(receipt.resultSequence, recorded?.sequence); + + // Nothing runs the cancelled capture, and a restart adds nothing. + assert.equal(yield* drainWorker, 0); + yield* restartServer; + assert.deepEqual(yield* finalizationRecords, failure); + }).pipe(Effect.provide(makeLayer(Effect.die("a cancelled capture must not run")))), +); + +const captureDefect = Effect.die("simulated unexpected checkpoint defect"); +const refreshDefect = () => Effect.die("simulated unexpected refresh defect"); + +it.effect.each([ + { + label: "a capture defect after a waiting turn", + turn: "waiting" as const, + capture: captureDefect, + refresh: undefined, + operation: "capture-checkpoint" as const, + runAfterRestart: "cancelled", + }, + { + label: "a capture defect after an interrupted turn", + turn: "interrupted" as const, + capture: captureDefect, + refresh: undefined, + operation: "capture-checkpoint" as const, + runAfterRestart: "interrupted", + }, + { + label: "a refresh defect after a completed run's checkpoint", + turn: "waiting" as const, + capture: commitCapture("completed"), + refresh: refreshDefect, + operation: "refresh-workspace" as const, + runAfterRestart: "completed", + }, + { + label: "a refresh defect after an interrupted run's checkpoint", + turn: "interrupted" as const, + capture: commitCapture("interrupted"), + refresh: refreshDefect, + operation: "refresh-workspace" as const, + runAfterRestart: "interrupted", + }, +])("$label records the failure when retries run out", (testCase) => + Effect.gen(function* () { + yield* seedThread; + yield* finishProviderTurn(testCase.turn); + yield* drainWorker; + assert.deepEqual(yield* captureStatus, Option.some("pending")); + assert.lengthOf(yield* finalizationRecords, 0); + + yield* TestClock.adjust(firstRetryDelay); + yield* drainWorker; + assert.deepEqual(yield* captureStatus, Option.some("failed")); + const failure = [ + { + type: "run.finalization-failed" as const, + payload: { runId, operation: testCase.operation }, + }, + ]; + assert.deepEqual(yield* finalizationRecords, failure); + + yield* restartServer; + yield* drainWorker; + assert.equal(yield* runStatus, testCase.runAfterRestart); + assert.deepEqual(yield* captureStatus, Option.some("failed")); + assert.deepEqual(yield* finalizationRecords, failure); + }).pipe(Effect.provide(makeLayer(testCase.capture, testCase.refresh))), +); + +it.effect("a failure record that cannot commit keeps the capture recoverable", () => { + let faults = 1; + let captures = 0; + return Effect.gen(function* () { + yield* seedThread; + yield* finishProviderTurn("waiting"); + yield* drainWorker; + yield* TestClock.adjust(firstRetryDelay); + // The last attempt fails and its failure record cannot commit. + const exit = yield* Effect.exit(drainWorker); + assert.isTrue(Exit.isFailure(exit)); + assert.equal(faults, 0); + // The rolled-back commit neither failed the capture nor recorded anything. + assert.deepEqual(yield* captureStatus, Option.some("pending")); + assert.lengthOf(yield* finalizationRecords, 0); + // The capture waits out the backoff instead of running again at once. + yield* drainWorker; + assert.equal(captures, 2); + assert.deepEqual(yield* captureStatus, Option.some("pending")); + + // A restart keeps the work; the next attempt gives up and records it. + yield* restartServer; + yield* TestClock.adjust(lastRetryDelay); + yield* drainWorker; + assert.deepEqual(yield* captureStatus, Option.some("failed")); + assert.deepEqual(yield* finalizationRecords, [ + { + type: "run.finalization-failed", + payload: { runId, operation: "capture-checkpoint" }, + }, + ]); + }).pipe( + Effect.provide( + makeLayer( + Effect.suspend(() => { + captures += 1; + return failCapture; + }), + undefined, + (sink) => ({ + ...sink, + // Also append an event whose id is taken, so the commit fails after + // the capture's terminal status was written inside it. + failEffect: (input) => + Effect.suspend(() => { + if (faults === 0) return sink.failEffect(input); + faults -= 1; + const at = input.events[0]!.occurredAt; + return sink.failEffect({ + ...input, + events: [ + ...input.events, + { + ...runEvent("run.updated", makeRun(at, "waiting"), at), + id: EventId.make("event:run-finalized-test:thread"), + }, + ], + }); + }), + }), + ), + ), + ); +}); + +it.effect("a restart after a failure record but before settling never finalizes again", () => { + let captures = 0; + return Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + yield* seedThread; + yield* finishProviderTurn("waiting"); + // A worker recorded the failure, then its process died before settling + // the capture (the state a non-atomic writer could leave). + yield* outbox.claimNext({ workerId: "worker:crashed", leaseDurationMs: 60_000 }); + const context = yield* ProjectionStore.ProjectionStoreV2.pipe( + Effect.flatMap((projections) => + projections.getCheckpointCaptureContext(threadId, { runId, scopeId }), + ), + ); + yield* eventSink.write({ + events: [ + makeRunFinalizationFailedEvent({ + run: context.run!, + operation: "capture-checkpoint", + occurredAt: yield* DateTime.now, + }), + ], + }); + + const summary = yield* restartServer; + assert.equal(summary.requeuedEffects, 1); + yield* drainWorker; + assert.equal(captures, 0); + assert.deepEqual(yield* captureStatus, Option.some("succeeded")); + assert.deepEqual(yield* finalizationRecords, [ + { + type: "run.finalization-failed", + payload: { runId, operation: "capture-checkpoint" }, + }, + ]); + }).pipe( + Effect.provide( + makeLayer( + Effect.suspend(() => { + captures += 1; + return commitCapture("completed"); + }), + ), + ), + ); +}); + +it.effect("an interrupted capture is replayed, never recorded as a failure", () => { + let captures = 0; + const started = Deferred.makeUnsafe(); + return Effect.gen(function* () { + yield* seedThread; + yield* finishProviderTurn("waiting"); + // Interrupted on every attempt in a live worker: retried, not given up. + yield* drainWorker; + yield* TestClock.adjust(firstRetryDelay); + yield* drainWorker; + yield* TestClock.adjust("30 seconds"); + assert.deepEqual(yield* captureStatus, Option.some("pending")); + assert.lengthOf(yield* finalizationRecords, 0); + + // A shutdown interrupts the worker mid-capture and leaves the claim running. + const shuttingDown = yield* Effect.forkChild(drainWorker); + yield* Deferred.await(started); + yield* Fiber.interrupt(shuttingDown); + assert.deepEqual(yield* captureStatus, Option.some("running")); + assert.lengthOf(yield* finalizationRecords, 0); + + // Restart replays it and the capture now succeeds. + yield* restartServer; + yield* drainWorker; + assert.equal(captures, 4); + assert.deepEqual(yield* finalizationRecords, [ + { type: "run.finalized", payload: { runId, outcome: "completed", checkpointId } }, + ]); + }).pipe( + Effect.provide( + makeLayer( + Effect.suspend(() => { + captures += 1; + return captures <= 2 + ? Effect.interrupt + : captures === 3 + ? Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)) + : commitCapture("completed"); + }), + ), + ), + ); +}); diff --git a/apps/server/src/orchestration-v2/RunFinalized.ts b/apps/server/src/orchestration-v2/RunFinalized.ts new file mode 100644 index 000000000000..c63292032030 --- /dev/null +++ b/apps/server/src/orchestration-v2/RunFinalized.ts @@ -0,0 +1,88 @@ +import { + EventId, + type OrchestrationV2DomainEvent, + type OrchestrationV2Run, + type OrchestrationV2RunFinalizationOperation, + type OrchestrationV2RunFinalizedOutcome, + type RunId, +} from "@t3tools/contracts"; +import type * as DateTime from "effect/DateTime"; + +/** + * The id of a run's one finalization record, `run.finalized` or + * `run.finalization-failed`. The event log's unique event id keeps a run to + * one of them, and consumers can deduplicate deliveries by it. + */ +export const runFinalizedEventId = (runId: RunId) => EventId.make(`event:run-finalized:${runId}`); + +/** + * The checkpoint capture a finished run enqueues. RunFinalizationService + * records `run.finalized` once capture and refresh succeed, or + * `run.finalization-failed` in the same commit that gives up on the capture. + * Cancelling the capture records that failure too. + */ +export const checkpointCaptureEffectId = (runId: RunId) => `effect:checkpoint.capture:${runId}`; + +const SETTLED_RUN_STATUSES: ReadonlySet = new Set([ + "completed", + "failed", + "interrupted", + "cancelled", + "rolled_back", +]); + +/** Whether a persisted run status is final, including a discarded (rolled-back) run. */ +export const isSettledRunStatus = (status: string) => SETTLED_RUN_STATUSES.has(status); + +/** The finalized outcome for a run status, or null when the run is not finished or was discarded. */ +export const runFinalizedOutcome = ( + status: OrchestrationV2Run["status"], +): OrchestrationV2RunFinalizedOutcome | null => + status === "completed" || + status === "failed" || + status === "interrupted" || + status === "cancelled" + ? status + : null; + +/** + * The step a run's finalization stopped at when its capture was abandoned + * without reporting one: before the checkpoint commit or after it. + */ +export const abandonedOperation = ( + run: OrchestrationV2Run, +): OrchestrationV2RunFinalizationOperation => + run.checkpointId === null ? "capture-checkpoint" : "refresh-workspace"; + +const recordEnvelope = (run: OrchestrationV2Run, occurredAt: DateTime.Utc) => ({ + id: runFinalizedEventId(run.id), + threadId: run.threadId, + runId: run.id, + ...(run.rootNodeId === null ? {} : { nodeId: run.rootNodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt, +}); + +export const makeRunFinalizedEvent = (input: { + readonly run: OrchestrationV2Run; + readonly outcome: OrchestrationV2RunFinalizedOutcome; + readonly occurredAt: DateTime.Utc; +}): Extract => ({ + ...recordEnvelope(input.run, input.occurredAt), + type: "run.finalized", + payload: { + runId: input.run.id, + outcome: input.outcome, + checkpointId: input.run.checkpointId, + }, +}); + +export const makeRunFinalizationFailedEvent = (input: { + readonly run: OrchestrationV2Run; + readonly operation: OrchestrationV2RunFinalizationOperation; + readonly occurredAt: DateTime.Utc; +}): Extract => ({ + ...recordEnvelope(input.run, input.occurredAt), + type: "run.finalization-failed", + payload: { runId: input.run.id, operation: input.operation }, +}); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 2442a4fa010c..4de558749a9a 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -198,7 +198,13 @@ const layerCheckpointCaptureServiceProvided = CheckpointCaptureService.layer.pip ), ); const layerRunFinalizationServiceProvided = RunFinalizationService.layer.pipe( - Layer.provide(Layer.merge(layerCheckpointCaptureServiceProvided, ProjectionStore.layer)), + Layer.provide( + Layer.mergeAll( + layerCheckpointCaptureServiceProvided, + layerEventSinkProvided, + ProjectionStore.layer, + ), + ), ); const layerOrchestratorProvided = Orchestrator.layer.pipe( diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 3d8ef589050e..88e67ecd55c7 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -208,8 +208,18 @@ function scenarioCommands(scenario: OrchestratorV2Scenario): ReadonlyArray - projection.runtimeRequests.find((request) => request.status === "pending"); + projection.runtimeRequests.find( + (request) => + request.status === "pending" && + projection.turnItems.some( + (item) => + (item.type === "approval_request" || item.type === "user_input_request") && + item.requestId === request.id, + ), + ); const hasActiveRun = (projection: OrchestrationV2ThreadProjection) => projection.runs.some((run) => diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 27c1cc28ae5a..8c45c2a7b65b 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -426,7 +426,9 @@ export function layerWithRegistry( ), ); const layerRunFinalizationServiceProvided = RunFinalizationService.layer.pipe( - Layer.provide(Layer.merge(layerCheckpointCaptureServiceProvided, layerStores)), + Layer.provide( + Layer.mergeAll(layerCheckpointCaptureServiceProvided, layerEventSinkProvided, layerStores), + ), ); const layerThreadTitleRegenerationTest = Layer.succeed( ThreadTitleRegenerationService.ThreadTitleRegenerationService, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 35c1a3fdce4b..1165722ddabf 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -74,6 +74,9 @@ import Migration0057 from "./Migrations/057_ScheduledTaskWebhooks.ts"; import Migration0058 from "./Migrations/058_WebhookRelayDeliveries.ts"; import Migration0059 from "./Migrations/059_McpAppModelContext.ts"; import Migration0060 from "./Migrations/060_ThreadSnapshotWindowIndexes.ts"; +import Migration0061 from "./Migrations/061_PluginInstallations.ts"; +import Migration0062 from "./Migrations/062_PluginEventCursors.ts"; +import Migration0063 from "./Migrations/063_PluginSettings.ts"; /** * Migration loader with all migrations defined inline. @@ -148,6 +151,9 @@ export const migrationEntries = [ [58, "WebhookRelayDeliveries", Migration0058], [59, "McpAppModelContext", Migration0059], [60, "ThreadSnapshotWindowIndexes", Migration0060], + [61, "PluginInstallations", Migration0061], + [62, "PluginEventCursors", Migration0062], + [63, "PluginSettings", Migration0063], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index 5e45ed05d302..bb6d1ef5d693 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 60 }, (_, index) => index + 1), + Array.from({ length: 63 }, (_, index) => index + 1), ); }), ); @@ -32,6 +32,9 @@ layer("055_OrchestrationV2", (it) => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], + [61, "PluginInstallations"], + [62, "PluginEventCursors"], + [63, "PluginSettings"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -58,6 +61,9 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 58, name: "WebhookRelayDeliveries" }, { migration_id: 59, name: "McpAppModelContext" }, { migration_id: 60, name: "ThreadSnapshotWindowIndexes" }, + { migration_id: 61, name: "PluginInstallations" }, + { migration_id: 62, name: "PluginEventCursors" }, + { migration_id: 63, name: "PluginSettings" }, ]); const tables = yield* sql<{ readonly name: string }>` diff --git a/apps/server/src/persistence/Migrations/061_PluginInstallations.ts b/apps/server/src/persistence/Migrations/061_PluginInstallations.ts new file mode 100644 index 000000000000..fc8e31c2cd87 --- /dev/null +++ b/apps/server/src/persistence/Migrations/061_PluginInstallations.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // One row per plugin directory added to this environment. `record_json` holds the last + // inspection and the consent; the directory is unique so one folder is one installation. + yield* sql` + CREATE TABLE IF NOT EXISTS plugin_installations ( + installation_id TEXT PRIMARY KEY, + directory TEXT NOT NULL UNIQUE, + record_json TEXT NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/Migrations/062_PluginEventCursors.ts b/apps/server/src/persistence/Migrations/062_PluginEventCursors.ts new file mode 100644 index 000000000000..cf24e4b05455 --- /dev/null +++ b/apps/server/src/persistence/Migrations/062_PluginEventCursors.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // One row per plugin installation that receives events: the event log sequence it has + // acknowledged through. Removing the installation removes its row. + yield* sql` + CREATE TABLE IF NOT EXISTS plugin_event_cursors ( + installation_id TEXT PRIMARY KEY, + acknowledged_sequence INTEGER NOT NULL, + updated_at TEXT NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/Migrations/063_PluginSettings.ts b/apps/server/src/persistence/Migrations/063_PluginSettings.ts new file mode 100644 index 000000000000..0dde99c4fb12 --- /dev/null +++ b/apps/server/src/persistence/Migrations/063_PluginSettings.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Saved plugin setting values per installation, secrets excepted. + yield* sql` + CREATE TABLE IF NOT EXISTS plugin_settings ( + installation_id TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + PRIMARY KEY (installation_id, key) + ) + `; + // Secrets whose value may be in the server secret store. A row is written before its file and + // deleted after it, so cleanup can always find the file; `saved` is 1 once the value is complete + // and 0 while it is written or deleted. + yield* sql` + CREATE TABLE IF NOT EXISTS plugin_setting_secrets ( + installation_id TEXT NOT NULL, + key TEXT NOT NULL, + saved INTEGER NOT NULL, + PRIMARY KEY (installation_id, key) + ) + `; + // Each installation's private key-value storage; `bytes` is the value's encoded size for quotas. + yield* sql` + CREATE TABLE IF NOT EXISTS plugin_storage ( + installation_id TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + bytes INTEGER NOT NULL, + PRIMARY KEY (installation_id, key) + ) + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 675d5f5db021..aae0178bc1ea 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -41,6 +41,9 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], + [61, "PluginInstallations"], + [62, "PluginEventCursors"], + [63, "PluginSettings"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -124,6 +127,9 @@ describe("V2 preview upgrade", () => { [58, "WebhookRelayDeliveries"], [59, "McpAppModelContext"], [60, "ThreadSnapshotWindowIndexes"], + [61, "PluginInstallations"], + [62, "PluginEventCursors"], + [63, "PluginSettings"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/plugins/PluginActions.test.ts b/apps/server/src/plugins/PluginActions.test.ts new file mode 100644 index 000000000000..94693dec3fa1 --- /dev/null +++ b/apps/server/src/plugins/PluginActions.test.ts @@ -0,0 +1,489 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT, + PLUGIN_ACTIONS_MAX_PER_PLUGIN, + PLUGIN_ACTIONS_SNAPSHOT_MAX_BYTES, + PluginActionId, + PluginActionInvokeInput, + PluginId, + PluginInstallationId, + ProjectId, + ThreadId, + type OrchestrationProjectShell, + type OrchestrationV2ThreadShell, + type PluginActionsSnapshot, + type PluginInstallation, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +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 Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import { ProjectStoreV2 } from "../orchestration-v2/ProjectStore.ts"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as PluginActions from "./PluginActions.ts"; +import * as PluginCatalog from "./PluginCatalog.ts"; +import { loadPluginDirectory } from "./PluginManifestLoader.ts"; +import * as PluginSupervisor from "./PluginSupervisor.ts"; + +// Children run the real CLI entry, which routes `__plugin-host` to the child runtime. +const BIN_PATH = `${import.meta.dirname}/../bin.ts`; +const FIXTURE = `${import.meta.dirname}/testFixtures/actions`; + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const fromJson = Schema.decodeSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +const THREAD = ThreadId.make("thread-1"); +const PROJECT = ProjectId.make("project-1"); + +/** One thread in one project; every other target does not exist. */ +const threadsAndProjects = Layer.merge( + Layer.mock(ThreadManagementService)({ + getThreadShell: (threadId) => + Effect.succeed( + threadId === THREAD + ? ({ + projectId: PROJECT, + worktreePath: null, + branch: "main", + } as OrchestrationV2ThreadShell) + : null, + ), + }), + Layer.mock(ProjectStoreV2)({ + getShell: (projectId) => + Effect.succeed( + projectId === PROJECT + ? Option.some({ workspaceRoot: "/work/project" } as OrchestrationProjectShell) + : Option.none(), + ), + }), +); + +const startActions = Effect.fn("startActions")(function* (scope: Scope.Scope) { + const supervisor = yield* PluginSupervisor.make({ + heapLimitMb: 64, + activationTimeout: "10 seconds", + stopGrace: "1 second", + }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + Effect.provideService(Scope.Scope, scope), + ); + const catalog = yield* PluginCatalog.make().pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor), + Effect.provideService(Scope.Scope, scope), + ); + const actions = yield* PluginActions.make().pipe( + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + Effect.provide(threadsAndProjects), + ); + return { catalog, actions }; +}); + +/** Copies the fixture (or a variant of its manifest) into a fresh directory. */ +const preparePlugin = Effect.fn("preparePlugin")(function* ( + manifest?: (manifest: Record) => Record, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = path.join( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-actions-" }), + "plugin", + ); + yield* fs.makeDirectory(directory); + yield* fs.writeFileString( + path.join(directory, "main.mjs"), + yield* fs.readFileString(path.join(FIXTURE, "main.mjs")), + ); + const original = fromJson(yield* fs.readFileString(path.join(FIXTURE, "t3-plugin.json"))); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson(manifest ? manifest(original) : original), + ); + return directory; +}); + +const enablePlugin = Effect.fn("enablePlugin")(function* ( + catalog: PluginCatalog.PluginCatalog["Service"], + directory: string, +) { + const { installation } = yield* catalog.add({ directory }); + yield* catalog.consent({ + installationId: installation.installationId, + digest: installation.source!.digest, + }); + return (yield* catalog.enable({ installationId: installation.installationId })).installation; +}); + +const awaitActions = ( + actions: PluginActions.PluginActions["Service"], + predicate: (snapshot: PluginActionsSnapshot) => boolean, +) => + actions.subscribe.pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.map((snapshot) => Option.getOrThrow(snapshot).actions), + ); + +const awaitHostState = ( + catalog: PluginCatalog.PluginCatalog["Service"], + installationId: PluginInstallationId, + tags: ReadonlyArray, +) => + catalog.subscribe.pipe( + Stream.filter((snapshot) => + snapshot.installations.some( + (installation) => + installation.installationId === installationId && + tags.includes(installation.hostState?._tag ?? ""), + ), + ), + Stream.runHead, + ); + +const withDatabase = (effect: Effect.Effect) => + effect.pipe(Effect.provide(SqlitePersistence.layerMemory)); + +it.layer(NodeServices.layer)("PluginActions", (it) => { + it.effect( + "lists an enabled plugin's actions without starting it, and drops them on disable", + () => + withDatabase( + Effect.gen(function* () { + const { catalog, actions } = yield* startActions(yield* Scope.Scope); + const directory = yield* preparePlugin(); + const installation = yield* enablePlugin(catalog, directory); + const { installationId } = installation; + + const listed = yield* awaitActions(actions, (snapshot) => snapshot.actions.length > 0); + expect(listed.map((action) => [action.name, action.target, action.placements])).toEqual([ + ["echo-target", "thread", ["command-palette", "thread-menu", "composer-slash"]], + ["say-hello", "environment", ["command-palette"]], + ["fail", "environment", ["command-palette"]], + ["wait", "environment", ["command-palette"]], + ]); + expect(listed[0]).toMatchObject({ + pluginId: "test.actions", + pluginName: "Actions fixture", + title: "Echo target", + description: "Says which thread it ran on.", + }); + // Listing read the manifest only: no process was started. + const [row] = (yield* catalog.list).installations; + expect(row?.hostState).toEqual({ _tag: "idle" }); + + const hello = listed.find((action) => action.name === "say-hello")!; + expect( + yield* actions.invoke({ actionId: hello.id, target: { _tag: "environment" } }), + ).toEqual({ message: "Hello from test.actions" }); + + yield* catalog.disable({ installationId }); + expect(yield* awaitActions(actions, () => true)).toEqual([]); + const disabled = yield* actions + .invoke({ actionId: hello.id, target: { _tag: "environment" } }) + .pipe(Effect.flip); + expect(disabled.reason).toBe("not-found"); + + // Enabled again: new ids, and the old one can never reach the new registration. + yield* catalog.enable({ installationId }); + const relisted = yield* awaitActions(actions, (snapshot) => snapshot.actions.length > 0); + expect(relisted.find((action) => action.name === "say-hello")!.id).not.toBe(hello.id); + const stale = yield* actions + .invoke({ actionId: hello.id, target: { _tag: "environment" } }) + .pipe(Effect.flip); + expect(stale.reason).toBe("stale"); + + yield* catalog.remove({ installationId }); + expect(yield* awaitActions(actions, () => true)).toEqual([]); + }), + ), + ); + + it.effect("runs an action on its resolved target and reports typed failures", () => + withDatabase( + Effect.gen(function* () { + const { catalog, actions } = yield* startActions(yield* Scope.Scope); + const { installationId } = yield* enablePlugin(catalog, yield* preparePlugin()); + const listed = yield* awaitActions(actions, (snapshot) => snapshot.actions.length > 0); + const byName = (name: string) => listed.find((action) => action.name === name)!.id; + + expect( + yield* actions.invoke({ + actionId: byName("echo-target"), + target: { _tag: "thread", threadId: THREAD }, + }), + ).toEqual({ message: "thread thread-1 in /work/project" }); + + const failures = yield* Effect.forEach( + [ + { actionId: byName("echo-target"), target: { _tag: "environment" as const } }, + { + actionId: byName("echo-target"), + target: { _tag: "thread" as const, threadId: ThreadId.make("elsewhere") }, + }, + { actionId: byName("fail"), target: { _tag: "environment" as const } }, + { + actionId: PluginActionId.make(`${installationId}:1:missing`), + target: { _tag: "environment" as const }, + }, + { + actionId: PluginActionId.make("not-an-action"), + target: { _tag: "environment" as const }, + }, + ], + (input) => actions.invoke(input).pipe(Effect.flip), + ); + expect(failures.map((error) => [error.reason, error.message])).toEqual([ + ["target-mismatch", "Echo target runs on a thread."], + ["target-not-found", "That thread does not exist here."], + ["failed", "The fixture failed on purpose."], + ["not-found", "That action is not available now."], + ["not-found", "That action is not available here."], + ]); + + // A disable while the action runs revokes it at once. + const running = yield* actions + .invoke({ actionId: byName("wait"), target: { _tag: "environment" } }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* awaitHostState(catalog, installationId, ["starting", "running"]); + yield* catalog.disable({ installationId }); + expect((yield* Fiber.join(running)).reason).toBe("stopped"); + }), + ), + ); + + it.effect("refuses malformed ids the wire accepts with a typed error, never a defect", () => + withDatabase( + Effect.gen(function* () { + const { catalog, actions } = yield* startActions(yield* Scope.Scope); + const { installationId } = yield* enablePlugin(catalog, yield* preparePlugin()); + const decode = Schema.decodeUnknownEffect(PluginActionInvokeInput); + const exits = yield* Effect.forEach( + [ + ":1:go", + `${"x".repeat(65)}:1:go`, + "::", + `${installationId}::say-hello`, + `${installationId}:1.0:say-hello`, + `${installationId}:99999999999999999999:say-hello`, + `${installationId} :1:say-hello`, + ], + (actionId) => + decode({ actionId, target: { _tag: "environment" } }).pipe( + Effect.flatMap((input) => actions.invoke(input).pipe(Effect.exit)), + ), + ); + for (const exit of exits) { + expect(Exit.isFailure(exit) && !Cause.hasDies(exit.cause)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toMatchObject({ + _tag: "PluginActionError", + reason: "not-found", + }); + } + } + }), + ), + ); + + it.effect("offers plugins whole up to the environment bound and runs only those", () => + withDatabase( + Effect.gen(function* () { + const { catalog, actions } = yield* startActions(yield* Scope.Scope); + const pluginCount = PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT / PLUGIN_ACTIONS_MAX_PER_PLUGIN + 1; + const installations = yield* Effect.forEach( + Array.from({ length: pluginCount }, (_, index) => index), + (index) => + preparePlugin((manifest) => ({ + ...manifest, + id: `test.actions-${index}`, + actions: Array.from({ length: PLUGIN_ACTIONS_MAX_PER_PLUGIN }, (_, action) => ({ + name: `go-${action}`, + title: `Go ${action}`, + target: "environment", + placements: ["command-palette"], + })), + })).pipe(Effect.flatMap((directory) => enablePlugin(catalog, directory))), + ); + const full = yield* actions.subscribe.pipe( + Stream.filter((snapshot) => snapshot.omitted !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect(full.actions).toHaveLength(PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT); + expect(full.omitted).toEqual({ plugins: 1, actions: PLUGIN_ACTIONS_MAX_PER_PLUGIN }); + const listedIds = new Set(full.actions.map((action) => action.id.split(":")[0])); + const left = installations.find( + (installation) => !listedIds.has(installation.installationId), + )!; + + const refused = yield* actions + .invoke({ + actionId: PluginActionId.make(`${left.installationId}:${left.generation}:go-0`), + target: { _tag: "environment" }, + }) + .pipe(Effect.flip); + expect(refused.reason).toBe("not-found"); + expect(refused.message).toContain("more actions than it shows"); + + // Disabling a listed plugin makes room, and the left-out one is offered whole. + const listed = installations.find((installation) => installation !== left)!; + yield* catalog.disable({ installationId: listed.installationId }); + const after = yield* awaitActions(actions, (snapshot) => snapshot.omitted === undefined); + expect(after).toHaveLength(PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT); + expect( + after.filter((action) => action.id.startsWith(`${left.installationId}:`)), + ).toHaveLength(PLUGIN_ACTIONS_MAX_PER_PLUGIN); + }), + ), + ); + + it.effect("refuses declarations the host cannot honour", () => + Effect.gen(function* () { + const reasons = yield* Effect.forEach( + [ + (manifest: Record) => ({ ...manifest, capabilities: [] }), + (manifest: Record) => ({ ...manifest, proposedApi: false }), + (manifest: Record) => ({ + ...manifest, + actions: [ + { name: "same", title: "One", target: "environment", placements: ["thread-menu"] }, + { name: "same", title: "Two", target: "environment", placements: ["thread-menu"] }, + ], + }), + (manifest: Record) => ({ + ...manifest, + actions: [ + { + name: "twice", + title: "Twice", + target: "environment", + placements: ["thread-menu", "thread-menu"], + }, + ], + }), + ], + (variant) => + preparePlugin(variant).pipe( + Effect.flatMap(loadPluginDirectory), + Effect.flip, + Effect.map((error) => error.reason), + ), + ); + expect(reasons).toEqual([ + "it declares actions without the actions capability.", + "it declares actions, which need proposedApi: true.", + "it declares the action same twice.", + "the action twice repeats a placement.", + ]); + }), + ); +}); + +describe("pluginActionsFromCatalog", () => { + const digest = `sha256:${"a".repeat(64)}`; + const installation: PluginInstallation = { + installationId: PluginInstallationId.make("installation-1"), + generation: 3, + directory: "/srv/plugins/actions", + manifest: { + id: PluginId.make("test.actions"), + name: "Actions", + version: "1.0.0", + capabilities: ["actions"], + proposedApi: true, + actions: [{ name: "go", title: "Go", target: "environment", placements: ["thread-menu"] }], + }, + source: { digest, files: 2, bytes: 10 }, + problem: null, + inspectedAt: "2026-10-04T00:00:00.000Z", + consent: { digest, capabilities: ["actions"], grantedAt: "2026-10-04T00:00:00.000Z" }, + enabled: true, + hostState: { _tag: "running" }, + addedAt: "2026-10-04T00:00:00.000Z", + }; + const names = (installations: ReadonlyArray) => + PluginActions.pluginActionsFromCatalog({ installations }).actions.map((action) => action.id); + + const many = (count: number, description?: string) => + Array.from({ length: count }, (_, index): PluginInstallation => { + const installationId = PluginInstallationId.make(`installation-${index}`); + return { + ...installation, + installationId, + manifest: { + ...installation.manifest!, + id: PluginId.make(`test.actions-${index}`), + actions: Array.from({ length: PLUGIN_ACTIONS_MAX_PER_PLUGIN }, (_, action) => ({ + name: `go-${action}`, + title: `Go ${action}`, + ...(description === undefined ? {} : { description }), + target: "environment" as const, + placements: ["command-palette" as const], + })), + }, + }; + }); + const frameBytes = (snapshot: PluginActionsSnapshot) => + Buffer.byteLength(JSON.stringify(snapshot)); + + it("bounds the actions and bytes one environment offers, counting what it leaves out", () => { + const byCount = PluginActions.pluginActionsFromCatalog({ installations: many(1000) }); + expect(byCount.actions).toHaveLength(PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT); + const keptPlugins = PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT / PLUGIN_ACTIONS_MAX_PER_PLUGIN; + expect(byCount.omitted).toEqual({ + plugins: 1000 - keptPlugins, + actions: (1000 - keptPlugins) * PLUGIN_ACTIONS_MAX_PER_PLUGIN, + }); + // The first plugins in catalogue order are the ones kept. + expect(new Set(byCount.actions.map((action) => action.id.split(":")[0]))).toEqual( + new Set(Array.from({ length: keptPlugins }, (_, index) => `installation-${index}`)), + ); + expect(frameBytes(byCount)).toBeLessThanOrEqual(PLUGIN_ACTIONS_SNAPSHOT_MAX_BYTES); + + // Escaped control characters make each description six times its length on the wire. + const byBytes = PluginActions.pluginActionsFromCatalog({ + installations: many(1000, "\u0001".repeat(240)), + }); + expect(byBytes.actions.length).toBeLessThan(PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT); + expect(byBytes.actions.length % PLUGIN_ACTIONS_MAX_PER_PLUGIN).toBe(0); + expect(byBytes.omitted?.actions).toBe( + 1000 * PLUGIN_ACTIONS_MAX_PER_PLUGIN - byBytes.actions.length, + ); + expect(frameBytes(byBytes)).toBeLessThanOrEqual(PLUGIN_ACTIONS_SNAPSHOT_MAX_BYTES); + + // Within the bounds nothing is left out and `omitted` is absent. + expect( + PluginActions.pluginActionsFromCatalog({ installations: many(keptPlugins) }), + ).not.toHaveProperty("omitted"); + }); + + it("offers only actions that can run now", () => { + expect(names([installation])).toEqual(["installation-1:3:go"]); + // A state this server version does not know is unknown, not a reason to hide the action. + const { hostState: _hostState, ...unknownState } = installation; + expect(names([unknownState])).toEqual(["installation-1:3:go"]); + for (const hidden of [ + { ...installation, enabled: false }, + { ...installation, consent: null }, + { ...installation, source: null, problem: "gone" }, + { ...installation, hostState: { _tag: "quarantined" as const, failures: 3, reason: "x" } }, + { ...installation, hostState: { _tag: "incompatible" as const, reason: "x" } }, + { ...installation, manifest: { ...installation.manifest!, capabilities: [] } }, + ]) { + expect(names([hidden])).toEqual([]); + } + }); +}); diff --git a/apps/server/src/plugins/PluginActions.ts b/apps/server/src/plugins/PluginActions.ts new file mode 100644 index 000000000000..4d8f55c5a24f --- /dev/null +++ b/apps/server/src/plugins/PluginActions.ts @@ -0,0 +1,308 @@ +/** + * The actions enabled plugins offer, and running one. + * + * Actions come from the manifest of each enabled installation, so listing + * them never starts a plugin. Each listed action carries an id naming the + * installation's registration (`::`). An + * invoke with an id from an earlier registration is refused as `stale`, and + * the catalogue's own generation check refuses one that races a re-enable, + * so a click never reaches a plugin the list did not show. The list is + * bounded per environment (see `PluginActionsSnapshot`), and only listed + * actions run. + * + * Running an action calls the plugin's `action:` handler with the + * resolved target (see pluginApi.ts) under a deadline. Interrupting the + * caller (a client that goes away) cancels the call. + */ +import { + PLUGIN_ACTION_MESSAGE_MAX_LENGTH, + PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT, + PLUGIN_ACTIONS_MAX_PER_PLUGIN, + PLUGIN_ACTIONS_SNAPSHOT_MAX_BYTES, + PluginActionError, + PluginActionId, + PluginInstallationId, + pluginInstallationStatus, + type PluginAction, + type PluginActionInvokeInput, + type PluginActionInvokeResult, + type PluginActionsSnapshot, + type PluginActionTarget, + type PluginCatalogError, + type PluginCatalogSnapshot, + type PluginInstallation, + type ProjectId, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Duration from "effect/Duration"; +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 { ProjectStoreV2 } from "../orchestration-v2/ProjectStore.ts"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import { PluginCatalog } from "./PluginCatalog.ts"; +import type { PluginInvokeError } from "./PluginSupervisor.ts"; + +const PLUGIN_ACTION_TIMEOUT: Duration.Input = "30 seconds"; + +/** What an action handler receives as `input.target`: the target the user picked, resolved. */ +type PluginActionTargetContext = + | { readonly kind: "environment" } + | { readonly kind: "project"; readonly projectId: string; readonly workspaceRoot: string } + | { + readonly kind: "thread"; + readonly threadId: string; + readonly projectId: string; + /** The thread's worktree, or its project's workspace root. */ + readonly cwd: string; + readonly branch: string | null; + }; + +/** Looks a target up in this environment's projects and threads; none when it does not exist here. */ +const resolvePluginActionTargetFrom = + (lookups: { + readonly getThreadShell: (threadId: ThreadId) => Effect.Effect< + { + readonly projectId: ProjectId; + readonly worktreePath: string | null; + readonly branch: string | null; + } | null, + ThreadError + >; + readonly getProjectShell: ( + projectId: ProjectId, + ) => Effect.Effect, ProjectError>; + }) => + ( + target: PluginActionTarget, + ): Effect.Effect, PluginActionError> => + Effect.gen(function* () { + switch (target._tag) { + case "environment": + return Option.some({ kind: "environment" as const }); + case "project": { + const project = yield* lookups.getProjectShell(target.projectId); + return Option.map(project, (shell) => ({ + kind: "project" as const, + projectId: target.projectId, + workspaceRoot: shell.workspaceRoot, + })); + } + case "thread": { + const thread = yield* lookups.getThreadShell(target.threadId); + if (thread === null) return Option.none(); + const project = yield* lookups.getProjectShell(thread.projectId); + if (Option.isNone(project)) return Option.none(); + return Option.some({ + kind: "thread" as const, + threadId: target.threadId, + projectId: thread.projectId, + cwd: thread.worktreePath ?? project.value.workspaceRoot, + branch: thread.branch, + }); + } + } + }).pipe( + Effect.mapError(() => actionError("unavailable", "Could not look up the action's target.")), + ); + +export class PluginActions extends Context.Service< + PluginActions, + { + /** The current actions now, then a new list whenever it changes. */ + readonly subscribe: Stream.Stream; + readonly invoke: ( + input: PluginActionInvokeInput, + ) => Effect.Effect; + } +>()("t3/plugins/PluginActions") {} + +const pluginActionHandlerName = (name: string) => `action:${name}`; + +const actionId = (installationId: string, generation: number, name: string) => + PluginActionId.make(`${installationId}:${generation}:${name}`); + +const decodeInstallationId = Schema.decodeUnknownOption(PluginInstallationId); + +/** The parts of an id this server issued; undefined for anything else. */ +const parseActionId = (id: PluginActionId) => { + const parts = id.split(":"); + const name = parts.pop(); + const generationText = parts.pop(); + if (name === undefined || generationText === undefined || !/^[0-9]+$/.test(generationText)) + return undefined; + const generation = Number(generationText); + if (!Number.isSafeInteger(generation)) return undefined; + const segment = parts.join(":"); + const installationId = decodeInstallationId(segment); + if (Option.isNone(installationId) || installationId.value !== segment) return undefined; + return { installationId: installationId.value, generation, name }; +}; + +/** The actions one installation offers on its own: enabled and able to run now. */ +const installationActions = (installation: PluginInstallation): ReadonlyArray => { + const { manifest } = installation; + if (manifest === null || pluginInstallationStatus(installation) !== "enabled") return []; + if (!manifest.capabilities.includes("actions")) return []; + // These wait for someone to resume them; an action could only fail. + const state = installation.hostState?._tag; + if (state === "quarantined" || state === "incompatible") return []; + return (manifest.actions ?? []).slice(0, PLUGIN_ACTIONS_MAX_PER_PLUGIN).map((declaration) => ({ + id: actionId(installation.installationId, installation.generation, declaration.name), + pluginId: manifest.id, + pluginName: manifest.name, + name: declaration.name, + title: declaration.title, + ...(declaration.description === undefined ? {} : { description: declaration.description }), + target: declaration.target, + placements: declaration.placements, + })); +}; + +// Room for the frame around the actions: `{"actions":[`, `]`, and `omitted`. +const SNAPSHOT_ENVELOPE_BYTES = 128; +const ACTIONS_MAX_BYTES = PLUGIN_ACTIONS_SNAPSHOT_MAX_BYTES - SNAPSHOT_ENVELOPE_BYTES; + +/** Each action's JSON plus its separator. */ +const encodedBytes = (actions: ReadonlyArray) => + actions.reduce((total, action) => total + Buffer.byteLength(JSON.stringify(action)) + 1, 0); + +/** + * The actions a catalogue snapshot offers. Plugins are taken whole, in + * catalogue order, until one would pass the environment's action or byte + * bound; it and every later plugin are counted in `omitted` instead. + */ +export const pluginActionsFromCatalog = ( + snapshot: PluginCatalogSnapshot, +): PluginActionsSnapshot => { + const actions: Array = []; + let bytes = 0; + const omitted = { plugins: 0, actions: 0 }; + for (const installation of snapshot.installations) { + const offered = installationActions(installation); + if (offered.length === 0) continue; + if (omitted.plugins === 0) { + const size = encodedBytes(offered); + if ( + actions.length + offered.length <= PLUGIN_ACTIONS_MAX_PER_ENVIRONMENT && + bytes + size <= ACTIONS_MAX_BYTES + ) { + actions.push(...offered); + bytes += size; + continue; + } + } + omitted.plugins += 1; + omitted.actions += offered.length; + } + return omitted.plugins === 0 ? { actions } : { actions, omitted }; +}; + +const actionError = (reason: string, message: string) => + new PluginActionError({ reason, message: bound(message) }); + +/** At most the message bound, without splitting a surrogate pair. */ +const bound = (text: string) => { + const characters = Array.from(text); + return characters.length <= PLUGIN_ACTION_MESSAGE_MAX_LENGTH + ? text + : `${characters.slice(0, PLUGIN_ACTION_MESSAGE_MAX_LENGTH - 1).join("")}…`; +}; + +/** A plugin's `{ message }`, if it returned one. */ +const resultMessage = (value: Schema.Json): string | null => { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const message = (value as { readonly [key: string]: Schema.Json })["message"]; + if (typeof message !== "string" || message.trim() === "") return null; + return bound(message.trim()); +}; + +const fromInvokeError = ( + error: PluginCatalogError | PluginInvokeError, + title: string, +): PluginActionError => { + switch (error._tag) { + case "PluginCatalogError": + return error.reason === "generation-changed" + ? actionError("stale", "The plugin was enabled again since this action was listed.") + : error.reason === "not-found" + ? actionError("not-found", "That plugin is no longer installed.") + : actionError("unavailable", error.message); + case "PluginTimeoutError": + return actionError("timeout", `${title} did not finish in time.`); + case "PluginBusyError": + return actionError("busy", `${title} could not start: the plugin is busy.`); + case "PluginStoppedError": + return actionError("stopped", "The plugin was disabled while the action ran."); + case "PluginCallFailedError": + return actionError("failed", error.reason); + default: + return actionError("unavailable", error.message); + } +}; + +export const make = Effect.fn("PluginActions.make")(function* () { + const catalog = yield* PluginCatalog; + const threads = yield* ThreadManagementService; + const projects = yield* ProjectStoreV2; + const resolveTarget = resolvePluginActionTargetFrom({ + getThreadShell: threads.getThreadShell, + getProjectShell: projects.getShell, + }); + + const invoke = Effect.fn("PluginActions.invoke")(function* (input: PluginActionInvokeInput) { + const parsed = parseActionId(input.actionId); + if (parsed === undefined) + return yield* actionError("not-found", "That action is not available here."); + const snapshot = yield* catalog.list; + const installation = snapshot.installations.find( + (candidate) => candidate.installationId === parsed.installationId, + ); + if (installation === undefined) + return yield* actionError("not-found", "That plugin is no longer installed."); + if (installation.generation !== parsed.generation) + return yield* actionError( + "stale", + "The plugin was enabled again since this action was listed.", + ); + // Only what the list offers runs, so an action left out by the bounds cannot. + const action = pluginActionsFromCatalog(snapshot).actions.find( + (candidate) => candidate.id === input.actionId, + ); + if (action === undefined) + return yield* installationActions(installation).some( + (candidate) => candidate.name === parsed.name, + ) + ? actionError( + "not-found", + "That action is not offered: this environment's plugins declare more actions than it shows.", + ) + : actionError("not-found", "That action is not available now."); + if (action.target !== input.target._tag) + return yield* actionError("target-mismatch", `${action.title} runs on a ${action.target}.`); + const target = yield* resolveTarget(input.target); + if (Option.isNone(target)) + return yield* actionError("target-not-found", `That ${action.target} does not exist here.`); + const value = yield* catalog + .invoke( + installation.installationId, + pluginActionHandlerName(action.name), + { action: action.name, target: target.value }, + { generation: parsed.generation, timeout: PLUGIN_ACTION_TIMEOUT }, + ) + .pipe(Effect.mapError((error) => fromInvokeError(error, action.title))); + return { message: resultMessage(value) }; + }); + + return PluginActions.of({ + // Process state changes reach the catalogue too; most leave the list as it was. + subscribe: catalog.subscribe.pipe(Stream.map(pluginActionsFromCatalog), Stream.changes), + invoke, + }); +}); + +export const layer = Layer.effect(PluginActions, make()); diff --git a/apps/server/src/plugins/PluginActionsRpc.test.ts b/apps/server/src/plugins/PluginActionsRpc.test.ts new file mode 100644 index 000000000000..335d9a3eb5c5 --- /dev/null +++ b/apps/server/src/plugins/PluginActionsRpc.test.ts @@ -0,0 +1,120 @@ +import { + type AuthEnvironmentScope, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + AuthRelayReadScope, + AuthStandardClientScopes, + PluginActionId, + type PluginActionsSnapshot, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as RpcTest from "effect/rpc/RpcTest"; + +import { RPC_REQUIRED_SCOPES } from "../auth/RpcAuthorization.ts"; +import * as RpcAuthorization from "../auth/RpcAuthorization.ts"; + +type ActionMethod = + | typeof WS_METHODS.pluginActionsSubscribe + | typeof WS_METHODS.pluginActionsInvoke; +const actionMethods: ReadonlySet = new Set([ + WS_METHODS.pluginActionsSubscribe, + WS_METHODS.pluginActionsInvoke, +]); + +const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => + !actionMethods.has(tag), + ), +); + +const actionId = PluginActionId.make("installation-1:1:say-hello"); +const snapshot: PluginActionsSnapshot = { + actions: [ + { + id: actionId, + pluginId: "test.actions", + pluginName: "Actions fixture", + name: "say-hello", + title: "Say hello", + target: "environment", + placements: ["command-palette"], + }, + ], +}; +const invoke = { actionId, target: { _tag: "environment" as const } }; + +/** Serves the action RPCs through the real scope middleware; handlers record that they ran. */ +const makeClient = (scopes: ReadonlyArray, handled: Array) => + RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.pluginActionsSubscribe, () => + Stream.fromEffect( + Effect.sync(() => handled.push(WS_METHODS.pluginActionsSubscribe)).pipe( + Effect.as(snapshot), + ), + ), + ), + group.toLayerHandler(WS_METHODS.pluginActionsInvoke, () => + Effect.sync(() => handled.push(WS_METHODS.pluginActionsInvoke)).pipe( + Effect.as({ message: "Hello" }), + ), + ), + RpcAuthorization.layer(scopes), + ), + ), + ); + +describe("plugin action RPC scopes", () => { + it.effect("lets a standard pairing list and run the actions an administrator enabled", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient(AuthStandardClientScopes, handled); + + expect( + yield* client[WS_METHODS.pluginActionsSubscribe]({}).pipe( + Stream.take(1), + Stream.runCollect, + ), + ).toEqual([snapshot]); + expect(yield* client[WS_METHODS.pluginActionsInvoke](invoke)).toEqual({ message: "Hello" }); + expect(handled).toEqual([WS_METHODS.pluginActionsSubscribe, WS_METHODS.pluginActionsInvoke]); + }).pipe(Effect.scoped), + ); + + it.effect("refuses to run an action for a read-only session", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient([AuthOrchestrationReadScope], handled); + + expect(yield* client[WS_METHODS.pluginActionsInvoke](invoke).pipe(Effect.flip)).toMatchObject( + { + _tag: "EnvironmentAuthorizationError", + requiredScope: AuthOrchestrationOperateScope, + }, + ); + expect(handled).toEqual([]); + }).pipe(Effect.scoped), + ); + + it.effect("refuses the list without the orchestration read scope", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient([AuthRelayReadScope], handled); + + expect( + yield* client[WS_METHODS.pluginActionsSubscribe]({}).pipe(Stream.runCollect, Effect.flip), + ).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: AuthOrchestrationReadScope, + }); + expect(handled).toEqual([]); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/plugins/PluginCatalog.test.ts b/apps/server/src/plugins/PluginCatalog.test.ts new file mode 100644 index 000000000000..931c78c7f473 --- /dev/null +++ b/apps/server/src/plugins/PluginCatalog.test.ts @@ -0,0 +1,786 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + PluginInstallationId, + pluginInstallationStatus, + type PluginId, + type PluginInstallation, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +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 PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; + +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as PluginCatalog from "./PluginCatalog.ts"; +import type { PluginRegistration } from "./PluginManifestLoader.ts"; +import * as PluginSupervisor from "./PluginSupervisor.ts"; + +// Children run the real CLI entry, which routes `__plugin-host` to the child runtime. +const BIN_PATH = `${import.meta.dirname}/../bin.ts`; + +type Catalog = PluginCatalog.PluginCatalog["Service"]; + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +/** Starts a catalogue and its supervisor in `scope`, as one server start would. */ +const startCatalog = Effect.fn("startCatalog")(function* (scope: Scope.Scope) { + const supervisor = yield* PluginSupervisor.make({ + heapLimitMb: 64, + activationTimeout: "10 seconds", + stopGrace: "1 second", + }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + Effect.provideService(Scope.Scope, scope), + ); + return yield* PluginCatalog.make().pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor), + Effect.provideService(Scope.Scope, scope), + ); +}); + +/** + * Writes a plugin whose activation leaves a marker outside its own directory: + * a plugin writing into its directory changes its own digest. + */ +const preparePlugin = Effect.fn("preparePlugin")(function* (id: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-catalog-" }); + const directory = path.join(root, "plugin"); + yield* fs.makeDirectory(directory); + const marker = path.join(root, "activated"); + const entry = path.join(directory, "main.mjs"); + yield* fs.writeFileString( + entry, + [ + `import * as NodeFS from "node:fs";`, + `export function activate(context) {`, + ` NodeFS.writeFileSync(${toJson(marker)}, String(process.pid));`, + ` context.proposed.handle("ping", (input) => ({ pid: process.pid, input }));`, + `}`, + ``, + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ + id, + name: id, + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + proposedApi: true, + }), + ); + const edit = (line: string) => + fs + .readFileString(entry) + .pipe(Effect.flatMap((content) => fs.writeFileString(entry, `${content}// ${line}\n`))); + return { directory, marker, edit }; +}); + +/** Waits, through the subscription, for a snapshot that satisfies `predicate`. */ +const awaitSnapshot = ( + catalog: Catalog, + predicate: (installations: ReadonlyArray) => boolean, +) => + catalog.subscribe.pipe( + Stream.filter((snapshot) => predicate(snapshot.installations)), + Stream.runHead, + Effect.map((snapshot) => Option.getOrThrow(snapshot).installations), + ); + +/** A step a test can stop at: `reached` completes when it is entered, `release` lets it go on. */ +interface Hold { + readonly reached: Deferred.Deferred; + readonly release: Deferred.Deferred; +} + +const makeHold = Effect.gen(function* () { + const hold: Hold = { + reached: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + return hold; +}); + +const passHold = (hold: Hold | undefined) => + hold === undefined + ? Effect.void + : Deferred.succeed(hold.reached, undefined).pipe(Effect.andThen(Deferred.await(hold.release))); + +/** + * A supervisor without processes that behaves like the real one at its + * boundary: registration by plugin id, revocation at the start of `disable`, + * and `invoke` answering with the registered directory. Tests can hold + * `enable` and `disable` open. + */ +const makeStubSupervisor = Effect.gen(function* () { + const registrations = new Map(); + const invoked: Array = []; + const holds: { enable?: Hold; disable?: Hold } = {}; + const events = yield* PubSub.unbounded(); + const service = PluginSupervisor.PluginSupervisor.of({ + enable: (registration) => + Effect.suspend(() => { + const pluginId = registration.manifest.id; + if (registrations.has(pluginId)) + return Effect.fail(new PluginSupervisor.PluginAlreadyEnabledError({ pluginId })); + registrations.set(pluginId, registration); + return passHold(holds.enable); + }), + disable: (pluginId) => + Effect.suspend(() => { + registrations.delete(pluginId); + return passHold(holds.disable); + }), + resume: () => Effect.void, + invoke: (pluginId) => + Effect.suspend(() => { + const registration = registrations.get(pluginId); + if (registration === undefined) + return Effect.fail(new PluginSupervisor.PluginNotEnabledError({ pluginId })); + invoked.push(registration.directory); + return Effect.succeed(registration.directory); + }), + state: (pluginId) => + Effect.sync(() => + registrations.has(pluginId) ? Option.some({ _tag: "idle" as const }) : Option.none(), + ), + subscribe: PubSub.subscribe(events), + serveHostMethod: () => Effect.void, + }); + return { service, registrations, invoked, holds }; +}); + +/** The real file system, except that the next read of a held directory waits for its hold. */ +const makeHoldingFileSystem = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + let held: { readonly directory: string; readonly hold: Hold } | undefined; + const fileSystem: FileSystem.FileSystem = { + ...fs, + realPath: (target) => + Effect.suspend(() => { + if (held === undefined || !target.startsWith(held.directory)) return fs.realPath(target); + const { hold } = held; + held = undefined; + return passHold(hold).pipe(Effect.andThen(fs.realPath(target))); + }), + }; + const holdDirectory = Effect.fn("holdDirectory")(function* (directory: string) { + const hold = yield* makeHold; + held = { directory, hold }; + return hold; + }); + return { fileSystem, holdDirectory }; +}); + +/** A catalogue over the stub supervisor, reading files through `fileSystem`. */ +const startStubCatalog = Effect.fn("startStubCatalog")(function* ( + scope: Scope.Scope, + supervisor: PluginSupervisor.PluginSupervisor["Service"], + fileSystem?: FileSystem.FileSystem, +) { + return yield* PluginCatalog.make().pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor), + Effect.provideService(FileSystem.FileSystem, fileSystem ?? (yield* FileSystem.FileSystem)), + Effect.provideService(Scope.Scope, scope), + ); +}); + +const pidOf = (value: unknown) => (value as { readonly pid: number }).pid; + +const isProcessAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +// Each test gets its own database; the restart test shares one between two starts. +const withDatabase = (effect: Effect.Effect) => + effect.pipe(Effect.provide(SqlitePersistence.layerMemory)); + +it.layer(NodeServices.layer)("PluginCatalog", (it) => { + describe("consent", () => { + it.effect("runs nothing until the exact bytes are approved, then starts on first use", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sql = yield* SqlClient.SqlClient; + const catalog = yield* startCatalog(yield* Scope.Scope); + const plugin = yield* preparePlugin("test.lazy"); + + const relative = yield* catalog.add({ directory: "plugins/lazy" }).pipe(Effect.flip); + expect(relative.reason).toBe("invalid-directory"); + + const { installation: added } = yield* catalog.add({ directory: plugin.directory }); + const installationId = added.installationId; + expect(pluginInstallationStatus(added)).toBe("needs-consent"); + expect(added).toMatchObject({ enabled: false, consent: null, generation: 0 }); + expect(added.manifest?.id).toBe("test.lazy"); + expect(added.hostState).toBeUndefined(); + const again = yield* catalog.add({ directory: plugin.directory }).pipe(Effect.flip); + expect(again.reason).toBe("already-added"); + + const unapproved = yield* catalog.enable({ installationId }).pipe(Effect.flip); + expect(unapproved.reason).toBe("consent-required"); + const wrongDigest = yield* catalog + .consent({ installationId, digest: `sha256:${"0".repeat(64)}` }) + .pipe(Effect.flip); + expect(wrongDigest.reason).toBe("source-changed"); + + const digest = added.source!.digest; + const { installation: approved } = yield* catalog.consent({ installationId, digest }); + expect(pluginInstallationStatus(approved)).toBe("disabled"); + expect(approved.consent).toMatchObject({ digest, capabilities: [] }); + + const { installation: enabled } = yield* catalog.enable({ installationId }); + expect(pluginInstallationStatus(enabled)).toBe("enabled"); + expect(enabled).toMatchObject({ generation: 1, hostState: { _tag: "idle" } }); + expect(yield* fs.exists(plugin.marker)).toBe(false); + + const pid = pidOf(yield* catalog.invoke(installationId, "ping", null)); + expect(isProcessAlive(pid)).toBe(true); + yield* awaitSnapshot(catalog, ([row]) => row?.hostState?._tag === "running"); + + const { installation: disabled } = yield* catalog.disable({ installationId }); + expect(isProcessAlive(pid)).toBe(false); + expect(pluginInstallationStatus(disabled)).toBe("disabled"); + expect(disabled.hostState).toBeUndefined(); + const stopped = yield* catalog.invoke(installationId, "ping", null).pipe(Effect.flip); + expect(stopped._tag).toBe("PluginCatalogError"); + + expect((yield* catalog.enable({ installationId })).installation.generation).toBe(2); + expect(yield* catalog.remove({ installationId })).toEqual({ installationId }); + expect((yield* catalog.list).installations).toEqual([]); + // Settings promises that removing an added directory leaves it in place. + expect(yield* fs.exists(plugin.directory)).toBe(true); + expect(yield* sql`SELECT installation_id FROM plugin_installations`).toEqual([]); + }), + ), + ); + + it.effect("stops a plugin and asks again when its bytes change", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const catalog = yield* startCatalog(yield* Scope.Scope); + const plugin = yield* preparePlugin("test.changed"); + const { installation } = yield* catalog.add({ directory: plugin.directory }); + const installationId = installation.installationId; + const firstDigest = installation.source!.digest; + yield* catalog.consent({ installationId, digest: firstDigest }); + yield* catalog.enable({ installationId }); + const pid = pidOf(yield* catalog.invoke(installationId, "ping", null)); + + yield* plugin.edit("changed while running"); + const [refreshed] = (yield* catalog.refresh({ installationId })).installations; + expect(pluginInstallationStatus(refreshed!)).toBe("needs-consent"); + expect(refreshed!.enabled).toBe(false); + expect(isProcessAlive(pid)).toBe(false); + const changed = yield* catalog.enable({ installationId }).pipe(Effect.flip); + expect(changed.reason).toBe("consent-required"); + const stale = yield* catalog + .consent({ installationId, digest: firstDigest }) + .pipe(Effect.flip); + expect(stale.reason).toBe("source-changed"); + + yield* catalog.consent({ installationId, digest: refreshed!.source!.digest }); + yield* catalog.enable({ installationId }); + // Changed again with no refresh: the check before a fresh process catches it. + yield* plugin.edit("changed before first use"); + yield* fs.remove(plugin.marker); + const beforeStart = yield* catalog.invoke(installationId, "ping", null).pipe(Effect.flip); + expect(beforeStart).toMatchObject({ reason: "source-changed" }); + expect(yield* fs.exists(plugin.marker)).toBe(false); + const [revoked] = (yield* catalog.list).installations; + expect(pluginInstallationStatus(revoked!)).toBe("needs-consent"); + }), + ), + ); + + it.effect("checks the bytes when enabling an installation that is already enabled", () => + withDatabase( + Effect.gen(function* () { + const catalog = yield* startCatalog(yield* Scope.Scope); + const plugin = yield* preparePlugin("test.enable-again"); + const { installation } = yield* catalog.add({ directory: plugin.directory }); + const installationId = installation.installationId; + const approve = (digest: string) => + catalog + .consent({ installationId, digest }) + .pipe(Effect.andThen(catalog.enable({ installationId }))); + const { installation: enabled } = yield* approve(installation.source!.digest); + + // Unchanged: the same registration, not a new generation. + const { installation: same } = yield* catalog.enable({ installationId }); + expect(same).toMatchObject({ enabled: true, generation: enabled.generation }); + + // Changed while registered but idle. + yield* plugin.edit("changed while idle"); + const idle = yield* catalog.enable({ installationId }).pipe(Effect.flip); + expect(idle.reason).toBe("consent-required"); + const [afterIdle] = (yield* catalog.list).installations; + expect(pluginInstallationStatus(afterIdle!)).toBe("needs-consent"); + expect(afterIdle!.enabled).toBe(false); + + // Changed while its process runs: enabling again stops it. + yield* approve(afterIdle!.source!.digest); + const pid = pidOf(yield* catalog.invoke(installationId, "ping", null)); + yield* plugin.edit("changed while running"); + const running = yield* catalog.enable({ installationId }).pipe(Effect.flip); + expect(running.reason).toBe("consent-required"); + expect(isProcessAlive(pid)).toBe(false); + const [afterRunning] = (yield* catalog.list).installations; + expect(pluginInstallationStatus(afterRunning!)).toBe("needs-consent"); + expect(afterRunning!.hostState).toBeUndefined(); + }), + ), + ); + + it.effect("runs one directory per plugin id at a time", () => + withDatabase( + Effect.gen(function* () { + const catalog = yield* startCatalog(yield* Scope.Scope); + const approve = Effect.fn(function* (directory: string) { + const { installation } = yield* catalog.add({ directory }); + const installationId = installation.installationId; + yield* catalog.consent({ installationId, digest: installation.source!.digest }); + return installationId; + }); + const first = yield* approve((yield* preparePlugin("test.same")).directory); + const second = yield* approve((yield* preparePlugin("test.same")).directory); + + yield* catalog.enable({ installationId: first }); + const conflict = yield* catalog.enable({ installationId: second }).pipe(Effect.flip); + expect(conflict.reason).toBe("plugin-id-conflict"); + yield* catalog.disable({ installationId: first }); + const { installation } = yield* catalog.enable({ installationId: second }); + expect(installation.enabled).toBe(true); + }), + ), + ); + + it.effect("keeps disable and remove available when the directory is gone", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const catalog = yield* startCatalog(yield* Scope.Scope); + const plugin = yield* preparePlugin("test.gone"); + const { installation } = yield* catalog.add({ directory: plugin.directory }); + const installationId = installation.installationId; + yield* catalog.consent({ installationId, digest: installation.source!.digest }); + yield* catalog.enable({ installationId }); + + yield* fs.remove(plugin.directory, { recursive: true }); + const [missing] = (yield* catalog.refresh({})).installations; + expect(pluginInstallationStatus(missing!)).toBe("unavailable"); + expect(missing).toMatchObject({ enabled: false, source: null }); + expect(missing!.problem).toContain("does not exist"); + expect(missing!.manifest?.id).toBe("test.gone"); + expect((yield* catalog.enable({ installationId }).pipe(Effect.flip)).reason).toBe( + "unavailable", + ); + expect((yield* catalog.disable({ installationId })).installation.enabled).toBe(false); + yield* catalog.remove({ installationId }); + const unknown = yield* catalog + .disable({ installationId: PluginInstallationId.make("missing") }) + .pipe(Effect.flip); + expect(unknown.reason).toBe("not-found"); + }), + ), + ); + }); + + describe("calls racing management", () => { + it.effect("fails a call whose installation is replaced while its bytes are checked", () => + withDatabase( + Effect.gen(function* () { + const stub = yield* makeStubSupervisor; + const files = yield* makeHoldingFileSystem; + const catalog = yield* startStubCatalog( + yield* Scope.Scope, + stub.service, + files.fileSystem, + ); + const approve = Effect.fn(function* (directory: string) { + const { installation } = yield* catalog.add({ directory }); + yield* catalog.consent({ + installationId: installation.installationId, + digest: installation.source!.digest, + }); + return installation; + }); + const first = yield* approve((yield* preparePlugin("test.same")).directory); + const second = yield* approve((yield* preparePlugin("test.same")).directory); + yield* catalog.enable({ installationId: first.installationId }); + + // The call stops in its byte check, before a fresh process would start. + const hold = yield* files.holdDirectory(first.directory); + const call = yield* catalog + .invoke(first.installationId, "ping", null) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(hold.reached); + yield* catalog.disable({ installationId: first.installationId }); + yield* catalog.enable({ installationId: second.installationId }); + yield* Deferred.succeed(hold.release, undefined); + + const refused = yield* Fiber.join(call).pipe(Effect.flip); + expect(refused).toMatchObject({ _tag: "PluginCatalogError", reason: "unavailable" }); + expect(stub.invoked).toEqual([]); + expect(yield* catalog.invoke(second.installationId, "ping", null)).toBe(second.directory); + }), + ), + ); + + it.effect( + "fails a call when its installation is enabled again or names an old generation", + () => + withDatabase( + Effect.gen(function* () { + const stub = yield* makeStubSupervisor; + const files = yield* makeHoldingFileSystem; + const catalog = yield* startStubCatalog( + yield* Scope.Scope, + stub.service, + files.fileSystem, + ); + const plugin = yield* preparePlugin("test.generation"); + const { installation } = yield* catalog.add({ directory: plugin.directory }); + const installationId = installation.installationId; + yield* catalog.consent({ installationId, digest: installation.source!.digest }); + const { installation: enabled } = yield* catalog.enable({ installationId }); + + const hold = yield* files.holdDirectory(installation.directory); + const call = yield* catalog + .invoke(installationId, "ping", null) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(hold.reached); + yield* catalog.disable({ installationId }); + const { installation: again } = yield* catalog.enable({ installationId }); + yield* Deferred.succeed(hold.release, undefined); + expect(yield* Fiber.join(call).pipe(Effect.flip)).toMatchObject({ + reason: "unavailable", + }); + expect(stub.invoked).toEqual([]); + + expect(again.generation).toBe(enabled.generation + 1); + const stale = yield* catalog + .invoke(installationId, "ping", null, { generation: enabled.generation }) + .pipe(Effect.flip); + expect(stale).toMatchObject({ reason: "generation-changed" }); + expect(stub.invoked).toEqual([]); + expect( + yield* catalog.invoke(installationId, "ping", null, { generation: again.generation }), + ).toBe(installation.directory); + }), + ), + ); + }); + + describe("subscription", () => { + it.effect("sends a snapshot only when a step changed what it shows", () => + withDatabase( + Effect.gen(function* () { + const stub = yield* makeStubSupervisor; + const catalog = yield* startStubCatalog(yield* Scope.Scope, stub.service); + const plugin = yield* preparePlugin("test.quiet"); + const { installation } = yield* catalog.add({ directory: plugin.directory }); + const installationId = installation.installationId; + const digest = installation.source!.digest; + yield* catalog.consent({ installationId, digest }); + + const snapshots = yield* Queue.unbounded>(); + yield* catalog.subscribe.pipe( + Stream.runForEach((snapshot) => Queue.offer(snapshots, snapshot.installations)), + Effect.forkScoped, + ); + const [initial] = yield* Queue.take(snapshots); + expect(initial).toMatchObject({ enabled: false }); + + // Failures and steps that find nothing new. + yield* catalog.add({ directory: "relative" }).pipe(Effect.flip); + yield* catalog.add({ directory: plugin.directory }).pipe(Effect.flip); + yield* catalog + .consent({ installationId, digest: `sha256:${"0".repeat(64)}` }) + .pipe(Effect.flip); + yield* catalog.resume({ installationId }).pipe(Effect.flip); + yield* catalog.refresh({}); + yield* catalog.disable({ installationId }); + + yield* catalog.enable({ installationId }); + const [enabled] = yield* Queue.take(snapshots); + expect(enabled).toMatchObject({ enabled: true, inspectedAt: initial!.inspectedAt }); + + yield* catalog.enable({ installationId }); + yield* catalog.resume({ installationId }); + yield* catalog.disable({ installationId }); + const [disabled] = yield* Queue.take(snapshots); + expect(disabled).toMatchObject({ enabled: false }); + }), + ), + ); + }); + + describe("interrupted management", () => { + /** An approved, enabled installation in a catalogue that can be restarted on the same database. */ + const enabledInStub = Effect.fn("enabledInStub")(function* () { + const stub = yield* makeStubSupervisor; + const before = yield* Scope.make(); + const catalog = yield* startStubCatalog(before, stub.service); + const plugin = yield* preparePlugin("test.interrupted"); + const { installation } = yield* catalog.add({ directory: plugin.directory }); + const installationId = installation.installationId; + yield* catalog.consent({ installationId, digest: installation.source!.digest }); + yield* catalog.enable({ installationId }); + return { stub, before, catalog, installationId }; + }); + + /** Starts the catalogue again and waits until its startup re-registration has run. */ + const restart = Effect.fn("restart")(function* () { + const stub = yield* makeStubSupervisor; + const catalog = yield* startStubCatalog(yield* Scope.Scope, stub.service); + // Management steps queue behind startup, so this returns after it. + const { installations } = yield* catalog.refresh({}); + return { stub, installations }; + }); + + const storedRows = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly record_json: string }>` + SELECT record_json FROM plugin_installations + `; + return rows.map((row) => JSON.parse(row.record_json) as { readonly enabled: boolean }); + }); + + it.effect("keeps a disable whose caller left while the process stopped", () => + withDatabase( + Effect.gen(function* () { + const { stub, before, catalog, installationId } = yield* enabledInStub(); + const stopping = yield* makeHold; + stub.holds.disable = stopping; + const disabling = yield* catalog + .disable({ installationId }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(stopping.reached); + yield* Fiber.interrupt(disabling); + + const [row] = (yield* catalog.list).installations; + expect(row).toMatchObject({ enabled: false }); + expect(row!.hostState).toBeUndefined(); + expect(yield* storedRows).toMatchObject([{ enabled: false }]); + expect(stub.registrations.size).toBe(0); + yield* Deferred.succeed(stopping.release, undefined); + yield* Scope.close(before, Exit.void); + + const after = yield* restart(); + expect(after.installations).toMatchObject([{ installationId, enabled: false }]); + expect(after.stub.registrations.size).toBe(0); + }), + ), + ); + + it.effect("makes a repeated disable wait for the interrupted stop", () => + withDatabase( + Effect.gen(function* () { + const { stub, catalog, installationId } = yield* enabledInStub(); + const stopping = yield* makeHold; + stub.holds.disable = stopping; + const disabling = yield* catalog + .disable({ installationId }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(stopping.reached); + yield* Fiber.interrupt(disabling); + + const retry = yield* catalog + .disable({ installationId }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(retry.pollUnsafe()).toBeUndefined(); + expect(stub.registrations.size).toBe(0); + + yield* Deferred.succeed(stopping.release, undefined); + const { installation } = yield* Fiber.join(retry); + expect(installation).toMatchObject({ enabled: false }); + expect(installation.hostState).toBeUndefined(); + expect(stub.registrations.size).toBe(0); + }), + ), + ); + + it.effect("keeps a remove whose caller left while the process stopped", () => + withDatabase( + Effect.gen(function* () { + const { stub, before, catalog, installationId } = yield* enabledInStub(); + const stopping = yield* makeHold; + stub.holds.disable = stopping; + const removing = yield* catalog + .remove({ installationId }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(stopping.reached); + yield* Fiber.interrupt(removing); + + expect((yield* catalog.list).installations).toEqual([]); + expect(yield* storedRows).toEqual([]); + expect(stub.registrations.size).toBe(0); + yield* Deferred.succeed(stopping.release, undefined); + yield* Scope.close(before, Exit.void); + + const after = yield* restart(); + expect(after.installations).toEqual([]); + expect(after.stub.registrations.size).toBe(0); + }), + ), + ); + + it.effect("finishes an enable whose caller left during registration", () => + withDatabase( + Effect.gen(function* () { + const { stub, before, catalog, installationId } = yield* enabledInStub(); + yield* catalog.disable({ installationId }); + const registering = yield* makeHold; + stub.holds.enable = registering; + const enabling = yield* catalog + .enable({ installationId }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(registering.reached); + const interrupting = yield* Fiber.interrupt(enabling).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(registering.release, undefined); + yield* Fiber.join(interrupting); + + // Registered, saved, and shown together: never one without the others. + const [row] = (yield* catalog.list).installations; + expect(row).toMatchObject({ enabled: true, generation: 2 }); + expect(yield* storedRows).toMatchObject([{ enabled: true }]); + expect(stub.registrations.size).toBe(1); + yield* Scope.close(before, Exit.void); + }), + ), + ); + + it.effect("holds a call made during startup until the plugin is registered again", () => + withDatabase( + Effect.gen(function* () { + const { before, installationId } = yield* enabledInStub(); + yield* Scope.close(before, Exit.void); + + const stub = yield* makeStubSupervisor; + const registering = yield* makeHold; + stub.holds.enable = registering; + const after = yield* startStubCatalog(yield* Scope.Scope, stub.service); + // Runs before the catalogue's finalizers, so a failing test can still close its scope. + yield* Effect.addFinalizer(() => Deferred.succeed(registering.release, undefined)); + yield* Deferred.await(registering.reached); + const calling = yield* after + .invoke(installationId, "ping", null) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(calling.pollUnsafe()).toBeUndefined(); + + yield* Deferred.succeed(registering.release, undefined); + const [directory] = [...stub.registrations.values()].map((r) => r.directory); + expect(yield* Fiber.join(calling)).toBe(directory); + expect(stub.invoked).toEqual([directory]); + }), + ), + ); + + it.effect("keeps a remove made as the catalogue starts", () => + withDatabase( + Effect.gen(function* () { + const { before, installationId } = yield* enabledInStub(); + yield* Scope.close(before, Exit.void); + + const stub = yield* makeStubSupervisor; + const after = yield* startStubCatalog(yield* Scope.Scope, stub.service); + yield* after.remove({ installationId }); + // A call waits for startup to finish restoring. + yield* after.invoke(installationId, "ping", null).pipe(Effect.flip); + + expect((yield* after.list).installations).toEqual([]); + expect(yield* storedRows).toEqual([]); + expect(stub.registrations.size).toBe(0); + }), + ), + ); + + it.effect("keeps an enable made as the catalogue starts", () => + withDatabase( + Effect.gen(function* () { + const { before, installationId } = yield* enabledInStub(); + yield* Scope.close(before, Exit.void); + + const stub = yield* makeStubSupervisor; + const after = yield* startStubCatalog(yield* Scope.Scope, stub.service); + yield* after.enable({ installationId }); + const [directory] = [...stub.registrations.values()].map((r) => r.directory); + expect(yield* after.invoke(installationId, "ping", null)).toBe(directory); + + const [row] = (yield* after.list).installations; + expect(row).toMatchObject({ enabled: true, generation: 2 }); + expect(stub.registrations.size).toBe(1); + }), + ), + ); + }); + + describe("server restart", () => { + it.effect("re-enables approved plugins without starting them and drops changed ones", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const kept = yield* preparePlugin("test.kept"); + const changed = yield* preparePlugin("test.restart-changed"); + + const before = yield* Scope.make(); + const first = yield* startCatalog(before); + const ids = yield* Effect.forEach([kept, changed], (plugin) => + Effect.gen(function* () { + const { installation } = yield* first.add({ directory: plugin.directory }); + const installationId = installation.installationId; + yield* first.consent({ installationId, digest: installation.source!.digest }); + yield* first.enable({ installationId }); + return installationId; + }), + ); + const pid = pidOf(yield* first.invoke(ids[0]!, "ping", null)); + yield* Scope.close(before, Exit.void); + expect(isProcessAlive(pid)).toBe(false); + + yield* fs.remove(kept.marker); + yield* changed.edit("changed while the server was down"); + const after = yield* startCatalog(yield* Scope.Scope); + const restarted = yield* awaitSnapshot(after, (rows) => + rows.every((row) => row.hostState?._tag === "idle" || !row.enabled), + ); + const keptRow = restarted.find((row) => row.installationId === ids[0]); + const changedRow = restarted.find((row) => row.installationId === ids[1]); + expect(keptRow).toMatchObject({ enabled: true, generation: 2 }); + expect(pluginInstallationStatus(changedRow!)).toBe("needs-consent"); + expect(changedRow!.enabled).toBe(false); + expect(yield* fs.exists(kept.marker)).toBe(false); + + const restartedPid = pidOf(yield* after.invoke(ids[0]!, "ping", null)); + expect(restartedPid).not.toBe(pid); + expect(yield* fs.exists(kept.marker)).toBe(true); + }), + ), + ); + }); +}); diff --git a/apps/server/src/plugins/PluginCatalog.ts b/apps/server/src/plugins/PluginCatalog.ts new file mode 100644 index 000000000000..c05b29dc975a --- /dev/null +++ b/apps/server/src/plugins/PluginCatalog.ts @@ -0,0 +1,912 @@ +/** + * The environment's trusted local plugins: which directories were added, what + * their exact bytes were, who consented to which bytes, and which are enabled. + * + * Nothing in a plugin directory runs until its current digest has consent and + * the installation is enabled. Enabling registers it with the supervisor, + * which still starts no process until the first invoke. The bytes are checked + * again whenever they are about to matter: on add, refresh, consent, enable, + * server start, and before an invoke that would start a fresh process. A + * change found at any of those points disables the installation and leaves it + * needing consent; it is never re-enabled automatically. + * + * Plugins are trusted OS-user code. The digest pins what the user agreed to + * run, not what the directory's owner can do between checks. + */ +import { + PluginCatalogError, + PluginInstallation, + pluginInstallationStatus, + type PluginAddInput, + type PluginCatalogSnapshot, + type PluginConsentInput, + type PluginId, + type PluginInstallationInput, + type PluginInstallationManifest, + type PluginInstallationResult, + type PluginManifest, + type PluginRefreshInput, + type PluginRemoveResult, + type PluginSource, + PluginInstallationId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Equal from "effect/Equal"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +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 PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { PluginEventDelivery } from "./PluginEventDelivery.ts"; +import { loadPluginDirectory, type PluginRegistration } from "./PluginManifestLoader.ts"; +import { + defaultPluginSourceLimits, + digestPluginSource, + type PluginSourceLimits, +} from "./pluginSource.ts"; +import { PluginSupervisor, type PluginInvokeError } from "./PluginSupervisor.ts"; + +/** What is persisted: the wire record without the live process and delivery states. */ +const PluginInstallationRecord = PluginInstallation.mapFields( + ({ hostState: _hostState, eventDelivery: _eventDelivery, ...fields }) => fields, +); +type PluginInstallationRecord = typeof PluginInstallationRecord.Type; + +const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginInstallationRecord)); +const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(PluginInstallationRecord)); + +/** One registration with the supervisor. A re-enable makes a new one; it is never mutated. */ +interface Registration { + readonly pluginId: PluginId; + readonly generation: number; +} + +interface Installation { + record: PluginInstallationRecord; + /** The registration this installation runs under, while enabled. */ + registered: Registration | undefined; + /** Waits for the process of the last registration revoked here to exit. */ + stopping: Fiber.Fiber | undefined; +} + +type Inspection = + | { + readonly _tag: "ok"; + readonly registration: PluginRegistration; + readonly source: PluginSource; + } + | { readonly _tag: "failed"; readonly reason: string }; + +/** + * How long a call waits for startup to re-register enabled plugins before it + * proceeds without them, matching the supervisor's default activation timeout. + */ +const STARTUP_RESTORE_WAIT = Duration.seconds(10); + +/** What one admission attempt of `invoke` found. */ +type Admission = + | { readonly _tag: "called"; readonly value: Schema.Json } + | { readonly _tag: "revoked" } + /** A fresh process would start: the bytes must be checked against `consented` first. */ + | { readonly _tag: "check"; readonly consented: string }; + +/** What clients see of a manifest; also the summary of a downloaded npm update under review. */ +export const summarizePluginManifest = (manifest: PluginManifest): PluginInstallationManifest => ({ + id: manifest.id, + name: manifest.name, + version: manifest.version, + ...(manifest.description === undefined ? {} : { description: manifest.description }), + capabilities: manifest.capabilities, + proposedApi: manifest.proposedApi, + ...(manifest.tools === undefined || manifest.tools.length === 0 ? {} : { tools: manifest.tools }), + ...(manifest.settings === undefined ? {} : { settings: manifest.settings }), + ...(manifest.actions === undefined || manifest.actions.length === 0 + ? {} + : { actions: manifest.actions }), +}); + +const catalogError = ( + reason: string, + message: string, + installationId?: PluginInstallationId, +): PluginCatalogError => + new PluginCatalogError({ + reason, + message, + ...(installationId === undefined ? {} : { installationId }), + }); + +const storageError = (cause: unknown) => + Effect.logWarning("Plugin catalogue storage failed", { cause }).pipe( + Effect.andThen(Effect.fail(catalogError("storage", "Could not save the plugin catalogue."))), + ); + +export class PluginCatalog extends Context.Service< + PluginCatalog, + { + readonly list: Effect.Effect; + /** Changes whenever `list` could show different installation records, so a reader can cache what it derives from them. */ + readonly revision: Effect.Effect; + /** One snapshot now, then a fresh one after every catalogue or plugin state change. */ + readonly subscribe: Stream.Stream; + readonly add: ( + input: PluginAddInput, + ) => Effect.Effect; + readonly refresh: ( + input: PluginRefreshInput, + ) => Effect.Effect; + readonly consent: ( + input: PluginConsentInput, + ) => Effect.Effect; + readonly enable: ( + input: PluginInstallationInput, + ) => Effect.Effect; + readonly disable: ( + input: PluginInstallationInput, + ) => Effect.Effect; + readonly remove: ( + input: PluginInstallationInput, + ) => Effect.Effect; + /** Clears the process's backoff, quarantine, or incompatibility, and restarts stopped event delivery. */ + readonly resume: ( + input: PluginInstallationInput, + ) => Effect.Effect; + /** + * Replaces the files in an installation's directory and consents to + * `digest`, as one management step: no other step, such as a disable from + * another client, runs in between, and the installation stays revoked + * throughout. The files are claimed first (see `changeFiles`), with + * `paths` naming everywhere `swap` and `restore` move files. `swap` puts + * the new files in place. If it fails, or the directory then does not hold + * `digest`, `restore` puts the old files back. Afterwards the installation + * is enabled again only if it was enabled and the bytes on disk have + * consent: the new ones, or the old ones still. + */ + readonly replace: ( + input: PluginConsentInput, + files: { + readonly paths: ReadonlyArray; + readonly swap: Effect.Effect; + readonly restore: Effect.Effect; + }, + ) => Effect.Effect; + /** + * Settles a `replace` that was cut short, by a crash or a failed `restore`, + * as one management step. If the installation has consent to `digest`, the + * replacement committed and nothing changes. Otherwise `files.restore`, + * when given, puts the old files back after claiming `files.paths` (see + * `changeFiles`), and the files are inspected again. The installation + * stays disabled, and consent is never granted. + */ + readonly settleReplace: ( + input: PluginConsentInput, + files?: { + readonly paths: ReadonlyArray; + readonly restore: Effect.Effect; + }, + ) => Effect.Effect<"committed" | "rolled-back", PluginCatalogError>; + /** + * Runs `effect`, which moves or deletes files under `paths`, as one + * management step. It fails `already-added`, and touches nothing, if any + * installation is rooted in or around one of the paths. Otherwise every + * process that ran from files there has exited before `effect` starts, + * including one whose disable or remove was cut short. + */ + readonly changeFiles: ( + paths: ReadonlyArray, + effect: Effect.Effect, + ) => Effect.Effect; + /** + * Calls a handler of an enabled installation. A call that would start a + * fresh process first checks the bytes still match the consent. Pass the + * `generation` the caller saw to refuse a call that would reach a later + * registration (`generation-changed`). A call whose installation is + * disabled, removed, or re-registered before the supervisor takes it + * fails; it never follows the plugin id to a replacement. + */ + readonly invoke: ( + installationId: PluginInstallationId, + handler: string, + input: Schema.Json, + options?: { readonly timeout?: Duration.Input; readonly generation?: number }, + ) => Effect.Effect; + } +>()("t3/plugins/PluginCatalog") {} + +export const make = Effect.fn("PluginCatalog.make")(function* ( + sourceLimits: PluginSourceLimits = defaultPluginSourceLimits, +) { + const sql = yield* SqlClient.SqlClient; + const supervisor = yield* PluginSupervisor; + const crypto = yield* Crypto.Crypto; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const scope = yield* Effect.scope; + const eventDeliveries = yield* Effect.serviceOption(PluginEventDelivery); + + const installations = new Map(); + // Management is rare and each step may wait for a process to exit; one at a time keeps the + // catalogue, the table, and the supervisor in step. + const lock = yield* Semaphore.make(1); + /** + * Processes still exiting after their registration was revoked, by the + * directory they ran from. Kept apart from `registered`, since a disable + * or remove whose caller went away no longer waits for its process. + */ + const exiting = new Map, string>(); + const changes = yield* PubSub.sliding(1); + const notify = PubSub.publish(changes, undefined).pipe(Effect.asVoid); + // Counts changes a snapshot can show, so a step that changed nothing tells no one. + let revision = 0; + // Completes when startup has tried to re-register every enabled installation. + const restored = yield* Deferred.make(); + + const now = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + const save = (record: PluginInstallationRecord) => + encodeRecord(record).pipe( + Effect.flatMap( + (json) => sql` + INSERT INTO plugin_installations (installation_id, directory, record_json) + VALUES (${record.installationId}, ${record.directory}, ${json}) + ON CONFLICT (installation_id) DO UPDATE SET + directory = excluded.directory, + record_json = excluded.record_json + `, + ), + Effect.asVoid, + Effect.catch(storageError), + ); + + /** Saves `record` as enabled, with the event cursor its capabilities need, or neither. */ + const saveEnabled = (record: PluginInstallationRecord, capabilities: ReadonlyArray) => + Option.match(eventDeliveries, { + onNone: () => save(record), + onSome: (delivery) => + sql + .withTransaction( + delivery + .begin(record.installationId, capabilities) + .pipe(Effect.catch(storageError), Effect.andThen(save(record))), + ) + .pipe(Effect.catchTags({ SqlError: storageError })), + }); + + /** Saves `record` and then shows it, or neither. */ + const commit = (installation: Installation, record: PluginInstallationRecord) => + save(record).pipe( + Effect.andThen( + Effect.sync(() => { + installation.record = record; + revision++; + }), + ), + Effect.uninterruptible, + ); + + const inspect = (directory: string): Effect.Effect => + loadPluginDirectory(directory).pipe( + Effect.flatMap((registration) => + digestPluginSource(registration.directory, sourceLimits).pipe( + Effect.map((source) => ({ _tag: "ok" as const, registration, source })), + ), + ), + Effect.catch((error) => Effect.succeed({ _tag: "failed" as const, reason: error.reason })), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const isReady = (record: PluginInstallationRecord) => + pluginInstallationStatus({ ...record, enabled: true }) === "enabled"; + + /** + * Revokes the installation's registration and returns the fiber that waits + * for its process to exit. The supervisor revokes as soon as `disable` + * starts, so this starts it at once; only the wait may be cut short. An + * installation already revoked returns its earlier stop, so a retry still + * waits for that process and never touches a replacement under the same id. + */ + const unregister = Effect.fnUntraced(function* (installation: Installation) { + const registered = installation.registered; + if (registered === undefined) return installation.stopping; + installation.registered = undefined; + revision++; + const stopping = yield* supervisor + .disable(registered.pluginId) + .pipe(Effect.forkIn(scope, { startImmediately: true })); + installation.stopping = stopping; + exiting.set(stopping, installation.record.directory); + stopping.addObserver(() => exiting.delete(stopping)); + return stopping; + }); + + /** + * Saves `record` (normally with `enabled: false`), revokes the registration, + * and waits for the process to exit. The row is written first and + * everything but the wait finishes even if the caller goes away, so a + * disable that was cut short never comes back enabled at the next start. A + * failed save changes nothing. + */ + const revoke = (installation: Installation, record: PluginInstallationRecord) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + if (record !== installation.record) yield* commit(installation, record); + const stopping = yield* unregister(installation); + if (stopping) yield* restore(Fiber.join(stopping)); + }), + ); + + /** + * Inspects the directory again and records the result. An enabled + * installation whose bytes no longer match its consent is stopped and + * disabled. + */ + const reinspect = Effect.fnUntraced(function* (installation: Installation) { + const inspection = yield* inspect(installation.record.directory); + const current = installation.record; + const found = + inspection._tag === "ok" + ? { + manifest: summarizePluginManifest(inspection.registration.manifest), + source: inspection.source, + problem: null, + } + : { manifest: current.manifest, source: null, problem: inspection.reason }; + // The same result keeps the record as it was, `inspectedAt` included. + if ( + Equal.equals(found, { + manifest: current.manifest, + source: current.source, + problem: current.problem, + }) + ) + return inspection; + const record: PluginInstallationRecord = { ...current, ...found, inspectedAt: yield* now }; + if (record.enabled && !isReady(record)) { + yield* Effect.logWarning("Plugin source changed; disabling until consent is renewed", { + installationId: record.installationId, + directory: record.directory, + }); + yield* revoke(installation, { ...record, enabled: false }); + } else { + yield* commit(installation, record); + } + return inspection; + }); + + /** + * Registers a ready installation with the supervisor under a new generation + * and saves it as enabled. Both happen or neither, even if the caller is + * interrupted. + */ + const register = Effect.fnUntraced(function* ( + installation: Installation, + registration: PluginRegistration, + ) { + const pluginId = registration.manifest.id; + const holder = [...installations.values()].find( + (other) => other !== installation && other.registered?.pluginId === pluginId, + ); + if (holder) + return yield* catalogError( + "plugin-id-conflict", + `Another enabled plugin already uses the id ${pluginId} (${holder.record.directory}). Disable it first.`, + installation.record.installationId, + ); + yield* supervisor + .enable({ ...registration, installationId: installation.record.installationId }) + .pipe( + Effect.mapError(() => + catalogError( + "plugin-id-conflict", + `A plugin with the id ${pluginId} is already running.`, + installation.record.installationId, + ), + ), + ); + const record = { + ...installation.record, + enabled: true, + generation: installation.record.generation + 1, + }; + yield* saveEnabled(record, registration.manifest.capabilities).pipe( + Effect.tapError(() => supervisor.disable(pluginId)), + ); + // Together, so an invoke never sees the new registration with the old generation. + installation.record = record; + installation.registered = { pluginId, generation: record.generation }; + revision++; + }, Effect.uninterruptible); + + const find = (installationId: PluginInstallationId) => + Effect.suspend(() => { + const installation = installations.get(installationId); + return installation + ? Effect.succeed(installation) + : Effect.fail( + catalogError("not-found", "That plugin is not installed here.", installationId), + ); + }); + + const toWire = Effect.fnUntraced(function* (installation: Installation) { + const registered = installation.registered; + if (registered === undefined) return installation.record; + const hostState = yield* supervisor.state(registered.pluginId); + const eventDelivery = Option.isSome(eventDeliveries) + ? yield* eventDeliveries.value.state( + installation.record.installationId, + registered.generation, + ) + : Option.none(); + const wire: PluginInstallation = { + ...installation.record, + ...(Option.isSome(hostState) ? { hostState: hostState.value } : {}), + ...(Option.isSome(eventDelivery) ? { eventDelivery: eventDelivery.value } : {}), + }; + return wire; + }); + + const list = Effect.suspend(() => + Effect.forEach( + [...installations.values()].sort( + (a, b) => + a.record.addedAt.localeCompare(b.record.addedAt) || + a.record.installationId.localeCompare(b.record.installationId), + ), + toWire, + ), + ).pipe(Effect.map((installations) => ({ installations }))); + + const result = (installation: Installation) => + toWire(installation).pipe(Effect.map((wire) => ({ installation: wire }))); + + /** Runs a management step under the lock and tells subscribers if it changed anything, even when it then failed. */ + const managed = (effect: Effect.Effect) => + lock.withPermit( + Effect.suspend(() => { + const before = revision; + return effect.pipe( + Effect.ensuring(Effect.suspend(() => (revision === before ? Effect.void : notify))), + ); + }), + ); + + const add = Effect.fn("PluginCatalog.add")(function* (input: PluginAddInput) { + if (!path.isAbsolute(input.directory)) + return yield* catalogError( + "invalid-directory", + "Enter the plugin directory's absolute path on the server's machine.", + ); + const inspection = yield* inspect(input.directory); + if (inspection._tag === "failed") + return yield* catalogError("invalid-directory", inspection.reason); + const directory = inspection.registration.directory; + const existing = [...installations.values()].find( + (installation) => installation.record.directory === directory, + ); + if (existing) + return yield* catalogError( + "already-added", + `${directory} is already installed.`, + existing.record.installationId, + ); + const installationId = PluginInstallationId.make(yield* crypto.randomUUIDv4.pipe(Effect.orDie)); + const at = yield* now; + const record: PluginInstallationRecord = { + installationId, + generation: 0, + directory, + manifest: summarizePluginManifest(inspection.registration.manifest), + source: inspection.source, + problem: null, + inspectedAt: at, + consent: null, + enabled: false, + addedAt: at, + }; + const installation: Installation = { record, registered: undefined, stopping: undefined }; + yield* save(record).pipe( + Effect.andThen( + Effect.sync(() => { + installations.set(installationId, installation); + revision++; + }), + ), + Effect.uninterruptible, + ); + return yield* result(installation); + }); + + const refresh = Effect.fn("PluginCatalog.refresh")(function* (input: PluginRefreshInput) { + const targets = + input.installationId === undefined + ? [...installations.values()] + : [yield* find(input.installationId)]; + yield* Effect.forEach(targets, reinspect, { discard: true }); + return yield* list; + }); + + const consent = Effect.fn("PluginCatalog.consent")(function* (input: PluginConsentInput) { + const installation = yield* find(input.installationId); + const inspection = yield* reinspect(installation); + if (inspection._tag === "failed") + return yield* catalogError("unavailable", inspection.reason, input.installationId); + if (inspection.source.digest !== input.digest) + return yield* catalogError( + "source-changed", + "The plugin's files changed after they were reviewed. Review the current version.", + input.installationId, + ); + yield* commit(installation, { + ...installation.record, + consent: { + digest: inspection.source.digest, + capabilities: inspection.registration.manifest.capabilities, + grantedAt: yield* now, + }, + }); + return yield* result(installation); + }); + + const enable = Effect.fn("PluginCatalog.enable")(function* (input: PluginInstallationInput) { + const installation = yield* find(input.installationId); + // Checked even when already enabled: changed bytes stop it and need consent again. + const inspection = yield* reinspect(installation); + if (inspection._tag === "failed") + return yield* catalogError("unavailable", inspection.reason, input.installationId); + if (!isReady(installation.record)) + return yield* catalogError( + "consent-required", + installation.record.consent === null + ? "Review and approve the plugin before enabling it." + : "The plugin's files changed since they were approved. Review the current version.", + input.installationId, + ); + // Already enabled with these bytes: keep the registration and its generation. + if (installation.registered !== undefined) return yield* result(installation); + yield* register(installation, inspection.registration); + return yield* result(installation); + }); + + const disableInstallation = (installation: Installation) => + revoke( + installation, + installation.record.enabled + ? { ...installation.record, enabled: false } + : installation.record, + ); + + const overlaps = (a: string, b: string) => + a === b || a.startsWith(`${b}${path.sep}`) || b.startsWith(`${a}${path.sep}`); + + /** + * The one rule for moving or deleting files under `paths`, run inside a + * management step: no installation but `owner` may be rooted in or around + * them, `owner` is disabled, and every process that ran from files there + * has exited before this returns. + */ + const claimFiles = Effect.fnUntraced(function* ( + paths: ReadonlyArray, + owner?: Installation, + ) { + const other = [...installations.values()].find( + (installation) => + installation !== owner && + paths.some((target) => overlaps(target, installation.record.directory)), + ); + if (other) + return yield* catalogError( + "already-added", + `${other.record.directory} is installed as a plugin, so its files were left in place.`, + other.record.installationId, + ); + if (owner) yield* disableInstallation(owner); + yield* Effect.forEach( + [...exiting].filter(([, directory]) => paths.some((target) => overlaps(target, directory))), + ([stopping]) => Fiber.await(stopping), + { discard: true }, + ); + }); + + const disable = Effect.fn("PluginCatalog.disable")(function* (input: PluginInstallationInput) { + const installation = yield* find(input.installationId); + yield* disableInstallation(installation); + return yield* result(installation); + }); + + const remove = Effect.fn("PluginCatalog.remove")(function* (input: PluginInstallationInput) { + const installation = yield* find(input.installationId); + // Forgotten durably first, like disable, so an interrupted remove does not come back. + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + yield* sql`DELETE FROM plugin_installations WHERE installation_id = ${input.installationId}`.pipe( + Effect.catch(storageError), + ); + installations.delete(input.installationId); + revision++; + const stopping = yield* unregister(installation); + if (stopping) yield* restore(Fiber.join(stopping)); + }), + ); + return { installationId: input.installationId }; + }); + + const replace = Effect.fn("PluginCatalog.replace")(function* ( + input: PluginConsentInput, + files: Parameters[1], + ) { + const installation = yield* find(input.installationId); + const wasEnabled = installation.record.enabled; + yield* claimFiles([installation.record.directory, ...files.paths], installation); + const enableAgain = (inspection: Inspection) => + wasEnabled && inspection._tag === "ok" && isReady(installation.record) + ? register(installation, inspection.registration) + : Effect.void; + const inspection = yield* files.swap.pipe( + Effect.andThen(reinspect(installation)), + Effect.filterOrFail( + (inspection): inspection is Extract => + inspection._tag === "ok" && inspection.source.digest === input.digest, + () => + catalogError( + "source-changed", + "The new files changed after they were reviewed, so the old ones were put back.", + input.installationId, + ), + ), + Effect.tap((inspection) => + Effect.gen(function* () { + yield* commit(installation, { + ...installation.record, + consent: { + digest: inspection.source.digest, + capabilities: inspection.registration.manifest.capabilities, + grantedAt: yield* now, + }, + }); + }), + ), + Effect.tapError(() => + files.restore.pipe( + Effect.andThen(reinspect(installation)), + Effect.flatMap(enableAgain), + Effect.catch((error) => + Effect.logWarning("Could not put a plugin's old files back", { + installationId: input.installationId, + detail: error.message, + }), + ), + ), + ), + ); + yield* enableAgain(inspection); + return yield* result(installation); + }, Effect.uninterruptible); + + const settleReplace = Effect.fn("PluginCatalog.settleReplace")(function* ( + input: PluginConsentInput, + files: Parameters[1], + ) { + const installation = yield* find(input.installationId); + if (installation.record.consent?.digest === input.digest) return "committed" as const; + if (files !== undefined) { + yield* claimFiles([installation.record.directory, ...files.paths], installation); + yield* files.restore; + yield* reinspect(installation); + } + return "rolled-back" as const; + }, Effect.uninterruptible); + + const changeFiles = (paths: ReadonlyArray, effect: Effect.Effect) => + claimFiles(paths).pipe(Effect.andThen(effect), Effect.uninterruptible); + + const resume = Effect.fn("PluginCatalog.resume")(function* (input: PluginInstallationInput) { + const installation = yield* find(input.installationId); + if (installation.registered === undefined) + return yield* catalogError("unavailable", "The plugin is not enabled.", input.installationId); + yield* supervisor.resume(installation.registered.pluginId).pipe(Effect.ignore); + if (Option.isSome(eventDeliveries)) yield* eventDeliveries.value.resume(input.installationId); + return yield* result(installation); + }); + + /** + * Re-checks that `registered` is still this installation's registration and, unless the call + * would start a fresh process with unchecked bytes, hands the call to the supervisor. It runs + * in a fiber that starts synchronously, so no management step can revoke or replace the + * registration between the check and the supervisor admitting the call. + */ + const admit = ( + installation: Installation, + registered: Registration, + verified: string | undefined, + call: (pluginId: PluginId) => Effect.Effect, + ) => + Effect.uninterruptibleMask((restore) => + Effect.forkChild( + Effect.suspend((): Effect.Effect => { + if ( + installations.get(installation.record.installationId) !== installation || + installation.registered !== registered || + installation.record.consent === null + ) + return Effect.succeed({ _tag: "revoked" }); + const consented = installation.record.consent.digest; + return supervisor + .state(registered.pluginId) + .pipe( + Effect.flatMap((state): Effect.Effect => + Option.isSome(state) && state.value._tag === "idle" && verified !== consented + ? Effect.succeed({ _tag: "check", consented }) + : call(registered.pluginId).pipe( + Effect.map((value) => ({ _tag: "called", value })), + ), + ), + ); + }), + { startImmediately: true }, + ).pipe( + Effect.flatMap((fiber) => + restore(Fiber.join(fiber)).pipe(Effect.onInterrupt(() => Fiber.interrupt(fiber))), + ), + ), + ); + + const invoke: PluginCatalog["Service"]["invoke"] = Effect.fn("PluginCatalog.invoke")( + function* (installationId, handler, input, options) { + // A call right after a restart waits for its installation to be registered again. + yield* Deferred.await(restored).pipe(Effect.timeoutOption(STARTUP_RESTORE_WAIT)); + const installation = yield* find(installationId); + const registered = installation.registered; + if (registered === undefined) + return yield* catalogError("unavailable", "The plugin is not enabled.", installationId); + if (options?.generation !== undefined && options.generation !== registered.generation) + return yield* catalogError( + "generation-changed", + "The plugin was enabled again since this call was prepared.", + installationId, + ); + const call = (pluginId: PluginId) => + supervisor.invoke( + pluginId, + handler, + input, + options?.timeout === undefined ? undefined : { timeout: options.timeout }, + ); + let verified: string | undefined; + while (true) { + const admission = yield* admit(installation, registered, verified, call); + if (admission._tag === "called") return admission.value; + if (admission._tag === "revoked") + return yield* catalogError( + "unavailable", + "The plugin was disabled or replaced before the call started.", + installationId, + ); + const inspection = yield* inspect(installation.record.directory); + if (inspection._tag === "ok" && inspection.source.digest === admission.consented) { + verified = admission.consented; + continue; + } + // Skip the record if it was disabled or removed meanwhile, so it is not written back. + yield* managed( + Effect.suspend(() => + installations.get(installationId) === installation && + installation.registered === registered + ? reinspect(installation) + : Effect.void, + ), + ); + return yield* catalogError( + "source-changed", + "The plugin's files changed since they were approved, so it was disabled.", + installationId, + ); + } + }, + ); + + // Load what was installed before this start. + const rows = yield* sql<{ readonly record_json: string }>` + SELECT record_json FROM plugin_installations + `.pipe(Effect.orDie); + for (const row of rows) { + const decoded = yield* decodeRecord(row.record_json).pipe(Effect.option); + if (Option.isNone(decoded)) { + yield* Effect.logWarning("Skipping an unreadable plugin installation row"); + continue; + } + installations.set(decoded.value.installationId, { + record: decoded.value, + registered: undefined, + stopping: undefined, + }); + } + + // Plugin state changes reach subscribers as fresh snapshots. + const supervisorEvents = yield* supervisor.subscribe; + yield* Stream.fromSubscription(supervisorEvents).pipe( + Stream.filter((event) => event._tag === "StateChanged"), + Stream.runForEach(() => notify), + Effect.forkScoped, + ); + + // So do changes of event delivery state. + if (Option.isSome(eventDeliveries)) { + const deliveryChanges = yield* eventDeliveries.value.changes; + yield* Stream.fromSubscription(deliveryChanges).pipe( + Stream.runForEach(() => notify), + Effect.forkScoped, + ); + } + + // Re-register what was enabled, off the startup path. Changed bytes are disabled here; + // nothing starts a process until it is used. Starting at once takes the lock before this + // returns, so no management step can act on an installation still waiting to be restored. + yield* managed( + Effect.forEach( + [...installations.values()].filter((installation) => installation.record.enabled), + (installation) => + reinspect(installation).pipe( + Effect.flatMap((inspection) => + inspection._tag === "ok" && installation.record.enabled + ? register(installation, inspection.registration) + : Effect.void, + ), + Effect.catch((error) => + Effect.logWarning("Could not re-enable a plugin at startup", { + installationId: installation.record.installationId, + detail: error.message, + }).pipe(Effect.andThen(disableInstallation(installation).pipe(Effect.ignore))), + ), + ), + { discard: true }, + ), + ).pipe( + Effect.ensuring(Deferred.succeed(restored, undefined)), + Effect.forkIn(scope, { startImmediately: true }), + ); + + return PluginCatalog.of({ + list, + revision: Effect.sync(() => revision), + subscribe: Stream.unwrap( + // Subscribe before the first snapshot so a change in between is not lost. + PubSub.subscribe(changes).pipe( + Effect.map((subscription) => + Stream.concat( + Stream.fromEffect(list), + Stream.fromSubscription(subscription).pipe(Stream.mapEffect(() => list)), + ).pipe( + // A plugin state event and the step that caused it can describe the same snapshot. + Stream.changes, + ), + ), + ), + ), + add: (input) => managed(add(input)), + refresh: (input) => managed(refresh(input)), + consent: (input) => managed(consent(input)), + enable: (input) => managed(enable(input)), + disable: (input) => managed(disable(input)), + remove: (input) => managed(remove(input)), + resume: (input) => managed(resume(input)), + replace: (input, files) => managed(replace(input, files)), + settleReplace: (input, files) => managed(settleReplace(input, files)), + changeFiles: (paths, effect) => managed(changeFiles(paths, effect)), + invoke, + }); +}); + +export const layer = (sourceLimits?: PluginSourceLimits) => + Layer.effect(PluginCatalog, make(sourceLimits)); diff --git a/apps/server/src/plugins/PluginCatalogRpc.test.ts b/apps/server/src/plugins/PluginCatalogRpc.test.ts new file mode 100644 index 000000000000..bd46da4c1881 --- /dev/null +++ b/apps/server/src/plugins/PluginCatalogRpc.test.ts @@ -0,0 +1,148 @@ +import { + AuthAccessWriteScope, + AuthAdministrativeScopes, + type AuthEnvironmentScope, + AuthOrchestrationReadScope, + AuthRelayReadScope, + AuthStandardClientScopes, + PluginCatalogError, + PluginInstallationId, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as RpcTest from "effect/rpc/RpcTest"; + +import { RPC_REQUIRED_SCOPES } from "../auth/RpcAuthorization.ts"; +import * as RpcAuthorization from "../auth/RpcAuthorization.ts"; + +const reads = [WS_METHODS.pluginsList, WS_METHODS.pluginsSubscribe] as const; +const writes = [ + WS_METHODS.pluginsAdd, + WS_METHODS.pluginsRefresh, + WS_METHODS.pluginsConsent, + WS_METHODS.pluginsEnable, + WS_METHODS.pluginsDisable, + WS_METHODS.pluginsRemove, + WS_METHODS.pluginsResume, +] as const; +type PluginMethod = (typeof reads)[number] | (typeof writes)[number]; +const pluginMethods: ReadonlySet = new Set([...reads, ...writes]); + +const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => + !pluginMethods.has(tag), + ), +); + +const installationId = PluginInstallationId.make("fixture"); +const digest = `sha256:${"0".repeat(64)}`; + +/** Serves the plugin RPCs through the real scope middleware; handlers record that they ran. */ +const makeClient = (scopes: ReadonlyArray, handled: Array) => { + // Mutations answer with a catalogue error: reaching it proves the middleware let the call in. + const mutation = (method: string) => () => + Effect.sync(() => handled.push(method)).pipe( + Effect.andThen( + Effect.fail(new PluginCatalogError({ reason: "not-found", message: "fixture" })), + ), + ); + return RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.pluginsList, () => + Effect.sync(() => handled.push(WS_METHODS.pluginsList)).pipe( + Effect.as({ installations: [] }), + ), + ), + group.toLayerHandler(WS_METHODS.pluginsSubscribe, () => + Stream.fromEffect( + Effect.sync(() => handled.push(WS_METHODS.pluginsSubscribe)).pipe( + Effect.as({ installations: [] }), + ), + ), + ), + group.toLayerHandler(WS_METHODS.pluginsAdd, mutation(WS_METHODS.pluginsAdd)), + group.toLayerHandler(WS_METHODS.pluginsRefresh, mutation(WS_METHODS.pluginsRefresh)), + group.toLayerHandler(WS_METHODS.pluginsConsent, mutation(WS_METHODS.pluginsConsent)), + group.toLayerHandler(WS_METHODS.pluginsEnable, mutation(WS_METHODS.pluginsEnable)), + group.toLayerHandler(WS_METHODS.pluginsDisable, mutation(WS_METHODS.pluginsDisable)), + group.toLayerHandler(WS_METHODS.pluginsRemove, mutation(WS_METHODS.pluginsRemove)), + group.toLayerHandler(WS_METHODS.pluginsResume, mutation(WS_METHODS.pluginsResume)), + RpcAuthorization.layer(scopes), + ), + ), + ); +}; + +/** Calls every management RPC and returns the tag each one failed with. */ +const callWrites = (client: Effect.Success>) => + Effect.all([ + client[WS_METHODS.pluginsAdd]({ directory: "/plugins/fixture" }).pipe(Effect.flip), + client[WS_METHODS.pluginsRefresh]({}).pipe(Effect.flip), + client[WS_METHODS.pluginsConsent]({ installationId, digest }).pipe(Effect.flip), + client[WS_METHODS.pluginsEnable]({ installationId }).pipe(Effect.flip), + client[WS_METHODS.pluginsDisable]({ installationId }).pipe(Effect.flip), + client[WS_METHODS.pluginsRemove]({ installationId }).pipe(Effect.flip), + client[WS_METHODS.pluginsResume]({ installationId }).pipe(Effect.flip), + ]); + +describe("plugin RPC scopes", () => { + it.effect("lets a standard pairing read the catalogue but not change what runs", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient(AuthStandardClientScopes, handled); + + expect(yield* client[WS_METHODS.pluginsList]({})).toEqual({ installations: [] }); + expect( + yield* client[WS_METHODS.pluginsSubscribe]({}).pipe(Stream.take(1), Stream.runCollect), + ).toEqual([{ installations: [] }]); + + const failures = yield* callWrites(client); + for (const failure of failures) { + expect(failure).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: AuthAccessWriteScope, + requiredPermission: AuthAccessWriteScope, + }); + } + expect(handled).toEqual([WS_METHODS.pluginsList, WS_METHODS.pluginsSubscribe]); + }).pipe(Effect.scoped), + ); + + it.effect("lets an administrative pairing manage plugins", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient(AuthAdministrativeScopes, handled); + + const failures = yield* callWrites(client); + for (const failure of failures) expect(failure._tag).toBe("PluginCatalogError"); + expect(handled).toEqual([...writes]); + }).pipe(Effect.scoped), + ); + + it.effect("refuses catalogue reads without the orchestration read scope", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient([AuthRelayReadScope], handled); + + expect(yield* client[WS_METHODS.pluginsList]({}).pipe(Effect.flip)).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: AuthOrchestrationReadScope, + requiredPermission: AuthOrchestrationReadScope, + }); + expect( + yield* client[WS_METHODS.pluginsSubscribe]({}).pipe(Stream.runCollect, Effect.flip), + ).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: AuthOrchestrationReadScope, + requiredPermission: AuthOrchestrationReadScope, + }); + expect(handled).toEqual([]); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/plugins/PluginEventDelivery.ts b/apps/server/src/plugins/PluginEventDelivery.ts new file mode 100644 index 000000000000..ed06a5de5c62 --- /dev/null +++ b/apps/server/src/plugins/PluginEventDelivery.ts @@ -0,0 +1,116 @@ +/** + * The part of plugin event delivery that the plugin catalogue calls into. It + * lets the catalogue start a plugin's event cursor, show how delivery is + * going, and resume it, without depending on the event feed, which itself + * depends on the catalogue. + */ +import { + PLUGIN_EVENTS_CAPABILITY, + type PluginEventDeliveryState, + type PluginInstallationId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import type * as Scope from "effect/Scope"; +import * as SqlClient from "effect/sql/SqlClient"; +import type * as SqlError from "effect/sql/SqlError"; + +export class PluginEventDelivery extends Context.Service< + PluginEventDelivery, + { + /** + * Gives an installation that declares `events` its starting cursor, the + * current end of the event log, unless it already has one. The catalogue + * runs this in the transaction that saves the installation as enabled, so + * every event committed after the enable is delivered and none from + * before it. Re-enables and restarts keep the stored cursor. + */ + readonly begin: ( + installationId: PluginInstallationId, + capabilities: ReadonlyArray, + ) => Effect.Effect; + /** The delivery state of one registration, if the feed reported one. */ + readonly state: ( + installationId: PluginInstallationId, + generation: number, + ) => Effect.Effect>; + /** Reports a registration's state; `undefined` when it no longer delivers. */ + readonly report: ( + installationId: PluginInstallationId, + generation: number, + state: PluginEventDeliveryState | undefined, + ) => Effect.Effect; + /** Signals each reported change of a state (never a cursor move). Sliding, 1. */ + readonly changes: Effect.Effect, never, Scope.Scope>; + /** Restarts quarantined or retrying delivery from its cursor. Nothing else changes. */ + readonly resume: (installationId: PluginInstallationId) => Effect.Effect; + /** Installs what `resume` runs, for as long as the scope is open. */ + readonly handleResume: ( + resume: (installationId: PluginInstallationId) => Effect.Effect, + ) => Effect.Effect; + } +>()("t3/plugins/PluginEventDelivery") {} + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const states = new Map< + PluginInstallationId, + { readonly generation: number; readonly state: PluginEventDeliveryState } + >(); + const changes = yield* PubSub.sliding(1); + let resume: ((installationId: PluginInstallationId) => Effect.Effect) | undefined; + + return PluginEventDelivery.of({ + begin: (installationId, capabilities) => + capabilities.includes(PLUGIN_EVENTS_CAPABILITY) + ? Effect.gen(function* () { + // One statement, so the end it reads is the end when the row is written. + yield* sql` + INSERT INTO plugin_event_cursors (installation_id, acknowledged_sequence, updated_at) + SELECT ${installationId}, COALESCE(MAX(sequence), 0), ${DateTime.formatIso(yield* DateTime.now)} + FROM orchestration_events + WHERE application_event_version = 2 + AND aggregate_kind = 'thread' + ON CONFLICT (installation_id) DO NOTHING + `; + }) + : Effect.void, + state: (installationId, generation) => + Effect.sync(() => { + const current = states.get(installationId); + return current?.generation === generation ? Option.some(current.state) : Option.none(); + }), + report: (installationId, generation, state) => + Effect.suspend(() => { + const current = states.get(installationId); + if (state === undefined) { + if (current?.generation !== generation) return Effect.void; + states.delete(installationId); + } else { + if (current?.generation === generation && Equal.equals(current.state, state)) + return Effect.void; + states.set(installationId, { generation, state }); + } + return PubSub.publish(changes, undefined).pipe(Effect.asVoid); + }), + changes: PubSub.subscribe(changes), + resume: (installationId) => Effect.suspend(() => resume?.(installationId) ?? Effect.void), + handleResume: (handler) => + Effect.acquireRelease( + Effect.sync(() => { + resume = handler; + }), + () => + Effect.sync(() => { + if (resume === handler) resume = undefined; + }), + ), + }); +}); + +export const layer = Layer.effect(PluginEventDelivery, make); diff --git a/apps/server/src/plugins/PluginEventFeed.test.ts b/apps/server/src/plugins/PluginEventFeed.test.ts new file mode 100644 index 000000000000..b97e0f81000c --- /dev/null +++ b/apps/server/src/plugins/PluginEventFeed.test.ts @@ -0,0 +1,857 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + EventId, + PluginCatalogError, + type PluginCatalogSnapshot, + type PluginEventDeliveryState, + type PluginInstallationId, + ProjectId, + ProviderInstanceId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +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 PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; +import * as TestClock from "effect/testing/TestClock"; + +import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; +import * as EventSink from "../orchestration-v2/EventSink.ts"; +import * as EventStore from "../orchestration-v2/EventStore.ts"; +import { LiveStreamBufferError } from "../orchestration-v2/LiveStreamBudget.ts"; +import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as PluginCatalog from "./PluginCatalog.ts"; +import * as PluginEventDelivery from "./PluginEventDelivery.ts"; +import * as PluginEventFeed from "./PluginEventFeed.ts"; +import * as PluginManifestLoader from "./PluginManifestLoader.ts"; +import * as PluginSupervisor from "./PluginSupervisor.ts"; + +// Children run the real CLI entry, which routes `__plugin-host` to the child runtime. +const BIN_PATH = `${import.meta.dirname}/../bin.ts`; + +const environmentId = EnvironmentId.make("environment:plugin-events"); +const threadId = ThreadId.make("thread:plugin-events"); +const projectId = ProjectId.make("project:plugin-events"); +const providerInstanceId = ProviderInstanceId.make("codex"); + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +type Receipts = PubSub.Subscription; + +type Catalog = PluginCatalog.PluginCatalog["Service"]; + +/** A catalogue whose snapshots the feed sees only once `observed` completes. */ +const observedAfter = + (observed: Deferred.Deferred) => + (catalog: Catalog): Catalog => ({ + ...catalog, + subscribe: Stream.unwrap(Deferred.await(observed).pipe(Effect.as(catalog.subscribe))), + }); + +type Sink = EventSink.EventSinkV2["Service"]; + +/** + * Starts a supervisor, catalogue and event feed in `scope`, as one server start + * would. `feedSees` changes the catalogue or event sink the feed sees. + */ +const startServer = Effect.fn("startServer")(function* ( + scope: Scope.Scope, + options: Partial = {}, + feedSees: { + readonly catalog?: (catalog: Catalog) => Catalog; + readonly eventSink?: (eventSink: Sink) => Sink; + } = {}, +) { + const delivery = yield* PluginEventDelivery.make; + const supervisor = yield* PluginSupervisor.make({ + heapLimitMb: 64, + activationTimeout: "10 seconds", + stopGrace: "1 second", + }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + Effect.provideService(Scope.Scope, scope), + ); + const catalog = yield* PluginCatalog.make().pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor), + Effect.provideService(PluginEventDelivery.PluginEventDelivery, delivery), + Effect.provideService(Scope.Scope, scope), + ); + const feed = yield* PluginEventFeed.make(options).pipe( + Effect.provideService(PluginCatalog.PluginCatalog, feedSees.catalog?.(catalog) ?? catalog), + Effect.provideService( + EventSink.EventSinkV2, + feedSees.eventSink?.(yield* EventSink.EventSinkV2) ?? (yield* EventSink.EventSinkV2), + ), + Effect.provideService(PluginEventDelivery.PluginEventDelivery, delivery), + Effect.provideService( + ServerEnvironment, + ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.die("unused"), + }), + ), + Effect.provideService(Scope.Scope, scope), + ); + const receipts: Receipts = yield* feed.subscribe.pipe(Effect.provideService(Scope.Scope, scope)); + return { catalog, feed, receipts }; +}); + +/** + * Writes a plugin that appends each event it handles to `events.log` next to + * its directory, and fails every event while a `fail` file exists there. + */ +const preparePlugin = Effect.fn("preparePlugin")(function* (id: string, onEvent = true) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-events-" }); + const directory = path.join(root, "plugin"); + yield* fs.makeDirectory(directory); + const log = path.join(root, "events.log"); + const fail = path.join(root, "fail"); + yield* fs.writeFileString( + path.join(directory, "main.mjs"), + [ + `import * as NodeFS from "node:fs";`, + `export function activate(context) {`, + ` if (!${onEvent}) return;`, + ` context.proposed.onEvent((event) => {`, + ` if (NodeFS.existsSync(${toJson(fail)})) throw new Error("told to fail");`, + ` NodeFS.appendFileSync(${toJson(log)}, JSON.stringify(event) + "\\n");`, + ` });`, + `}`, + ``, + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ + id, + name: id, + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + capabilities: ["events"], + proposedApi: true, + }), + ); + const handled = fs.exists(log).pipe( + Effect.flatMap((exists) => (exists ? fs.readFileString(log) : Effect.succeed(""))), + Effect.map((content) => + content + .split("\n") + .filter((line) => line !== "") + .map((line) => JSON.parse(line) as Record), + ), + ); + return { + directory, + handled, + failing: (on: boolean) => (on ? fs.writeFileString(fail, "") : fs.remove(fail)), + }; +}); + +const install = Effect.fn("install")(function* (catalog: Catalog, directory: string) { + const { installation } = yield* catalog.add({ directory }); + const installationId = installation.installationId; + yield* catalog.consent({ installationId, digest: installation.source!.digest }); + yield* catalog.enable({ installationId }); + return installationId; +}); + +/** Follows the event delivery states that catalogue subscribers see. */ +const deliveryStates = Effect.fn("deliveryStates")(function* (catalog: Catalog) { + const snapshots = yield* Queue.unbounded(); + yield* catalog.subscribe.pipe( + Stream.runForEach((snapshot) => Queue.offer(snapshots, snapshot)), + Effect.forkScoped, + ); + const stateOf = (snapshot: PluginCatalogSnapshot, installationId: PluginInstallationId) => + snapshot.installations.find((installation) => installation.installationId === installationId) + ?.eventDelivery; + return { + /** Takes snapshots through the first that shows `tag`; returns each state that changed on the way. */ + untilTag: (installationId: PluginInstallationId, tag: PluginEventDeliveryState["_tag"]) => + Effect.gen(function* () { + const states: Array = []; + while (true) { + const state = stateOf(yield* Queue.take(snapshots), installationId); + if (state?._tag !== states.at(-1)?._tag) states.push(state); + if (state?._tag === tag) return states; + } + }), + /** Takes snapshots until one shows `tag` for the installation; earlier ones are dropped. */ + next: (installationId: PluginInstallationId, tag: PluginEventDeliveryState["_tag"]) => + Effect.gen(function* () { + while (true) { + const snapshot = yield* Queue.take(snapshots); + const state = snapshot.installations.find( + (installation) => installation.installationId === installationId, + )?.eventDelivery; + if (state?._tag === tag) return state; + } + }), + }; +}); + +/** Takes receipts until one matches; earlier ones are dropped. */ +const next = ( + receipts: Receipts, + tag: Tag, + installationId: PluginInstallationId, +) => + Effect.gen(function* () { + while (true) { + const receipt = yield* PubSub.take(receipts); + if (receipt._tag === tag && receipt.installationId === installationId) + return receipt as Extract; + } + }); + +const seedThread = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:plugin-events:thread"), + type: "thread.created", + threadId, + providerInstanceId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId, + title: "Fix the login bug", + providerInstanceId, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }, + ], + }); +}); + +/** Records a run's finalization the way RunFinalizationService does; returns its sequence. */ +const finalizeRun = (name: string, failedAt?: "refresh-workspace") => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const runId = RunId.make(`run:${name}`); + const envelope = { + id: EventId.make(`event:run-finalized:${runId}`), + threadId, + runId, + providerInstanceId, + occurredAt: yield* DateTime.now, + }; + const [stored] = yield* eventSink.write({ + events: [ + failedAt === undefined + ? { + ...envelope, + type: "run.finalized", + payload: { runId, outcome: "completed", checkpointId: null }, + } + : { + ...envelope, + type: "run.finalization-failed", + payload: { runId, operation: failedAt }, + }, + ], + }); + return stored!.sequence; + }); + +const storedCursor = (installationId: PluginInstallationId) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly acknowledged_sequence: number }>` + SELECT acknowledged_sequence FROM plugin_event_cursors + WHERE installation_id = ${installationId} + `; + return rows[0]?.acknowledged_sequence; + }); + +// One database, event store and projection per test; "restarts" replace the plugin side. +const withStores = (effect: Effect.Effect) => { + const stores = Layer.mergeAll(EventStore.layer, ProjectionStore.layer).pipe( + Layer.provideMerge(SqlitePersistence.layerMemory), + ); + return effect.pipe(Effect.provide(EventSink.layer.pipe(Layer.provideMerge(stores)))); +}; + +it.layer(NodeServices.layer)("PluginEventFeed", (it) => { + describe("delivery", () => { + it.effect("delivers finished turns once, in order, and keeps the cursor across a restart", () => + withStores( + Effect.gen(function* () { + yield* seedThread; + const plugin = yield* preparePlugin("test.turn-notifier"); + const first = yield* Scope.make(); + const server = yield* startServer(first, { pageSize: 2 }); + const installationId = yield* install(server.catalog, plugin.directory); + // The first start begins at the end of the log: the thread's creation is not delivered. + const started = yield* next(server.receipts, "Started", installationId); + + const sequences = yield* Effect.forEach(["a", "b", "c"], (name) => finalizeRun(name)); + const failedSequence = yield* finalizeRun("d", "refresh-workspace"); + let acknowledged = started.cursor; + while (acknowledged < failedSequence) { + const receipt = yield* next(server.receipts, "Acknowledged", installationId); + // Pages hold at most two events. + expect(receipt.delivered).toBeLessThanOrEqual(2); + acknowledged = receipt.throughSequence; + } + const handled = yield* plugin.handled; + expect(handled.map((event) => event.sequence)).toEqual([...sequences, failedSequence]); + expect(handled[0]).toEqual({ + deliveryId: "event:run-finalized:run:a", + sequence: sequences[0], + occurredAt: expect.any(String), + environmentId, + threadId, + runId: "run:a", + thread: { projectId, title: "Fix the login bug" }, + type: "run.finalized", + outcome: "completed", + }); + expect(handled[3]).toMatchObject({ + deliveryId: "event:run-finalized:run:d", + type: "run.finalization-failed", + operation: "refresh-workspace", + }); + expect(yield* storedCursor(installationId)).toBe(failedSequence); + + yield* Scope.close(first, Exit.void); + const later = yield* finalizeRun("e"); + const restarted = yield* startServer(yield* Scope.Scope); + const receipt = yield* next(restarted.receipts, "Acknowledged", installationId); + expect(receipt).toMatchObject({ delivered: 1, throughSequence: later }); + // Nothing acknowledged before the restart arrives again. + expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([ + ...sequences, + failedSequence, + later, + ]); + }), + ), + ); + }); + + describe("wakeups", () => { + it.effect("replaces a wakeup subscription that fell behind and delivers what it missed", () => + withStores( + Effect.gen(function* () { + yield* seedThread; + const plugin = yield* preparePlugin("test.overflow"); + const overflow = yield* Deferred.make(); + const resubscribed = yield* Deferred.make(); + const reopen = yield* Deferred.make(); + const requests: Array[0]> = []; + const { catalog, receipts } = yield* startServer( + yield* Scope.Scope, + {}, + { + eventSink: (eventSink) => ({ + ...eventSink, + stream: (input) => { + requests.push(input); + // The first subscription per type overflows on demand; the next ones wait + // for `reopen`, so commits in between reach no live subscription. + return requests.length <= 2 + ? Stream.merge( + eventSink.stream(input), + Stream.fromEffect(Deferred.await(overflow)).pipe( + Stream.flatMap(() => + Stream.fail( + new EventSink.EventSinkStreamError({ + cause: new LiveStreamBufferError({ message: "full" }), + }), + ), + ), + ), + ) + : Stream.unwrap( + Deferred.succeed(resubscribed, undefined).pipe( + Effect.andThen(Deferred.await(reopen)), + Effect.as(eventSink.stream(input)), + ), + ); + }, + }), + }, + ); + const installationId = yield* install(catalog, plugin.directory); + yield* next(receipts, "Started", installationId); + const before = yield* finalizeRun("before-overflow"); + yield* next(receipts, "Acknowledged", installationId); + + yield* Deferred.succeed(overflow, undefined); + yield* Deferred.await(resubscribed); + const missed = yield* finalizeRun("while-resubscribing"); + yield* Deferred.succeed(reopen, undefined); + expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({ + delivered: 1, + throughSequence: missed, + }); + expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([before, missed]); + // Every subscription is bounded, and a new one starts at the log's end, not at startup. + expect(requests.every((input) => input?.bounded === true)).toBe(true); + expect(requests.slice(2).map((input) => input?.afterSequence)).toEqual([before, before]); + }), + ), + ); + }); + + describe("starting point", () => { + it.effect( + "delivers events committed after enable even when the feed sees the enable late", + () => + withStores( + Effect.gen(function* () { + yield* seedThread; + const plugin = yield* preparePlugin("test.late-observer"); + const observed = yield* Deferred.make(); + const server = yield* startServer( + yield* Scope.Scope, + {}, + { catalog: observedAfter(observed) }, + ); + const installationId = yield* install(server.catalog, plugin.directory); + const enabledAt = yield* storedCursor(installationId); + const sequence = yield* finalizeRun("right-after-enable"); + expect(enabledAt).toBeLessThan(sequence); + + yield* Deferred.succeed(observed, undefined); + expect(yield* next(server.receipts, "Started", installationId)).toMatchObject({ + cursor: enabledAt, + }); + expect(yield* next(server.receipts, "Acknowledged", installationId)).toMatchObject({ + delivered: 1, + throughSequence: sequence, + }); + expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([sequence]); + }), + ), + ); + + it.effect( + "keeps the enable's starting point when the server stops before the feed saw it", + () => + withStores( + Effect.gen(function* () { + yield* seedThread; + const plugin = yield* preparePlugin("test.early-restart"); + const first = yield* Scope.make(); + const server = yield* startServer( + first, + {}, + { catalog: observedAfter(yield* Deferred.make()) }, + ); + const installationId = yield* install(server.catalog, plugin.directory); + const sequence = yield* finalizeRun("before-restart"); + yield* Scope.close(first, Exit.void); + + const restarted = yield* startServer(yield* Scope.Scope); + expect(yield* next(restarted.receipts, "Acknowledged", installationId)).toMatchObject({ + delivered: 1, + throughSequence: sequence, + }); + expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([sequence]); + }), + ), + ); + }); + + describe("handler failures", () => { + it.effect("retries a failing page, quarantines it without moving the cursor, and resumes", () => + withStores( + Effect.gen(function* () { + yield* seedThread; + const plugin = yield* preparePlugin("test.failing"); + const { catalog, feed, receipts } = yield* startServer(yield* Scope.Scope, { + maxFailures: 2, + retryBackoff: "1 second", + }); + const shown = yield* deliveryStates(catalog); + const installationId = yield* install(catalog, plugin.directory); + const { cursor } = yield* next(receipts, "Started", installationId); + expect(yield* shown.next(installationId, "active")).toEqual({ _tag: "active" }); + + yield* plugin.failing(true); + const sequence = yield* finalizeRun("failing"); + const failed = yield* next(receipts, "Failed", installationId); + expect(failed).toMatchObject({ cursor, failures: 1 }); + expect(failed.reason).toContain("told to fail"); + expect(yield* shown.next(installationId, "retrying")).toMatchObject({ failures: 1 }); + yield* TestClock.adjust("1 second"); + // The retry itself is not shown as active again. + const states = yield* shown.untilTag(installationId, "quarantined"); + expect(states.map((state) => state?._tag)).not.toContain("active"); + const quarantined = yield* next(receipts, "Quarantined", installationId); + expect(quarantined).toMatchObject({ cursor, failures: 2 }); + expect(yield* storedCursor(installationId)).toBe(cursor); + const status = Option.getOrThrow(yield* feed.status(installationId)); + expect(status).toMatchObject({ cursor, state: { _tag: "quarantined", failures: 2 } }); + // Management sees the quarantine and why, beside a plugin process that still runs. + const visible = states.at(-1)!; + expect(visible).toMatchObject({ failures: 2 }); + expect(visible._tag === "quarantined" && visible.reason).toContain("told to fail"); + const [listed] = (yield* catalog.list).installations; + expect(listed?.hostState?._tag).toBe("running"); + expect(listed?.eventDelivery?._tag).toBe("quarantined"); + + // Quarantine waits for a person, even when the handler would succeed now. + yield* plugin.failing(false); + yield* TestClock.adjust("1 minute"); + expect((yield* feed.status(installationId)).pipe(Option.getOrThrow).state._tag).toBe( + "quarantined", + ); + expect(yield* plugin.handled).toEqual([]); + + // The one management resume clears it, and its answer already shows delivery active. + const resumed = yield* catalog.resume({ installationId }); + expect(resumed.installation.eventDelivery).toEqual({ _tag: "active" }); + expect(yield* next(receipts, "Started", installationId)).toMatchObject({ cursor }); + expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({ + delivered: 1, + throughSequence: sequence, + }); + expect(yield* shown.next(installationId, "active")).toEqual({ _tag: "active" }); + expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([sequence]); + + // Disable removes the state with the registration. + const disabled = yield* catalog.disable({ installationId }); + expect(disabled.installation.eventDelivery).toBeUndefined(); + }), + ), + ); + it.effect("stops in front of an unreadable event and delivers it once repaired", () => + withStores( + Effect.gen(function* () { + yield* seedThread; + const sql = yield* SqlClient.SqlClient; + const plugin = yield* preparePlugin("test.unreadable"); + const observed = yield* Deferred.make(); + const { catalog, receipts } = yield* startServer( + yield* Scope.Scope, + {}, + { catalog: observedAfter(observed) }, + ); + const installationId = yield* install(catalog, plugin.directory); + const [first, broken, last] = yield* Effect.forEach(["a", "b", "c"], (name) => + finalizeRun(name), + ); + const [stored] = yield* sql<{ readonly payload_json: string }>` + SELECT payload_json FROM orchestration_events WHERE sequence = ${broken} + `; + yield* sql`UPDATE orchestration_events SET payload_json = '{}' WHERE sequence = ${broken}`; + yield* Deferred.succeed(observed, undefined); + + expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({ + delivered: 1, + throughSequence: broken! - 1, + }); + const quarantined = yield* next(receipts, "Quarantined", installationId); + expect(quarantined).toMatchObject({ cursor: broken! - 1, failures: 0 }); + expect(quarantined.reason).toContain(`sequence ${broken}`); + expect(yield* storedCursor(installationId)).toBe(broken! - 1); + expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([first]); + + yield* sql` + UPDATE orchestration_events SET payload_json = ${stored!.payload_json} + WHERE sequence = ${broken} + `; + yield* catalog.resume({ installationId }); + expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({ + delivered: 2, + throughSequence: last, + }); + expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([ + first, + broken, + last, + ]); + }), + ), + ); + it.effect( + "stops in front of an event with unreadable identifiers and delivers it once repaired", + () => + withStores( + Effect.gen(function* () { + yield* seedThread; + const sql = yield* SqlClient.SqlClient; + const plugin = yield* preparePlugin("test.unreadable-ids"); + const observed = yield* Deferred.make(); + const { catalog, receipts } = yield* startServer( + yield* Scope.Scope, + {}, + { catalog: observedAfter(observed) }, + ); + const installationId = yield* install(catalog, plugin.directory); + const [first, broken, last] = yield* Effect.forEach(["a", "b", "c"], (name) => + finalizeRun(name), + ); + const [stored] = yield* sql<{ readonly event_id: string; readonly stream_id: string }>` + SELECT event_id, stream_id FROM orchestration_events WHERE sequence = ${broken} + `; + yield* sql` + UPDATE orchestration_events SET event_id = '', stream_id = '' WHERE sequence = ${broken} + `; + yield* Deferred.succeed(observed, undefined); + + expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({ + delivered: 1, + throughSequence: broken! - 1, + }); + const quarantined = yield* next(receipts, "Quarantined", installationId); + expect(quarantined).toMatchObject({ cursor: broken! - 1, failures: 0 }); + expect(quarantined.reason).toContain(`sequence ${broken}`); + expect(yield* storedCursor(installationId)).toBe(broken! - 1); + expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([first]); + + yield* sql` + UPDATE orchestration_events + SET event_id = ${stored!.event_id}, stream_id = ${stored!.stream_id} + WHERE sequence = ${broken} + `; + yield* catalog.resume({ installationId }); + expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({ + delivered: 2, + throughSequence: last, + }); + expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([ + first, + broken, + last, + ]); + }), + ), + ); + it.effect("retries after catalogue errors while still registered, without quarantine", () => + withStores( + Effect.gen(function* () { + yield* seedThread; + const plugin = yield* preparePlugin("test.storage"); + // The first two calls fail outside the plugin, the way a failed catalogue save does. + const refusals = ["storage", "unavailable"]; + const { catalog, feed, receipts } = yield* startServer( + yield* Scope.Scope, + { maxFailures: 1, retryBackoff: "1 second" }, + { + catalog: (catalog) => ({ + ...catalog, + invoke: (installationId, handler, input, options) => { + const reason = refusals.shift(); + return reason === undefined + ? catalog.invoke(installationId, handler, input, options) + : Effect.fail( + new PluginCatalogError({ reason, message: `Refused: ${reason}.` }), + ); + }, + }), + }, + ); + const installationId = yield* install(catalog, plugin.directory); + const { cursor, generation } = yield* next(receipts, "Started", installationId); + const sequence = yield* finalizeRun("after-storage-trouble"); + + expect(yield* next(receipts, "Retrying", installationId)).toMatchObject({ + cursor, + generation, + reason: "Refused: storage.", + }); + expect(Option.getOrThrow(yield* feed.status(installationId)).state).toMatchObject({ + _tag: "retrying", + failures: 1, + }); + yield* TestClock.adjust("1 second"); + expect(yield* next(receipts, "Retrying", installationId)).toMatchObject({ + reason: "Refused: unavailable.", + }); + yield* TestClock.adjust("2 seconds"); + expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({ + generation, + delivered: 1, + throughSequence: sequence, + }); + expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([sequence]); + }), + ), + ); + it.effect("keeps showing retrying after a cursor save fails until the page is saved", () => + withStores( + Effect.gen(function* () { + yield* seedThread; + const sql = yield* SqlClient.SqlClient; + const plugin = yield* preparePlugin("test.cursor-save"); + // The second call, the retry after the failed save, waits for `release`. + const retried = yield* Deferred.make(); + const release = yield* Deferred.make(); + let calls = 0; + const { catalog, feed, receipts } = yield* startServer( + yield* Scope.Scope, + {}, + { + catalog: (catalog) => ({ + ...catalog, + invoke: (installationId, handler, input, options) => { + const held = + ++calls === 2 + ? Deferred.succeed(retried, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ) + : Effect.void; + return held.pipe( + Effect.andThen(catalog.invoke(installationId, handler, input, options)), + ); + }, + }), + }, + ); + const shown = yield* deliveryStates(catalog); + const installationId = yield* install(catalog, plugin.directory); + const { cursor } = yield* next(receipts, "Started", installationId); + expect(yield* shown.next(installationId, "active")).toEqual({ _tag: "active" }); + + yield* sql` + CREATE TRIGGER refuse_cursor_save BEFORE UPDATE ON plugin_event_cursors + BEGIN SELECT RAISE(ABORT, 'disk trouble'); END + `; + const sequence = yield* finalizeRun("cursor-save-fails"); + expect(yield* shown.next(installationId, "retrying")).toMatchObject({ + failures: 1, + reason: "Could not read or save event delivery progress.", + }); + yield* sql`DROP TRIGGER refuse_cursor_save`; + yield* TestClock.adjust("1 minute"); + + // The page is being delivered again but has not succeeded: still retrying, not active. + yield* Deferred.await(retried); + expect(Option.getOrThrow(yield* feed.status(installationId)).state._tag).toBe("retrying"); + const [listed] = (yield* catalog.list).installations; + expect(listed?.eventDelivery?._tag).toBe("retrying"); + expect(yield* storedCursor(installationId)).toBe(cursor); + + yield* Deferred.succeed(release, undefined); + expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({ + delivered: 1, + throughSequence: sequence, + }); + expect(yield* shown.next(installationId, "active")).toEqual({ _tag: "active" }); + // At least once: the page answered before the failed save is delivered again. + expect((yield* plugin.handled).map((event) => event.sequence)).toEqual([ + sequence, + sequence, + ]); + }), + ), + ); + }); + + describe("plugin contract", () => { + it.effect("refuses the events capability without the proposed API opt-in", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-events-" }); + yield* fs.writeFileString( + path.join(directory, "main.mjs"), + "export function activate() {}\n", + ); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ + id: "test.no-opt-in", + name: "test.no-opt-in", + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + capabilities: ["events"], + }), + ); + const error = yield* PluginManifestLoader.loadPluginDirectory(directory).pipe(Effect.flip); + expect(error.reason).toContain('"events" capability needs "proposedApi": true'); + }).pipe(Effect.scoped), + ); + it.effect("fails delivery to a plugin that registered no onEvent handler", () => + withStores( + Effect.gen(function* () { + yield* seedThread; + const plugin = yield* preparePlugin("test.no-handler", false); + const { catalog, receipts } = yield* startServer(yield* Scope.Scope); + const installationId = yield* install(catalog, plugin.directory); + const { cursor } = yield* next(receipts, "Started", installationId); + yield* finalizeRun("unhandled"); + const failed = yield* next(receipts, "Failed", installationId); + expect(failed).toMatchObject({ cursor, failures: 1 }); + expect(failed.reason).toContain("registered no onEvent handler"); + }), + ), + ); + }); + + describe("reverse states", () => { + it.effect("stops on disable, resumes from the cursor on enable, and forgets it on remove", () => + withStores( + Effect.gen(function* () { + yield* seedThread; + const plugin = yield* preparePlugin("test.reverse"); + const { catalog, feed, receipts } = yield* startServer(yield* Scope.Scope); + const installationId = yield* install(catalog, plugin.directory); + const { cursor } = yield* next(receipts, "Started", installationId); + + yield* catalog.disable({ installationId }); + const missed = yield* finalizeRun("while-disabled"); + expect(yield* feed.status(installationId)).toEqual(Option.none()); + expect(yield* plugin.handled).toEqual([]); + + yield* catalog.enable({ installationId }); + expect(yield* next(receipts, "Started", installationId)).toMatchObject({ + cursor, + generation: 2, + }); + expect(yield* next(receipts, "Acknowledged", installationId)).toMatchObject({ + delivered: 1, + throughSequence: missed, + }); + + yield* catalog.remove({ installationId }); + yield* next(receipts, "Forgotten", installationId); + expect(yield* storedCursor(installationId)).toBeUndefined(); + expect(yield* feed.status(installationId)).toEqual(Option.none()); + }), + ), + ); + }); +}); diff --git a/apps/server/src/plugins/PluginEventFeed.ts b/apps/server/src/plugins/PluginEventFeed.ts new file mode 100644 index 000000000000..3c56caba7f8a --- /dev/null +++ b/apps/server/src/plugins/PluginEventFeed.ts @@ -0,0 +1,635 @@ +/** + * Delivers the curated event projection (contracts `PluginEvent`) to every + * enabled plugin that declares the `events` capability. + * + * Each such installation has a durable cursor: the event log sequence it has + * acknowledged through. Its worker reads the log after the cursor in bounded + * windows and pages, calls the plugin's `t3.events` handler through the + * catalogue, and moves the cursor past a page only after the plugin answered + * it. A failed page is retried with backoff and, after `maxFailures` + * consecutive failures, the worker is quarantined with the cursor unchanged + * until `plugins.resume`, a re-enable, or a server restart. Delivery is + * at-least-once. Each worker reports its state through `PluginEventDelivery`, + * which the catalogue shows as the installation's `eventDelivery`. + * + * The cursor starts at the end of the log when an installation is first + * enabled (the catalogue records it with the enable, through + * `PluginEventDelivery`), so a new plugin sees exactly the later events. It + * belongs to the installation: disable and re-enable, consent to changed + * bytes, and restarts continue from it; remove forgets it. Workers never + * subscribe to raw events: a commit of a projected event type only wakes + * them, and they read the store. + */ +import { + OrchestrationV2RunFinalizationFailed, + OrchestrationV2RunFinalized, + PLUGIN_EVENT_THREAD_TITLE_MAX_LENGTH, + PLUGIN_EVENT_TYPES, + PLUGIN_EVENTS_CAPABILITY, + PluginEvent, + PluginEventPage, + type EnvironmentId, + type PluginInstallation, + type PluginRunFinalizationFailedEvent, + type PluginRunFinalizedEvent, + type PluginInstallationId, + EventId, + ThreadId, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +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 Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; +import { EventSinkV2, type EventSinkV2Error } from "../orchestration-v2/EventSink.ts"; +import { LiveStreamBufferError } from "../orchestration-v2/LiveStreamBudget.ts"; +import { ProjectionStoreV2 } from "../orchestration-v2/ProjectionStore.ts"; +import { PluginCatalog } from "./PluginCatalog.ts"; +import { PluginEventDelivery } from "./PluginEventDelivery.ts"; +import { PLUGIN_EVENTS_HANDLER } from "./pluginIpcFraming.ts"; + +export interface PluginEventFeedOptions { + /** Most events in one page; at most what `PluginEventPage` accepts. */ + readonly pageSize: number; + /** Most log sequences one read scans, so a far-behind cursor never scans the whole log at once. */ + readonly scanWindow: number; + /** How long the plugin may take to answer one page. */ + readonly deliveryTimeout: Duration.Input; + readonly retryBackoff: Duration.Input; + readonly maxRetryBackoff: Duration.Input; + /** Consecutive failed attempts at one page before the worker is quarantined. */ + readonly maxFailures: number; +} + +const defaultOptions: PluginEventFeedOptions = { + pageSize: 32, + scanWindow: 4096, + deliveryTimeout: Duration.seconds(30), + retryBackoff: Duration.seconds(2), + maxRetryBackoff: Duration.minutes(1), + maxFailures: 5, +}; + +/** What one installation's worker is doing. Kept in memory; only the cursor is durable. */ +type PluginEventFeedState = + | { readonly _tag: "waiting" } + | { readonly _tag: "delivering" } + | { + readonly _tag: "retrying"; + readonly failures: number; + readonly reason: string; + readonly retryAt: string; + } + /** `failures` is 0 when delivery stopped in front of an event it cannot read. */ + | { readonly _tag: "quarantined"; readonly failures: number; readonly reason: string } + /** The registration it delivered to is gone; the next catalogue change replaces it. */ + | { readonly _tag: "stopped" }; + +interface PluginEventFeedStatus { + readonly generation: number; + /** The log sequence acknowledged through, or undefined before it was loaded. */ + readonly cursor: number | undefined; + readonly state: PluginEventFeedState; +} + +/** Milestones a worker reaches, for logs and tests. */ +export type PluginEventFeedReceipt = + | { + /** A worker loaded its cursor and delivers everything after it. */ + readonly _tag: "Started"; + readonly installationId: PluginInstallationId; + readonly generation: number; + readonly cursor: number; + } + | { + readonly _tag: "Acknowledged"; + readonly installationId: PluginInstallationId; + readonly generation: number; + /** The new cursor. */ + readonly throughSequence: number; + /** Events the plugin answered for; 0 when the window held none. */ + readonly delivered: number; + } + | { + readonly _tag: "Failed"; + readonly installationId: PluginInstallationId; + readonly generation: number; + readonly cursor: number; + readonly failures: number; + readonly reason: string; + } + | { + /** A step outside the plugin failed, such as catalogue storage; retried without counting. */ + readonly _tag: "Retrying"; + readonly installationId: PluginInstallationId; + readonly generation: number; + readonly cursor: number; + readonly reason: string; + } + | { + readonly _tag: "Quarantined"; + readonly installationId: PluginInstallationId; + readonly generation: number; + readonly cursor: number; + readonly failures: number; + readonly reason: string; + } + /** A removed installation's cursor was deleted. */ + | { readonly _tag: "Forgotten"; readonly installationId: PluginInstallationId }; + +export class PluginEventFeed extends Context.Service< + PluginEventFeed, + { + readonly status: ( + installationId: PluginInstallationId, + ) => Effect.Effect>; + /** Subscribes before returning, so no receipt after this point is missed (sliding, 1024). */ + readonly subscribe: Effect.Effect< + PubSub.Subscription, + never, + Scope.Scope + >; + } +>()("t3/plugins/PluginEventFeed") {} + +interface EventRow { + readonly sequence: number; + readonly event_id: string; + readonly event_type: string; + readonly stream_id: string; + readonly occurred_at: string; + readonly payload_json: string; +} + +interface Worker { + readonly generation: number; + cursor: number | undefined; + state: PluginEventFeedState; + fiber: Fiber.Fiber | undefined; +} + +const decodeFinalized = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2RunFinalized), +); +const decodeFinalizationFailed = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2RunFinalizationFailed), +); +const decodeEnvelope = Schema.decodeUnknownEffect( + Schema.Struct({ event_id: EventId, stream_id: ThreadId }), +); +const checkEvent = Schema.encodeEffect(PluginEvent); +const encodePage = Schema.encodeEffect(PluginEventPage); +const isLiveStreamBufferError = Schema.is(LiveStreamBufferError); + +/** Cuts `text` to `max` UTF-16 units without splitting a surrogate pair. */ +const truncate = (text: string, max: number) => { + if (text.length <= max) return text; + const end = /[\uD800-\uDBFF]/.test(text.charAt(max - 1)) ? max - 1 : max; + return text.slice(0, end); +}; + +/** True for an installation the feed delivers to: enabled, registered, and asking for events. */ +const receivesEvents = (installation: PluginInstallation) => + installation.enabled && + installation.hostState !== undefined && + installation.manifest?.capabilities.includes(PLUGIN_EVENTS_CAPABILITY) === true; + +export const make = Effect.fn("PluginEventFeed.make")(function* ( + overrides: Partial = {}, +) { + const options = { ...defaultOptions, ...overrides }; + const retryBackoff = Duration.fromInputUnsafe(options.retryBackoff); + const maxRetryBackoff = Duration.fromInputUnsafe(options.maxRetryBackoff); + const sql = yield* SqlClient.SqlClient; + const catalog = yield* PluginCatalog; + const eventSink = yield* EventSinkV2; + const projections = yield* ProjectionStoreV2; + const delivery = yield* PluginEventDelivery; + const environmentId: EnvironmentId = yield* (yield* ServerEnvironment).getEnvironmentId; + const scope = yield* Effect.scope; + + const workers = new Map(); + // Reconciling and resuming both replace workers; one at a time. + const lock = yield* Semaphore.make(1); + // A pending wake per worker is enough: the worker reads everything after its cursor. + const wakes = yield* PubSub.sliding(1); + const receipts = yield* PubSub.sliding(1024); + const publish = (receipt: PluginEventFeedReceipt) => + PubSub.publish(receipts, receipt).pipe(Effect.asVoid); + + const loadCursor = Effect.fnUntraced(function* (installationId: PluginInstallationId) { + // The catalogue started the cursor when the plugin was enabled; this covers a registration + // that skipped it. + yield* delivery.begin(installationId, [PLUGIN_EVENTS_CAPABILITY]); + const rows = yield* sql<{ readonly acknowledged_sequence: number }>` + SELECT acknowledged_sequence FROM plugin_event_cursors + WHERE installation_id = ${installationId} + `; + return rows[0]?.acknowledged_sequence ?? (yield* eventSink.latestSequence()); + }); + + const saveCursor = Effect.fnUntraced(function* ( + installationId: PluginInstallationId, + sequence: number, + ) { + yield* sql` + UPDATE plugin_event_cursors + SET acknowledged_sequence = ${sequence}, + updated_at = ${DateTime.formatIso(yield* DateTime.now)} + WHERE installation_id = ${installationId} + `; + }); + + const readRows = (afterSequence: number, throughSequence: number) => + sql` + SELECT sequence, event_id, event_type, stream_id, occurred_at, payload_json + FROM orchestration_events + WHERE sequence > ${afterSequence} + AND sequence <= ${throughSequence} + AND application_event_version = 2 + AND aggregate_kind = 'thread' + AND event_type IN ${sql.in(PLUGIN_EVENT_TYPES)} + ORDER BY sequence ASC + LIMIT ${options.pageSize} + `; + + /** + * Projects one stored row, or returns why it cannot be read. Such a row is + * never skipped: delivery stops in front of it until someone resumes. + */ + const project = Effect.fnUntraced(function* (row: EventRow) { + const decoding: Effect.Effect< + | Pick + | Pick, + Schema.SchemaError + > = + row.event_type === "run.finalized" + ? decodeFinalized(row.payload_json).pipe( + Effect.map((payload) => ({ + type: "run.finalized" as const, + runId: payload.runId, + outcome: payload.outcome, + })), + ) + : decodeFinalizationFailed(row.payload_json).pipe( + Effect.map((payload) => ({ + type: "run.finalization-failed" as const, + runId: payload.runId, + operation: payload.operation, + })), + ); + const unreadable = { + unreadable: `The stored event at sequence ${row.sequence} cannot be read.`, + }; + const decoded = yield* Effect.result(Effect.all([decodeEnvelope(row), decoding])); + if (Result.isFailure(decoded)) return unreadable; + const [{ event_id, stream_id }, payload] = decoded.success; + const shell = yield* projections.getThreadShell(stream_id); + const event: PluginEvent = { + deliveryId: event_id, + sequence: row.sequence, + occurredAt: row.occurred_at, + environmentId, + threadId: stream_id, + thread: + shell === null + ? null + : { + projectId: shell.projectId, + title: truncate(shell.title, PLUGIN_EVENT_THREAD_TITLE_MAX_LENGTH), + }, + ...payload, + }; + // Checked here so one bad row quarantines in front of itself instead of failing its page. + if (Result.isFailure(yield* Effect.result(checkEvent(event)))) return unreadable; + return { event }; + }); + + const backoff = (failures: number) => + Duration.min(Duration.times(retryBackoff, 2 ** (failures - 1)), maxRetryBackoff); + + /** Records a worker's state and shows it on its installation; cursor moves change nothing shown. */ + const setState = ( + installationId: PluginInstallationId, + worker: Worker, + state: PluginEventFeedState, + ) => + Effect.suspend(() => { + worker.state = state; + return delivery.report( + installationId, + worker.generation, + state._tag === "waiting" || state._tag === "delivering" + ? { _tag: "active" } + : state._tag === "stopped" + ? undefined + : state, + ); + }); + + /** True while the catalogue still shows this registration enabled. */ + const isRegistered = (installationId: PluginInstallationId, generation: number) => + catalog.list.pipe( + Effect.map(({ installations }) => + installations.some( + (installation) => + installation.installationId === installationId && + installation.generation === generation && + receivesEvents(installation), + ), + ), + ); + + /** Delivers to one registration until interrupted. */ + const run = (installationId: PluginInstallationId, worker: Worker) => { + const generation = worker.generation; + // Consecutive failures at the pending page: the plugin's, and those outside it. They outlive + // a storage retry, which starts the loop over from the stored cursor. + let failures = 0; + let transient = 0; + /** + * Every retry path shows `retrying` through here. It stays shown until the pending page is + * acknowledged, there is nothing left to deliver, or the worker is quarantined. + */ + const retrying = (attempts: number, reason: string, delay: Duration.Duration) => + Effect.flatMap(DateTime.now, (now) => + setState(installationId, worker, { + _tag: "retrying", + failures: attempts, + reason, + retryAt: DateTime.formatIso(DateTime.addDuration(now, delay)), + }), + ); + return Effect.gen(function* () { + const wake = yield* PubSub.subscribe(wakes); + let cursor = yield* loadCursor(installationId); + worker.cursor = cursor; + yield* publish({ _tag: "Started", installationId, generation, cursor }); + /** Stops at the cursor until `resume`, a re-enable, or a restart. */ + const quarantine = (attempts: number, reason: string) => + Effect.gen(function* () { + yield* setState(installationId, worker, { + _tag: "quarantined", + failures: attempts, + reason, + }); + yield* publish({ + _tag: "Quarantined", + installationId, + generation, + cursor, + failures: attempts, + reason, + }); + return yield* Effect.never; + }); + while (true) { + const head = yield* eventSink.latestSequence(); + if (cursor >= head) { + transient = 0; + yield* setState(installationId, worker, { _tag: "waiting" }); + yield* PubSub.take(wake); + continue; + } + const through = Math.min(head, cursor + options.scanWindow); + const rows = yield* readRows(cursor, through); + const events: Array = []; + let unreadable: { readonly sequence: number; readonly reason: string } | undefined; + for (const row of rows) { + const projected = yield* project(row); + if ("unreadable" in projected) { + unreadable = { sequence: row.sequence, reason: projected.unreadable }; + break; + } + events.push(projected.event); + } + // A full page covers the log only up to its last event, and an unreadable one up to + // just before it. + const covered = + unreadable !== undefined + ? unreadable.sequence - 1 + : rows.length === options.pageSize + ? (rows.at(-1)?.sequence ?? through) + : through; + if (unreadable !== undefined && covered === cursor) { + yield* Effect.logWarning("Stopped a plugin's event delivery at an unreadable event", { + installationId, + sequence: unreadable.sequence, + }); + return yield* quarantine(0, unreadable.reason); + } + if (events.length > 0) { + if (worker.state._tag !== "retrying") + yield* setState(installationId, worker, { _tag: "delivering" }); + const input = yield* encodePage({ events }).pipe(Effect.orDie); + const exit = yield* catalog + .invoke(installationId, PLUGIN_EVENTS_HANDLER, input, { + timeout: options.deliveryTimeout, + generation, + }) + .pipe(Effect.exit); + if (exit._tag === "Failure") { + const error = exit.cause.reasons.find((reason) => reason._tag === "Fail")?.error; + if (error?._tag === "PluginCatalogError" || error?._tag === "PluginStoppedError") { + // Revoked or replaced: this registration is over, and a catalogue change follows. + if (!(yield* isRegistered(installationId, generation))) { + yield* setState(installationId, worker, { _tag: "stopped" }); + return yield* Effect.never; + } + // Still registered (a storage failure, or a call that raced a management step + // that changed nothing): not the plugin's failure, so retry without counting it. + transient++; + const delay = backoff(transient); + const reason = error.message.slice(0, 1000); + yield* retrying(transient, reason, delay); + yield* publish({ _tag: "Retrying", installationId, generation, cursor, reason }); + yield* Effect.sleep(delay); + continue; + } + if (error === undefined && Cause.hasInterrupts(exit.cause)) + return yield* Effect.failCause(exit.cause); + // A defect counts like a failed page, so a bug never silently ends delivery. + failures++; + const reason = (error?.message ?? Cause.pretty(exit.cause)).slice(0, 1000); + if (failures >= options.maxFailures) { + yield* Effect.logWarning("Quarantined a plugin's event delivery", { + installationId, + cursor, + failures, + reason, + }); + return yield* quarantine(failures, reason); + } + const delay = backoff(failures); + yield* retrying(failures, reason, delay); + yield* publish({ + _tag: "Failed", + installationId, + generation, + cursor, + failures, + reason, + }); + yield* Effect.sleep(delay); + continue; + } + failures = 0; + } + yield* saveCursor(installationId, covered); + cursor = covered; + worker.cursor = cursor; + transient = 0; + if (worker.state._tag === "retrying") + yield* setState(installationId, worker, { _tag: "delivering" }); + yield* publish({ + _tag: "Acknowledged", + installationId, + generation, + throughSequence: covered, + delivered: events.length, + }); + } + }).pipe( + Effect.scoped, + // Storage trouble is not the plugin's failure: wait and start over from the stored cursor. + Effect.tapError((error) => + Effect.gen(function* () { + transient++; + yield* retrying( + transient, + "Could not read or save event delivery progress.", + maxRetryBackoff, + ); + yield* Effect.logWarning("Plugin event delivery could not read or save its cursor", { + installationId, + error, + }); + }), + ), + Effect.retry(Schedule.spaced(maxRetryBackoff)), + Effect.asVoid, + ); + }; + + const start = Effect.fnUntraced(function* ( + installationId: PluginInstallationId, + generation: number, + cursor: number | undefined, + ) { + const worker: Worker = { generation, cursor, state: { _tag: "waiting" }, fiber: undefined }; + workers.set(installationId, worker); + yield* delivery.report(installationId, generation, { _tag: "active" }); + worker.fiber = yield* run(installationId, worker).pipe( + Effect.forkIn(scope, { startImmediately: true }), + ); + }); + + const stop = Effect.fnUntraced(function* (installationId: PluginInstallationId) { + const worker = workers.get(installationId); + if (worker === undefined) return; + workers.delete(installationId); + if (worker.fiber) yield* Fiber.interrupt(worker.fiber); + yield* delivery.report(installationId, worker.generation, undefined); + }); + + let known: ReadonlySet | undefined; + + const reconcile = (installations: ReadonlyArray) => + lock.withPermit( + Effect.gen(function* () { + const wanted = new Map( + installations + .filter(receivesEvents) + .map((installation) => [installation.installationId, installation.generation]), + ); + for (const [installationId, worker] of workers) { + if (wanted.get(installationId) !== worker.generation) yield* stop(installationId); + } + for (const [installationId, generation] of wanted) { + if (!workers.has(installationId)) yield* start(installationId, generation, undefined); + } + // Removed installations forget their cursor; a later add is a new installation. + const present = new Set(installations.map((installation) => installation.installationId)); + for (const installationId of known ?? []) { + if (present.has(installationId)) continue; + yield* sql`DELETE FROM plugin_event_cursors WHERE installation_id = ${installationId}`.pipe( + Effect.andThen(publish({ _tag: "Forgotten", installationId })), + Effect.catch((error) => + Effect.logWarning("Could not forget a removed plugin's event cursor", { + installationId, + error, + }), + ), + ); + } + known = present; + }), + ); + + yield* catalog.subscribe.pipe( + Stream.runForEach((snapshot) => reconcile(snapshot.installations)), + Effect.forkScoped, + ); + + // Only commits of projected types wake workers; the events themselves are read from the store. + // The subscriptions are bounded. One that falls behind fails and is replaced at once: the wake + // sent before resubscribing makes every worker read what was committed meanwhile. + const wakeOnCommits = Effect.gen(function* () { + const head = yield* eventSink.latestSequence(); + yield* PubSub.publish(wakes, undefined); + yield* Stream.mergeAll( + PLUGIN_EVENT_TYPES.map((eventType) => + eventSink.stream({ eventType, afterSequence: head, bounded: true }), + ), + { concurrency: "unbounded" }, + ).pipe(Stream.runForEach(() => PubSub.publish(wakes, undefined))); + }); + const fellBehind = (error: EventSinkV2Error) => + error._tag === "EventSinkStreamError" && isLiveStreamBufferError(error.cause); + yield* wakeOnCommits.pipe( + Effect.retry({ while: fellBehind }), + Effect.tapError((error) => Effect.logWarning("Plugin event wakeups stopped", { error })), + Effect.retry(Schedule.spaced(maxRetryBackoff)), + Effect.forkScoped, + ); + + // `plugins.resume` reaches this through the catalogue. + yield* delivery.handleResume((installationId) => + lock.withPermit( + Effect.suspend(() => { + const worker = workers.get(installationId); + if (worker?.state._tag !== "quarantined" && worker?.state._tag !== "retrying") + return Effect.void; + return stop(installationId).pipe( + Effect.andThen(start(installationId, worker.generation, worker.cursor)), + ); + }), + ), + ); + + return PluginEventFeed.of({ + status: (installationId) => + Effect.sync(() => + Option.fromNullishOr(workers.get(installationId)).pipe( + Option.map(({ generation, cursor, state }) => ({ generation, cursor, state })), + ), + ), + subscribe: PubSub.subscribe(receipts), + }); +}); + +export const layer = (overrides?: Partial) => + Layer.effect(PluginEventFeed, make(overrides)); diff --git a/apps/server/src/plugins/PluginIpc.ts b/apps/server/src/plugins/PluginIpc.ts new file mode 100644 index 000000000000..16adf51395d0 --- /dev/null +++ b/apps/server/src/plugins/PluginIpc.ts @@ -0,0 +1,76 @@ +/** + * Messages between the server and one plugin child, carried as + * newline-delimited JSON on the child's fd 3 (see pluginIpcFraming.ts). + * + * Both ends ship in the same server build, so this protocol carries no + * version: only the plugin API (PLUGIN_API_VERSION) is versioned. The server + * decodes every child message with these schemas; a message that fails to + * decode or exceeds the byte bound gets the child killed. + * + * Every `Invoke` is answered by exactly one `Succeeded` or `Failed` with the + * same `requestId`, including after `Cancel`; that answer is how the server + * learns a cancelled call has settled. + * + * The other direction is a `HostCall`: the plugin asks the server for + * something a capability provides (such as a setting value), and the server + * answers with one `HostCallSucceeded` or `HostCallFailed`. Host call ids are + * the child's own sequence, separate from `Invoke` ids. + */ +import { PluginId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const RequestId = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); + +/** Name a plugin registers a handler under, and the server invokes it by. */ +export const PluginHandlerName = Schema.String.check( + Schema.isMaxLength(128), + Schema.isPattern(/^[A-Za-z][A-Za-z0-9_.:-]*$/), +); + +const PluginErrorMessage = Schema.String.check(Schema.isMaxLength(2000)); + +/** A server method a capability serves to plugins, such as `settings.get`. */ +const PluginHostMethodName = Schema.String.check( + Schema.isMaxLength(64), + Schema.isPattern(/^[a-z][A-Za-z0-9]*(?:\.[a-z][A-Za-z0-9]*)+$/), +); + +const PluginLogLevel = Schema.Literals(["debug", "info", "warn", "error"]); +export type PluginLogLevel = typeof PluginLogLevel.Type; + +const PluginHostMessage = Schema.TaggedUnion({ + Activate: { + pluginId: PluginId, + version: Schema.String, + apiVersion: Schema.Int, + entryPath: Schema.String, + proposedApi: Schema.Boolean, + /** The manifest's capabilities, so the child offers only the APIs they grant. */ + capabilities: Schema.Array(Schema.String), + maxMessageBytes: Schema.Int, + }, + Invoke: { requestId: RequestId, handler: PluginHandlerName, input: Schema.Json }, + Cancel: { requestId: RequestId }, + Deactivate: {}, + HostCallSucceeded: { requestId: RequestId, value: Schema.Json }, + HostCallFailed: { requestId: RequestId, message: PluginErrorMessage }, +}); +export type PluginHostMessage = typeof PluginHostMessage.Type; + +const PluginChildMessage = Schema.TaggedUnion({ + Ready: {}, + ActivationFailed: { message: PluginErrorMessage }, + /** The entry cannot load on any runtime this server ships on; retrying cannot help. */ + Incompatible: { message: PluginErrorMessage }, + Succeeded: { requestId: RequestId, value: Schema.Json }, + Failed: { requestId: RequestId, message: PluginErrorMessage }, + Log: { level: PluginLogLevel, message: Schema.String.check(Schema.isMaxLength(4000)) }, + Deactivated: {}, + HostCall: { requestId: RequestId, method: PluginHostMethodName, input: Schema.Json }, +}); +export type PluginChildMessage = typeof PluginChildMessage.Type; + +export const decodePluginChildMessage = Schema.decodeUnknownExit( + Schema.fromJsonString(PluginChildMessage), +); +export const encodePluginHostMessage = Schema.encodeExit(Schema.fromJsonString(PluginHostMessage)); diff --git a/apps/server/src/plugins/PluginManifestLoader.ts b/apps/server/src/plugins/PluginManifestLoader.ts new file mode 100644 index 000000000000..4fff96d17d2d --- /dev/null +++ b/apps/server/src/plugins/PluginManifestLoader.ts @@ -0,0 +1,155 @@ +import { + PLUGIN_API_VERSION, + PLUGIN_EVENTS_CAPABILITY, + PLUGIN_MANIFEST_FILE, + PLUGIN_SETTINGS_CAPABILITY, + PLUGIN_TOOLS_CAPABILITY, + PLUGIN_VIEWS_CAPABILITY, + PluginManifest, + type PluginCapabilityName, + type PluginInstallationId, +} from "@t3tools/contracts"; +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 { preparePluginTools } from "./pluginToolDeclarations.ts"; + +/** Capabilities this server implements. A plugin declaring any other is not loaded. */ +const SUPPORTED_PLUGIN_CAPABILITIES: ReadonlySet = new Set([ + PLUGIN_EVENTS_CAPABILITY, + PLUGIN_SETTINGS_CAPABILITY, + PLUGIN_TOOLS_CAPABILITY, + "actions", + PLUGIN_VIEWS_CAPABILITY, +]); + +const MAX_MANIFEST_BYTES = 64 * 1024; + +const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); + +class PluginManifestError extends Schema.TaggedError()("PluginManifestError", { + directory: Schema.String, + reason: Schema.String, +}) { + override get message(): string { + return `Cannot load the plugin in ${this.directory}: ${this.reason}`; + } +} + +/** A validated plugin directory: what the supervisor needs to run it. */ +export interface PluginRegistration { + readonly manifest: PluginManifest; + /** Real path of the plugin directory, used as the child's working directory. */ + readonly directory: string; + /** Real path of the entry module, inside `directory`. */ + readonly entryPath: string; + /** The catalogue installation this registration runs, set when the catalogue enables it. */ + readonly installationId?: PluginInstallationId; +} + +/** Declared tools need the capability and the proposed `handle` API, and must compile. */ +const checkTools = (manifest: PluginManifest): string | undefined => { + const tools = manifest.tools ?? []; + if (tools.length === 0) return undefined; + if (!manifest.capabilities.includes(PLUGIN_TOOLS_CAPABILITY)) + return "it declares tools without the tools capability."; + if (!manifest.proposedApi) return "it declares tools, which need proposedApi: true."; + const prepared = preparePluginTools(manifest, tools); + return "problem" in prepared ? prepared.problem : undefined; +}; + +/** Declared actions need the capability and the proposed `handle` API, and unique names. */ +const checkActions = (manifest: PluginManifest): string | undefined => { + const actions = manifest.actions ?? []; + if (actions.length === 0) return undefined; + if (!manifest.capabilities.includes("actions")) + return "it declares actions without the actions capability."; + if (!manifest.proposedApi) return "it declares actions, which need proposedApi: true."; + const names = new Set(); + for (const action of actions) { + if (names.has(action.name)) return `it declares the action ${action.name} twice.`; + names.add(action.name); + if (new Set(action.placements).size !== action.placements.length) + return `the action ${action.name} repeats a placement.`; + } + return undefined; +}; + +/** + * Reads and validates `t3-plugin.json` in `directory`. The entry must resolve, + * after symlinks, to a file inside the directory, and the manifest must target + * this server's plugin API version with only supported capabilities. + */ +export const loadPluginDirectory = Effect.fn("PluginManifestLoader.loadPluginDirectory")(function* ( + directory: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fail = (reason: string) => new PluginManifestError({ directory, reason }); + + const realDirectory = yield* fs + .realPath(directory) + .pipe(Effect.mapError(() => fail("the directory does not exist."))); + const manifestPath = path.join(realDirectory, PLUGIN_MANIFEST_FILE); + const info = yield* fs + .stat(manifestPath) + .pipe(Effect.mapError(() => fail(`${PLUGIN_MANIFEST_FILE} is missing.`))); + if (info.type !== "File" || Number(info.size) > MAX_MANIFEST_BYTES) + return yield* fail(`${PLUGIN_MANIFEST_FILE} must be a file of at most 64 KiB.`); + const raw = yield* fs + .readFileString(manifestPath) + .pipe(Effect.mapError(() => fail(`${PLUGIN_MANIFEST_FILE} is not readable.`))); + const manifest = yield* decodeManifest(raw).pipe( + Effect.mapError((error) => fail(`${PLUGIN_MANIFEST_FILE} is invalid: ${error.message}`)), + ); + + if (manifest.apiVersion !== PLUGIN_API_VERSION) + return yield* fail( + `it targets plugin API version ${manifest.apiVersion}; this server implements version ${PLUGIN_API_VERSION}.`, + ); + const unsupported = manifest.capabilities.filter( + (capability) => !SUPPORTED_PLUGIN_CAPABILITIES.has(capability), + ); + if (unsupported.length > 0) + return yield* fail(`this server does not support ${unsupported.join(", ")}.`); + // Events arrive through `context.proposed.onEvent`, which only exists with the opt-in. + if (manifest.capabilities.includes(PLUGIN_EVENTS_CAPABILITY) && !manifest.proposedApi) + return yield* fail(`the "${PLUGIN_EVENTS_CAPABILITY}" capability needs "proposedApi": true.`); + const toolProblem = checkTools(manifest); + if (toolProblem !== undefined) return yield* fail(toolProblem); + const hasSettings = manifest.capabilities.includes(PLUGIN_SETTINGS_CAPABILITY); + if (manifest.settings !== undefined && !hasSettings) + return yield* fail( + `it declares settings without the "${PLUGIN_SETTINGS_CAPABILITY}" capability.`, + ); + // The settings API is still proposed, so it only exists with the opt-in. + if (hasSettings && !manifest.proposedApi) + return yield* fail(`the "${PLUGIN_SETTINGS_CAPABILITY}" capability needs "proposedApi": true.`); + const actionProblem = checkActions(manifest); + if (actionProblem !== undefined) return yield* fail(actionProblem); + // Views reach their plugin through `context.proposed.handle`, which only exists with the opt-in. + if (manifest.capabilities.includes(PLUGIN_VIEWS_CAPABILITY) && !manifest.proposedApi) + return yield* fail(`the "${PLUGIN_VIEWS_CAPABILITY}" capability needs "proposedApi": true.`); + + const entryPath = yield* fs + .realPath(path.resolve(realDirectory, manifest.entry)) + .pipe(Effect.mapError(() => fail(`the entry ${manifest.entry} does not exist.`))); + const relative = path.relative(realDirectory, entryPath); + if ( + relative === "" || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) + return yield* fail(`the entry ${manifest.entry} resolves outside the plugin directory.`); + if (!/\.m?js$/.test(entryPath)) + return yield* fail(`the entry ${manifest.entry} must be a .js or .mjs file.`); + const entryInfo = yield* fs + .stat(entryPath) + .pipe(Effect.mapError(() => fail(`the entry ${manifest.entry} is not readable.`))); + if (entryInfo.type !== "File") return yield* fail(`the entry ${manifest.entry} is not a file.`); + + return { manifest, directory: realDirectory, entryPath } satisfies PluginRegistration; +}); diff --git a/apps/server/src/plugins/PluginNpm.test.ts b/apps/server/src/plugins/PluginNpm.test.ts new file mode 100644 index 000000000000..a4a09b41789e --- /dev/null +++ b/apps/server/src/plugins/PluginNpm.test.ts @@ -0,0 +1,2096 @@ +// @effect-diagnostics nodeBuiltinImport:off -- A TCP gate hears from a plugin's process. +import * as NodeNet from "node:net"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + pluginInstallationStatus, + type PluginInstallationId, + PluginNpmPackageResult, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; +import { HttpClient } from "effect/http"; + +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as PluginCatalog from "./PluginCatalog.ts"; +import * as PluginNpm from "./PluginNpm.ts"; +import * as PluginSupervisor from "./PluginSupervisor.ts"; +import { + integrityOf, + makeRegistry, + makeTarball, + REGISTRY, + type TarEntry, +} from "./npmTarball.testkit.ts"; + +// Children run the real CLI entry, which routes `__plugin-host` to the child runtime. +const BIN_PATH = `${import.meta.dirname}/../bin.ts`; + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const fromJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const encodeReply = Schema.encodeSync(Schema.fromJsonString(PluginNpmPackageResult)); +const decodeReply = Schema.decodeUnknownSync(Schema.fromJsonString(PluginNpmPackageResult)); + +type Catalog = PluginCatalog.PluginCatalog["Service"]; +type Npm = PluginNpm.PluginNpm["Service"]; +type Registry = ReturnType; + +interface CatalogOptions { + readonly stopGrace?: `${number} seconds`; + /** Completed when the supervisor starts disabling a plugin. */ + readonly disableStarted?: Deferred.Deferred; + readonly fileSystem?: FileSystem.FileSystem; +} + +const startCatalog = Effect.fn("startCatalog")(function* ( + scope: Scope.Scope, + options: CatalogOptions = {}, +) { + const supervisor = yield* PluginSupervisor.make({ + heapLimitMb: 64, + activationTimeout: "10 seconds", + stopGrace: options.stopGrace ?? "1 second", + }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + Effect.provideService(Scope.Scope, scope), + ); + const started = options.disableStarted; + return yield* PluginCatalog.make().pipe( + Effect.provideService( + FileSystem.FileSystem, + options.fileSystem ?? (yield* FileSystem.FileSystem), + ), + Effect.provideService( + PluginSupervisor.PluginSupervisor, + started === undefined + ? supervisor + : { + ...supervisor, + disable: (pluginId) => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(supervisor.disable(pluginId)), + ), + }, + ), + Effect.provideService(Scope.Scope, scope), + ); +}); + +const startNpm = Effect.fn("startNpm")(function* ( + scope: Scope.Scope, + catalog: Catalog, + registry: Registry, + root: string, + fileSystem?: FileSystem.FileSystem, +) { + return yield* PluginNpm.make({ root, registry: REGISTRY }).pipe( + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + Effect.provideService(HttpClient.HttpClient, registry.client), + Effect.provideService(FileSystem.FileSystem, fileSystem ?? (yield* FileSystem.FileSystem)), + Effect.provideService(Scope.Scope, scope), + ); +}); + +interface Fixture { + readonly catalog: Catalog; + readonly npm: Npm; + readonly registry: Registry; + readonly root: string; + /** Activation writes the version here, outside the plugin's own directory. */ + readonly marker: string; + /** Any package script that ran would write here. */ + readonly scriptMarker: string; + readonly plugin: ( + name: string, + version: string, + options?: { + readonly pluginId?: string; + readonly packageJson?: Record; + readonly extra?: ReadonlyArray; + readonly manifest?: boolean; + /** Merged into `t3-plugin.json`. */ + readonly declares?: Record; + }, + ) => Uint8Array; +} + +const setup = Effect.fn("setup")(function* ( + fileSystem?: FileSystem.FileSystem, + catalogOptions?: CatalogOptions, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Scope.Scope; + const base = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-npm-" }); + const root = path.join(base, "npm"); + const marker = path.join(base, "activated"); + const scriptMarker = path.join(base, "script-ran"); + const registry = makeRegistry(); + const catalog = yield* startCatalog(scope, catalogOptions); + const npm = yield* startNpm(scope, catalog, registry, root, fileSystem); + const plugin: Fixture["plugin"] = (name, version, options = {}) => + makeTarball([ + { + path: "package/package.json", + data: toJson({ + name, + version, + // Never run: T3 does not run package scripts. + scripts: { + prepare: `node -e "require('fs').writeFileSync(${toJson(scriptMarker)}, 'prepare')"`, + }, + ...options.packageJson, + }), + }, + ...(options.manifest === false + ? [] + : [ + { + path: "package/t3-plugin.json", + data: toJson({ + id: options.pluginId ?? "test.npm", + name, + version, + apiVersion: 1, + entry: "dist/main.mjs", + proposedApi: true, + ...options.declares, + }), + }, + ]), + { + path: "package/dist/main.mjs", + data: [ + `import * as NodeFS from "node:fs";`, + `export function activate(context) {`, + ` NodeFS.writeFileSync(${toJson(marker)}, ${toJson(version)});`, + ` context.proposed.handle("version", () => ({ version: ${toJson(version)}, pid: process.pid }));`, + `}`, + ``, + ].join("\n"), + }, + ...(options.extra ?? []), + ]); + return { catalog, npm, registry, root, marker, scriptMarker, plugin } satisfies Fixture; +}); + +const callVersion = (catalog: Catalog, installationId: PluginInstallationId) => + catalog + .invoke(installationId, "version", null) + .pipe(Effect.map((value) => value as { readonly version: string; readonly pid: number })); + +const isProcessAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +/** Names in `directory`, or none when it is gone. */ +const entries = (directory: string) => + FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.readDirectory(directory)), + Effect.orElseSucceed((): ReadonlyArray => []), + Effect.map((names) => [...names].sort()), + ); + +type Faulted = "rename" | "writeFileString" | "remove" | "exists" | "readDirectory"; + +/** The real file system, with failures and a hold that a test arms per call. */ +const makeFaults = (realFs: FileSystem.FileSystem) => { + const armed: { + /** Fails every call it returns true for; `detail` is a rename's target or the data written. */ + fail: ((method: Faulted, target: string, detail?: string) => boolean) | undefined; + /** Pauses the next matching call until `release`. */ + hold: + | { + readonly matches: (method: Faulted, target: string, to?: string) => boolean; + readonly entered: Deferred.Deferred; + readonly release: Deferred.Deferred; + } + | undefined; + } = { fail: undefined, hold: undefined }; + const failure = (method: Faulted, target: string) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method, + pathOrDescriptor: target, + description: "Injected failure.", + }), + ); + const held = ( + method: Faulted, + target: string, + to: string | undefined, + run: Effect.Effect, + ) => + Effect.suspend(() => { + const hold = armed.hold; + if (hold === undefined || !hold.matches(method, target, to)) return run; + armed.hold = undefined; + return Deferred.succeed(hold.entered, undefined).pipe( + Effect.andThen(Deferred.await(hold.release)), + Effect.andThen(run), + ); + }); + const fileSystem: FileSystem.FileSystem = { + ...realFs, + rename: (from, to) => + Effect.suspend(() => + armed.fail?.("rename", from, to) + ? failure("rename", from) + : held("rename", from, to, realFs.rename(from, to)), + ), + writeFileString: (target, data, options) => + Effect.suspend(() => + armed.fail?.("writeFileString", target, data) + ? failure("writeFileString", target) + : realFs.writeFileString(target, data, options), + ), + remove: (target, options) => + Effect.suspend(() => + armed.fail?.("remove", target) + ? failure("remove", target) + : held("remove", target, undefined, realFs.remove(target, options)), + ), + exists: (target) => + Effect.suspend(() => + armed.fail?.("exists", target) + ? failure("exists", target) + : held("exists", target, undefined, realFs.exists(target)), + ), + readDirectory: (target, options) => + held("readDirectory", target, undefined, realFs.readDirectory(target, options)), + }; + return { fileSystem, armed }; +}; + +/** + * A plugin whose `files` handler reports the version it loaded and the one now + * in its directory. With a `gate` port, deactivating connects to it, waits for + * the test to answer, then sends the same report back. + */ +const filesTarball = (name: string, version: string, gate?: number) => + makeTarball([ + { path: "package/package.json", data: toJson({ name, version }) }, + { + path: "package/t3-plugin.json", + data: toJson({ + id: `test.${name}`, + name, + version, + apiVersion: 1, + entry: "main.mjs", + proposedApi: true, + }), + }, + { path: "package/version.txt", data: version }, + { + path: "package/main.mjs", + data: [ + `import * as NodeFS from "node:fs";`, + `import * as NodeNet from "node:net";`, + `const disk = () => {`, + ` try {`, + ` return NodeFS.readFileSync(new URL("./version.txt", import.meta.url), "utf8");`, + ` } catch {`, + ` return "MISSING";`, + ` }`, + `};`, + `export function activate(context) {`, + ` context.proposed.handle("files", () => ({`, + ` loaded: ${toJson(version)},`, + ` disk: disk(),`, + ` pid: process.pid,`, + ` }));`, + `}`, + ...(gate === undefined + ? [] + : [ + `export async function deactivate() {`, + ` const socket = NodeNet.connect(${gate}, "127.0.0.1");`, + ` await new Promise((resolve) => socket.once("data", resolve));`, + ` const report = JSON.stringify({ loaded: ${toJson(version)}, disk: disk() });`, + ` await new Promise((resolve) => socket.end(report, resolve));`, + `}`, + ]), + ``, + ].join("\n"), + }, + ]); + +const callFiles = (catalog: Catalog, installationId: PluginInstallationId) => + catalog + .invoke(installationId, "files", null) + .pipe( + Effect.map( + (value) => + value as { readonly loaded: string; readonly disk: string; readonly pid: number }, + ), + ); + +/** + * Leaves what a server that stopped applying an update to 1.1.0 after both + * renames, before the consent, leaves behind: the journal, the 1.0.0 files in + * `.previous`, and the 1.1.0 files in `package/`. The journal names + * `journaled`, which a test may set to a version other than the files. + */ +const crashBeforeConsent = Effect.fn("crashBeforeConsent")(function* ( + scope: Scope.Scope, + name: string, + journaled = "1.1.0", + options: CatalogOptions & { readonly gate?: number } = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const registry = makeRegistry(); + const base = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-npm-crash-" }); + const root = path.join(base, "npm"); + const catalog = yield* startCatalog(scope, options); + const firstScope = yield* Scope.make(); + const first = yield* startNpm(firstScope, catalog, registry, root); + for (const version of ["1.0.0", "1.1.0", "1.2.0"]) + registry.publish(name, version, { tarball: filesTarball(name, version, options.gate) }); + const added = yield* first.add({ name, version: "1.0.0" }); + const installationId = added.installation.installationId; + const home = path.dirname(added.installation.directory); + const oldDigest = added.installation.source!.digest; + yield* catalog.consent({ installationId, digest: oldDigest }); + const journal = (yield* first.stageUpdate({ installationId, version: journaled })).package + .stagedUpdate!; + const files = + journaled === "1.1.0" + ? journal + : (yield* first.stageUpdate({ installationId, version: "1.1.0" })).package.stagedUpdate!; + const next = { ...added.package.source, version: journaled, integrity: journal.integrity }; + const stagingName = (yield* entries(home)).find((entry) => entry.startsWith(".staging-"))!; + yield* fs.writeFileString( + path.join(home, "npm.json"), + toJson({ source: added.package.source, swap: { source: next, digest: journal.source.digest } }), + ); + yield* fs.rename(added.installation.directory, path.join(home, ".previous")); + yield* fs.rename(path.join(home, stagingName), added.installation.directory); + yield* Scope.close(firstScope, Exit.void); + return { + catalog, + registry, + root, + installationId, + home, + oldDigest, + next, + journalDigest: journal.source.digest, + filesDigest: files.source.digest, + }; +}); + +const withDatabase = (effect: Effect.Effect) => + effect.pipe(Effect.provide(SqlitePersistence.layerMemory)); + +it.layer(NodeServices.layer)("PluginNpm", (it) => { + describe("install", () => { + it.effect("installs one exact version and runs nothing until its digest is approved", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { catalog, npm, registry, root, marker, scriptMarker, plugin } = yield* setup(); + const tarball = plugin("t3-plugin-hello", "1.0.0"); + registry.publish("t3-plugin-hello", "1.0.0", { tarball }); + registry.tag("t3-plugin-hello", "latest", "1.0.0"); + + const added = yield* npm.add({ name: "t3-plugin-hello", version: "latest" }); + // The tag is resolved once; only the exact version and its integrity are kept. + expect(added.package.source).toMatchObject({ + registry: REGISTRY, + name: "t3-plugin-hello", + version: "1.0.0", + integrity: integrityOf(tarball), + }); + expect(added.package.stagedUpdate).toBeNull(); + const installationId = added.installation.installationId; + expect(pluginInstallationStatus(added.installation)).toBe("needs-consent"); + expect(path.dirname(path.dirname(added.installation.directory))).toBe( + yield* fs.realPath(root), + ); + expect(yield* entries(path.dirname(added.installation.directory))).toEqual([ + "npm.json", + "package", + ]); + expect((yield* npm.list).packages.map((item) => item.installationId)).toEqual([ + installationId, + ]); + + const unapproved = yield* catalog.enable({ installationId }).pipe(Effect.flip); + expect(unapproved.reason).toBe("consent-required"); + yield* catalog.consent({ installationId, digest: added.installation.source!.digest }); + yield* catalog.enable({ installationId }); + expect(yield* fs.exists(marker)).toBe(false); + expect((yield* callVersion(catalog, installationId)).version).toBe("1.0.0"); + expect(yield* fs.readFileString(marker)).toBe("1.0.0"); + expect(yield* fs.exists(scriptMarker)).toBe(false); + + const again = yield* npm + .add({ name: "t3-plugin-hello", version: "1.0.0" }) + .pipe(Effect.flip); + expect(again.reason).toBe("already-added"); + }), + ), + ); + + it.effect("refuses tampered, unsafe, or uninstallable packages and leaves nothing behind", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { catalog, npm, registry, root, scriptMarker, plugin } = yield* setup(); + const good = plugin("good", "1.0.0"); + const cases: ReadonlyArray<{ + readonly name: string; + readonly reason: string; + readonly publish?: () => void; + readonly version?: string; + readonly registry?: string; + readonly offline?: boolean; + }> = [ + { + name: "tampered", + reason: "npm-integrity-mismatch", + publish: () => + registry.publish("tampered", "1.0.0", { + tarball: plugin("tampered", "1.0.0"), + served: plugin("tampered", "1.0.0", { + extra: [{ path: "package/evil.js", data: "x" }], + }), + }), + }, + { + name: "no-integrity", + reason: "npm-integrity-missing", + publish: () => + registry.publish("no-integrity", "1.0.0", { + tarball: plugin("no-integrity", "1.0.0"), + integrity: undefined, + }), + }, + { + name: "wrong-version", + reason: "npm-registry-invalid", + publish: () => + registry.publish("wrong-version", "1.0.0", { + tarball: plugin("wrong-version", "1.0.0"), + claimedVersion: "1.0.1", + }), + }, + { + name: "symlink", + reason: "npm-archive-unsafe", + publish: () => + registry.publish("symlink", "1.0.0", { + tarball: plugin("symlink", "1.0.0", { + extra: [{ path: "package/dist/link.mjs", type: "2", linkname: "/etc/hosts" }], + }), + }), + }, + { + name: "escape", + reason: "npm-archive-unsafe", + publish: () => + registry.publish("escape", "1.0.0", { + tarball: plugin("escape", "1.0.0", { + extra: [{ path: "package/../../escaped.js", data: "x" }], + }), + }), + }, + { + name: "install-script", + reason: "npm-install-scripts", + publish: () => + registry.publish("install-script", "1.0.0", { + tarball: plugin("install-script", "1.0.0", { + packageJson: { scripts: { postinstall: "node -e 1" } }, + }), + }), + }, + { + name: "dependency", + reason: "npm-dependencies", + publish: () => + registry.publish("dependency", "1.0.0", { + tarball: plugin("dependency", "1.0.0", { + packageJson: { dependencies: { "left-pad": "^1.0.0" } }, + }), + }), + }, + { + // Bundled, but what the bundled package needs in turn is not shipped. + name: "missing-transitive", + reason: "npm-dependencies", + publish: () => + registry.publish("missing-transitive", "1.0.0", { + tarball: plugin("missing-transitive", "1.0.0", { + packageJson: { dependencies: { tiny: "1.0.0" }, bundleDependencies: ["tiny"] }, + extra: [ + { + path: "package/node_modules/tiny/package.json", + data: toJson({ name: "tiny", dependencies: { "not-shipped": "1.0.0" } }), + }, + ], + }), + }), + }, + { + // Shipped, but only where Node would not look for it from `tiny`. + name: "unreachable-transitive", + reason: "npm-dependencies", + publish: () => + registry.publish("unreachable-transitive", "1.0.0", { + tarball: plugin("unreachable-transitive", "1.0.0", { + packageJson: { + dependencies: { tiny: "1.0.0", other: "1.0.0" }, + bundleDependencies: true, + }, + extra: [ + { + path: "package/node_modules/tiny/package.json", + data: toJson({ name: "tiny", peerDependencies: { hidden: "1.0.0" } }), + }, + { path: "package/node_modules/other/package.json", data: "{}" }, + { + path: "package/node_modules/other/node_modules/hidden/package.json", + data: "{}", + }, + ], + }), + }), + }, + { + name: "renamed", + reason: "npm-package-mismatch", + publish: () => + registry.publish("renamed", "1.0.0", { tarball: plugin("other-name", "1.0.0") }), + }, + { + name: "no-manifest", + reason: "invalid-directory", + publish: () => + registry.publish("no-manifest", "1.0.0", { + tarball: plugin("no-manifest", "1.0.0", { manifest: false }), + }), + }, + { name: "unpublished", reason: "npm-not-found" }, + { + name: "good", + reason: "npm-not-found", + version: "2.0.0", + publish: () => registry.publish("good", "1.0.0", { tarball: good }), + }, + { name: "good", reason: "npm-registry-unavailable", offline: true }, + { + name: "good", + reason: "npm-invalid-request", + registry: "https://user:pw@registry.test", + }, + { + name: "good", + reason: "npm-invalid-request", + registry: "http://registry.test", + }, + ]; + for (const testCase of cases) { + testCase.publish?.(); + registry.state.offline = testCase.offline ?? false; + const error = yield* npm + .add({ + name: testCase.name, + version: testCase.version ?? "1.0.0", + ...(testCase.registry === undefined ? {} : { registry: testCase.registry }), + }) + .pipe(Effect.flip); + expect(error.reason, testCase.name).toBe(testCase.reason); + expect(yield* entries(root), testCase.name).toEqual([]); + } + expect((yield* catalog.list).installations).toEqual([]); + expect((yield* npm.list).packages).toEqual([]); + expect(yield* fs.exists(scriptMarker)).toBe(false); + + // Dependencies shipped inside the package are fine, hoisted or nested, and so is a + // missing peer its dependent marks optional. + registry.state.offline = false; + registry.publish("bundled", "1.0.0", { + tarball: plugin("bundled", "1.0.0", { + packageJson: { dependencies: { tiny: "1.0.0" }, bundleDependencies: ["tiny"] }, + extra: [ + { + path: "package/node_modules/tiny/package.json", + data: toJson({ + name: "tiny", + dependencies: { hoisted: "1.0.0", "@scope/nested": "1.0.0" }, + }), + }, + { + path: "package/node_modules/hoisted/package.json", + data: toJson({ + name: "hoisted", + peerDependencies: { tiny: "1.0.0", absent: "1.0.0" }, + peerDependenciesMeta: { absent: { optional: true } }, + }), + }, + { + path: "package/node_modules/tiny/node_modules/@scope/nested/package.json", + data: toJson({ name: "@scope/nested", dependencies: { hoisted: "1.0.0" } }), + }, + ], + }), + }); + const bundled = yield* npm.add({ name: "bundled", version: "1.0.0" }); + expect(bundled.package.source.version).toBe("1.0.0"); + }), + ), + ); + + it.effect("trusts metadata only from https or this machine, across redirects and updates", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Scope.Scope; + const registry = makeRegistry(); + const base = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-npm-transport-" }); + const root = path.join(base, "npm"); + const catalog = yield* startCatalog(scope); + const firstScope = yield* Scope.make(); + const first = yield* startNpm(firstScope, catalog, registry, root); + for (const version of ["1.0.0", "1.1.0"]) + registry.publish("moved", version, { tarball: filesTarball("moved", version) }); + + // One plain http hop is refused before it is fetched. + const metadata = `${REGISTRY}/moved/1.0.0`; + registry.redirects.set(metadata, "http://mirror.test/moved/1.0.0"); + const insecure = yield* first.add({ name: "moved", version: "1.0.0" }).pipe(Effect.flip); + expect(insecure.reason).toBe("npm-registry-invalid"); + expect(registry.requests).not.toContain("http://mirror.test/moved/1.0.0"); + expect(yield* entries(root)).toEqual([]); + + // https hops and a loopback registry are followed. + registry.redirects.set(metadata, "https://mirror.test/moved/1.0.0"); + registry.redirects.set( + "https://mirror.test/moved/1.0.0", + "http://127.0.0.1:4873/moved/1.0.0", + ); + const added = yield* first.add({ name: "moved", version: "1.0.0" }); + expect(registry.requests).toContain("http://127.0.0.1:4873/moved/1.0.0"); + + // An installation saved from a plain http registry cannot update after a restart. + const home = path.dirname(added.installation.directory); + yield* fs.writeFileString( + path.join(home, "npm.json"), + toJson({ source: { ...added.package.source, registry: "http://registry.test" } }), + ); + yield* Scope.close(firstScope, Exit.void); + const second = yield* startNpm(scope, catalog, registry, root); + const requested = registry.requests.length; + const update = yield* second + .stageUpdate({ installationId: added.installation.installationId, version: "1.1.0" }) + .pipe(Effect.flip); + expect(update.reason).toBe("npm-registry-invalid"); + expect(registry.requests.length).toBe(requested); + expect((yield* second.list).packages[0]!.stagedUpdate).toBeNull(); + }), + ), + ); + + it.effect("deletes a package's files when it is removed from the catalogue", () => + withDatabase( + Effect.gen(function* () { + const path = yield* Path.Path; + const { catalog, npm, registry, root, plugin } = yield* setup(); + registry.publish("removed", "1.0.0", { tarball: plugin("removed", "1.0.0") }); + const added = yield* npm.add({ name: "removed", version: "1.0.0" }); + const installationId = added.installation.installationId; + yield* npm.stageUpdate({ installationId, version: "1.0.0" }); + expect(yield* entries(root)).toEqual([ + path.basename(path.dirname(added.installation.directory)), + ]); + + yield* catalog.remove({ installationId }); + expect((yield* npm.list).packages).toEqual([]); + // Installing it again first collects what the catalogue dropped. + const again = yield* npm.add({ name: "removed", version: "1.0.0" }); + expect(again.installation.installationId).not.toBe(installationId); + expect(yield* entries(root)).toEqual([ + path.basename(path.dirname(again.installation.directory)), + ]); + }), + ), + ); + }); + + describe("remove", () => { + it.effect("retries deleting a removed package's files until it works", () => + withDatabase( + Effect.gen(function* () { + const path = yield* Path.Path; + const faults = makeFaults(yield* FileSystem.FileSystem); + const { catalog, npm, registry, root, plugin } = yield* setup(faults.fileSystem); + registry.publish("kept", "1.0.0", { tarball: plugin("kept", "1.0.0") }); + registry.publish("gone", "1.0.0", { tarball: plugin("gone", "1.0.0") }); + const kept = yield* npm.add({ name: "kept", version: "1.0.0" }); + const gone = yield* npm.add({ name: "gone", version: "1.0.0" }); + const keptHome = path.basename(path.dirname(kept.installation.directory)); + const goneHome = path.dirname(gone.installation.directory); + faults.armed.fail = (method, target) => method === "remove" && target === goneHome; + + yield* catalog.remove({ installationId: gone.installation.installationId }); + yield* npm.discardUpdate({ installationId: kept.installation.installationId }); + expect((yield* npm.list).packages.map((item) => item.source.name)).toEqual(["kept"]); + expect(yield* entries(root)).toEqual([keptHome, path.basename(goneHome)].sort()); + + faults.armed.fail = undefined; + yield* npm.discardUpdate({ installationId: kept.installation.installationId }); + expect(yield* entries(root)).toEqual([keptHome]); + }), + ), + ); + }); + + describe("update", () => { + it.effect("stages a version beside the running one and swaps it in with new consent", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { catalog, npm, registry, plugin } = yield* setup(); + registry.publish("updated", "1.0.0", { tarball: plugin("updated", "1.0.0") }); + registry.publish("updated", "1.1.0", { tarball: plugin("updated", "1.1.0") }); + registry.publish("updated", "1.2.0", { tarball: plugin("updated", "1.2.0") }); + registry.publish("updated", "2.0.0", { + tarball: plugin("updated", "2.0.0", { pluginId: "test.other" }), + }); + const added = yield* npm.add({ name: "updated", version: "1.0.0" }); + const installationId = added.installation.installationId; + const home = path.dirname(added.installation.directory); + const oldDigest = added.installation.source!.digest; + yield* catalog.consent({ installationId, digest: oldDigest }); + yield* catalog.enable({ installationId }); + const before = yield* callVersion(catalog, installationId); + expect(before.version).toBe("1.0.0"); + + const missing = yield* npm + .applyUpdate({ installationId, digest: oldDigest }) + .pipe(Effect.flip); + expect(missing.reason).toBe("npm-no-update"); + + const { package: staged } = yield* npm.stageUpdate({ installationId, version: "1.1.0" }); + const update = staged.stagedUpdate!; + expect(update).toMatchObject({ version: "1.1.0", manifest: { id: "test.npm" } }); + expect(update.source.digest).not.toBe(oldDigest); + // Staging changes nothing about what is installed or running. + expect(staged.source.version).toBe("1.0.0"); + const stillRunning = yield* callVersion(catalog, installationId); + expect(stillRunning).toEqual(before); + const row = (yield* catalog.list).installations[0]!; + expect(row).toMatchObject({ enabled: true, source: { digest: oldDigest } }); + + const notReviewed = yield* npm + .applyUpdate({ installationId, digest: oldDigest }) + .pipe(Effect.flip); + expect(notReviewed.reason).toBe("source-changed"); + expect(isProcessAlive(before.pid)).toBe(true); + + const applied = yield* npm.applyUpdate({ installationId, digest: update.source.digest }); + expect(applied.package).toMatchObject({ + source: { version: "1.1.0", integrity: update.integrity }, + stagedUpdate: null, + }); + expect(applied.installation).toMatchObject({ + enabled: true, + source: { digest: update.source.digest }, + consent: { digest: update.source.digest }, + }); + expect(isProcessAlive(before.pid)).toBe(false); + expect((yield* callVersion(catalog, installationId)).version).toBe("1.1.0"); + expect(yield* entries(home)).toEqual(["npm.json", "package"]); + const record = fromJson(yield* fs.readFileString(path.join(home, "npm.json"))); + expect(record).toEqual({ source: expect.objectContaining({ version: "1.1.0" }) }); + + // A staged update can be dropped, and one for another plugin id is refused. + yield* npm.stageUpdate({ installationId, version: "1.2.0" }); + const discarded = yield* npm.discardUpdate({ installationId }); + expect(discarded.package.stagedUpdate).toBeNull(); + const otherId = yield* npm + .stageUpdate({ installationId, version: "2.0.0" }) + .pipe(Effect.flip); + expect(otherId.reason).toBe("npm-plugin-id-changed"); + const unpublished = yield* npm + .stageUpdate({ installationId, version: "9.9.9" }) + .pipe(Effect.flip); + expect(unpublished.reason).toBe("npm-not-found"); + expect(yield* entries(home)).toEqual(["npm.json", "package"]); + }), + ), + ); + + it.effect("refuses an update to the files already installed", () => + withDatabase( + Effect.gen(function* () { + const path = yield* Path.Path; + const { catalog, npm, registry, plugin } = yield* setup(); + registry.publish("same", "1.0.0", { tarball: plugin("same", "1.0.0") }); + const added = yield* npm.add({ name: "same", version: "1.0.0" }); + const installationId = added.installation.installationId; + const home = path.dirname(added.installation.directory); + const digest = added.installation.source!.digest; + yield* catalog.consent({ installationId, digest }); + yield* catalog.enable({ installationId }); + + // A restart between its two moves could not tell such a swap from a finished one. + const { package: staged } = yield* npm.stageUpdate({ installationId, version: "1.0.0" }); + expect(staged.stagedUpdate?.source.digest).toBe(digest); + const refused = yield* npm.applyUpdate({ installationId, digest }).pipe(Effect.flip); + expect(refused.reason).toBe("npm-no-update"); + expect(yield* entries(home)).toEqual(["npm.json", "package"]); + expect((yield* callVersion(catalog, installationId)).version).toBe("1.0.0"); + }), + ), + ); + + it.effect("summarizes a downloaded update's declarations as the catalogue does", () => + withDatabase( + Effect.gen(function* () { + const { catalog, npm, registry, plugin } = yield* setup(); + const declares = { + capabilities: ["tools", "settings", "actions"], + tools: [ + { + name: "word_count", + description: "Count the words in a text.", + inputSchema: { type: "object" }, + sideEffect: "read", + }, + ], + settings: [{ type: "boolean", key: "verbose", label: "Verbose logging" }], + actions: [ + { + name: "say-hello", + title: "Say hello", + target: "environment", + placements: ["command-palette"], + }, + ], + }; + registry.publish("declares", "1.0.0", { tarball: plugin("declares", "1.0.0") }); + registry.publish("declares", "1.1.0", { + tarball: plugin("declares", "1.1.0", { declares }), + }); + const added = yield* npm.add({ name: "declares", version: "1.0.0" }); + const installationId = added.installation.installationId; + + const reply = yield* npm.stageUpdate({ installationId, version: "1.1.0" }); + // What a client decodes from the reply it reviews. + const staged = decodeReply(encodeReply(reply)).package; + const reviewed = staged.stagedUpdate!.manifest; + expect(reviewed).toMatchObject({ + tools: [{ name: "word_count" }], + settings: [{ key: "verbose" }], + actions: [{ name: "say-hello" }], + }); + + // Once applied, the catalogue lists exactly what was reviewed. + yield* npm.applyUpdate({ installationId, digest: staged.stagedUpdate!.source.digest }); + const row = (yield* catalog.list).installations[0]!; + expect(row.manifest).toEqual(reviewed); + }), + ), + ); + + it.effect("discards a staged update whose files changed after it was checked", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { catalog, npm, registry, plugin } = yield* setup(); + registry.publish("staged", "1.0.0", { tarball: plugin("staged", "1.0.0") }); + registry.publish("staged", "1.1.0", { tarball: plugin("staged", "1.1.0") }); + const added = yield* npm.add({ name: "staged", version: "1.0.0" }); + const installationId = added.installation.installationId; + const home = path.dirname(added.installation.directory); + yield* catalog.consent({ installationId, digest: added.installation.source!.digest }); + yield* catalog.enable({ installationId }); + const before = yield* callVersion(catalog, installationId); + + const { package: staged } = yield* npm.stageUpdate({ installationId, version: "1.1.0" }); + const stagingName = (yield* entries(home)).find((name) => name.startsWith(".staging-"))!; + yield* fs.writeFileString(path.join(home, stagingName, "dist", "extra.mjs"), "x"); + + const error = yield* npm + .applyUpdate({ installationId, digest: staged.stagedUpdate!.source.digest }) + .pipe(Effect.flip); + expect(error.reason).toBe("source-changed"); + expect((yield* npm.list).packages[0]!.stagedUpdate).toBeNull(); + expect(yield* entries(home)).toEqual(["npm.json", "package"]); + // The installed version never stopped. + expect(yield* callVersion(catalog, installationId)).toEqual(before); + }), + ), + ); + + it.effect("keeps deleting staged files whose discard was interrupted waiting its turn", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Scope.Scope; + const registry = makeRegistry(); + const base = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-npm-discard-" }); + const catalog = yield* startCatalog(scope); + // Completed with the paths of the next deletion npm asks the catalogue for. + let requested: Deferred.Deferred> | undefined; + const npm = yield* startNpm( + scope, + { + ...catalog, + changeFiles: (paths, effect) => + Effect.suspend(() => + requested === undefined ? Effect.void : Deferred.succeed(requested, paths), + ).pipe(Effect.andThen(catalog.changeFiles(paths, effect))), + }, + registry, + path.join(base, "npm"), + ); + for (const version of ["1.0.0", "1.1.0"]) + registry.publish("cut", version, { tarball: filesTarball("cut", version) }); + const added = yield* npm.add({ name: "cut", version: "1.0.0" }); + const installationId = added.installation.installationId; + const home = path.dirname(added.installation.directory); + const staging = () => + entries(home).pipe( + Effect.map((names) => + names + .filter((name) => name.startsWith(".staging-")) + .map((name) => path.join(home, name)), + ), + ); + /** Runs `step` until it asks to delete files, while another catalogue step holds the lock, then interrupts it. */ + const interruptWaiting = Effect.fn("interruptWaiting")(function* ( + step: Effect.Effect, + ) { + const holding = yield* Deferred.make(); + const hold = yield* Deferred.make(); + const holder = yield* catalog + .changeFiles( + [path.join(base, "elsewhere")], + Deferred.succeed(holding, undefined).pipe(Effect.andThen(Deferred.await(hold))), + ) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(holding); + requested = yield* Deferred.make>(); + const fiber = yield* step.pipe(Effect.forkChild({ startImmediately: true })); + const paths = yield* Deferred.await(requested); + requested = undefined; + yield* Fiber.interrupt(fiber); + yield* Deferred.succeed(hold, undefined); + yield* Fiber.join(holder); + return paths; + }); + + // Discard: the staged files are dropped from the package before the deletion runs. + yield* npm.stageUpdate({ installationId, version: "1.1.0" }); + const [first] = yield* staging(); + expect(yield* interruptWaiting(npm.discardUpdate({ installationId }))).toEqual([first]); + expect((yield* npm.list).packages[0]!.stagedUpdate).toBeNull(); + // Still queued, but an installation added from the directory keeps it. + const direct = (yield* catalog.add({ directory: first! })).installation; + yield* npm.discardUpdate({ installationId }); + expect(yield* staging()).toEqual([first]); + yield* catalog.remove({ installationId: direct.installationId }); + yield* npm.discardUpdate({ installationId }); + expect(yield* entries(home)).toEqual(["npm.json", "package"]); + + // Staging again: interrupted while dropping the earlier stage, neither directory is forgotten. + yield* npm.stageUpdate({ installationId, version: "1.1.0" }); + const [earlier] = yield* staging(); + expect( + yield* interruptWaiting(npm.stageUpdate({ installationId, version: "1.1.0" })), + ).toEqual([earlier]); + expect(yield* staging()).toHaveLength(2); + yield* npm.discardUpdate({ installationId }); + expect(yield* entries(home)).toEqual(["npm.json", "package"]); + expect((yield* npm.list).packages[0]!.stagedUpdate).toBeNull(); + }), + ), + ); + + it.effect("puts the old version back when the update fails after the swap", () => + withDatabase( + Effect.gen(function* () { + const realFs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Models the new files changing between the swap and the consent. + const fileSystem: FileSystem.FileSystem = { + ...realFs, + rename: (from, to) => + realFs + .rename(from, to) + .pipe( + Effect.andThen( + path.basename(from).startsWith(".staging-") && path.basename(to) === "package" + ? realFs.writeFileString(path.join(to, "late.mjs"), "x") + : Effect.void, + ), + ), + }; + const { catalog, npm, registry, plugin } = yield* setup(fileSystem); + registry.publish("rollback", "1.0.0", { tarball: plugin("rollback", "1.0.0") }); + registry.publish("rollback", "1.1.0", { tarball: plugin("rollback", "1.1.0") }); + const added = yield* npm.add({ name: "rollback", version: "1.0.0" }); + const installationId = added.installation.installationId; + const home = path.dirname(added.installation.directory); + const oldDigest = added.installation.source!.digest; + yield* catalog.consent({ installationId, digest: oldDigest }); + yield* catalog.enable({ installationId }); + const before = yield* callVersion(catalog, installationId); + + const { package: staged } = yield* npm.stageUpdate({ installationId, version: "1.1.0" }); + const error = yield* npm + .applyUpdate({ installationId, digest: staged.stagedUpdate!.source.digest }) + .pipe(Effect.flip); + expect(error.reason).toBe("source-changed"); + + const row = (yield* catalog.list).installations[0]!; + expect(row).toMatchObject({ + enabled: true, + source: { digest: oldDigest }, + consent: { digest: oldDigest }, + }); + expect((yield* npm.list).packages[0]).toMatchObject({ + source: { version: "1.0.0" }, + stagedUpdate: null, + }); + expect(isProcessAlive(before.pid)).toBe(false); + expect((yield* callVersion(catalog, installationId)).version).toBe("1.0.0"); + expect(yield* entries(home)).toEqual(["npm.json", "package"]); + const record = fromJson(yield* realFs.readFileString(path.join(home, "npm.json"))); + expect(record).toEqual({ source: added.package.source }); + }), + ), + ); + + it.effect("applies an update as one catalogue step that other management waits behind", () => + withDatabase( + Effect.gen(function* () { + const path = yield* Path.Path; + const faults = makeFaults(yield* FileSystem.FileSystem); + const { catalog, npm, registry, plugin } = yield* setup(faults.fileSystem); + const versions = ["1.0.0", "1.1.0", "1.2.0", "1.3.0", "1.4.0", "1.5.0"]; + for (const version of versions) + registry.publish("raced", version, { tarball: plugin("raced", version) }); + const added = yield* npm.add({ name: "raced", version: "1.0.0" }); + const installationId = added.installation.installationId; + yield* catalog.consent({ installationId, digest: added.installation.source!.digest }); + yield* catalog.enable({ installationId }); + expect((yield* callVersion(catalog, installationId)).version).toBe("1.0.0"); + const row = Effect.map(catalog.list, (snapshot) => + snapshot.installations.find((item) => item.installationId === installationId)!, + ); + + /** Applies `version` while another client runs `other` during the directory swap. */ + const applyWhile = Effect.fnUntraced(function* ( + version: string, + other: Effect.Effect, + ) { + const { package: staged } = yield* npm.stageUpdate({ installationId, version }); + const digest = staged.stagedUpdate!.source.digest; + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + faults.armed.hold = { + matches: (method, from, to) => + method === "rename" && + path.basename(from).startsWith(".staging-") && + to !== undefined && + path.basename(to) === "package", + entered, + release, + }; + const applying = yield* npm + .applyUpdate({ installationId, digest }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(entered); + // The old registration is revoked for the whole step: no call reaches either version. + const call = yield* callVersion(catalog, installationId).pipe(Effect.flip); + expect(call).toMatchObject({ reason: "unavailable" }); + const racing = yield* other.pipe( + Effect.exit, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(release, undefined); + const applied = yield* Fiber.join(applying); + return { applied, other: yield* Fiber.join(racing), digest }; + }); + + // A disable from another client lands after the update and stays. + const disabled = yield* applyWhile("1.1.0", catalog.disable({ installationId })); + expect(disabled.applied.installation.enabled).toBe(true); + expect(Exit.isSuccess(disabled.other)).toBe(true); + expect(yield* row).toMatchObject({ + enabled: false, + consent: { digest: disabled.digest }, + }); + + // An enable waits for the new consent, then runs the new version. + const enabled = yield* applyWhile("1.2.0", catalog.enable({ installationId })); + expect(enabled.applied.installation.enabled).toBe(false); + expect(Exit.isSuccess(enabled.other)).toBe(true); + expect((yield* callVersion(catalog, installationId)).version).toBe("1.2.0"); + + // Consent to the replaced bytes is refused once the new ones are in place. + const consented = yield* applyWhile( + "1.3.0", + catalog.consent({ installationId, digest: enabled.digest }), + ); + expect(Exit.isFailure(consented.other)).toBe(true); + expect(yield* row).toMatchObject({ + enabled: true, + consent: { digest: consented.digest }, + }); + expect((yield* callVersion(catalog, installationId)).version).toBe("1.3.0"); + + const refreshed = yield* applyWhile("1.4.0", catalog.refresh({ installationId })); + expect(Exit.isSuccess(refreshed.other)).toBe(true); + expect(yield* row).toMatchObject({ + enabled: true, + source: { digest: refreshed.digest }, + consent: { digest: refreshed.digest }, + }); + + const removed = yield* applyWhile("1.5.0", catalog.remove({ installationId })); + expect(Exit.isSuccess(removed.other)).toBe(true); + expect((yield* catalog.list).installations).toEqual([]); + expect((yield* npm.list).packages).toEqual([]); + }), + ), + ); + + it.effect("keeps the installed version running when the update cannot be journaled", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const faults = makeFaults(fs); + const { catalog, npm, registry, plugin } = yield* setup(faults.fileSystem); + registry.publish("journal", "1.0.0", { tarball: plugin("journal", "1.0.0") }); + registry.publish("journal", "1.1.0", { tarball: plugin("journal", "1.1.0") }); + const added = yield* npm.add({ name: "journal", version: "1.0.0" }); + const installationId = added.installation.installationId; + const home = path.dirname(added.installation.directory); + const oldDigest = added.installation.source!.digest; + yield* catalog.consent({ installationId, digest: oldDigest }); + yield* catalog.enable({ installationId }); + const before = yield* callVersion(catalog, installationId); + const { package: staged } = yield* npm.stageUpdate({ installationId, version: "1.1.0" }); + const digest = staged.stagedUpdate!.source.digest; + + const failures: ReadonlyArray< + readonly [string, (method: Faulted, target: string, to?: string) => boolean] + > = [ + [ + "journal write", + (method, target) => + method === "writeFileString" && path.basename(target) === ".npm.json.tmp", + ], + [ + "journal rename", + (method, _target, to) => + method === "rename" && to !== undefined && path.basename(to) === "npm.json", + ], + [ + "old .previous cleanup", + (method, target) => method === "remove" && path.basename(target) === ".previous", + ], + ]; + for (const [what, matches] of failures) { + faults.armed.fail = (method, target, to) => { + if (!matches(method, target, to)) return false; + faults.armed.fail = undefined; + return true; + }; + const error = yield* npm.applyUpdate({ installationId, digest }).pipe(Effect.flip); + expect(error.reason, what).toBe("storage"); + // A failed journal step is the cause; a refused cleanup is retried later and has none. + expect(PlatformError.isPlatformError(error.cause), what).toBe( + what !== "old .previous cleanup", + ); + expect(faults.armed.fail, what).toBeUndefined(); + // Never stopped: the same process answers, and the update can be applied again. + expect(yield* callVersion(catalog, installationId), what).toEqual(before); + expect((yield* catalog.list).installations[0], what).toMatchObject({ + enabled: true, + consent: { digest: oldDigest }, + }); + expect((yield* npm.list).packages[0]!.stagedUpdate?.source.digest, what).toBe(digest); + const record = fromJson(yield* fs.readFileString(path.join(home, "npm.json"))); + expect(record, what).toEqual({ source: added.package.source }); + } + + const applied = yield* npm.applyUpdate({ installationId, digest }); + expect(applied.package.source.version).toBe("1.1.0"); + expect((yield* callVersion(catalog, installationId)).version).toBe("1.1.0"); + }), + ), + ); + + it.effect("keeps an interrupted update it cannot undo yet, and undoes it on a later try", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Scope.Scope; + const registry = makeRegistry(); + const base = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-npm-retry-" }); + const root = path.join(base, "npm"); + const catalog = yield* startCatalog(scope); + const firstScope = yield* Scope.make(); + const first = yield* startNpm(firstScope, catalog, registry, root); + for (const version of ["1.0.0", "1.1.0"]) + registry.publish("retried", version, { + tarball: makeTarball([ + { path: "package/package.json", data: toJson({ name: "retried", version }) }, + { + path: "package/t3-plugin.json", + data: toJson({ + id: "test.retried", + name: "retried", + version, + apiVersion: 1, + entry: "main.mjs", + }), + }, + { path: "package/main.mjs", data: `export function activate() {} // ${version}` }, + ]), + }); + const added = yield* first.add({ name: "retried", version: "1.0.0" }); + const installationId = added.installation.installationId; + const home = path.dirname(added.installation.directory); + const oldDigest = added.installation.source!.digest; + yield* catalog.consent({ installationId, digest: oldDigest }); + const { package: staged } = yield* first.stageUpdate({ + installationId, + version: "1.1.0", + }); + const stagingName = (yield* entries(home)).find((name) => name.startsWith(".staging-"))!; + const journal = toJson({ + source: added.package.source, + swap: { + source: { ...added.package.source, version: "1.1.0" }, + digest: staged.stagedUpdate!.source.digest, + }, + }); + // The server stopped after the swap, before the consent. + yield* fs.writeFileString(path.join(home, "npm.json"), journal); + yield* fs.rename(added.installation.directory, path.join(home, ".previous")); + yield* fs.rename(path.join(home, stagingName), added.installation.directory); + yield* Scope.close(firstScope, Exit.void); + + const faults = makeFaults(fs); + faults.armed.fail = (method, target) => + method === "rename" && path.basename(target) === ".previous"; + const second = yield* startNpm(scope, catalog, registry, root, faults.fileSystem); + yield* second.list; + // Putting the old files back failed: the journal and the only old copy are kept. + expect(yield* entries(home)).toEqual([".previous", "npm.json"]); + expect(yield* fs.readFileString(path.join(home, "npm.json"))).toBe(journal); + const blocked = yield* second + .stageUpdate({ installationId, version: "1.1.0" }) + .pipe(Effect.flip); + expect(blocked.reason).toBe("storage"); + + // The next try moves the files back, but cannot write the record: still retried. + faults.armed.fail = (method, target) => + method === "writeFileString" && path.basename(target) === ".npm.json.tmp"; + const stillBlocked = yield* second.discardUpdate({ installationId }).pipe(Effect.flip); + expect(stillBlocked.reason).toBe("storage"); + expect(yield* entries(home)).toEqual(["npm.json", "package"]); + expect(yield* fs.readFileString(path.join(home, "npm.json"))).toBe(journal); + + faults.armed.fail = undefined; + yield* second.discardUpdate({ installationId }); + expect(yield* entries(home)).toEqual(["npm.json", "package"]); + const record = fromJson(yield* fs.readFileString(path.join(home, "npm.json"))); + expect(record).toEqual({ source: added.package.source }); + expect((yield* second.list).packages[0]!.source.version).toBe("1.0.0"); + const row = (yield* catalog.list).installations[0]!; + expect(row.source?.digest).toBe(oldDigest); + expect(pluginInstallationStatus(row)).toBe("disabled"); + }), + ), + ); + + it.effect("finishes or rolls back an update a restart interrupted, and deletes leftovers", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Scope.Scope; + const registry = makeRegistry(); + const base = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-npm-restart-" }); + const root = path.join(base, "npm"); + const catalog = yield* startCatalog(scope); + const firstScope = yield* Scope.make(); + const first = yield* startNpm(firstScope, catalog, registry, root); + const tarballFor = (name: string, version: string) => + makeTarball([ + { path: "package/package.json", data: toJson({ name, version }) }, + { + path: "package/t3-plugin.json", + data: toJson({ + id: `test.${name}`, + name, + version, + apiVersion: 1, + entry: "main.mjs", + }), + }, + { path: "package/main.mjs", data: `export function activate() {} // ${version}` }, + ]); + // Every point an apply can stop at, in order: each step includes the ones before it. + const stages = [ + "journaled", + "stopped", + "moved-old", + "moved-new", + "consented", + "recorded", + ] as const; + for (const name of stages) + for (const version of ["1.0.0", "1.1.0"]) + registry.publish(name, version, { tarball: tarballFor(name, version) }); + + /** Leaves the state an enabled installation's update had when the server stopped. */ + const interrupt = Effect.fn("interrupt")(function* (stage: (typeof stages)[number]) { + const reached = (step: (typeof stages)[number]) => + stages.indexOf(stage) >= stages.indexOf(step); + const added = yield* first.add({ name: stage, version: "1.0.0" }); + const installationId = added.installation.installationId; + const home = path.dirname(added.installation.directory); + yield* catalog.consent({ installationId, digest: added.installation.source!.digest }); + yield* catalog.enable({ installationId }); + const { package: staged } = yield* first.stageUpdate({ + installationId, + version: "1.1.0", + }); + const update = staged.stagedUpdate!; + const stagingName = (yield* entries(home)).find((entry) => + entry.startsWith(".staging-"), + )!; + const next = { ...added.package.source, version: "1.1.0", integrity: update.integrity }; + yield* fs.writeFileString( + path.join(home, "npm.json"), + toJson({ + source: added.package.source, + swap: { source: next, digest: update.source.digest }, + }), + ); + if (reached("stopped")) yield* catalog.disable({ installationId }); + if (reached("moved-old")) + yield* fs.rename(added.installation.directory, path.join(home, ".previous")); + if (reached("moved-new")) + yield* fs.rename(path.join(home, stagingName), added.installation.directory); + if (reached("consented")) + yield* catalog.consent({ installationId, digest: update.source.digest }); + if (reached("recorded")) + yield* fs.writeFileString(path.join(home, "npm.json"), toJson({ source: next })); + return { + stage, + installationId, + home, + oldDigest: added.installation.source!.digest, + update, + }; + }); + const interrupted = yield* Effect.forEach(stages, interrupt); + yield* fs.makeDirectory(path.join(interrupted[3]!.home, ".staging-left")); + yield* fs.makeDirectory(path.join(root, "pkg-orphan", "package"), { recursive: true }); + yield* Scope.close(firstScope, Exit.void); + + const second = yield* startNpm(scope, catalog, registry, root); + const packages = (yield* second.list).packages; + const rows = (yield* catalog.list).installations; + expect((yield* entries(root)).length).toBe(stages.length); + for (const { stage, installationId, home, oldDigest, update } of interrupted) { + // Rolled back before the consent: the old bytes, under the consent they kept. + // Finished after it: the consented new bytes. Only a swap that never stopped the + // plugin leaves it enabled. + const finished = stage === "consented" || stage === "recorded"; + const version = finished ? "1.1.0" : "1.0.0"; + const row = rows.find((item) => item.installationId === installationId)!; + expect(yield* entries(home), stage).toEqual(["npm.json", "package"]); + expect( + packages.find((item) => item.installationId === installationId)?.source, + stage, + ).toMatchObject({ version, ...(finished ? { integrity: update.integrity } : {}) }); + const record = fromJson(yield* fs.readFileString(path.join(home, "npm.json"))); + expect(record, stage).toEqual({ source: expect.objectContaining({ version }) }); + expect(row.source?.digest, stage).toBe(finished ? update.source.digest : oldDigest); + expect(pluginInstallationStatus(row), stage).toBe( + stage === "journaled" ? "enabled" : "disabled", + ); + } + }), + ), + ); + }); + + describe("file moves", () => { + /** + * A gate port for `filesTarball`, opened when the test ends so no deactivation waits. + * `open` answers every deactivation already waiting and each one that connects later. + */ + const makeGate = Effect.fn("makeGate")(function* () { + const report = yield* Deferred.make(); + let opened = false; + const waiting = new Set(); + const server = yield* Effect.acquireRelease( + Effect.promise( + () => + new Promise((resolve) => { + const server = NodeNet.createServer((socket) => { + let text = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => (text += chunk)); + socket.on("close", () => Deferred.doneUnsafe(report, Exit.succeed(text))); + if (opened) socket.write("open"); + else waiting.add(socket); + }); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), + ), + (server) => Effect.sync(() => server.close()), + ); + const open = Effect.sync(() => { + opened = true; + for (const socket of waiting) socket.write("open"); + waiting.clear(); + }); + const deactivated = Deferred.await(report).pipe(Effect.map(fromJson)); + return { gate: (server.address() as NodeNet.AddressInfo).port, open, deactivated }; + }); + + /** Disables the installation and interrupts the caller once the supervisor has started stopping it. */ + const cutShortDisable = Effect.fn("cutShortDisable")(function* ( + catalog: Catalog, + installationId: PluginInstallationId, + disableStarted: Deferred.Deferred, + ) { + const disabling = yield* catalog + .disable({ installationId }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(disableStarted); + yield* Fiber.interrupt(disabling); + }); + + it.effect("waits for a plugin whose disable was cut short before swapping its files", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Scope.Scope; + const { gate, open, deactivated } = yield* makeGate(); + const disableStarted = yield* Deferred.make(); + const catalog = yield* startCatalog(scope, { stopGrace: "30 seconds", disableStarted }); + // Added after the catalogue, so it runs before the supervisor stops what is left. + yield* Effect.addFinalizer(() => open); + const faults = makeFaults(fs); + const registry = makeRegistry(); + const root = path.join( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-npm-" }), + "npm", + ); + const npm = yield* startNpm(scope, catalog, registry, root, faults.fileSystem); + for (const version of ["1.0.0", "1.1.0"]) + registry.publish("gated", version, { tarball: filesTarball("gated", version, gate) }); + const added = yield* npm.add({ name: "gated", version: "1.0.0" }); + const installationId = added.installation.installationId; + const directory = added.installation.directory; + yield* catalog.consent({ installationId, digest: added.installation.source!.digest }); + yield* catalog.enable({ installationId }); + const running = yield* callFiles(catalog, installationId); + const { package: staged } = yield* npm.stageUpdate({ installationId, version: "1.1.0" }); + const digest = staged.stagedUpdate!.source.digest; + + yield* cutShortDisable(catalog, installationId, disableStarted); + // Disabled, and the process is still deactivating. + expect((yield* catalog.list).installations[0]!.enabled).toBe(false); + expect(isProcessAlive(running.pid)).toBe(true); + + const journaled = yield* Deferred.make(); + let aliveAtSwap: boolean | undefined; + faults.armed.fail = (method, target, detail) => { + if (method === "writeFileString" && detail?.includes('"swap"')) + Deferred.doneUnsafe(journaled, Exit.void); + if (method === "rename" && target === directory) + aliveAtSwap ??= isProcessAlive(running.pid); + return false; + }; + const applying = yield* npm + .applyUpdate({ installationId, digest }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(journaled); + yield* open; + const applied = yield* Fiber.join(applying); + + expect(aliveAtSwap).toBe(false); + // Deactivating, the old version still read its own files. + expect(yield* deactivated).toEqual({ loaded: "1.0.0", disk: "1.0.0" }); + expect(applied.package.source.version).toBe("1.1.0"); + expect(applied.installation).toMatchObject({ enabled: false, consent: { digest } }); + }), + ), + ); + + it.effect("waits for a plugin whose disable was cut short before rolling its files back", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Scope.Scope; + const { gate, open, deactivated } = yield* makeGate(); + const disableStarted = yield* Deferred.make(); + // The journal expects 1.2.0, so the consented 1.1.0 files in place are rolled back. + const crashed = yield* crashBeforeConsent(scope, "rolled", "1.2.0", { + gate, + stopGrace: "30 seconds", + disableStarted, + }); + yield* Effect.addFinalizer(() => open); + const { catalog, installationId, home, oldDigest, filesDigest } = crashed; + yield* catalog.consent({ installationId, digest: filesDigest }); + yield* catalog.enable({ installationId }); + const running = yield* callFiles(catalog, installationId); + expect(running).toMatchObject({ loaded: "1.1.0", disk: "1.1.0" }); + + yield* cutShortDisable(catalog, installationId, disableStarted); + expect(isProcessAlive(running.pid)).toBe(true); + + const faults = makeFaults(fs); + let aliveAtMove: boolean | undefined; + faults.armed.fail = (method, target) => { + if (method === "remove" && target === path.join(home, "package")) + aliveAtMove ??= isProcessAlive(running.pid); + return false; + }; + const npm = yield* startNpm( + scope, + catalog, + crashed.registry, + crashed.root, + faults.fileSystem, + ); + yield* open; + expect((yield* npm.list).packages[0]!.source.version).toBe("1.0.0"); + + expect(aliveAtMove).toBe(false); + expect(yield* deactivated).toEqual({ loaded: "1.1.0", disk: "1.1.0" }); + expect(yield* entries(home)).toEqual(["npm.json", "package"]); + expect((yield* catalog.list).installations[0]).toMatchObject({ + enabled: false, + source: { digest: oldDigest }, + }); + }), + ), + ); + + it.effect("keeps a removed package's files while another installation runs from them", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const faults = makeFaults(fs); + const { catalog, npm, registry, root } = yield* setup(faults.fileSystem); + registry.publish("readded", "1.0.0", { tarball: filesTarball("readded", "1.0.0") }); + registry.publish("other", "1.0.0", { tarball: filesTarball("other", "1.0.0") }); + const other = yield* npm.add({ name: "other", version: "1.0.0" }); + const added = yield* npm.add({ name: "readded", version: "1.0.0" }); + const directory = added.installation.directory; + const home = path.dirname(directory); + faults.armed.fail = (method, target) => method === "remove" && target === home; + yield* catalog.remove({ installationId: added.installation.installationId }); + yield* npm.discardUpdate({ installationId: other.installation.installationId }); + expect(yield* fs.exists(directory)).toBe(true); + + // Another client adds the same directory back, approves it, and runs it. + const readded = (yield* catalog.add({ directory })).installation; + yield* catalog.consent({ + installationId: readded.installationId, + digest: readded.source!.digest, + }); + yield* catalog.enable({ installationId: readded.installationId }); + const running = yield* callFiles(catalog, readded.installationId); + expect(running).toMatchObject({ loaded: "1.0.0", disk: "1.0.0" }); + + // The retried deletion finds the directory in use and leaves it. + faults.armed.fail = undefined; + yield* npm.discardUpdate({ installationId: other.installation.installationId }); + expect(yield* callFiles(catalog, readded.installationId)).toEqual(running); + expect( + (yield* catalog.list).installations.find( + (row) => row.installationId === readded.installationId, + ), + ).toMatchObject({ enabled: true }); + + // Once nothing uses it, the next retry deletes it. + yield* catalog.remove({ installationId: readded.installationId }); + yield* npm.discardUpdate({ installationId: other.installation.installationId }); + expect(yield* entries(root)).toEqual([ + path.basename(path.dirname(other.installation.directory)), + ]); + }), + ), + ); + + it.effect("decides who owns a directory in the same step that deletes it", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const faults = makeFaults(fs); + const events: Array = []; + let directory = ""; + const { catalog, npm, registry } = yield* setup(faults.fileSystem, { + fileSystem: { + ...fs, + realPath: (target) => + Effect.suspend(() => { + if (target === directory) events.push("inspected"); + return fs.realPath(target); + }), + }, + }); + registry.publish("deleted", "1.0.0", { tarball: filesTarball("deleted", "1.0.0") }); + const added = yield* npm.add({ name: "deleted", version: "1.0.0" }); + directory = added.installation.directory; + const home = path.dirname(directory); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + faults.armed.hold = { + matches: (method, target) => method === "remove" && target === home, + entered, + release, + }; + yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined)); + // The removal reaches npm through the catalogue's change notification. + yield* catalog.remove({ installationId: added.installation.installationId }); + yield* Deferred.await(entered); + // Adding the directory back waits for the deletion, then finds nothing to add. + const adding = yield* catalog + .add({ directory }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + events.push("released"); + yield* Deferred.succeed(release, undefined); + expect((yield* Fiber.join(adding)).reason).toBe("invalid-directory"); + expect(events[0]).toBe("released"); + expect(yield* fs.exists(home)).toBe(false); + expect((yield* catalog.list).installations).toEqual([]); + }), + ), + ); + + it.effect("keeps a home that is added back while startup is deciding what to delete", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Scope.Scope; + const registry = makeRegistry(); + const base = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-npm-startup-" }); + const root = path.join(base, "npm"); + const catalog = yield* startCatalog(scope); + const firstScope = yield* Scope.make(); + const first = yield* startNpm(firstScope, catalog, registry, root); + registry.publish("startup", "1.0.0", { tarball: filesTarball("startup", "1.0.0") }); + const added = yield* first.add({ name: "startup", version: "1.0.0" }); + const directory = added.installation.directory; + yield* Scope.close(firstScope, Exit.void); + // Removed while no npm service ran, so its home is left for startup. + yield* catalog.remove({ installationId: added.installation.installationId }); + + const faults = makeFaults(fs); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const realRoot = yield* fs.realPath(root); + // Startup has read the catalogue, which no longer has the home. + faults.armed.hold = { + matches: (method, target) => method === "readDirectory" && target === realRoot, + entered, + release, + }; + const second = yield* startNpm(scope, catalog, registry, root, faults.fileSystem); + yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined)); + yield* Deferred.await(entered); + const readded = (yield* catalog.add({ directory })).installation; + yield* catalog.consent({ + installationId: readded.installationId, + digest: readded.source!.digest, + }); + yield* catalog.enable({ installationId: readded.installationId }); + const running = yield* callFiles(catalog, readded.installationId); + yield* Deferred.succeed(release, undefined); + + expect((yield* second.list).packages).toEqual([]); + expect(yield* callFiles(catalog, readded.installationId)).toEqual(running); + expect(running).toMatchObject({ disk: "1.0.0" }); + }), + ), + ); + }); + + describe("recovery", () => { + it.effect("keeps an update whose consent another client finished before recovery decided", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Scope.Scope; + const crashed = yield* crashBeforeConsent(scope, "kept"); + const { catalog, installationId, home, next, journalDigest } = crashed; + const faults = makeFaults(fs); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + // Recovery has read its journal; the consent it settles by is not read yet. + faults.armed.hold = { + matches: (method, target) => + method === "exists" && path.basename(target) === ".previous", + entered, + release, + }; + const npm = yield* startNpm( + scope, + catalog, + crashed.registry, + crashed.root, + faults.fileSystem, + ); + // A failed assertion must not leave recovery parked on the hold. + yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined)); + yield* Deferred.await(entered); + // Another client approves the files now in place and runs them. + yield* catalog.consent({ installationId, digest: journalDigest }); + yield* catalog.enable({ installationId }); + const running = yield* callFiles(catalog, installationId); + expect(running).toMatchObject({ loaded: "1.1.0", disk: "1.1.0" }); + yield* Deferred.succeed(release, undefined); + + expect((yield* npm.list).packages[0]!.source).toEqual(next); + // The same process still finds its own files: recovery finished the update. + expect(yield* callFiles(catalog, installationId)).toEqual(running); + expect(yield* entries(home)).toEqual(["npm.json", "package"]); + expect(fromJson(yield* fs.readFileString(path.join(home, "npm.json")))).toEqual({ + source: next, + }); + expect((yield* catalog.list).installations[0]).toMatchObject({ + enabled: true, + consent: { digest: journalDigest }, + }); + }), + ), + ); + + it.effect("stops a running plugin before putting old files back, and other steps wait", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Scope.Scope; + // The journal expects 1.2.0, so the 1.1.0 files in place are rolled back even + // though they have consent and run. + const crashed = yield* crashBeforeConsent(scope, "stopped", "1.2.0"); + const { catalog, installationId, home, oldDigest, filesDigest } = crashed; + yield* catalog.consent({ installationId, digest: filesDigest }); + yield* catalog.enable({ installationId }); + const running = yield* callFiles(catalog, installationId); + expect(running).toMatchObject({ loaded: "1.1.0", disk: "1.1.0" }); + + const faults = makeFaults(fs); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const directory = path.join(home, "package"); + faults.armed.hold = { + matches: (method, target) => method === "remove" && target === directory, + entered, + release, + }; + const npm = yield* startNpm( + scope, + catalog, + crashed.registry, + crashed.root, + faults.fileSystem, + ); + // A failed assertion must not leave recovery parked on the hold. + yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined)); + yield* Deferred.await(entered); + // About to remove the files: the registration is revoked and its process has exited. + const call = yield* callFiles(catalog, installationId).pipe(Effect.flip); + expect(call).toMatchObject({ reason: "unavailable" }); + expect(isProcessAlive(running.pid)).toBe(false); + const racing = yield* catalog + .consent({ installationId, digest: filesDigest }) + .pipe( + Effect.andThen(catalog.enable({ installationId })), + Effect.flip, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(release, undefined); + + expect((yield* npm.list).packages[0]!.source.version).toBe("1.0.0"); + // Queued behind recovery, the consent finds the old files instead. + expect((yield* Fiber.join(racing)).reason).toBe("source-changed"); + expect(yield* entries(home)).toEqual(["npm.json", "package"]); + const row = (yield* catalog.list).installations[0]!; + // Disabled, and the consent it had is to the files that are gone. + expect(row).toMatchObject({ + enabled: false, + source: { digest: oldDigest }, + consent: { digest: filesDigest }, + }); + expect(pluginInstallationStatus(row)).toBe("needs-consent"); + }), + ), + ); + + it.effect("reports the applied version whichever cleanup after the commit fails", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const faults = makeFaults(fs); + const { catalog, npm, registry, plugin } = yield* setup(faults.fileSystem); + // Each fails only once the journal is saved, so only work after the commit fails. + // `record` is the last record written, saved or not. + const cases: ReadonlyArray<{ + readonly name: string; + readonly fails: ( + method: Faulted, + target: string, + detail: string | undefined, + record: string, + ) => boolean; + /** The journal stays on disk until the failed step works. */ + readonly pending: boolean; + }> = [ + { + // A commit is settled without checking for `.previous`. + name: "backup-check", + fails: (method, target) => + method === "exists" && path.basename(target) === ".previous", + pending: false, + }, + { + name: "record-write", + fails: (method, target, detail) => + method === "writeFileString" && + path.basename(target) === ".npm.json.tmp" && + !detail?.includes('"swap"'), + pending: true, + }, + { + name: "record-rename", + fails: (method, _target, detail, record) => + method === "rename" && + detail !== undefined && + path.basename(detail) === "npm.json" && + !record.includes('"swap"'), + pending: true, + }, + { + name: "backup-delete", + fails: (method, target) => + method === "remove" && path.basename(target) === ".previous", + pending: false, + }, + ]; + for (const { name, fails, pending } of cases) { + registry.publish(name, "1.0.0", { + tarball: plugin(name, "1.0.0", { pluginId: `test.${name}` }), + }); + registry.publish(name, "1.1.0", { + tarball: plugin(name, "1.1.0", { pluginId: `test.${name}` }), + }); + const added = yield* npm.add({ name, version: "1.0.0" }); + const installationId = added.installation.installationId; + const home = path.dirname(added.installation.directory); + yield* catalog.consent({ installationId, digest: added.installation.source!.digest }); + yield* catalog.enable({ installationId }); + const { package: staged } = yield* npm.stageUpdate({ + installationId, + version: "1.1.0", + }); + const update = staged.stagedUpdate!; + let journaled = false; + let written = ""; + faults.armed.fail = (method, target, detail) => { + if (method === "writeFileString" && detail !== undefined) written = detail; + const failed = journaled && fails(method, target, detail, written); + if (method === "rename" && !failed && written.includes('"swap"')) journaled = true; + return failed; + }; + + const applied = yield* npm.applyUpdate({ + installationId, + digest: update.source.digest, + }); + // The reply, the npm list, the consent, and the running code all say 1.1.0. + const next = { version: "1.1.0", integrity: update.integrity }; + expect(applied.package.source, name).toMatchObject(next); + expect(applied.installation.consent?.digest, name).toBe(update.source.digest); + const listed = (yield* npm.list).packages.find( + (item) => item.installationId === installationId, + ); + expect(listed?.source, name).toEqual(applied.package.source); + const row = (yield* catalog.list).installations.find( + (item) => item.installationId === installationId, + ); + expect(row?.consent?.digest, name).toBe(update.source.digest); + expect((yield* callVersion(catalog, installationId)).version, name).toBe("1.1.0"); + const journal = fromJson(yield* fs.readFileString(path.join(home, "npm.json"))); + expect(journal, name).toEqual( + pending + ? { + source: added.package.source, + swap: expect.objectContaining({ source: expect.objectContaining(next) }), + } + : { source: applied.package.source }, + ); + + faults.armed.fail = undefined; + yield* npm.discardUpdate({ installationId }); + expect(yield* entries(home), name).toEqual(["npm.json", "package"]); + const record = fromJson(yield* fs.readFileString(path.join(home, "npm.json"))); + expect(record, name).toEqual({ source: applied.package.source }); + expect((yield* callVersion(catalog, installationId)).version, name).toBe("1.1.0"); + } + }), + ), + ); + + it.effect("reports the applied version when the plugin cannot be enabled again", () => + withDatabase( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const { catalog, npm, registry, plugin } = yield* setup(); + registry.publish("reenable", "1.0.0", { tarball: plugin("reenable", "1.0.0") }); + registry.publish("reenable", "1.1.0", { tarball: plugin("reenable", "1.1.0") }); + const added = yield* npm.add({ name: "reenable", version: "1.0.0" }); + const installationId = added.installation.installationId; + yield* catalog.consent({ installationId, digest: added.installation.source!.digest }); + yield* catalog.enable({ installationId }); + const { package: staged } = yield* npm.stageUpdate({ installationId, version: "1.1.0" }); + const update = staged.stagedUpdate!; + // Enabling the new files fails after their consent is saved. + yield* sql` + CREATE TRIGGER refuse_enable BEFORE UPDATE ON plugin_installations + WHEN json_extract(NEW.record_json, '$.generation') > json_extract(OLD.record_json, '$.generation') + BEGIN SELECT RAISE(ABORT, 'refused'); END + `; + + const applied = yield* npm.applyUpdate({ + installationId, + digest: update.source.digest, + }); + expect(applied.package.source).toMatchObject({ + version: "1.1.0", + integrity: update.integrity, + }); + expect(applied.package.stagedUpdate).toBeNull(); + expect(applied.installation.consent?.digest).toBe(update.source.digest); + expect(applied.installation.enabled).toBe(false); + }), + ), + ); + + it.effect("reports the committed version after a restart while cleanup still fails", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Scope.Scope; + const faults = makeFaults(fs); + const registry = makeRegistry(); + const base = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-npm-restart-" }); + const root = path.join(base, "npm"); + const firstScope = yield* Scope.make(); + const firstCatalog = yield* startCatalog(firstScope); + const first = yield* startNpm( + firstScope, + firstCatalog, + registry, + root, + faults.fileSystem, + ); + for (const version of ["1.0.0", "1.1.0"]) + registry.publish("restarted", version, { tarball: filesTarball("restarted", version) }); + const added = yield* first.add({ name: "restarted", version: "1.0.0" }); + const installationId = added.installation.installationId; + const home = path.dirname(added.installation.directory); + yield* firstCatalog.consent({ + installationId, + digest: added.installation.source!.digest, + }); + yield* firstCatalog.enable({ installationId }); + const update = (yield* first.stageUpdate({ installationId, version: "1.1.0" })).package + .stagedUpdate!; + // Every `.previous` check and every record write but the journal fails, before and + // after the restart. + faults.armed.fail = (method, target, detail) => + (method === "exists" && path.basename(target) === ".previous") || + (method === "writeFileString" && + path.basename(target) === ".npm.json.tmp" && + !detail?.includes('"swap"')); + const applied = yield* first.applyUpdate({ + installationId, + digest: update.source.digest, + }); + expect(applied.package.source.version).toBe("1.1.0"); + yield* Scope.close(firstScope, Exit.void); + + // The catalogue, supervisor, and npm service all start again on the same state. + const catalog = yield* startCatalog(scope); + const npm = yield* startNpm(scope, catalog, registry, root, faults.fileSystem); + expect((yield* npm.list).packages[0]!.source).toEqual(applied.package.source); + const journal = fromJson(yield* fs.readFileString(path.join(home, "npm.json"))); + expect(journal).toMatchObject({ source: added.package.source, swap: {} }); + yield* catalog.subscribe.pipe( + Stream.filter((snapshot) => + snapshot.installations.some((row) => row.hostState !== undefined), + ), + Stream.runHead, + ); + expect(yield* callFiles(catalog, installationId)).toMatchObject({ loaded: "1.1.0" }); + expect((yield* catalog.list).installations[0]!.consent?.digest).toBe( + update.source.digest, + ); + + faults.armed.fail = undefined; + yield* npm.discardUpdate({ installationId }); + expect(yield* entries(home)).toEqual(["npm.json", "package"]); + expect(fromJson(yield* fs.readFileString(path.join(home, "npm.json")))).toEqual({ + source: applied.package.source, + }); + }), + ), + ); + }); +}); diff --git a/apps/server/src/plugins/PluginNpm.ts b/apps/server/src/plugins/PluginNpm.ts new file mode 100644 index 000000000000..fea6c1dba3ba --- /dev/null +++ b/apps/server/src/plugins/PluginNpm.ts @@ -0,0 +1,1013 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Hashes a downloaded tarball with node:crypto to check its sha512 integrity. +/** + * Installs trusted plugins from an npm registry into directories the server + * owns, then hands each one to the catalogue like any added directory. + * + * Installing runs nothing. One exact version is resolved, its tarball must + * match the registry's sha512 integrity, and the archive is checked whole + * before a byte is written (see `npmTarball.ts`). Package scripts never run + * and dependencies are never installed: a package must bundle what it needs, + * so a package with an install script, or whose dependencies (or theirs in + * turn) do not resolve inside it, is refused. The catalogue then requires + * consent to the unpacked digest before the plugin can be enabled. + * + * Layout under `/plugins/npm//`: + * - `package/` the installed version: the catalogue installation's directory. + * - `npm.json` where it came from, and an update swap in progress, if any. + * - `.staging-*` a version being unpacked or a staged update; never survives a restart. + * - `.previous` the replaced version, only while an update is being applied. + * + * An update unpacks next to `package/` and leaves it running. Applying it + * first journals the swap in `npm.json`, then hands the catalogue one step + * (`PluginCatalog.replace`) that stops the installation, swaps the + * directories, and consents to the staged digest; that consent is the commit + * point, and a failure before it puts the old directory back. The journal is + * then settled by another catalogue step (`PluginCatalog.settleReplace`), + * which decides from the consent it holds at that moment: finished when it + * has consent to the new digest, otherwise rolled back from `.previous` after + * the plugin has stopped. Until settling works, the journal and `.previous` + * stay, and it is retried before every npm step, after every catalogue + * change, and at startup. + * + * Every file move or deletion under the root is one catalogue step that + * claims the paths first (`replace`, `settleReplace`, `changeFiles`): it is + * refused while any installation is rooted there, whatever its id, and waits + * until every process that ran from those files has exited. A deletion that + * is refused or fails stays queued and is retried the same way. + * + * Removing the installation from the catalogue deletes its `` directory. + */ +import * as NodeCrypto from "node:crypto"; + +import { + PluginCatalogError, + PluginNpmIntegrity, + PluginNpmSource, + PluginNpmVersion, + PluginSourceDigest, + type PluginInstallationId, + type PluginInstallationInput, + type PluginNpmAddInput, + type PluginNpmApplyUpdateInput, + type PluginNpmInstallationResult, + type PluginNpmListResult, + type PluginNpmPackage, + type PluginNpmPackageResult, + type PluginNpmStagedUpdate, + type PluginNpmStageUpdateInput, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +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 Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; + +import { ServerConfig } from "../config.ts"; +import * as PluginCatalog from "./PluginCatalog.ts"; +import { loadPluginDirectory } from "./PluginManifestLoader.ts"; +import { digestPluginSource } from "./pluginSource.ts"; +import { + defaultNpmTarballLimits, + readNpmTarball, + type NpmTarballFile, + type NpmTarballLimits, +} from "./npmTarball.ts"; + +const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org"; + +/** Logs a failed file step and fails with it as the cause of a `storage` error. */ +const storageError = (cause: unknown) => + Effect.logWarning("Plugin npm storage failed", { cause }).pipe( + Effect.andThen( + Effect.fail( + new PluginCatalogError({ + reason: "storage", + message: "Could not write the plugin's files.", + cause, + }), + ), + ), + ); + +const MAX_METADATA_BYTES = 1024 * 1024; +const MAX_METADATA_REDIRECTS = 5; +const MAX_PACKAGE_JSON_BYTES = 1024 * 1024; +/** Dependency declarations followed through one package's bundled tree. */ +const MAX_DEPENDENCY_EDGES = 20_000; + +const NpmVersionMetadata = Schema.Struct({ + name: Schema.String, + version: Schema.String, + dist: Schema.Struct({ tarball: Schema.String, integrity: Schema.optionalKey(Schema.String) }), +}); +const decodeMetadata = Schema.decodeUnknownEffect(Schema.fromJsonString(NpmVersionMetadata)); + +const DependencyMap = Schema.Record(Schema.String, Schema.Unknown); +const BundledDependencies = Schema.Union([Schema.Array(Schema.String), Schema.Boolean]); +const NpmPackageJson = Schema.Struct({ + name: Schema.optionalKey(Schema.String), + version: Schema.optionalKey(Schema.String), + scripts: Schema.optionalKey(DependencyMap), + dependencies: Schema.optionalKey(DependencyMap), + optionalDependencies: Schema.optionalKey(DependencyMap), + peerDependencies: Schema.optionalKey(DependencyMap), + peerDependenciesMeta: Schema.optionalKey( + Schema.Record(Schema.String, Schema.Struct({ optional: Schema.optionalKey(Schema.Boolean) })), + ), + bundleDependencies: Schema.optionalKey(BundledDependencies), + bundledDependencies: Schema.optionalKey(BundledDependencies), +}); +type NpmPackageJson = typeof NpmPackageJson.Type; +const decodePackageJson = Schema.decodeUnknownEffect(Schema.fromJsonString(NpmPackageJson)); +const decodePackageJsonOption = Schema.decodeUnknownOption(Schema.fromJsonString(NpmPackageJson)); + +/** `npm.json`: the installed version and, while an update is applied, the one replacing it. */ +const NpmRecord = Schema.Struct({ + source: PluginNpmSource, + swap: Schema.optionalKey(Schema.Struct({ source: PluginNpmSource, digest: PluginSourceDigest })), +}); +type NpmRecord = typeof NpmRecord.Type; +const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(NpmRecord)); +const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(NpmRecord)); +const isExactVersion = Schema.is(PluginNpmVersion); +const isIntegrity = Schema.is(PluginNpmIntegrity); + +const INSTALL_SCRIPTS = ["preinstall", "install", "postinstall"]; + +interface Resolved { + readonly version: PluginNpmVersion; + readonly integrity: PluginNpmIntegrity; + readonly tarball: string; +} + +interface Installed { + /** `/`. */ + readonly home: string; + /** `/package`, the catalogue installation's directory. */ + readonly directory: string; + readonly installationId: PluginInstallationId; + source: PluginNpmSource; + staged: { readonly update: PluginNpmStagedUpdate; readonly directory: string } | undefined; + /** A swap journaled in `npm.json` and not yet finished or rolled back. */ + swap: + | { + readonly previous: PluginNpmSource; + readonly next: PluginNpmSource; + readonly digest: string; + } + | undefined; +} + +const toPackage = (entry: Installed): PluginNpmPackage => ({ + installationId: entry.installationId, + source: entry.source, + stagedUpdate: entry.staged?.update ?? null, +}); + +// Over plain http the registry's integrity and the tarball travel together, so +// the digest check would authenticate nothing; only a loopback registry may use it. +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** Whether metadata fetched from `url` can vouch for a tarball's integrity. */ +const isTrustedRegistryUrl = (url: URL) => + url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)); + +/** Registry base URL without a trailing slash; credentials, queries, and fragments are refused. */ +const normalizeRegistry = (input: string) => { + const url = URL.parse(input); + if ( + url === null || + !isTrustedRegistryUrl(url) || + url.username !== "" || + url.password !== "" || + url.search !== "" || + url.hash !== "" + ) + return Effect.fail( + new PluginCatalogError({ + reason: "npm-invalid-request", + message: + "Enter the registry as an https URL (http only on this machine) without credentials, query, or fragment.", + }), + ); + return Effect.succeed(url.href.replace(/\/+$/, "")); +}; + +/** What a package needs at run time; a peer it marks optional is left out. */ +const runtimeDependencies = (packageJson: NpmPackageJson) => [ + ...new Set([ + ...Object.keys(packageJson.dependencies ?? {}), + ...Object.keys(packageJson.optionalDependencies ?? {}), + ...Object.keys(packageJson.peerDependencies ?? {}).filter( + (peer) => packageJson.peerDependenciesMeta?.[peer]?.optional !== true, + ), + ]), +]; + +/** `node_modules//package.json`, with the last `node_modules` taken. */ +const BUNDLED_MANIFEST = /^(.*\/)?node_modules\/((?:@[^/]+\/)?[^/@][^/]*)\/package\.json$/; + +/** + * Follows every runtime dependency from the root through the shipped + * `node_modules` the way Node looks them up, but never above the package + * root: one found nowhere inside would come from outside what the user + * consented to, or not at all. Returns why the package is refused, if it is. + */ +const findUnbundled = (files: ReadonlyArray, root: NpmPackageJson) => { + // Parent directory (`""` or ending in `/`) -> package name -> that package's manifest. + const shipped = new Map>(); + for (const file of files) { + const match = BUNDLED_MANIFEST.exec(file.path); + if (match === null) continue; + const parent = match[1] ?? ""; + const names = shipped.get(parent) ?? new Map(); + names.set(match[2]!, file); + shipped.set(parent, names); + } + const queue: Array = [["", root]]; + const seen = new Set(); + let edges = 0; + for (let index = 0; index < queue.length; index++) { + const [from, packageJson] = queue[index]!; + // Where `from` looks, nearest first: itself and each ancestor that is not a `node_modules`. + const parents = [""]; + let prefix = ""; + for (const segment of from === "" ? [] : from.split("/")) { + prefix += `${segment}/`; + if (segment !== "node_modules") parents.push(prefix); + } + parents.reverse(); + for (const dependency of runtimeDependencies(packageJson)) { + if (++edges > MAX_DEPENDENCY_EDGES) + return `The package declares more than ${MAX_DEPENDENCY_EDGES} dependencies in its bundled tree.`; + const parent = parents.find((candidate) => shipped.get(candidate)?.has(dependency)); + if (parent === undefined) + return `${from === "" ? "The package" : from} depends on ${dependency}, which the package does not ship.`; + const manifest = shipped.get(parent)!.get(dependency)!; + if (seen.has(manifest)) continue; + seen.add(manifest); + const decoded = + manifest.data.length > MAX_PACKAGE_JSON_BYTES + ? Option.none() + : decodePackageJsonOption(new TextDecoder().decode(manifest.data)); + if (Option.isNone(decoded)) return `${manifest.path} is not a readable package.json.`; + queue.push([`${parent}node_modules/${dependency}`, decoded.value]); + } + } + return undefined; +}; + +/** Refuses a package that would need a script or an install step T3 never runs. */ +const checkPackage = Effect.fnUntraced(function* ( + files: ReadonlyArray, + name: string, + version: string, +) { + const manifest = files.find((file) => file.path === "package.json"); + if (manifest === undefined || manifest.data.length > MAX_PACKAGE_JSON_BYTES) + return yield* new PluginCatalogError({ + reason: "npm-package-mismatch", + message: "The package has no readable package.json.", + }); + const packageJson = yield* decodePackageJson(new TextDecoder().decode(manifest.data)).pipe( + Effect.mapError( + () => + new PluginCatalogError({ + reason: "npm-package-mismatch", + message: "The package's package.json is invalid.", + }), + ), + ); + if (packageJson.name !== name || packageJson.version !== version) + return yield* new PluginCatalogError({ + reason: "npm-package-mismatch", + message: `The tarball contains ${packageJson.name ?? "an unnamed package"}@${packageJson.version ?? "?"}, not ${name}@${version}.`, + }); + const scripts = INSTALL_SCRIPTS.filter((script) => packageJson.scripts?.[script] !== undefined); + if (files.some((file) => file.path === "binding.gyp")) + scripts.push("a native build (binding.gyp)"); + if (scripts.length > 0) + return yield* new PluginCatalogError({ + reason: "npm-install-scripts", + message: `The package needs ${scripts.join(", ")} to run at install, and T3 never runs package scripts.`, + }); + const bundled = packageJson.bundleDependencies ?? packageJson.bundledDependencies ?? []; + const unlisted = runtimeDependencies(packageJson).filter( + (dependency) => !(bundled === true || (Array.isArray(bundled) && bundled.includes(dependency))), + ); + const refusal = + unlisted.length > 0 + ? `The package depends on ${unlisted.join(", ")} without bundling it.` + : findUnbundled(files, packageJson); + if (refusal !== undefined) + return yield* new PluginCatalogError({ + reason: "npm-dependencies", + message: `${refusal} T3 installs no dependencies; bundle the plugin into its package.`, + }); +}); + +export class PluginNpm extends Context.Service< + PluginNpm, + { + readonly list: Effect.Effect; + /** Downloads, verifies, and unpacks one exact version, then adds it to the catalogue. Runs nothing. */ + readonly add: ( + input: PluginNpmAddInput, + ) => Effect.Effect; + /** Unpacks a version next to the installed one, replacing any earlier staged update. */ + readonly stageUpdate: ( + input: PluginNpmStageUpdateInput, + ) => Effect.Effect; + /** Consents to the staged digest and swaps it in; the old version comes back on failure. */ + readonly applyUpdate: ( + input: PluginNpmApplyUpdateInput, + ) => Effect.Effect; + readonly discardUpdate: ( + input: PluginInstallationInput, + ) => Effect.Effect; + } +>()("t3/plugins/PluginNpm") {} + +interface PluginNpmOptions { + /** Directory that holds the installed packages. */ + readonly root: string; + readonly registry?: string; + readonly limits?: NpmTarballLimits; + readonly metadataTimeout?: `${number} seconds`; + readonly tarballTimeout?: `${number} seconds`; +} + +export const make = Effect.fn("PluginNpm.make")(function* (options: PluginNpmOptions) { + const catalog = yield* PluginCatalog.PluginCatalog; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const http = HttpClient.withScope(yield* HttpClient.HttpClient); + const scope = yield* Effect.scope; + const limits = options.limits ?? defaultNpmTarballLimits; + const defaultRegistry = options.registry ?? DEFAULT_NPM_REGISTRY; + + yield* fs.makeDirectory(options.root, { recursive: true }).pipe(Effect.orDie); + // The catalogue records real paths, so ours must be real too. + const root = yield* fs.realPath(options.root).pipe(Effect.orDie); + + const installed = new Map(); + const lock = yield* Semaphore.make(1); + const recovered = yield* Deferred.make(); + const now = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + /** + * Paths under the root still to delete: removed homes, leftovers, discarded + * staging, and directories a step made and has not published yet. + */ + const doomed = new Set(); + + /** Deletes `target` as one catalogue step; otherwise keeps it queued. Succeeds with whether it is gone. */ + const release = (target: string) => + catalog.changeFiles([target], fs.remove(target, { recursive: true, force: true })).pipe( + Effect.as(true), + Effect.catch((cause) => + (cause._tag === "PluginCatalogError" && cause.reason === "already-added" + ? Effect.logDebug("Keeping npm plugin files another installation uses", { target }) + : Effect.logWarning("Could not delete npm plugin files; retrying later", { + target, + cause, + }) + ).pipe(Effect.as(false)), + ), + Effect.tap((gone) => Effect.sync(() => (gone ? doomed.delete(target) : doomed.add(target)))), + ); + + /** Makes a directory under `parent`, queued for deletion until the step that made it publishes it. */ + const makeOwned = (parent: string, prefix: string) => + fs.makeTempDirectory({ directory: parent, prefix }).pipe( + Effect.tap((directory) => Effect.sync(() => doomed.add(directory))), + Effect.uninterruptible, + Effect.catch(storageError), + ); + + const writeRecord = (home: string, record: NpmRecord) => + encodeRecord(record).pipe( + Effect.flatMap((json) => { + // Write then rename, so `npm.json` is always one whole record. + const temporary = path.join(home, ".npm.json.tmp"); + return fs + .writeFileString(temporary, json) + .pipe(Effect.andThen(fs.rename(temporary, path.join(home, "npm.json")))); + }), + Effect.catch(storageError), + ); + + const readRecord = (home: string) => + fs + .readFileString(path.join(home, "npm.json")) + .pipe(Effect.flatMap(decodeRecord), Effect.option); + + /** + * GETs metadata from `url`, following each redirect only to a registry + * address `isTrustedRegistryUrl` accepts: one plain http hop would let a + * network attacker answer with its own integrity and tarball. + */ + const getMetadata = Effect.fnUntraced(function* (url: string, what: string) { + let target = url; + for (let hop = 0; ; hop += 1) { + const response = yield* http + .execute(HttpClientRequest.get(target)) + .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); + const location = response.headers.location; + if (response.status < 300 || response.status >= 400 || location === undefined) + return response; + const next = URL.parse(location, target); + if (hop >= MAX_METADATA_REDIRECTS || next === null || !isTrustedRegistryUrl(next)) + return yield* new PluginCatalogError({ + reason: "npm-registry-invalid", + message: `The registry redirected the ${what} to an address it cannot be trusted from.`, + }); + target = next.href; + } + }); + + /** GETs `url` into memory, at most `maxBytes`. */ + const fetchBytes = ( + url: string, + maxBytes: number, + timeout: `${number} seconds`, + what: string, + kind: "metadata" | "tarball", + ) => + Effect.gen(function* () { + // A tarball may come from anywhere: authenticated metadata pinned its integrity. + const response = + kind === "metadata" + ? yield* getMetadata(url, what) + : yield* http.execute(HttpClientRequest.get(url)); + if (response.status === 404) + return yield* new PluginCatalogError({ + reason: "npm-not-found", + message: `The registry has no ${what}.`, + }); + if (response.status < 200 || response.status >= 300) + return yield* new PluginCatalogError({ + reason: "npm-registry-unavailable", + message: `The registry answered ${response.status} for the ${what}.`, + }); + const chunks: Array = []; + let total = 0; + yield* response.stream.pipe( + Stream.runForEach((chunk) => { + total += chunk.byteLength; + if (total > maxBytes) + return Effect.fail( + new PluginCatalogError({ + reason: "npm-too-large", + message: `The ${what} is larger than ${maxBytes} bytes.`, + }), + ); + chunks.push(chunk); + return Effect.void; + }), + ); + return Buffer.concat(chunks); + }).pipe( + Effect.scoped, + Effect.timeout(timeout), + Effect.catchTags({ + HttpClientError: () => + Effect.fail( + new PluginCatalogError({ + reason: "npm-registry-unavailable", + message: `Could not download the ${what}.`, + }), + ), + TimeoutError: () => + Effect.fail( + new PluginCatalogError({ + reason: "npm-registry-unavailable", + message: `The registry did not send the ${what} in time.`, + }), + ), + }), + ); + + /** Asks the registry which exact version `request` names, and its tarball's integrity. */ + const resolve = Effect.fnUntraced(function* (registry: string, name: string, request: string) { + // Adding checks the registry too, but an installation saved before that check may not pass it. + const base = URL.parse(registry); + if (base === null || !isTrustedRegistryUrl(base)) + return yield* new PluginCatalogError({ + reason: "npm-registry-invalid", + message: `${name} was installed from ${registry}, which serves packages over plain http. Remove it and add it again from an https registry.`, + }); + const encodedName = name.startsWith("@") ? name.replace("/", "%2f") : name; + const what = `${name}@${request}`; + const body = yield* fetchBytes( + `${registry}/${encodedName}/${encodeURIComponent(request)}`, + MAX_METADATA_BYTES, + options.metadataTimeout ?? "30 seconds", + `package ${what}`, + "metadata", + ); + const metadata = yield* decodeMetadata(new TextDecoder().decode(body)).pipe( + Effect.mapError( + () => + new PluginCatalogError({ + reason: "npm-registry-invalid", + message: `The registry's description of ${what} is invalid.`, + }), + ), + ); + if ( + metadata.name !== name || + !isExactVersion(metadata.version) || + (isExactVersion(request) && metadata.version !== request) + ) + return yield* new PluginCatalogError({ + reason: "npm-registry-invalid", + message: `The registry answered ${what} with ${metadata.name}@${metadata.version}.`, + }); + const integrity = (metadata.dist.integrity ?? "").split(/\s+/).find(isIntegrity); + if (integrity === undefined) + return yield* new PluginCatalogError({ + reason: "npm-integrity-missing", + message: `The registry publishes no sha512 integrity for ${name}@${metadata.version}.`, + }); + const tarball = URL.parse(metadata.dist.tarball); + if (tarball === null || (tarball.protocol !== "https:" && tarball.protocol !== "http:")) + return yield* new PluginCatalogError({ + reason: "npm-registry-invalid", + message: `The registry's tarball address for ${name}@${metadata.version} is not http or https.`, + }); + return { version: metadata.version, integrity, tarball: tarball.href } satisfies Resolved; + }); + + /** Downloads and checks a resolved version, then writes it into a new staging directory under `home`. */ + const stage = Effect.fnUntraced(function* (home: string, name: string, resolved: Resolved) { + const tarball = yield* fetchBytes( + resolved.tarball, + limits.maxTarballBytes, + options.tarballTimeout ?? "120 seconds", + `tarball for ${name}@${resolved.version}`, + "tarball", + ); + const actual = `sha512-${NodeCrypto.createHash("sha512").update(tarball).digest("base64")}`; + if (actual !== resolved.integrity) + return yield* new PluginCatalogError({ + reason: "npm-integrity-mismatch", + message: `The tarball for ${name}@${resolved.version} does not match the registry's integrity. Nothing was installed.`, + }); + const files = yield* readNpmTarball(tarball, limits).pipe( + Effect.mapError( + (error) => new PluginCatalogError({ reason: error.reason, message: error.message }), + ), + ); + yield* checkPackage(files, name, resolved.version); + const staging = yield* makeOwned(home, ".staging-"); + yield* Effect.forEach( + files, + (file) => { + const target = path.join(staging, ...file.path.split("/")); + return fs.makeDirectory(path.dirname(target), { recursive: true }).pipe( + // `wx` never follows or replaces something already there. + Effect.andThen( + fs.writeFile(target, file.data, { flag: "wx", mode: file.executable ? 0o755 : 0o644 }), + ), + ); + }, + { discard: true }, + ).pipe( + Effect.catch(storageError), + Effect.onError(() => release(staging)), + ); + return staging; + }); + + const findInstalled = (installationId: PluginInstallationId) => + Effect.suspend(() => { + const entry = installed.get(installationId); + if (entry === undefined) + return Effect.fail( + new PluginCatalogError({ + reason: "not-found", + message: "That plugin was not installed from npm here.", + installationId, + }), + ); + if (entry.swap !== undefined) + return Effect.fail( + new PluginCatalogError({ + reason: "storage", + message: + "An earlier update of this plugin could not be finished or undone yet. It is retried before each plugin change and when the server starts.", + installationId, + }), + ); + return Effect.succeed(entry); + }); + + const catalogRow = (installationId: PluginInstallationId) => + catalog.list.pipe( + Effect.flatMap((snapshot) => { + const row = snapshot.installations.find((item) => item.installationId === installationId); + return row + ? Effect.succeed(row) + : Effect.fail( + new PluginCatalogError({ + reason: "not-found", + message: "That plugin is not installed here.", + installationId, + }), + ); + }), + ); + + /** Forgets every installation the catalogue no longer has, then deletes what is queued. */ + const collect = Effect.gen(function* () { + const snapshot = yield* catalog.list; + const present = new Set(snapshot.installations.map((row) => row.installationId)); + for (const entry of installed.values()) { + if (present.has(entry.installationId)) continue; + installed.delete(entry.installationId); + doomed.add(entry.home); + } + for (const target of doomed) yield* release(target); + }); + + /** + * Finishes or rolls back a journaled swap. The catalogue decides under its + * management lock, so a consent or enable from another client lands wholly + * before or after: finished when it holds consent to the new digest, + * otherwise `.previous` goes back once the plugin has stopped. The decision + * sets the reported version at once; the journal and `.previous` stay until + * the cleanup after it has worked, so a failure is retried later and never + * loses a version. + */ + const settle = Effect.fnUntraced(function* (entry: Installed) { + const swap = entry.swap; + if (swap === undefined) return; + const previous = path.join(entry.home, ".previous"); + const decide = ( + files?: Parameters[1], + ) => + catalog + .settleReplace({ installationId: entry.installationId, digest: swap.digest }, files) + .pipe( + // Removed meanwhile: `collect` deletes the files. + Effect.catchIf( + (error) => error.reason === "not-found", + () => Effect.succeed("removed" as const), + ), + ); + // Without files the step changes nothing, so a commit is known before any probe can fail. + let outcome = yield* decide(); + // Only npm steps, which hold the npm lock, move `.previous`. + if (outcome === "rolled-back" && (yield* fs.exists(previous).pipe(Effect.catch(storageError)))) + // Decided again in the step that puts the old files back. + outcome = yield* decide({ + paths: [entry.home], + restore: fs + .remove(entry.directory, { recursive: true, force: true }) + .pipe(Effect.andThen(fs.rename(previous, entry.directory)), Effect.catch(storageError)), + }); + if (outcome === "removed") return; + entry.source = outcome === "committed" ? swap.next : swap.previous; + yield* writeRecord(entry.home, { source: entry.source }); + entry.swap = undefined; + if (outcome === "committed") yield* release(previous); + }); + + /** Retries every unsettled swap and every deletion that failed before. */ + const tidy = Effect.gen(function* () { + for (const entry of installed.values()) + if (entry.swap !== undefined) + yield* settle(entry).pipe( + Effect.catch((error) => + Effect.logWarning("Could not settle an interrupted plugin update; retrying later", { + installationId: entry.installationId, + detail: error.message, + }), + ), + ); + yield* collect; + }); + + /** Never waits for an install in progress; a package the catalogue no longer has is left out. */ + const list = Deferred.await(recovered).pipe( + Effect.andThen(catalog.list), + Effect.map((snapshot) => { + const present = new Set(snapshot.installations.map((row) => row.installationId)); + return { + // Stable across updates; clients join these to catalogue rows by installation id. + packages: [...installed.values()] + .filter((entry) => present.has(entry.installationId)) + .sort((a, b) => a.installationId.localeCompare(b.installationId)) + .map(toPackage), + }; + }), + ); + + const add = Effect.fn("PluginNpm.add")(function* (input: PluginNpmAddInput) { + const registry = yield* normalizeRegistry(input.registry ?? defaultRegistry); + const existing = [...installed.values()].find( + (entry) => entry.source.registry === registry && entry.source.name === input.name, + ); + if (existing) + return yield* new PluginCatalogError({ + reason: "already-added", + message: `${input.name} is already installed from this registry. Update it instead.`, + installationId: existing.installationId, + }); + const resolved = yield* resolve(registry, input.name, input.version); + const home = yield* makeOwned(root, "pkg-"); + return yield* Effect.gen(function* () { + const staging = yield* stage(home, input.name, resolved); + const source: PluginNpmSource = { + registry, + name: input.name, + version: resolved.version, + integrity: resolved.integrity, + installedAt: yield* now, + }; + const directory = path.join(home, "package"); + return yield* Effect.gen(function* () { + yield* writeRecord(home, { source }); + yield* catalog.changeFiles( + [home], + fs.rename(staging, directory).pipe(Effect.catch(storageError)), + ); + doomed.delete(staging); + // The catalogue reads the manifest and digests the files; nothing runs. + const { installation } = yield* catalog.add({ directory }); + const entry: Installed = { + home, + directory, + installationId: installation.installationId, + source, + staged: undefined, + swap: undefined, + }; + installed.set(entry.installationId, entry); + doomed.delete(home); + return { installation, package: toPackage(entry) }; + }).pipe(Effect.uninterruptible); + // Nothing in the catalogue points here; deleted now, or by a later collection. + }).pipe(Effect.onError(() => collect)); + }); + + const discardStaged = (entry: Installed) => + Effect.suspend(() => { + const staged = entry.staged; + if (staged === undefined) return Effect.void; + // Queued as it is dropped, so a step interrupted while waiting to delete it keeps it queued. + entry.staged = undefined; + doomed.add(staged.directory); + return release(staged.directory); + }); + + const stageUpdate = Effect.fn("PluginNpm.stageUpdate")(function* ( + input: PluginNpmStageUpdateInput, + ) { + const entry = yield* findInstalled(input.installationId); + const row = yield* catalogRow(input.installationId); + const resolved = yield* resolve(entry.source.registry, entry.source.name, input.version); + const staging = yield* stage(entry.home, entry.source.name, resolved); + const update = yield* Effect.gen(function* () { + const registration = yield* loadPluginDirectory(staging).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError( + (error) => + new PluginCatalogError({ reason: "npm-package-mismatch", message: error.message }), + ), + ); + if (row.manifest !== null && registration.manifest.id !== row.manifest.id) + return yield* new PluginCatalogError({ + reason: "npm-plugin-id-changed", + message: `The new version is the plugin ${registration.manifest.id}, not ${row.manifest.id}. Install it separately.`, + installationId: input.installationId, + }); + const source = yield* digestPluginSource(staging, limits).pipe( + Effect.mapError( + (error) => + new PluginCatalogError({ reason: "npm-archive-unsafe", message: error.message }), + ), + ); + return { + version: resolved.version, + integrity: resolved.integrity, + manifest: PluginCatalog.summarizePluginManifest(registration.manifest), + source, + stagedAt: yield* now, + } satisfies PluginNpmStagedUpdate; + }).pipe(Effect.onError(() => release(staging))); + yield* discardStaged(entry); + entry.staged = { update, directory: staging }; + doomed.delete(staging); + return { package: toPackage(entry) }; + }); + + const applyUpdate = Effect.fn("PluginNpm.applyUpdate")(function* ( + input: PluginNpmApplyUpdateInput, + ) { + const entry = yield* findInstalled(input.installationId); + const staged = entry.staged; + if (staged === undefined) + return yield* new PluginCatalogError({ + reason: "npm-no-update", + message: "There is no downloaded update to apply. Download it again.", + installationId: input.installationId, + }); + if (staged.update.source.digest !== input.digest) + return yield* new PluginCatalogError({ + reason: "source-changed", + message: "The downloaded update is not the one you reviewed. Review the current one.", + installationId: input.installationId, + }); + const onDisk = yield* digestPluginSource(staged.directory, limits).pipe(Effect.option); + if (Option.isNone(onDisk) || onDisk.value.digest !== input.digest) { + yield* discardStaged(entry); + return yield* new PluginCatalogError({ + reason: "source-changed", + message: "The downloaded update changed on disk after it was checked, so it was discarded.", + installationId: input.installationId, + }); + } + // Recovery reads consent to the new digest as a finished swap, which these files already have. + const row = yield* catalogRow(input.installationId); + if (row.source?.digest === input.digest || row.consent?.digest === input.digest) { + yield* discardStaged(entry); + return yield* new PluginCatalogError({ + reason: "npm-no-update", + message: "The downloaded update has the files already installed, so it was discarded.", + installationId: input.installationId, + }); + } + const next: PluginNpmSource = { + ...entry.source, + version: staged.update.version, + integrity: staged.update.integrity, + installedAt: yield* now, + }; + const previous = path.join(entry.home, ".previous"); + + // Nothing is stopped or moved before the journal is written, so a failure up to here + // leaves the installed version running and the staged one ready to apply again. + if (!(yield* release(previous))) + return yield* new PluginCatalogError({ + reason: "storage", + message: "Could not delete the files an earlier update left behind.", + installationId: input.installationId, + }); + yield* writeRecord(entry.home, { + source: entry.source, + swap: { source: next, digest: input.digest }, + }); + entry.swap = { previous: entry.source, next, digest: input.digest }; + + let movedOld = false; + let movedNew = false; + const replaced = yield* catalog + .replace( + { installationId: input.installationId, digest: input.digest }, + { + paths: [entry.home], + swap: fs.rename(entry.directory, previous).pipe( + Effect.andThen( + Effect.sync(() => { + movedOld = true; + }), + ), + Effect.andThen(fs.rename(staged.directory, entry.directory)), + Effect.andThen( + Effect.sync(() => { + movedNew = true; + }), + ), + Effect.catch(storageError), + ), + restore: Effect.suspend(() => + movedOld + ? fs + .remove(entry.directory, { recursive: true, force: true }) + .pipe(Effect.andThen(fs.rename(previous, entry.directory))) + : Effect.void, + ).pipe(Effect.catch(storageError)), + }, + ) + .pipe(Effect.exit); + // The commit point: the new version is the installed one from here, whatever + // the cleanup below does. + if (Exit.isSuccess(replaced)) entry.source = next; + // Staged files that never moved can be applied again. + if (movedNew || Exit.isSuccess(replaced)) entry.staged = undefined; + // After the commit a failure here is cleanup only: the apply still succeeds and + // reports the new version, and npm steps fail `storage` until the journal settles. + yield* settle(entry).pipe( + Effect.catch((error) => + Effect.logWarning("Could not settle a plugin update; retrying later", { + installationId: entry.installationId, + detail: error.message, + }), + ), + ); + // A step after the commit, such as enabling the plugin again, failed: the update still applied. + if (Exit.isFailure(replaced) && entry.source === next) + return { installation: yield* catalogRow(input.installationId), package: toPackage(entry) }; + const { installation } = yield* replaced; + return { installation, package: toPackage(entry) }; + }); + + const discardUpdate = Effect.fn("PluginNpm.discardUpdate")(function* ( + input: PluginInstallationInput, + ) { + const entry = yield* findInstalled(input.installationId); + yield* discardStaged(entry); + return { package: toPackage(entry) }; + }); + + /** + * Reads what was installed before this start: staging directories are + * deleted, an interrupted update swap is settled, and a directory the + * catalogue no longer has is deleted. + */ + const recover = Effect.gen(function* () { + const snapshot = yield* catalog.list; + const byDirectory = new Map(snapshot.installations.map((row) => [row.directory, row])); + const names = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => [])); + for (const name of names) { + const home = path.join(root, name); + const directory = path.join(home, "package"); + const row = byDirectory.get(directory); + if (row === undefined) { + // Removed from the catalogue, or interrupted before it was added. + doomed.add(home); + continue; + } + const record = yield* readRecord(home); + if (Option.isNone(record)) { + yield* Effect.logWarning("Leaving an npm plugin directory whose npm.json is unreadable", { + directory, + }); + continue; + } + const { source, swap } = record.value; + const inside = yield* fs.readDirectory(home).pipe(Effect.orElseSucceed(() => [])); + for (const child of inside) + if ( + child.startsWith(".staging-") || + child.startsWith(".npm.json.tmp") || + // Left by a finished update; an unfinished one still needs it. + (child === ".previous" && swap === undefined) + ) + doomed.add(path.join(home, child)); + installed.set(row.installationId, { + home, + directory, + installationId: row.installationId, + source, + staged: undefined, + swap: + swap === undefined + ? undefined + : { previous: source, next: swap.source, digest: swap.digest }, + }); + } + yield* tidy; + }); + + // Taken before any request can be, so requests wait for recovery. + yield* lock + .withPermit(recover.pipe(Effect.ensuring(Deferred.succeed(recovered, undefined)))) + .pipe(Effect.forkIn(scope, { startImmediately: true })); + // A removal through `plugins.remove` deletes the package's files. + yield* catalog.subscribe.pipe( + Stream.runForEach(() => lock.withPermit(tidy)), + Effect.forkIn(scope, { startImmediately: true }), + ); + + const managed = (effect: Effect.Effect) => + lock.withPermit(tidy.pipe(Effect.andThen(effect))); + + return PluginNpm.of({ + list, + add: (input) => managed(add(input)), + stageUpdate: (input) => managed(stageUpdate(input)), + applyUpdate: (input) => managed(applyUpdate(input).pipe(Effect.uninterruptible)), + discardUpdate: (input) => managed(discardUpdate(input)), + }); +}); + +export const layer = Layer.effect( + PluginNpm, + Effect.gen(function* () { + const config = yield* ServerConfig; + const path = yield* Path.Path; + return yield* make({ root: path.join(config.stateDir, "plugins", "npm") }); + }), +); diff --git a/apps/server/src/plugins/PluginNpmRpc.test.ts b/apps/server/src/plugins/PluginNpmRpc.test.ts new file mode 100644 index 000000000000..005d594dccd5 --- /dev/null +++ b/apps/server/src/plugins/PluginNpmRpc.test.ts @@ -0,0 +1,131 @@ +import { + AuthAccessWriteScope, + AuthAdministrativeScopes, + type AuthEnvironmentScope, + AuthOrchestrationReadScope, + AuthRelayReadScope, + AuthStandardClientScopes, + PluginCatalogError, + PluginInstallationId, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as RpcTest from "effect/rpc/RpcTest"; + +import { RPC_REQUIRED_SCOPES } from "../auth/RpcAuthorization.ts"; +import * as RpcAuthorization from "../auth/RpcAuthorization.ts"; + +const writes = [ + WS_METHODS.pluginsNpmAdd, + WS_METHODS.pluginsNpmStageUpdate, + WS_METHODS.pluginsNpmApplyUpdate, + WS_METHODS.pluginsNpmDiscardUpdate, +] as const; +type NpmMethod = typeof WS_METHODS.pluginsNpmList | (typeof writes)[number]; +const npmMethods: ReadonlySet = new Set([WS_METHODS.pluginsNpmList, ...writes]); + +const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => !npmMethods.has(tag), + ), +); + +const installationId = PluginInstallationId.make("fixture"); +const digest = `sha256:${"0".repeat(64)}`; + +/** Serves the npm RPCs through the real scope middleware; handlers record that they ran. */ +const makeClient = (scopes: ReadonlyArray, handled: Array) => { + // Mutations answer with a catalogue error: reaching it proves the middleware let the call in. + const mutation = (method: string) => () => + Effect.sync(() => handled.push(method)).pipe( + Effect.andThen( + Effect.fail(new PluginCatalogError({ reason: "npm-not-found", message: "fixture" })), + ), + ); + return RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.pluginsNpmList, () => + Effect.sync(() => handled.push(WS_METHODS.pluginsNpmList)).pipe( + Effect.as({ packages: [] }), + ), + ), + group.toLayerHandler(WS_METHODS.pluginsNpmAdd, mutation(WS_METHODS.pluginsNpmAdd)), + group.toLayerHandler( + WS_METHODS.pluginsNpmStageUpdate, + mutation(WS_METHODS.pluginsNpmStageUpdate), + ), + group.toLayerHandler( + WS_METHODS.pluginsNpmApplyUpdate, + mutation(WS_METHODS.pluginsNpmApplyUpdate), + ), + group.toLayerHandler( + WS_METHODS.pluginsNpmDiscardUpdate, + mutation(WS_METHODS.pluginsNpmDiscardUpdate), + ), + RpcAuthorization.layer(scopes), + ), + ), + ); +}; + +/** Calls every npm mutation and returns what each one failed with. */ +const callWrites = (client: Effect.Success>) => + Effect.all([ + client[WS_METHODS.pluginsNpmAdd]({ name: "t3-plugin-hello", version: "1.0.0" }).pipe( + Effect.flip, + ), + client[WS_METHODS.pluginsNpmStageUpdate]({ installationId, version: "1.1.0" }).pipe( + Effect.flip, + ), + client[WS_METHODS.pluginsNpmApplyUpdate]({ installationId, digest }).pipe(Effect.flip), + client[WS_METHODS.pluginsNpmDiscardUpdate]({ installationId }).pipe(Effect.flip), + ]); + +describe("npm plugin RPC scopes", () => { + it.effect("lets a standard pairing list npm packages but not install or update them", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient(AuthStandardClientScopes, handled); + + expect(yield* client[WS_METHODS.pluginsNpmList]({})).toEqual({ packages: [] }); + for (const failure of yield* callWrites(client)) { + expect(failure).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: AuthAccessWriteScope, + requiredPermission: AuthAccessWriteScope, + }); + } + expect(handled).toEqual([WS_METHODS.pluginsNpmList]); + }).pipe(Effect.scoped), + ); + + it.effect("lets an administrative pairing install and update from npm", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient(AuthAdministrativeScopes, handled); + + for (const failure of yield* callWrites(client)) { + expect(failure).toMatchObject({ _tag: "PluginCatalogError", reason: "npm-not-found" }); + } + expect(handled).toEqual([...writes]); + }).pipe(Effect.scoped), + ); + + it.effect("refuses the package list without the orchestration read scope", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient([AuthRelayReadScope], handled); + + expect(yield* client[WS_METHODS.pluginsNpmList]({}).pipe(Effect.flip)).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: AuthOrchestrationReadScope, + requiredPermission: AuthOrchestrationReadScope, + }); + expect(handled).toEqual([]); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/plugins/PluginSettings.test.ts b/apps/server/src/plugins/PluginSettings.test.ts new file mode 100644 index 000000000000..bb388b06cdcc --- /dev/null +++ b/apps/server/src/plugins/PluginSettings.test.ts @@ -0,0 +1,1249 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import type { PluginInstallationId, PluginSettingsValues } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Logger from "effect/Logger"; +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 Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { + SecretStorePersistError, + SecretStoreRemoveError, + ServerSecretStore, +} from "../auth/ServerSecretStore.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as PluginCatalog from "./PluginCatalog.ts"; +import { loadPluginDirectory } from "./PluginManifestLoader.ts"; +import * as PluginSettings from "./PluginSettings.ts"; +import * as PluginSupervisor from "./PluginSupervisor.ts"; + +const FIXTURE_DIR = `${import.meta.dirname}/testFixtures/settingsPlugin`; +// Children run the real CLI entry, which routes `__plugin-host` to the child runtime. +const BIN_PATH = `${import.meta.dirname}/../bin.ts`; +// Or a stand-in that speaks the IPC directly, as a plugin writing raw lines to fd 3 could. +const RAW_CHILD_PATH = `${import.meta.dirname}/testFixtures/rawHostCallChild.mjs`; +const SECRET = "s3cret-token-value"; + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const parseManifest = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +/** + * A secret store in memory, so a test can see exactly what was saved and deleted. `faults` + * makes the next writes fail after saving (as an interrupted save would) or deletes fail, and + * `beforeSet` and `beforeGet` hold writes and reads. + */ +const makeSecretStore = () => { + const entries = new Map(); + const faults = { set: false, remove: false }; + const hooks: { beforeSet: Effect.Effect; beforeGet: Effect.Effect } = { + beforeSet: Effect.void, + beforeGet: Effect.void, + }; + const service = ServerSecretStore.of({ + get: (name) => + Effect.suspend(() => hooks.beforeGet).pipe( + Effect.andThen(Effect.sync(() => Option.fromUndefinedOr(entries.get(name)))), + ), + set: (name, value) => + Effect.suspend(() => hooks.beforeSet).pipe( + Effect.andThen( + Effect.suspend(() => { + entries.set(name, value); + return faults.set + ? Effect.fail(new SecretStorePersistError({ resource: name, cause: "fault" })) + : Effect.void; + }), + ), + ), + create: (name, value) => Effect.sync(() => void entries.set(name, value)), + getOrCreateRandom: (name, bytes) => + Effect.sync(() => { + const value = entries.get(name) ?? new Uint8Array(bytes); + entries.set(name, value); + return value; + }), + remove: (name) => + Effect.suspend(() => + faults.remove + ? Effect.fail(new SecretStoreRemoveError({ resource: name, cause: "fault" })) + : Effect.sync(() => void entries.delete(name)), + ), + }); + return { entries, faults, hooks, service }; +}; + +/** Starts a supervisor, catalogue and settings in `scope`, as one server start would. */ +const startPlugins = Effect.fn("startPlugins")(function* ( + scope: Scope.Scope, + secretStore: ServerSecretStore["Service"], + limits?: PluginSettings.PluginStorageLimits, + /** Runs as each settings host call starts, so a test can see that one arrived. */ + onHostCall: (method: string) => Effect.Effect = () => Effect.void, +) { + const supervisor = yield* PluginSupervisor.make({ + heapLimitMb: 64, + activationTimeout: "10 seconds", + stopGrace: "1 second", + }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + Effect.provideService(Scope.Scope, scope), + ); + const catalog = yield* PluginCatalog.make().pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor), + Effect.provideService(Scope.Scope, scope), + ); + const settings = yield* PluginSettings.make(limits).pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, { + ...supervisor, + serveHostMethod: (method, handler) => + supervisor.serveHostMethod(method, (call) => + onHostCall(method).pipe(Effect.andThen(handler(call))), + ), + }), + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + Effect.provideService(ServerSecretStore, secretStore), + Effect.provideService(Scope.Scope, scope), + ); + return { supervisor, catalog, settings }; +}); + +/** Writes the fixture's manifest into `directory`, with `manifest` overriding its keys. */ +const writeManifest = Effect.fn("writeManifest")(function* ( + directory: string, + manifest: Record = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = parseManifest(yield* fs.readFileString(path.join(FIXTURE_DIR, "t3-plugin.json"))); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ ...fixture, ...manifest }), + ); +}); + +/** Copies the fixture into a scoped temp directory, optionally under a changed manifest. */ +const preparePlugin = Effect.fn("preparePlugin")(function* ( + manifest: Record = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-settings-" }); + yield* fs.copyFile(path.join(FIXTURE_DIR, "main.mjs"), path.join(directory, "main.mjs")); + yield* writeManifest(directory, manifest); + return directory; +}); + +/** A plugin directory for the raw IPC child, which reads `config` from `raw-child.json`. */ +const prepareRawPlugin = Effect.fn("prepareRawPlugin")(function* ( + id: string, + config: Record, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* preparePlugin({ id }); + yield* fs.writeFileString(path.join(directory, "raw-child.json"), toJson(config)); + return { directory, registration: yield* loadPluginDirectory(directory) }; +}); + +/** A supervisor whose children run `childPath`. */ +const makeSupervisor = ( + scope: Scope.Scope, + childPath: string, + options: Partial = {}, +) => + PluginSupervisor.make({ heapLimitMb: 64, stopGrace: "1 second", ...options }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, childPath]), + Effect.provideService(Scope.Scope, scope), + ); + +/** Starts waiting for the first log line of `pluginId` that satisfies `predicate`. */ +const awaitLog = ( + supervisor: PluginSupervisor.PluginSupervisor["Service"], + pluginId: string, + predicate: (message: string) => boolean, +) => + supervisor.subscribe.pipe( + Effect.flatMap((subscription) => + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event._tag === "Log" && event.pluginId === pluginId), + Stream.map((event) => (event._tag === "Log" ? event.message : "")), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild({ startImmediately: true }), + ), + ), + ); + +/** Closes `scope` and starts the plugin services again on the same database, as a restart would. */ +const restart = Effect.fn("restart")(function* ( + scope: Scope.Closeable, + secretStore: ServerSecretStore["Service"], +) { + yield* Scope.close(scope, Exit.void); + const next = yield* Scope.make(); + return { scope: next, ...(yield* startPlugins(next, secretStore)) }; +}); + +/** Adds, approves and enables the plugin in `directory`. */ +const install = Effect.fn("install")(function* ( + catalog: PluginCatalog.PluginCatalog["Service"], + directory: string, +) { + const { installation } = yield* catalog.add({ directory }); + const installationId = installation.installationId; + yield* catalog.consent({ installationId, digest: installation.source!.digest }); + yield* catalog.enable({ installationId }); + return installationId; +}); + +/** Waits, through the subscription, for values that satisfy `predicate`. */ +const awaitValues = ( + settings: PluginSettings.PluginSettings["Service"], + installationId: PluginInstallationId, + predicate: (values: PluginSettingsValues) => boolean, +) => + settings + .subscribe(installationId) + .pipe(Stream.filter(predicate), Stream.runHead, Effect.map(Option.getOrThrow)); + +const parseRefusals = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Array(Schema.String))); + +const valueOf = (values: PluginSettingsValues, key: string) => + values.values.find((entry) => entry.key === key)?.value; + +const countRows = Effect.fn("countRows")(function* (installationId: PluginInstallationId) { + const sql = yield* SqlClient.SqlClient; + const settings = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM plugin_settings WHERE installation_id = ${installationId} + `; + const secrets = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM plugin_setting_secrets WHERE installation_id = ${installationId} + `; + const storage = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count FROM plugin_storage WHERE installation_id = ${installationId} + `; + return { settings: settings[0]!.count, secrets: secrets[0]!.count, storage: storage[0]!.count }; +}); + +// Each test gets its own database. +const withDatabase = (effect: Effect.Effect) => + effect.pipe(Effect.provide(SqlitePersistence.layerMemory)); + +it.layer(NodeServices.layer)("PluginSettings", (it) => { + describe("values", () => { + it.effect("saves what the plugin reads and never sends a secret back", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const installationId = yield* install(catalog, yield* preparePlugin()); + + // The catalogue carries the declared fields for clients to render. + const listed = (yield* catalog.list).installations[0]!; + expect(listed.manifest?.settings?.map((field) => field.key)).toEqual([ + "apiUrl", + "token", + "verbose", + "retries", + "mode", + ]); + + const initial = yield* awaitValues(settings, installationId, () => true); + expect(initial).toEqual({ installationId, values: [], secrets: [] }); + // Unsaved fields read as their defaults; a secret without a value reads as unset. + expect(yield* catalog.invoke(installationId, "activationMode", null)).toBe("safe"); + expect(yield* catalog.invoke(installationId, "read", { key: "retries" })).toEqual({ + value: 2, + }); + expect(yield* catalog.invoke(installationId, "read", { key: "token" })).toEqual({ + unset: true, + }); + + const watching = yield* awaitValues( + settings, + installationId, + (values) => values.secrets.length > 0, + ).pipe(Effect.forkChild({ startImmediately: true })); + const saved = yield* settings.update({ + installationId, + changes: [ + { key: "apiUrl", value: "https://other.example.com" }, + { key: "token", value: SECRET }, + { key: "verbose", value: true }, + { key: "retries", value: 4 }, + { key: "mode", value: "fast" }, + ], + }); + expect(saved.secrets).toEqual(["token"]); + expect(valueOf(saved, "retries")).toBe(4); + expect(valueOf(saved, "token")).toBeUndefined(); + expect(toJson(saved)).not.toContain(SECRET); + // Every subscriber hears about the change, still without the secret. + const heard = yield* Fiber.join(watching); + expect(heard).toEqual(saved); + + for (const [key, value] of [ + ["apiUrl", "https://other.example.com"], + ["token", SECRET], + ["verbose", true], + ["retries", 4], + ["mode", "fast"], + ] as const) + expect(yield* catalog.invoke(installationId, "read", { key })).toEqual({ value }); + const undeclared = yield* catalog.invoke(installationId, "attempt", { + method: "get", + key: "missing", + }); + expect(undeclared).toEqual({ + ok: false, + message: '"missing" is not a declared setting.', + }); + + // Clearing returns a field to its default and deletes a secret. + const cleared = yield* settings.update({ + installationId, + changes: [ + { key: "token", value: null }, + { key: "retries", value: null }, + { key: "verbose", value: null }, + ], + }); + expect(cleared.secrets).toEqual([]); + expect(valueOf(cleared, "retries")).toBeUndefined(); + // A reset boolean keeps no override, so the plugin reads whatever its default is. + expect(valueOf(cleared, "verbose")).toBeUndefined(); + expect(yield* catalog.invoke(installationId, "read", { key: "verbose" })).toEqual({ + value: false, + }); + expect(secrets.entries.size).toBe(0); + expect(yield* catalog.invoke(installationId, "read", { key: "token" })).toEqual({ + unset: true, + }); + expect(yield* catalog.invoke(installationId, "read", { key: "retries" })).toEqual({ + value: 2, + }); + }), + ), + ); + + it.effect("checks every change before saving any, without repeating the value", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const installationId = yield* install(catalog, yield* preparePlugin()); + const reject = (changes: Parameters[0]["changes"]) => + settings.update({ installationId, changes }).pipe(Effect.flip); + + for (const [changes, message] of [ + [[{ key: "nope", value: "x" }], 'This plugin has no setting "nope".'], + [[{ key: "verbose", value: "yes" }], "Verbose logging must be on or off."], + [[{ key: "retries", value: 9 }], "Retries must be at most 5."], + [[{ key: "retries", value: 1.5 }], "Retries must be a whole number."], + [[{ key: "mode", value: "turbo" }], "Mode must be one of its options."], + [[{ key: "token", value: "" }], "API token must be non-empty text."], + [ + [ + { key: "mode", value: "fast" }, + { key: "mode", value: "safe" }, + ], + '"mode" is changed twice.', + ], + // A valid change next to an invalid one is not saved either. + [ + [ + { key: "token", value: SECRET }, + { key: "retries", value: -1 }, + ], + "Retries must be at least 0.", + ], + ] as const) { + const error = yield* reject(changes); + expect(error).toMatchObject({ reason: "invalid-setting", message }); + } + const tooLong = yield* reject([{ key: "token", value: SECRET.repeat(1000) }]); + expect(tooLong.message).not.toContain(SECRET); + expect(yield* countRows(installationId)).toEqual({ settings: 0, secrets: 0, storage: 0 }); + expect(secrets.entries.size).toBe(0); + + const unknown = yield* settings + .update({ + installationId: "missing" as PluginInstallationId, + changes: [{ key: "mode", value: "fast" }], + }) + .pipe(Effect.flip); + expect(unknown.reason).toBe("not-found"); + }), + ), + ); + + it.effect("refuses manifests whose settings it cannot honor", () => + withDatabase( + Effect.gen(function* () { + const { catalog } = yield* startPlugins(yield* Scope.Scope, makeSecretStore().service); + const field = { type: "text", key: "a", label: "A" }; + for (const [manifest, problem] of [ + [{ capabilities: [] }, 'declares settings without the "settings" capability'], + [{ proposedApi: false }, 'needs "proposedApi": true'], + [{ settings: [field, field] }, "settings: keys repeat."], + [ + { + settings: [ + { ...field, type: "select", options: [{ value: "x", label: "X" }], default: "y" }, + ], + }, + "a: the default is invalid.", + ], + [{ settings: [{ ...field, type: "number", min: 2, max: 1 }] }, "min is greater"], + ] as const) { + const error = yield* catalog + .add({ directory: yield* preparePlugin(manifest) }) + .pipe(Effect.flip); + expect(error.reason).toBe("invalid-directory"); + expect(error.message).toContain(problem); + } + }), + ), + ); + }); + + describe("storage", () => { + it.effect("keeps a bounded private store per installation", () => + withDatabase( + Effect.gen(function* () { + const { catalog } = yield* startPlugins(yield* Scope.Scope, makeSecretStore().service, { + maxKeyLength: 8, + maxValueBytes: 100, + maxKeys: 2, + maxTotalBytes: 150, + }); + const first = yield* install(catalog, yield* preparePlugin()); + const second = yield* install( + catalog, + yield* preparePlugin({ id: "test.settings-other" }), + ); + const attempt = (installationId: PluginInstallationId, key: string, value: unknown) => + catalog.invoke(installationId, "attempt", { + method: "set", + key, + value: value as Schema.Json, + }); + + expect(yield* attempt(first, "a", { n: 1 })).toEqual({ ok: true, result: null }); + expect(yield* catalog.invoke(first, "load", { key: "a" })).toEqual({ + value: { n: 1 }, + }); + // Another installation sees nothing of it. + expect(yield* catalog.invoke(second, "load", { key: "a" })).toEqual({ missing: true }); + + expect(yield* attempt(first, "big", "x".repeat(200))).toMatchObject({ + ok: false, + message: "The value is 202 bytes; the limit is 100.", + }); + expect(yield* attempt(first, "much-too-long", 1)).toMatchObject({ ok: false }); + expect(yield* attempt(first, "bad\nkey", 1)).toMatchObject({ ok: false }); + expect(yield* attempt(first, "b", "y".repeat(60))).toEqual({ ok: true, result: null }); + expect(yield* attempt(first, "c", 1)).toEqual({ + ok: false, + message: "The plugin already stores 2 keys.", + }); + // Replacing a key counts its new size, not both. + expect(yield* attempt(first, "a", "z".repeat(90))).toMatchObject({ + ok: false, + message: "Saving this would store more than 150 bytes for the plugin.", + }); + expect(yield* attempt(first, "a", "z".repeat(40))).toEqual({ ok: true, result: null }); + expect(yield* catalog.invoke(first, "keys", null)).toEqual(["a", "b"]); + + yield* catalog.invoke(first, "drop", { key: "a" }); + expect(yield* catalog.invoke(first, "load", { key: "a" })).toEqual({ missing: true }); + expect(yield* catalog.invoke(first, "keys", null)).toEqual(["b"]); + }), + ), + ); + + it.effect("lets a plugin wait for at most 16 host calls at a time", () => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + const supervisor = yield* PluginSupervisor.make({ heapLimitMb: 64 }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + Effect.provideService(Scope.Scope, scope), + ); + const release = yield* Deferred.make(); + let inFlight = 0; + yield* supervisor + .serveHostMethod("storage.get", () => + Effect.sync(() => inFlight++).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({ found: false, value: null }), + ), + ) + .pipe(Effect.provideService(Scope.Scope, scope)); + yield* supervisor + .serveHostMethod("settings.get", () => Effect.succeed({ value: null })) + .pipe(Effect.provideService(Scope.Scope, scope)); + const registration = yield* loadPluginDirectory(yield* preparePlugin()); + yield* supervisor.enable(registration); + const refused = yield* supervisor.subscribe.pipe( + Effect.map((subscription) => + Stream.fromSubscription(subscription).pipe( + Stream.filter( + (event) => event._tag === "Log" && event.message === "host-call-refused", + ), + Stream.runHead, + ), + ), + ); + const waitingForRefusal = yield* refused.pipe(Effect.forkChild({ startImmediately: true })); + + const burst = yield* supervisor + .invoke(registration.manifest.id, "burst", { count: 17 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Fiber.join(waitingForRefusal); + yield* Deferred.succeed(release, undefined); + const results = (yield* Fiber.join(burst)) as ReadonlyArray; + expect(results.filter((result) => result === "ok")).toHaveLength(16); + expect(results.filter((result) => result !== "ok")).toEqual([ + "16 calls to the server are already in flight.", + ]); + expect(inFlight).toBe(16); + }), + ); + }); + + describe("lifetime", () => { + it.effect("keeps values across disable and re-enable, and deletes them on remove", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const installationId = yield* install(catalog, yield* preparePlugin()); + yield* settings.update({ + installationId, + changes: [ + { key: "token", value: SECRET }, + { key: "mode", value: "fast" }, + ], + }); + yield* catalog.invoke(installationId, "store", { key: "cursor", value: 42 }); + + yield* catalog.disable({ installationId }); + // Disabled plugins keep their values and can still be configured. + yield* settings.update({ installationId, changes: [{ key: "verbose", value: true }] }); + yield* catalog.enable({ installationId }); + expect(yield* catalog.invoke(installationId, "read", { key: "token" })).toEqual({ + value: SECRET, + }); + expect(yield* catalog.invoke(installationId, "load", { key: "cursor" })).toEqual({ + value: 42, + }); + expect(yield* countRows(installationId)).toEqual({ settings: 2, secrets: 1, storage: 1 }); + expect(secrets.entries.size).toBe(1); + + // The settings stream ends once the cleanup after the removal has run. + const ended = yield* settings + .subscribe(installationId) + .pipe(Stream.runDrain, Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* catalog.remove({ installationId }); + expect((yield* Fiber.join(ended)).reason).toBe("not-found"); + expect(yield* countRows(installationId)).toEqual({ settings: 0, secrets: 0, storage: 0 }); + expect(secrets.entries.size).toBe(0); + const late = yield* settings + .update({ installationId, changes: [{ key: "mode", value: "safe" }] }) + .pipe(Effect.flip); + expect(late.reason).toBe("not-found"); + expect(yield* countRows(installationId)).toEqual({ settings: 0, secrets: 0, storage: 0 }); + }), + ), + ); + + it.effect("deletes what an earlier run left for removed installations at start", () => + withDatabase( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const secrets = makeSecretStore(); + const gone = "gone-installation" as PluginInstallationId; + yield* sql` + INSERT INTO plugin_settings (installation_id, key, value_json) + VALUES (${gone}, 'mode', '"fast"') + `; + yield* sql` + INSERT INTO plugin_setting_secrets (installation_id, key, saved) + VALUES (${gone}, 'token', 1) + `; + yield* sql` + INSERT INTO plugin_storage (installation_id, key, value_json, bytes) + VALUES (${gone}, 'cursor', '1', 1) + `; + const secretName = `plugin-setting-${Buffer.from(gone).toString("base64url")}-${Buffer.from("token").toString("base64url")}`; + secrets.entries.set(secretName, new TextEncoder().encode(SECRET)); + + yield* startPlugins(yield* Scope.Scope, secrets.service); + expect(yield* countRows(gone)).toEqual({ settings: 0, secrets: 0, storage: 0 }); + expect(secrets.entries.size).toBe(0); + }), + ), + ); + + it.effect("refuses settings and storage to a registration without the capability", () => + Effect.gen(function* () { + const methods = new Map(); + const supervisor = PluginSupervisor.PluginSupervisor.of({ + enable: () => Effect.void, + disable: () => Effect.void, + resume: () => Effect.void, + invoke: () => Effect.succeed(null), + state: () => Effect.succeedNone, + subscribe: Effect.die("unused"), + serveHostMethod: (method, handler) => + Effect.sync(() => void methods.set(method, handler)), + }); + const catalog = PluginCatalog.PluginCatalog.of({ + list: Effect.succeed({ installations: [] }), + revision: Effect.succeed(0), + subscribe: Stream.empty, + add: () => Effect.die("unused"), + refresh: () => Effect.die("unused"), + consent: () => Effect.die("unused"), + enable: () => Effect.die("unused"), + disable: () => Effect.die("unused"), + remove: () => Effect.die("unused"), + resume: () => Effect.die("unused"), + replace: () => Effect.die("unused"), + settleReplace: () => Effect.die("unused"), + changeFiles: () => Effect.die("unused"), + invoke: () => Effect.die("unused"), + }); + yield* PluginSettings.make().pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor), + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + Effect.provideService(ServerSecretStore, makeSecretStore().service), + Effect.provide(SqlitePersistence.layerMemory), + ); + const registration = yield* loadPluginDirectory( + yield* preparePlugin({ capabilities: [], settings: undefined }), + ); + for (const method of ["settings.get", "storage.get", "storage.set", "storage.keys"]) { + const error = yield* methods.get(method)!({ + registration: { ...registration, installationId: "x" as PluginInstallationId }, + input: { key: "a", value: 1 }, + admitted: Effect.void, + }).pipe(Effect.flip); + expect(error.message).toBe('The plugin did not declare the "settings" capability.'); + } + }), + ); + }); + + describe("host calls", () => { + /** Serves `storage.get` with a call held until `release`, recording how it ended. */ + const holdStorageGet = Effect.fn("holdStorageGet")(function* ( + supervisor: PluginSupervisor.PluginSupervisor["Service"], + ) { + const scope = yield* Scope.Scope; + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const ended = yield* Deferred.make>(); + const counts = { reads: 0, writes: 0 }; + yield* supervisor + .serveHostMethod("settings.get", () => + Effect.sync(() => { + counts.reads++; + return { value: null }; + }), + ) + .pipe(Effect.provideService(Scope.Scope, scope)); + yield* supervisor + .serveHostMethod("storage.get", () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + // Stands for a side effect after an awaited host operation. + Effect.andThen(Effect.sync(() => counts.writes++)), + Effect.as({ found: false, value: null }), + Effect.onExit((exit) => Deferred.succeed(ended, exit)), + ), + ) + .pipe(Effect.provideService(Scope.Scope, scope)); + return { started, release, ended, counts }; + }); + + it.effect("ends a disabled generation's host work and refuses its later calls", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(yield* Scope.Scope, BIN_PATH); + const held = yield* holdStorageGet(supervisor); + const registration = yield* loadPluginDirectory(yield* preparePlugin()); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const load = yield* supervisor + .invoke(pluginId, "load", { key: "a" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(held.started); + const deactivated = yield* awaitLog(supervisor, pluginId, (message) => + message.startsWith("deactivate:"), + ); + const readsBefore = held.counts.reads; + + yield* supervisor.disable(pluginId); + // The held call ended with its generation, before disable returned. + expect(yield* Deferred.isDone(held.ended)).toBe(true); + expect(Exit.hasInterrupts(yield* Deferred.await(held.ended))).toBe(true); + expect((yield* Fiber.join(load))._tag).toBe("PluginStoppedError"); + // deactivate() asked for a setting after the revocation: refused, never served. + expect(yield* Fiber.join(deactivated)).toBe("deactivate: The plugin was stopped."); + expect(held.counts.reads).toBe(readsBefore); + yield* Deferred.succeed(held.release, undefined); + expect(held.counts.writes).toBe(0); + }), + ); + + it.effect("ends host work of a process that exits", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(yield* Scope.Scope, BIN_PATH); + const held = yield* holdStorageGet(supervisor); + const registration = yield* loadPluginDirectory(yield* preparePlugin()); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const load = yield* supervisor + .invoke(pluginId, "load", { key: "a" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(held.started); + + const crashed = yield* supervisor.invoke(pluginId, "exit", null).pipe(Effect.flip); + expect(crashed._tag).toBe("PluginCrashedError"); + expect(Exit.hasInterrupts(yield* Deferred.await(held.ended))).toBe(true); + expect((yield* Fiber.join(load))._tag).toBe("PluginCrashedError"); + yield* Deferred.succeed(held.release, undefined); + expect(held.counts.writes).toBe(0); + }), + ); + + it.effect("waits for host work still ending when disabling a process that exited", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(yield* Scope.Scope, BIN_PATH); + const started = yield* Deferred.make(); + const ending = yield* Deferred.make(); + const release = yield* Deferred.make(); + const order: Array = []; + yield* supervisor.serveHostMethod("settings.get", () => Effect.succeed({ value: null })); + yield* supervisor.serveHostMethod("storage.get", () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Effect.never), + // Stands for cleanup that outlasts the process, such as releasing a lock. + Effect.onInterrupt(() => + Deferred.succeed(ending, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Effect.sync(() => order.push("host work ended"))), + ), + ), + ), + ); + const registration = yield* loadPluginDirectory(yield* preparePlugin()); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + yield* supervisor + .invoke(pluginId, "load", { key: "a" }) + .pipe(Effect.ignore, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + yield* supervisor + .invoke(pluginId, "exit", null) + .pipe(Effect.ignore, Effect.forkChild({ startImmediately: true })); + // The process has exited and its host work is ending. + yield* Deferred.await(ending); + + const disabling = yield* supervisor + .disable(pluginId) + .pipe( + Effect.andThen(Effect.sync(() => order.push("disabled"))), + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(disabling); + expect(order).toEqual(["host work ended", "disabled"]); + }), + ); + + it.effect("drops a write that waited for the settings lock past its generation", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const storeArrived = yield* Deferred.make(); + const readArrived = yield* Deferred.make(); + const { catalog, settings } = yield* startPlugins( + yield* Scope.Scope, + secrets.service, + undefined, + (method) => + method === "storage.set" + ? Deferred.succeed(storeArrived, undefined) + : Deferred.isDone(storeArrived).pipe( + Effect.flatMap((stored) => + stored ? Deferred.succeed(readArrived, undefined) : Effect.void, + ), + ), + ); + const installationId = yield* install(catalog, yield* preparePlugin()); + expect(yield* catalog.invoke(installationId, "read", { key: "mode" })).toEqual({ + value: "safe", + }); + + // A client save holds the settings lock while its secret is written. + const writing = yield* Deferred.make(); + const finishWrite = yield* Deferred.make(); + secrets.hooks.beforeSet = Deferred.succeed(writing, undefined).pipe( + Effect.andThen(Deferred.await(finishWrite)), + ); + const saving = yield* settings + .update({ installationId, changes: [{ key: "token", value: SECRET }] }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(writing); + const storing = yield* catalog + .invoke(installationId, "store", { key: "cursor", value: 1 }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(storeArrived); + // So does its read of the secret being saved. + const reading = yield* catalog + .invoke(installationId, "read", { key: "token" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(readArrived); + + // The plugin's write waits for the lock while the plugin is disabled and enabled again. + yield* catalog.disable({ installationId }); + // Disabling ended the waiting read instead of waiting for the lock itself. + expect((yield* Fiber.join(reading))._tag).toBe("PluginStoppedError"); + yield* catalog.enable({ installationId }); + yield* Deferred.succeed(finishWrite, undefined); + expect((yield* Fiber.join(saving)).secrets).toEqual(["token"]); + yield* Fiber.join(storing); + expect((yield* countRows(installationId)).storage).toBe(0); + + // The new generation saves as usual. + yield* catalog.invoke(installationId, "store", { key: "cursor", value: 2 }); + expect(yield* catalog.invoke(installationId, "load", { key: "cursor" })).toEqual({ + value: 2, + }); + }), + ), + ); + + // Below the channel's 64 KiB buffer a full bound can come without a write that waits for drain. + it.effect.each([ + { maxMessageBytes: 128 * 1024, answerBytes: 60_000 }, + { maxMessageBytes: 32 * 1024, answerBytes: 20_000 }, + ])( + "stops reading a plugin that does not read its answers, and loses none ($maxMessageBytes bytes)", + ({ maxMessageBytes, answerBytes }) => { + const backedUp = Deferred.makeUnsafe(); + const logger = Logger.make(({ message }) => { + if (String(message).includes("not reading the server's answers")) + Deferred.doneUnsafe(backedUp, Exit.void); + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supervisor = yield* makeSupervisor(yield* Scope.Scope, RAW_CHILD_PATH, { + maxMessageBytes, + }); + let served = 0; + yield* supervisor + .serveHostMethod("flood.get", () => + Effect.sync(() => { + served++; + return { data: "x".repeat(answerBytes) }; + }), + ) + .pipe(Effect.provideService(Scope.Scope, yield* Scope.Scope)); + const requests = 400; + const flood = yield* prepareRawPlugin("test.flood", { + mode: "flood", + requests, + method: "flood.get", + }); + const echo = yield* prepareRawPlugin("test.echo", { mode: "echo" }); + yield* supervisor.enable(flood.registration); + yield* supervisor.enable(echo.registration); + const summary = yield* awaitLog(supervisor, "test.flood", (message) => + message.startsWith("answered"), + ); + const first = yield* supervisor + .invoke(flood.registration.manifest.id, "ping", 1) + .pipe(Effect.forkChild({ startImmediately: true })); + + yield* Deferred.await(backedUp); + // Other plugins keep working while this one is not read. + expect(yield* supervisor.invoke(echo.registration.manifest.id, "ping", 2)).toBe(2); + // Without backpressure all 400 answers would sit in the server's write buffer. + expect(served).toBeGreaterThan(0); + expect(served).toBeLessThan(40); + + // Once the plugin reads again, every request gets exactly one answer. + const pid = Number(yield* fs.readFileString(path.join(flood.directory, "raw-child.pid"))); + process.kill(pid, "SIGUSR2"); + const answered = yield* Fiber.join(summary); + const [, total, refused, messages] = /^answered (\d+), refused (\d+): (.*)$/.exec( + answered, + )!; + expect(Number(total)).toBe(requests); + expect(served + Number(refused)).toBe(requests); + // A refusal can only be the cap, while answers wait for the plugin to read. + for (const message of parseRefusals(messages!)) + expect(message).toBe("16 calls to the server are already in flight."); + expect(yield* Fiber.join(first)).toBe(1); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }, + ); + + it.effect("refuses past 16 host calls from a plugin that bypasses the API", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(yield* Scope.Scope, RAW_CHILD_PATH); + const release = yield* Deferred.make(); + let inFlight = 0; + yield* supervisor + .serveHostMethod("burst.get", () => + Effect.sync(() => inFlight++).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(null), + ), + ) + .pipe(Effect.provideService(Scope.Scope, yield* Scope.Scope)); + const burst = yield* prepareRawPlugin("test.burst", { + mode: "burst", + requests: 17, + method: "burst.get", + }); + const pluginId = burst.registration.manifest.id; + yield* supervisor.enable(burst.registration); + const refused = yield* awaitLog(supervisor, pluginId, (message) => + message.startsWith("refused"), + ); + const summary = yield* awaitLog(supervisor, pluginId, (message) => + message.startsWith("answered"), + ); + expect(yield* supervisor.invoke(pluginId, "ping", 1)).toBe(1); + expect(yield* Fiber.join(refused)).toBe( + "refused: 16 calls to the server are already in flight.", + ); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(summary)).toBe( + 'answered 17, refused 1: ["16 calls to the server are already in flight."]', + ); + expect(inFlight).toBe(16); + }), + ); + }); + + describe("secrets", () => { + it.effect("keeps a secret's row until its file is deleted, so cleanup always finishes", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + let run = yield* restart(yield* Scope.make(), secrets.service); + const installationId = yield* install(run.catalog, yield* preparePlugin()); + const saveToken = run.settings.update({ + installationId, + changes: [{ key: "token", value: SECRET }], + }); + yield* saveToken; + + // A clear whose file deletion fails reads as cleared, and keeps the row that finds it. + secrets.faults.remove = true; + const clearing = yield* run.settings + .update({ installationId, changes: [{ key: "token", value: null }] }) + .pipe(Effect.flip); + expect(clearing.reason).toBe("storage"); + expect((yield* awaitValues(run.settings, installationId, () => true)).secrets).toEqual( + [], + ); + expect(yield* run.catalog.invoke(installationId, "read", { key: "token" })).toEqual({ + unset: true, + }); + expect(secrets.entries.size).toBe(1); + secrets.faults.remove = false; + run = yield* restart(run.scope, secrets.service); + expect(secrets.entries.size).toBe(0); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 0, + storage: 0, + }); + + // A save interrupted after its file was written is found the same way. + secrets.faults.set = true; + const saving = yield* run.settings + .update({ installationId, changes: [{ key: "token", value: SECRET }] }) + .pipe(Effect.flip); + expect(saving.reason).toBe("storage"); + expect(secrets.entries.size).toBe(1); + expect((yield* awaitValues(run.settings, installationId, () => true)).secrets).toEqual( + [], + ); + secrets.faults.set = false; + run = yield* restart(run.scope, secrets.service); + expect(secrets.entries.size).toBe(0); + expect((yield* countRows(installationId)).secrets).toBe(0); + + // A removal whose file deletion fails finishes at the next start. + yield* run.settings.update({ + installationId, + changes: [{ key: "token", value: SECRET }], + }); + secrets.faults.remove = true; + const ended = yield* run.settings + .subscribe(installationId) + .pipe(Stream.runDrain, Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* run.catalog.remove({ installationId }); + expect((yield* Fiber.join(ended)).reason).toBe("not-found"); + expect(secrets.entries.size).toBe(1); + expect((yield* countRows(installationId)).secrets).toBe(1); + secrets.faults.remove = false; + run = yield* restart(run.scope, secrets.service); + expect(secrets.entries.size).toBe(0); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 0, + storage: 0, + }); + yield* Scope.close(run.scope, Exit.void); + }), + ), + ); + + it.effect("never lets a plugin read a secret whose save did not finish", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const installationId = yield* install(catalog, yield* preparePlugin()); + yield* settings.update({ + installationId, + changes: [{ key: "token", value: "old-valid" }], + }); + + // The plugin's read has found the saved secret and is about to read its file. + const reading = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); + secrets.hooks.beforeGet = Deferred.succeed(reading, undefined).pipe( + Effect.andThen(Deferred.await(finishRead)), + ); + const read = yield* catalog + .invoke(installationId, "read", { key: "token" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(reading); + + // Meanwhile a client clears it, then a new save fails after writing its file. + const changing = yield* Effect.gen(function* () { + yield* settings.update({ installationId, changes: [{ key: "token", value: null }] }); + secrets.faults.set = true; + return yield* settings + .update({ installationId, changes: [{ key: "token", value: "new-failed" }] }) + .pipe(Effect.flip); + }).pipe(Effect.forkChild({ startImmediately: true })); + + secrets.hooks.beforeGet = Effect.void; + yield* Deferred.succeed(finishRead, undefined); + // The read sees the secret saved when it started, never the unfinished one. + expect(yield* Fiber.join(read)).toEqual({ value: "old-valid" }); + expect((yield* Fiber.join(changing)).reason).toBe("storage"); + expect(yield* catalog.invoke(installationId, "read", { key: "token" })).toEqual({ + unset: true, + }); + }), + ), + ); + + it.effect("keeps only the fields the manifest declares now", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const directory = yield* preparePlugin(); + const installationId = yield* install(catalog, directory); + const redeclare = (fields: ReadonlyArray>) => + writeManifest(directory, { settings: fields }).pipe( + Effect.andThen(catalog.refresh({ installationId })), + ); + yield* settings.update({ + installationId, + changes: [ + { key: "token", value: SECRET }, + { key: "mode", value: "fast" }, + ], + }); + + // A secret field that becomes text loses its secret before the text is saved. + yield* redeclare([{ type: "text", key: "token", label: "API token" }]); + const retyped = yield* settings.update({ + installationId, + changes: [{ key: "token", value: "plain" }], + }); + expect(retyped).toEqual({ + installationId, + values: [{ key: "token", value: "plain" }], + secrets: [], + }); + expect(secrets.entries.size).toBe(0); + expect(yield* countRows(installationId)).toEqual({ + settings: 1, + secrets: 0, + storage: 0, + }); + + // Manifests that keep renaming 32 fields keep 32 values, and frames stay one size. + const sizes = []; + for (const generation of [0, 1, 2]) { + const keys = Array.from( + { length: 32 }, + (_, index) => `g${generation}k${String(index).padStart(2, "0")}`, + ); + yield* redeclare(keys.map((key) => ({ type: "text", key, label: key }))); + const saved = yield* settings.update({ + installationId, + changes: keys.map((key) => ({ key, value: "v".repeat(2000) })), + }); + expect(saved.values.map((entry) => entry.key)).toEqual(keys); + expect(yield* countRows(installationId)).toEqual({ + settings: 32, + secrets: 0, + storage: 0, + }); + sizes.push(toJson(saved).length); + } + expect(new Set(sizes).size).toBe(1); + + // Values of fields no longer declared are not sent, even before the next save. + yield* redeclare([{ type: "boolean", key: "verbose", label: "Verbose" }]); + expect(yield* awaitValues(settings, installationId, () => true)).toEqual({ + installationId, + values: [], + secrets: [], + }); + }), + ), + ); + + it.effect("sends open subscriptions the new fields when the declaration changes", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const directory = yield* preparePlugin(); + const installationId = yield* install(catalog, directory); + yield* settings.update({ + installationId, + changes: [ + { key: "token", value: SECRET }, + { key: "mode", value: "fast" }, + ], + }); + const first = yield* Deferred.make(); + const snapshots = yield* settings.subscribe(installationId).pipe( + Stream.tap(() => Deferred.succeed(first, undefined)), + Stream.take(2), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(first); + + yield* writeManifest(directory, { + settings: [{ type: "boolean", key: "verbose", label: "Verbose" }], + }); + yield* catalog.refresh({ installationId }); + expect(yield* Fiber.join(snapshots)).toEqual([ + { installationId, values: [{ key: "mode", value: "fast" }], secrets: ["token"] }, + { installationId, values: [], secrets: [] }, + ]); + }), + ), + ); + + it.effect("saves nothing new while a retired secret cannot be deleted", () => + withDatabase( + Effect.gen(function* () { + const secrets = makeSecretStore(); + const { catalog, settings } = yield* startPlugins(yield* Scope.Scope, secrets.service); + const directory = yield* preparePlugin(); + const installationId = yield* install(catalog, directory); + const declare = (generation: number) => + Effect.gen(function* () { + const keys = Array.from( + { length: 32 }, + (_, index) => `g${generation}k${String(index).padStart(2, "0")}`, + ); + yield* writeManifest(directory, { + settings: keys.map((key) => ({ type: "secret", key, label: key })), + }); + yield* catalog.refresh({ installationId }); + return keys.map((key) => ({ key, value: SECRET })); + }); + yield* settings.update({ installationId, changes: yield* declare(0) }); + expect(secrets.entries.size).toBe(32); + + // Renaming every field while deletes fail refuses each save and stores nothing more. + secrets.faults.remove = true; + for (const generation of [1, 2, 3]) { + const refused = yield* settings + .update({ installationId, changes: yield* declare(generation) }) + .pipe(Effect.flip); + expect(refused.reason).toBe("storage"); + expect(secrets.entries.size).toBe(32); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 32, + storage: 0, + }); + } + // A secret field retyped as text keeps its secret and gets no value beside it. + yield* writeManifest(directory, { + settings: [{ type: "text", key: "g0k00", label: "g0k00" }], + }); + yield* catalog.refresh({ installationId }); + const retyped = yield* settings + .update({ installationId, changes: [{ key: "g0k00", value: "plain" }] }) + .pipe(Effect.flip); + expect(retyped.reason).toBe("storage"); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 32, + storage: 0, + }); + + // Once deletes work, the next save retires the old secrets first. + secrets.faults.remove = false; + const changes = yield* declare(3); + const saved = yield* settings.update({ installationId, changes }); + expect(saved.secrets).toEqual(changes.map((change) => change.key)); + expect(secrets.entries.size).toBe(32); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 32, + storage: 0, + }); + + const ended = yield* settings + .subscribe(installationId) + .pipe(Stream.runDrain, Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* catalog.remove({ installationId }); + expect((yield* Fiber.join(ended)).reason).toBe("not-found"); + expect(secrets.entries.size).toBe(0); + expect(yield* countRows(installationId)).toEqual({ + settings: 0, + secrets: 0, + storage: 0, + }); + }), + ), + ); + }); +}); diff --git a/apps/server/src/plugins/PluginSettings.ts b/apps/server/src/plugins/PluginSettings.ts new file mode 100644 index 000000000000..f1914ddb14d9 --- /dev/null +++ b/apps/server/src/plugins/PluginSettings.ts @@ -0,0 +1,565 @@ +/** + * Saved settings, secrets, and private storage of plugin installations. + * + * Users save values for the fields an installation's manifest declares + * (`plugins.settings.*`); the plugin reads them, and keeps its own small + * key-value storage, through host methods its child process calls. Values + * belong to the installation, so they outlive disable, re-enable, restarts + * and source changes, and are deleted when the installation is removed. + * + * A secret's value lives in the server secret store and is only ever read by + * the plugin; clients learn whether one is saved and nothing else. A row in + * `plugin_setting_secrets` exists before its file is written and goes only + * after the file is deleted, so a failure or crash at any step leaves a row + * that the next clear, removal, or start finishes. Every write and the + * removal cleanup run one at a time, and a write first checks that the + * installation still exists, so nothing is saved for an installation after + * its cleanup. + * + * Only the fields the installation declares now are kept: each update first + * deletes values (and secrets) of keys that are no longer declared, or no + * longer of that kind, and snapshots only carry declared fields. An update + * whose retired secret cannot be deleted fails and saves nothing, keeping the + * row for the next attempt, so what is stored and sent stays within one + * declaration's bounds as manifests change, even while deletion fails. + */ +import { + PLUGIN_SETTINGS_CAPABILITY, + PluginCatalogError, + PluginSettingValue, + pluginSettingValueProblem, + resolvePluginSettingValue, + type PluginCatalogSnapshot, + type PluginInstallationId, + type PluginSettingField, + type PluginSettingsUpdateInput, + type PluginSettingsValues, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; + +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import type { PluginRegistration } from "./PluginManifestLoader.ts"; +import { PluginCatalog } from "./PluginCatalog.ts"; +import { + PluginHostCallError, + PluginSupervisor, + type PluginHostMethod, +} from "./PluginSupervisor.ts"; + +type HostCall = Parameters[0]; + +/** Bounds of one installation's private storage. */ +export interface PluginStorageLimits { + readonly maxKeyLength: number; + readonly maxValueBytes: number; + readonly maxKeys: number; + readonly maxTotalBytes: number; +} + +const defaultPluginStorageLimits: PluginStorageLimits = { + maxKeyLength: 128, + maxValueBytes: 64 * 1024, + maxKeys: 256, + maxTotalBytes: 1024 * 1024, +}; + +const encodeName = (text: string) => Buffer.from(text, "utf8").toString("base64url"); +const secretName = (installationId: PluginInstallationId, key: string) => + `plugin-setting-${encodeName(installationId)}-${encodeName(key)}`; + +const SavedValueJson = Schema.fromJsonString(PluginSettingValue); +const decodeSavedValue = Schema.decodeUnknownOption(SavedValueJson); +const encodeSavedValue = Schema.encodeSync(SavedValueJson); +const StoredJson = Schema.fromJsonString(Schema.Json); +const decodeStoredJson = Schema.decodeUnknownOption(StoredJson); +const encodeStoredJson = Schema.encodeSync(StoredJson); + +const decodeKeyInput = Schema.decodeUnknownEffect(Schema.Struct({ key: Schema.String })); +const decodeSetInput = Schema.decodeUnknownEffect( + Schema.Struct({ key: Schema.String, value: Schema.Json }), +); + +const hasControlCharacter = (text: string) => { + for (let index = 0; index < text.length; index++) { + const code = text.charCodeAt(index); + if (code < 0x20 || code === 0x7f) return true; + } + return false; +}; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const catalogError = (reason: string, message: string, installationId: PluginInstallationId) => + new PluginCatalogError({ reason, message, installationId }); + +const hostError = (message: string) => new PluginHostCallError({ message }); + +interface SettingsEvent { + readonly installationId: PluginInstallationId; + readonly removed: boolean; +} + +export class PluginSettings extends Context.Service< + PluginSettings, + { + /** The saved values now, then after every change; fails `not-found` once the installation is removed. */ + readonly subscribe: ( + installationId: PluginInstallationId, + ) => Stream.Stream; + /** Checks every change against the declared fields, then saves them. Never returns a secret. */ + readonly update: ( + input: PluginSettingsUpdateInput, + ) => Effect.Effect; + } +>()("t3/plugins/PluginSettings") {} + +export const make = Effect.fn("PluginSettings.make")(function* ( + limits: PluginStorageLimits = defaultPluginStorageLimits, +) { + const sql = yield* SqlClient.SqlClient; + const secrets = yield* ServerSecretStore; + const catalog = yield* PluginCatalog; + const supervisor = yield* PluginSupervisor; + + // Writes, removal cleanup and secret reads run one at a time, so none sees another half done. + const lock = yield* Semaphore.make(1); + const events = yield* PubSub.sliding(256); + + const findInstallation = (installationId: PluginInstallationId) => + catalog.list.pipe( + Effect.map((snapshot) => + snapshot.installations.find( + (installation) => installation.installationId === installationId, + ), + ), + ); + + const notFound = (installationId: PluginInstallationId) => + catalogError("not-found", "That plugin is not installed here.", installationId); + + const storageFailed = (installationId: PluginInstallationId) => (cause: unknown) => + Effect.logWarning("Plugin settings storage failed", { installationId, cause }).pipe( + Effect.andThen( + Effect.fail( + catalogError("storage", "Could not save the plugin's settings.", installationId), + ), + ), + ); + + const savedRows = (installationId: PluginInstallationId) => + sql<{ readonly key: string; readonly value_json: string }>` + SELECT key, value_json FROM plugin_settings + WHERE installation_id = ${installationId} + ORDER BY key + `; + + /** Secrets that may have a file; `saved` is 0 while one is written or being deleted. */ + const secretRows = (installationId: PluginInstallationId) => + sql<{ readonly key: string; readonly saved: number }>` + SELECT key, saved FROM plugin_setting_secrets + WHERE installation_id = ${installationId} + ORDER BY key + `; + + const isSecret = (fields: ReadonlyArray, key: string) => + fields.some((field) => field.key === key && field.type === "secret"); + const isValue = (fields: ReadonlyArray, key: string) => + fields.some((field) => field.key === key && field.type !== "secret"); + + /** The saved values of the fields declared now. */ + const readValues = ( + installationId: PluginInstallationId, + fields: ReadonlyArray, + ) => + Effect.all([savedRows(installationId), secretRows(installationId)]).pipe( + Effect.map(([rows, secretKeys]): PluginSettingsValues => { + const values: Array<{ key: string; value: PluginSettingValue }> = []; + for (const row of rows) { + if (!isValue(fields, row.key)) continue; + const value = decodeSavedValue(row.value_json); + if (Option.isSome(value)) values.push({ key: row.key, value: value.value }); + } + return { + installationId, + values, + secrets: secretKeys + .filter((row) => row.saved === 1 && isSecret(fields, row.key)) + .map((row) => row.key), + }; + }), + Effect.catch(storageFailed(installationId)), + ); + + const current = Effect.fnUntraced(function* (installationId: PluginInstallationId) { + const installation = yield* findInstallation(installationId); + if (installation === undefined) return yield* notFound(installationId); + return yield* readValues(installationId, installation.manifest?.settings ?? []); + }); + + /** Saves a secret: its row is written first, so a file never exists without one. */ + const saveSecret = Effect.fnUntraced(function* ( + installationId: PluginInstallationId, + key: string, + value: string, + ) { + // A replaced secret stays saved; its file is swapped in one rename. + yield* sql` + INSERT INTO plugin_setting_secrets (installation_id, key, saved) + VALUES (${installationId}, ${key}, 0) + ON CONFLICT (installation_id, key) DO NOTHING + `; + yield* secrets.set(secretName(installationId, key), textEncoder.encode(value)); + yield* sql` + UPDATE plugin_setting_secrets SET saved = 1 + WHERE installation_id = ${installationId} AND key = ${key} + `; + }); + + /** Deletes a secret's file, then its row; a failure leaves the row for the next attempt. */ + const deleteSecret = Effect.fnUntraced(function* ( + installationId: PluginInstallationId, + key: string, + ) { + yield* sql` + UPDATE plugin_setting_secrets SET saved = 0 + WHERE installation_id = ${installationId} AND key = ${key} + `; + yield* secrets.remove(secretName(installationId, key)); + yield* sql` + DELETE FROM plugin_setting_secrets + WHERE installation_id = ${installationId} AND key = ${key} + `; + }); + + const deleteSecretOrWarn = (installationId: PluginInstallationId, key: string) => + deleteSecret(installationId, key).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not delete a plugin's secret; it is retried later", { + installationId, + cause, + }), + ), + ); + + /** + * Deletes what is saved for keys the declaration no longer has, or has as another kind. Fails + * if a secret's file cannot be deleted, so nothing new is saved beside it. + */ + const retireUndeclared = Effect.fnUntraced(function* ( + installationId: PluginInstallationId, + fields: ReadonlyArray, + ) { + for (const row of yield* secretRows(installationId)) + if (!isSecret(fields, row.key)) yield* deleteSecret(installationId, row.key); + for (const row of yield* savedRows(installationId)) + if (!isValue(fields, row.key)) + yield* sql` + DELETE FROM plugin_settings + WHERE installation_id = ${installationId} AND key = ${row.key} + `; + }); + + const update = Effect.fn("PluginSettings.update")(function* (input: PluginSettingsUpdateInput) { + const { installationId } = input; + const installation = yield* findInstallation(installationId); + if (installation === undefined) return yield* notFound(installationId); + const fields = installation.manifest?.settings ?? []; + const invalid = (message: string) => catalogError("invalid-setting", message, installationId); + const seen = new Set(); + const plan = []; + for (const change of input.changes) { + const field = fields.find((candidate) => candidate.key === change.key); + if (field === undefined) return yield* invalid(`This plugin has no setting "${change.key}".`); + if (seen.has(change.key)) return yield* invalid(`"${change.key}" is changed twice.`); + seen.add(change.key); + if (change.value !== null) { + const problem = pluginSettingValueProblem(field, change.value); + if (problem !== undefined) return yield* invalid(problem); + } + plan.push({ key: change.key, secret: field.type === "secret", value: change.value }); + } + const failed = storageFailed(installationId); + yield* retireUndeclared(installationId, fields).pipe(Effect.catch(failed)); + for (const step of plan) { + if (step.secret && step.value !== null) + yield* saveSecret(installationId, step.key, String(step.value)).pipe(Effect.catch(failed)); + else if (step.secret) + yield* deleteSecret(installationId, step.key).pipe(Effect.catch(failed)); + else if (step.value !== null) { + const json = encodeSavedValue(step.value); + yield* sql` + INSERT INTO plugin_settings (installation_id, key, value_json) + VALUES (${installationId}, ${step.key}, ${json}) + ON CONFLICT (installation_id, key) DO UPDATE SET value_json = excluded.value_json + `.pipe(Effect.catch(failed)); + } else { + yield* sql` + DELETE FROM plugin_settings WHERE installation_id = ${installationId} AND key = ${step.key} + `.pipe(Effect.catch(failed)); + } + } + return yield* readValues(installationId, fields); + }); + + /** Deletes everything saved for an installation that no longer exists. */ + const purge = Effect.fnUntraced(function* (installationId: PluginInstallationId) { + for (const row of yield* secretRows(installationId)) + yield* deleteSecretOrWarn(installationId, row.key); + yield* sql`DELETE FROM plugin_settings WHERE installation_id = ${installationId}`; + yield* sql`DELETE FROM plugin_storage WHERE installation_id = ${installationId}`; + }); + + const purgeRemoved = (installationIds: Iterable) => + lock.withPermit( + Effect.forEach( + installationIds, + (installationId) => + purge(installationId).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not delete a removed plugin's settings", { + installationId, + cause, + }), + ), + Effect.andThen(PubSub.publish(events, { installationId, removed: true })), + ), + { discard: true }, + ), + ); + + // ---- Host methods: what the plugin's own process may read and write ---- + + const owner = (registration: PluginRegistration) => + registration.installationId !== undefined && + registration.manifest.capabilities.includes(PLUGIN_SETTINGS_CAPABILITY) + ? Effect.succeed(registration.installationId) + : Effect.fail(hostError(`The plugin did not declare the "settings" capability.`)); + + const malformed = () => hostError("The request is malformed."); + + const checkStorageKey = (key: string) => + key.length > 0 && key.length <= limits.maxKeyLength && !hasControlCharacter(key) + ? Effect.void + : Effect.fail( + hostError( + `Storage keys must be 1 to ${limits.maxKeyLength} characters without control characters.`, + ), + ); + + const hostFailed = (cause: unknown) => + Effect.logWarning("Plugin storage failed", { cause }).pipe( + Effect.andThen(Effect.fail(hostError("The server could not read or save the value."))), + ); + + const settingsGet = Effect.fnUntraced(function* ({ registration, input }: HostCall) { + const installationId = yield* owner(registration); + const { key } = yield* decodeKeyInput(input).pipe(Effect.mapError(malformed)); + const field = registration.manifest.settings?.find((candidate) => candidate.key === key); + if (field === undefined) return yield* hostError(`"${key}" is not a declared setting.`); + if (field.type === "secret") { + // Under the lock, so no save or clear swaps the file between the check and the read. + return yield* lock.withPermit( + Effect.gen(function* () { + const rows = yield* sql<{ readonly saved: number }>` + SELECT saved FROM plugin_setting_secrets + WHERE installation_id = ${installationId} AND key = ${key} + `.pipe(Effect.catch(hostFailed)); + if (rows[0]?.saved !== 1) return { value: null }; + const secret = yield* secrets + .get(secretName(installationId, key)) + .pipe(Effect.catch(hostFailed)); + return { value: Option.isSome(secret) ? textDecoder.decode(secret.value) : null }; + }), + ); + } + const rows = yield* sql<{ readonly value_json: string }>` + SELECT value_json FROM plugin_settings + WHERE installation_id = ${installationId} AND key = ${key} + `.pipe(Effect.catch(hostFailed)); + const saved = + rows[0] === undefined + ? undefined + : Option.getOrUndefined(decodeSavedValue(rows[0].value_json)); + return { value: resolvePluginSettingValue(field, saved) ?? null }; + }); + + const storageGet = Effect.fnUntraced(function* ({ registration, input }: HostCall) { + const installationId = yield* owner(registration); + const { key } = yield* decodeKeyInput(input).pipe(Effect.mapError(malformed)); + yield* checkStorageKey(key); + const rows = yield* sql<{ readonly value_json: string }>` + SELECT value_json FROM plugin_storage + WHERE installation_id = ${installationId} AND key = ${key} + `.pipe(Effect.catch(hostFailed)); + const value = rows[0] === undefined ? Option.none() : decodeStoredJson(rows[0].value_json); + return Option.match(value, { + onNone: () => ({ found: false, value: null }), + onSome: (json) => ({ found: true, value: json }), + }); + }); + + const storageSet = Effect.fnUntraced(function* ({ registration, input, admitted }: HostCall) { + const installationId = yield* owner(registration); + const { key, value } = yield* decodeSetInput(input).pipe(Effect.mapError(malformed)); + yield* checkStorageKey(key); + const json = encodeStoredJson(value); + const bytes = Buffer.byteLength(json); + if (bytes > limits.maxValueBytes) + return yield* hostError(`The value is ${bytes} bytes; the limit is ${limits.maxValueBytes}.`); + return yield* lock.withPermit( + Effect.gen(function* () { + // Checked under the lock: a removed installation's cleanup may already have run. + if ((yield* findInstallation(installationId)) === undefined) + return yield* hostError("The plugin was removed."); + const usage = yield* sql<{ readonly keys: number; readonly bytes: number | null }>` + SELECT COUNT(*) AS keys, SUM(bytes) AS bytes FROM plugin_storage + WHERE installation_id = ${installationId} AND key != ${key} + `.pipe(Effect.catch(hostFailed)); + const others = usage[0] ?? { keys: 0, bytes: 0 }; + if (others.keys + 1 > limits.maxKeys) + return yield* hostError(`The plugin already stores ${limits.maxKeys} keys.`); + if ((others.bytes ?? 0) + bytes > limits.maxTotalBytes) + return yield* hostError( + `Saving this would store more than ${limits.maxTotalBytes} bytes for the plugin.`, + ); + // A revoked generation's write that waited for the lock is dropped here. + yield* admitted; + yield* sql` + INSERT INTO plugin_storage (installation_id, key, value_json, bytes) + VALUES (${installationId}, ${key}, ${json}, ${bytes}) + ON CONFLICT (installation_id, key) DO UPDATE SET + value_json = excluded.value_json, + bytes = excluded.bytes + `.pipe(Effect.catch(hostFailed)); + return null; + }), + ); + }); + + const storageDelete = Effect.fnUntraced(function* ({ registration, input, admitted }: HostCall) { + const installationId = yield* owner(registration); + const { key } = yield* decodeKeyInput(input).pipe(Effect.mapError(malformed)); + yield* checkStorageKey(key); + return yield* lock.withPermit( + Effect.gen(function* () { + yield* admitted; + yield* sql` + DELETE FROM plugin_storage WHERE installation_id = ${installationId} AND key = ${key} + `.pipe(Effect.catch(hostFailed)); + return null; + }), + ); + }); + + const storageKeys = Effect.fnUntraced(function* ({ registration }: HostCall) { + const installationId = yield* owner(registration); + const rows = yield* sql<{ readonly key: string }>` + SELECT key FROM plugin_storage WHERE installation_id = ${installationId} ORDER BY key + `.pipe(Effect.catch(hostFailed)); + return { keys: rows.map((row) => row.key) }; + }); + + yield* supervisor.serveHostMethod("settings.get", settingsGet); + yield* supervisor.serveHostMethod("storage.get", storageGet); + yield* supervisor.serveHostMethod("storage.set", storageSet); + yield* supervisor.serveHostMethod("storage.delete", storageDelete); + yield* supervisor.serveHostMethod("storage.keys", storageKeys); + + /** Each installation's settings declaration, to tell which ones a catalogue change touched. */ + const declarations = (snapshot: PluginCatalogSnapshot) => + new Map( + snapshot.installations.map((installation) => [ + installation.installationId, + JSON.stringify(installation.manifest?.settings ?? []), + ]), + ); + + // Delete what earlier runs saved for installations that are gone, finish secret writes and + // deletions an earlier run did not complete, then follow removals and changed declarations. + const listed = declarations(yield* catalog.list); + const stored = yield* sql<{ readonly installation_id: PluginInstallationId }>` + SELECT installation_id FROM plugin_settings + UNION + SELECT installation_id FROM plugin_setting_secrets + UNION + SELECT installation_id FROM plugin_storage + `.pipe(Effect.orDie); + yield* purgeRemoved( + stored + .map((row) => row.installation_id) + .filter((installationId) => !listed.has(installationId)), + ); + const unfinished = yield* sql<{ + readonly installation_id: PluginInstallationId; + readonly key: string; + }>`SELECT installation_id, key FROM plugin_setting_secrets WHERE saved = 0`.pipe(Effect.orDie); + yield* lock.withPermit( + Effect.forEach(unfinished, (row) => deleteSecretOrWarn(row.installation_id, row.key), { + discard: true, + }), + ); + let known = listed; + yield* catalog.subscribe.pipe( + Stream.runForEach((snapshot) => { + const next = declarations(snapshot); + const removed = [...known.keys()].filter((installationId) => !next.has(installationId)); + // Snapshots carry only declared fields, so subscribers re-read when the declaration changes. + const redeclared = [...next] + .filter( + ([installationId, fields]) => + known.has(installationId) && known.get(installationId) !== fields, + ) + .map(([installationId]) => installationId); + known = next; + return (removed.length === 0 ? Effect.void : purgeRemoved(removed)).pipe( + Effect.andThen( + Effect.forEach( + redeclared, + (installationId) => PubSub.publish(events, { installationId, removed: false }), + { discard: true }, + ), + ), + ); + }), + Effect.forkScoped, + ); + + return PluginSettings.of({ + subscribe: (installationId) => + Stream.unwrap( + // Subscribe before the first read so a change in between is not lost. + PubSub.subscribe(events).pipe( + Effect.map((subscription) => + Stream.concat( + Stream.fromEffect(current(installationId)), + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event.installationId === installationId), + Stream.mapEffect((event) => + event.removed ? Effect.fail(notFound(installationId)) : current(installationId), + ), + ), + ).pipe(Stream.changes), + ), + ), + ), + update: (input) => + lock + .withPermit(update(input)) + .pipe( + Effect.ensuring( + PubSub.publish(events, { installationId: input.installationId, removed: false }), + ), + ), + }); +}); + +export const layer = (limits?: PluginStorageLimits) => Layer.effect(PluginSettings, make(limits)); diff --git a/apps/server/src/plugins/PluginSettingsRpc.test.ts b/apps/server/src/plugins/PluginSettingsRpc.test.ts new file mode 100644 index 000000000000..4f4b8a1dcfbc --- /dev/null +++ b/apps/server/src/plugins/PluginSettingsRpc.test.ts @@ -0,0 +1,113 @@ +import { + AuthAccessWriteScope, + AuthAdministrativeScopes, + type AuthEnvironmentScope, + AuthOrchestrationReadScope, + AuthRelayReadScope, + AuthStandardClientScopes, + PluginInstallationId, + type PluginSettingsValues, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as RpcTest from "effect/rpc/RpcTest"; + +import { RPC_REQUIRED_SCOPES } from "../auth/RpcAuthorization.ts"; +import * as RpcAuthorization from "../auth/RpcAuthorization.ts"; + +type SettingsMethod = + | typeof WS_METHODS.pluginsSettingsSubscribe + | typeof WS_METHODS.pluginsSettingsUpdate; +const settingsMethods: ReadonlySet = new Set([ + WS_METHODS.pluginsSettingsSubscribe, + WS_METHODS.pluginsSettingsUpdate, +]); + +const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + (tag): tag is Exclude => + !settingsMethods.has(tag), + ), +); + +const installationId = PluginInstallationId.make("fixture"); +const values: PluginSettingsValues = { installationId, values: [], secrets: ["token"] }; + +/** Serves the settings RPCs through the real scope middleware; handlers record that they ran. */ +const makeClient = (scopes: ReadonlyArray, handled: Array) => + RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.pluginsSettingsSubscribe, () => + Stream.fromEffect( + Effect.sync(() => handled.push(WS_METHODS.pluginsSettingsSubscribe)).pipe( + Effect.as(values), + ), + ), + ), + group.toLayerHandler(WS_METHODS.pluginsSettingsUpdate, () => + Effect.sync(() => handled.push(WS_METHODS.pluginsSettingsUpdate)).pipe(Effect.as(values)), + ), + RpcAuthorization.layer(scopes), + ), + ), + ); + +const update = { installationId, changes: [{ key: "token", value: "fixture-secret" }] }; + +describe("plugin settings RPC scopes", () => { + it.effect("lets a standard pairing read values but not save them", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient(AuthStandardClientScopes, handled); + + expect( + yield* client[WS_METHODS.pluginsSettingsSubscribe]({ installationId }).pipe( + Stream.take(1), + Stream.runCollect, + ), + ).toEqual([values]); + expect( + yield* client[WS_METHODS.pluginsSettingsUpdate](update).pipe(Effect.flip), + ).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: AuthAccessWriteScope, + requiredPermission: AuthAccessWriteScope, + }); + expect(handled).toEqual([WS_METHODS.pluginsSettingsSubscribe]); + }).pipe(Effect.scoped), + ); + + it.effect("lets an administrative pairing save values", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient(AuthAdministrativeScopes, handled); + + expect(yield* client[WS_METHODS.pluginsSettingsUpdate](update)).toEqual(values); + expect(handled).toEqual([WS_METHODS.pluginsSettingsUpdate]); + }).pipe(Effect.scoped), + ); + + it.effect("refuses value reads without the orchestration read scope", () => + Effect.gen(function* () { + const handled: Array = []; + const client = yield* makeClient([AuthRelayReadScope], handled); + + expect( + yield* client[WS_METHODS.pluginsSettingsSubscribe]({ installationId }).pipe( + Stream.runCollect, + Effect.flip, + ), + ).toMatchObject({ + _tag: "EnvironmentAuthorizationError", + requiredScope: AuthOrchestrationReadScope, + requiredPermission: AuthOrchestrationReadScope, + }); + expect(handled).toEqual([]); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/plugins/PluginSupervisor.test.ts b/apps/server/src/plugins/PluginSupervisor.test.ts new file mode 100644 index 000000000000..c3c6ab316097 --- /dev/null +++ b/apps/server/src/plugins/PluginSupervisor.test.ts @@ -0,0 +1,749 @@ +// @effect-diagnostics nodeBuiltinImport:off -- A TCP listener hears from a process a plugin started. +import * as NodeNet from "node:net"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import type { PluginHostState, PluginId } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +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 PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import { TestClock } from "effect/testing"; + +import { loadPluginDirectory } from "./PluginManifestLoader.ts"; +import * as PluginSupervisor from "./PluginSupervisor.ts"; + +const FIXTURE_DIR = `${import.meta.dirname}/testFixtures/plugin`; +// Children run the real CLI entry, which routes `__plugin-host` to the child runtime. +const BIN_PATH = `${import.meta.dirname}/../bin.ts`; + +const testOptions = { + heapLimitMb: 64, + maxMessageBytes: 64 * 1024, + activationTimeout: "5 seconds", + callTimeout: "5 seconds", + cancelGrace: "1 second", + stopGrace: "1 second", + maxRestarts: 2, + restartBackoff: "1 second", + maxRestartBackoff: "4 seconds", + stableUptime: "1 minute", +} satisfies Partial; + +const makeSupervisor = (overrides: Partial = {}) => + PluginSupervisor.make({ ...testOptions, ...overrides }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + ); + +/** Copies the fixture plugin into a scoped temp directory under its own manifest. */ +const preparePlugin = Effect.fn("preparePlugin")(function* ( + id: string, + manifest: Record = {}, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-" }); + for (const file of yield* fs.readDirectory(FIXTURE_DIR)) + if (file.endsWith(".mjs")) + yield* fs.copyFile(path.join(FIXTURE_DIR, file), path.join(directory, file)); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ + id, + name: id, + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + proposedApi: true, + ...manifest, + }), + ); + return { directory, registration: yield* loadPluginDirectory(directory) }; +}); + +type Supervisor = PluginSupervisor.PluginSupervisor["Service"]; +type Subscription = PubSub.Subscription; + +const awaitState = Effect.fn("awaitState")(function* ( + supervisor: Supervisor, + subscription: Subscription, + pluginId: PluginId, + tag: Tag, +) { + let current = yield* supervisor.state(pluginId); + while (true) { + if (Option.isSome(current) && current.value._tag === tag) + return current.value as Extract; + const event = yield* PubSub.take(subscription); + if (event._tag === "StateChanged" && event.pluginId === pluginId) + current = Option.some(event.state); + } +}); + +const awaitLog = Effect.fn("awaitLog")(function* ( + subscription: Subscription, + pluginId: PluginId, + message: string, +) { + while (true) { + const event = yield* PubSub.take(subscription); + if (event._tag === "Log" && event.pluginId === pluginId && event.message === message) return; + } +}); + +const awaitLogMatching = Effect.fn("awaitLogMatching")(function* ( + subscription: Subscription, + pluginId: PluginId, + pattern: RegExp, +) { + while (true) { + const event = yield* PubSub.take(subscription); + if (event._tag === "Log" && event.pluginId === pluginId && pattern.test(event.message)) + return event.message; + } +}); + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +/** Collects what the first connection to a local port sends until it closes. */ +const listenOnce = () => + Effect.acquireRelease( + Effect.promise( + () => + new Promise<{ server: NodeNet.Server; port: number; received: Promise }>( + (resolve) => { + let report!: (text: string) => void; + const received = new Promise((done) => (report = done)); + const server = NodeNet.createServer((socket) => { + let text = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => (text += chunk)); + socket.on("close", () => report(text)); + }); + server.listen(0, "127.0.0.1", () => + resolve({ server, port: (server.address() as NodeNet.AddressInfo).port, received }), + ); + }, + ), + ), + ({ server }) => Effect.sync(() => server.close()), + ); + +const pidOf = (value: unknown) => (value as { readonly pid: number }).pid; + +const isProcessAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +it.layer(NodeServices.layer)("PluginSupervisor", (it) => { + describe("manifests", () => { + it.effect("loads the fixture and refuses incompatible or escaping plugins", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* loadPluginDirectory(FIXTURE_DIR); + expect(fixture.manifest).toMatchObject({ + id: "test.fixture", + apiVersion: 1, + capabilities: [], + proposedApi: true, + }); + expect(fixture.entryPath.endsWith(`${path.sep}main.mjs`)).toBe(true); + + const reason = (manifest: Record) => + preparePlugin("test.invalid", manifest).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + expect(yield* reason({ apiVersion: 2 })).toContain("targets plugin API version 2"); + expect(yield* reason({ capabilities: ["unimplemented"] })).toContain( + "does not support unimplemented", + ); + expect(yield* reason({ entry: "../main.mjs" })).toContain("is invalid"); + expect(yield* reason({ entry: "main.ts" })).toContain("is invalid"); + expect(yield* reason({ id: "Not-Qualified" })).toContain("is invalid"); + + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-outside-" }); + yield* fs.writeFileString( + path.join(outside, "escape.mjs"), + "export function activate() {}", + ); + const { directory } = yield* preparePlugin("test.symlink"); + yield* fs.symlink(path.join(outside, "escape.mjs"), path.join(directory, "link.mjs")); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ + id: "test.symlink", + name: "Symlink", + version: "1", + apiVersion: 1, + entry: "link.mjs", + }), + ); + const escaped = yield* loadPluginDirectory(directory).pipe(Effect.flip); + expect(escaped.message).toContain("resolves outside the plugin directory"); + }), + ); + }); + + describe("lifecycle", () => { + it.effect("starts no process until first use and stops it on disable and shutdown", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supervisor = yield* makeSupervisor(); + const { directory, registration } = yield* preparePlugin("test.lazy"); + const pluginId = registration.manifest.id; + const marker = path.join(directory, "activated.marker"); + + yield* supervisor.enable(registration); + expect(yield* supervisor.state(pluginId)).toEqual(Option.some({ _tag: "idle" })); + expect(yield* fs.exists(marker)).toBe(false); + const duplicate = yield* supervisor.enable(registration).pipe(Effect.flip); + expect(duplicate._tag).toBe("PluginAlreadyEnabledError"); + + const result = yield* supervisor.invoke(pluginId, "ping", { hello: "world" }); + expect(result).toMatchObject({ input: { hello: "world" } }); + const pid = pidOf(result); + expect(yield* fs.exists(marker)).toBe(true); + expect(yield* supervisor.state(pluginId)).toEqual(Option.some({ _tag: "running" })); + expect(isProcessAlive(pid)).toBe(true); + + yield* supervisor.disable(pluginId); + expect(isProcessAlive(pid)).toBe(false); + expect(yield* supervisor.state(pluginId)).toEqual(Option.none()); + const afterDisable = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(afterDisable._tag).toBe("PluginNotEnabledError"); + + // Closing the supervisor's scope stops every plugin it still runs. + const shutdownPid = yield* Effect.scoped( + Effect.gen(function* () { + const scoped = yield* makeSupervisor(); + yield* scoped.enable(registration); + return pidOf(yield* scoped.invoke(pluginId, "ping", null)); + }), + ); + expect(isProcessAlive(shutdownPid)).toBe(false); + }), + ); + + it.effect("kills a plugin stuck in a synchronous loop while other plugins keep answering", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const spinner = (yield* preparePlugin("test.spinner")).registration; + const bystander = (yield* preparePlugin("test.bystander")).registration; + yield* supervisor.enable(spinner); + yield* supervisor.enable(bystander); + const spinnerPid = pidOf(yield* supervisor.invoke(spinner.manifest.id, "ping", null)); + const bystanderPid = pidOf(yield* supervisor.invoke(bystander.manifest.id, "ping", null)); + + const spinning = yield* supervisor + .invoke(spinner.manifest.id, "spin", null, { timeout: "2 seconds" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + // The server's event loop is free while the spinner's is blocked. + const answer = yield* supervisor.invoke(bystander.manifest.id, "ping", "still here"); + expect(answer).toEqual({ pid: bystanderPid, input: "still here" }); + + yield* TestClock.adjust("2 seconds"); + const timedOut = yield* Fiber.join(spinning); + expect(timedOut._tag).toBe("PluginTimeoutError"); + expect(isProcessAlive(spinnerPid)).toBe(true); + + yield* TestClock.adjust("1 second"); + const backoff = yield* awaitState(supervisor, subscription, spinner.manifest.id, "backoff"); + expect(backoff.reason).toContain('did not stop "spin" within 1000ms of cancellation'); + expect(isProcessAlive(spinnerPid)).toBe(false); + expect(yield* supervisor.invoke(bystander.manifest.id, "ping", null)).toMatchObject({ + pid: bystanderPid, + }); + }), + ); + + it.effect("lets a handler that honours cancellation settle without a kill", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.cooperative"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + + const call = yield* supervisor + .invoke(pluginId, "cooperative", null, { timeout: "1 second" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + expect((yield* Fiber.join(call))._tag).toBe("PluginTimeoutError"); + // The child answers the cancel before it answers this later call. + expect(pidOf(yield* supervisor.invoke(pluginId, "ping", null))).toBe(pid); + + yield* TestClock.adjust("1 second"); + expect(pidOf(yield* supervisor.invoke(pluginId, "ping", null))).toBe(pid); + expect(yield* supervisor.state(pluginId)).toEqual(Option.some({ _tag: "running" })); + }), + ); + + it.effect("fails in-flight calls on disable and drops the plugin's late answer", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.late"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + + const call = yield* supervisor + .invoke(pluginId, "late", null) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "late-started"); + // Deactivation aborts the handler, which then answers "late value". + yield* supervisor.disable(pluginId); + + const exit = yield* Fiber.join(call); + expect(Exit.isFailure(exit)).toBe(true); + expect(Option.getOrUndefined(Exit.findErrorOption(exit))?._tag).toBe("PluginStoppedError"); + expect(isProcessAlive(pid)).toBe(false); + }), + ); + }); + + describe("concurrency", () => { + it.effect("counts cancelled calls against the cap until the plugin answers them", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor({ maxConcurrentCalls: 1 }); + const subscription = yield* supervisor.subscribe; + const stalled = (yield* preparePlugin("test.stalled")).registration; + const polite = (yield* preparePlugin("test.polite")).registration; + yield* supervisor.enable(stalled); + yield* supervisor.enable(polite); + const stalledPid = pidOf(yield* supervisor.invoke(stalled.manifest.id, "ping", null)); + + const stalling = yield* supervisor + .invoke(stalled.manifest.id, "stall", null, { timeout: "1 second" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + expect((yield* Fiber.join(stalling))._tag).toBe("PluginTimeoutError"); + // Retries after the timeout are refused while the plugin still holds the call. + const retries = yield* Effect.forEach(Array.from({ length: 4 }), () => + supervisor.invoke(stalled.manifest.id, "ping", null).pipe(Effect.flip), + ); + expect(retries.map((error) => error._tag)).toEqual(Array(4).fill("PluginBusyError")); + // The slot comes back only when the unanswered process is killed. + yield* TestClock.adjust("1 second"); + yield* awaitState(supervisor, subscription, stalled.manifest.id, "backoff"); + expect(isProcessAlive(stalledPid)).toBe(false); + + // A plugin that answers the cancel frees its slot without a kill. + const politePid = pidOf(yield* supervisor.invoke(polite.manifest.id, "ping", null)); + const cooperative = yield* supervisor + .invoke(polite.manifest.id, "cooperative", null, { timeout: "1 second" }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + // A cancel the child reads with its invoke is answered before the handler runs, + // and then nothing would log "cooperative-settled". + yield* awaitLog(subscription, polite.manifest.id, "cooperative-started"); + yield* TestClock.adjust("1 second"); + expect((yield* Fiber.join(cooperative))._tag).toBe("PluginTimeoutError"); + yield* awaitLog(subscription, polite.manifest.id, "cooperative-settled"); + expect(pidOf(yield* supervisor.invoke(polite.manifest.id, "ping", null))).toBe(politePid); + }), + ); + }); + + describe("disable", () => { + it.effect("keeps stopping a plugin after the disabling caller is interrupted", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const supervisor = yield* makeSupervisor().pipe(Scope.provide(scope)); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.interrupted"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + yield* supervisor.invoke(pluginId, "holdDeactivate", null); + + const disabling = yield* supervisor + .disable(pluginId) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "deactivate-held"); + yield* Fiber.interrupt(disabling); + expect(yield* supervisor.state(pluginId)).toEqual(Option.none()); + expect(isProcessAlive(pid)).toBe(true); + + // Shutdown still owns the stopping process and kills it after the grace. + const closing = yield* Scope.close(scope, Exit.void).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(closing); + expect(isProcessAlive(pid)).toBe(false); + }), + ); + + it.effect("makes a repeated disable wait for the interrupted stop to finish", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.retried"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + yield* supervisor.invoke(pluginId, "holdDeactivate", null); + + const disabling = yield* supervisor + .disable(pluginId) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "deactivate-held"); + yield* Fiber.interrupt(disabling); + + const retry = yield* supervisor + .disable(pluginId) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.yieldNow; + expect(retry.pollUnsafe()).toBeUndefined(); + expect(isProcessAlive(pid)).toBe(true); + expect(yield* supervisor.state(pluginId)).toEqual(Option.none()); + + yield* TestClock.adjust("1 second"); + yield* Fiber.join(retry); + expect(isProcessAlive(pid)).toBe(false); + }), + ); + + it.effect("fails a call waiting for activation once disable begins", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.racing", { + entry: "deferredActivate.mjs", + }); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + + const waiting = yield* supervisor + .invoke(pluginId, "ping", null) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "activating"); + // Deactivation lets activation finish, and the process keeps serving. + const disabling = yield* supervisor + .disable(pluginId) + .pipe(Effect.forkChild({ startImmediately: true })); + expect((yield* Fiber.join(waiting))._tag).toBe("PluginStoppedError"); + + // A re-enabled plugin with the same id waits for the old process to go. + const replacement = (yield* preparePlugin("test.racing")).registration; + yield* supervisor.enable(replacement); + const early = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(early.message).toContain("its previous process is still stopping"); + + yield* TestClock.adjust("1 second"); + yield* Fiber.join(disabling); + expect(yield* supervisor.invoke(pluginId, "ping", "fresh")).toMatchObject({ + input: "fresh", + }); + }), + ); + + it.effect( + "gives calls waiting on a start its outcome after the starting call is interrupted", + () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.abandoned", { + entry: "deferredActivate.mjs", + }); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + + const starting = yield* supervisor + .invoke(pluginId, "ping", null) + .pipe(Effect.forkChild({ startImmediately: true })); + const waiting = yield* supervisor + .invoke(pluginId, "ping", null) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* awaitLog(subscription, pluginId, "activating"); + // The start outlives its caller, and the waiting call hears how it ended. + const interrupting = yield* Fiber.interrupt(starting).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("5 seconds"); + yield* Fiber.join(interrupting); + expect((yield* Fiber.join(waiting)).message).toContain("did not activate within 5000ms"); + }), + ); + }); + + describe("faults", () => { + it.effect("reports a plugin that exhausts its heap", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.oom"); + yield* supervisor.enable(registration); + + const error = yield* supervisor + .invoke(registration.manifest.id, "oom", null) + .pipe(Effect.flip); + expect(error._tag).toBe("PluginCrashedError"); + expect(error.message).toContain("ran out of memory (heap limit 64 MB)"); + const state = yield* supervisor.state(registration.manifest.id); + expect(Option.getOrUndefined(state)?._tag).toBe("backoff"); + }), + ); + + it.effect("kills a plugin that sends malformed or oversized IPC", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const malformed = (yield* preparePlugin("test.malformed")).registration; + const oversized = (yield* preparePlugin("test.oversized")).registration; + yield* supervisor.enable(malformed); + yield* supervisor.enable(oversized); + + const garbage = yield* supervisor + .invoke(malformed.manifest.id, "malformed", null) + .pipe(Effect.flip); + expect(garbage.message).toContain("sent a malformed IPC message"); + + const flood = yield* supervisor + .invoke(oversized.manifest.id, "oversizedFrame", { bytes: 70_000 }) + .pipe(Effect.flip); + expect(flood.message).toContain("sent an IPC message larger than 65536 bytes"); + }), + ); + + it.effect("stops reading a plugin that floods logs and still delivers its result", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const flood = (yield* preparePlugin("test.flood")).registration; + const calm = (yield* preparePlugin("test.calm")).registration; + yield* supervisor.enable(flood); + yield* supervisor.enable(calm); + const floodPid = pidOf(yield* supervisor.invoke(flood.manifest.id, "ping", null)); + const calmPid = pidOf(yield* supervisor.invoke(calm.manifest.id, "ping", null)); + + // About 2.4 MB of logs in one synchronous burst against a 64 KiB read budget. + const flooding = yield* supervisor + .invoke(flood.manifest.id, "flood", { count: 8000, size: 250 }) + .pipe(Effect.forkChild({ startImmediately: true })); + expect(yield* supervisor.invoke(calm.manifest.id, "ping", "meanwhile")).toEqual({ + pid: calmPid, + input: "meanwhile", + }); + expect(yield* Fiber.join(flooding)).toEqual({ pid: floodPid, done: true }); + // The child saw the server stop reading and dropped logs, not the result. + const notice = yield* awaitLogMatching(subscription, flood.manifest.id, /^Dropped \d+ /); + expect(notice).toMatch(/^Dropped \d+ log messages while the server was busy\.$/); + expect(pidOf(yield* supervisor.invoke(flood.manifest.id, "ping", null))).toBe(floodPid); + }), + ); + + it.effect("bounds call and result sizes without stopping the plugin", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.bounds"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + + const bigInput = yield* supervisor + .invoke(pluginId, "ping", "x".repeat(70_000)) + .pipe(Effect.flip); + expect(bigInput._tag).toBe("PluginPayloadTooLargeError"); + const bigResult = yield* supervisor + .invoke(pluginId, "bigResult", { bytes: 70_000 }) + .pipe(Effect.flip); + expect(bigResult._tag).toBe("PluginCallFailedError"); + expect(bigResult.message).toContain("Result exceeds 65536 bytes"); + const thrown = yield* supervisor.invoke(pluginId, "throws", null).pipe(Effect.flip); + expect(thrown.message).toBe('Plugin test.bounds failed "throws": nope'); + expect(pidOf(yield* supervisor.invoke(pluginId, "ping", null))).toBe(pid); + }), + ); + + it.effect("fails results with no JSON form without stopping the plugin", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.unserializable"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const pid = pidOf(yield* supervisor.invoke(pluginId, "ping", null)); + + for (const handler of ["functionResult", "symbolResult", "undefinedJsonResult"]) { + const error = yield* supervisor.invoke(pluginId, handler, null).pipe(Effect.flip); + expect(error._tag).toBe("PluginCallFailedError"); + expect(error.message).toBe(`Plugin ${pluginId} failed "${handler}": Result is not JSON.`); + } + // The serializer's own error is cut to the 2000 characters the server accepts. + const thrown = yield* supervisor + .invoke(pluginId, "throwingJsonResult", null) + .pipe(Effect.flip); + expect(thrown._tag).toBe("PluginCallFailedError"); + const prefix = `Plugin ${pluginId} failed "throwingJsonResult": `; + expect(thrown.message.startsWith(`${prefix}Result is not JSON: xxx`)).toBe(true); + expect(thrown.message.length).toBe(prefix.length + 2000); + expect(pidOf(yield* supervisor.invoke(pluginId, "ping", null))).toBe(pid); + }), + ); + + it.effect("keeps one plugin's crash away from another", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const crasher = (yield* preparePlugin("test.crasher")).registration; + const survivor = (yield* preparePlugin("test.survivor")).registration; + yield* supervisor.enable(crasher); + yield* supervisor.enable(survivor); + const survivorPid = pidOf(yield* supervisor.invoke(survivor.manifest.id, "ping", null)); + + const crash = yield* supervisor.invoke(crasher.manifest.id, "exit", null).pipe(Effect.flip); + expect(crash._tag).toBe("PluginCrashedError"); + expect(crash.message).toContain("exited with code 3"); + expect(pidOf(yield* supervisor.invoke(survivor.manifest.id, "ping", null))).toBe( + survivorPid, + ); + expect(yield* supervisor.state(survivor.manifest.id)).toEqual( + Option.some({ _tag: "running" }), + ); + }), + ); + + it.effect("closes the stderr of a plugin that exited while a process it started holds it", () => + Effect.gen(function* () { + const { port, received } = yield* listenOnce(); + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.stderr-holder"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + const holderPid = pidOf( + yield* supervisor.invoke(pluginId, "holdStderr", { port: `${port}` }), + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => isProcessAlive(holderPid) && process.kill(holderPid)), + ); + + // The plugin's exit is handled after the drain timeout, as its stderr never ends. + const crash = yield* supervisor.invoke(pluginId, "exit", null).pipe(Effect.flip); + expect(crash.message).toContain("exited with code 3"); + // Once the server closes its end, the holder's next write fails. + expect(yield* Effect.promise(() => received)).toBe("closed"); + }).pipe(TestClock.withLive), + ); + + it.effect("backs off after each crash and quarantines past the restart cap", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const subscription = yield* supervisor.subscribe; + const { registration } = yield* preparePlugin("test.flaky"); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + + const crashAndWait = Effect.fn("crashAndWait")(function* (delay: Duration.Input) { + yield* supervisor.invoke(pluginId, "exit", null).pipe(Effect.flip); + const backoff = yield* awaitState(supervisor, subscription, pluginId, "backoff"); + const refused = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(refused._tag).toBe("PluginUnavailableError"); + yield* TestClock.adjust(delay); + yield* awaitState(supervisor, subscription, pluginId, "idle"); + return backoff; + }); + expect((yield* crashAndWait("1 second")).failures).toBe(1); + expect((yield* crashAndWait("2 seconds")).failures).toBe(2); + + yield* supervisor.invoke(pluginId, "exit", null).pipe(Effect.flip); + const quarantined = yield* awaitState(supervisor, subscription, pluginId, "quarantined"); + expect(quarantined).toMatchObject({ failures: 3 }); + expect(quarantined.reason).toContain("exited with code 3"); + + // Quarantine never lifts on its own. + yield* TestClock.adjust("10 minutes"); + const refused = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(refused.message).toContain("quarantined after 3 failures"); + + yield* supervisor.resume(pluginId); + expect(yield* supervisor.invoke(pluginId, "ping", "back")).toMatchObject({ input: "back" }); + }), + ); + + it.effect("refuses top-level await in an entry or its imports without spending restarts", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + for (const [id, entry] of [ + ["test.async-entry", "asyncEntry.mjs"], + ["test.async-dependency", "asyncDependency.mjs"], + ] as const) { + const { registration } = yield* preparePlugin(id, { entry }); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + // More attempts than the restart cap allows still never back off or quarantine. + for (let attempt = 0; attempt <= testOptions.maxRestarts + 1; attempt++) { + const error = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(error._tag).toBe("PluginIncompatibleError"); + expect(error.message).toContain("uses top-level await"); + const state = Option.getOrUndefined(yield* supervisor.state(pluginId)); + expect(state?._tag).toBe("incompatible"); + // Refused up front until someone resumes it. + const again = yield* supervisor.invoke(pluginId, "ping", null).pipe(Effect.flip); + expect(again._tag).toBe("PluginIncompatibleError"); + yield* supervisor.resume(pluginId); + } + } + }), + ); + + it.effect("fails activation that hangs, throws, or uses unrequested proposed APIs", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const hang = (yield* preparePlugin("test.hang", { entry: "spinActivate.mjs" })) + .registration; + const refuse = (yield* preparePlugin("test.refuse", { entry: "failActivate.mjs" })) + .registration; + const stable = (yield* preparePlugin("test.stable", { proposedApi: false })).registration; + yield* Effect.forEach([hang, refuse, stable], supervisor.enable, { discard: true }); + + const hanging = yield* supervisor + .invoke(hang.manifest.id, "ping", null) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("5 seconds"); + expect((yield* Fiber.join(hanging)).message).toContain("did not activate within 5000ms"); + + const refused = yield* supervisor + .invoke(refuse.manifest.id, "ping", null) + .pipe(Effect.flip); + expect(refused.message).toContain("activation failed: activation refused"); + + const gated = yield* supervisor.invoke(stable.manifest.id, "ping", null).pipe(Effect.flip); + expect(gated.message).toContain("activation failed"); + }), + ); + + it.effect("lets plugins register t3.tool handlers and keeps other t3 names reserved", () => + Effect.gen(function* () { + const supervisor = yield* makeSupervisor(); + const { registration } = yield* preparePlugin("test.reserved", { + entry: "reservedHandlers.mjs", + }); + const pluginId = registration.manifest.id; + yield* supervisor.enable(registration); + + expect(yield* supervisor.invoke(pluginId, "t3.tool.echo", { text: "hi" })).toEqual({ + handler: "t3.tool.echo", + input: { text: "hi" }, + }); + expect(yield* supervisor.invoke(pluginId, "refusals", null)).toEqual({ + "t3.events": 'Handler names starting with "t3." are reserved.', + "t3.other": 'Handler names starting with "t3." are reserved.', + }); + }), + ); + }); +}); diff --git a/apps/server/src/plugins/PluginSupervisor.ts b/apps/server/src/plugins/PluginSupervisor.ts new file mode 100644 index 000000000000..1540b5450646 --- /dev/null +++ b/apps/server/src/plugins/PluginSupervisor.ts @@ -0,0 +1,1078 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Each plugin child gets an extra fd 3 pipe, which needs Node's child_process. +/** + * Runs each enabled plugin in its own child process. + * + * A child starts the first time a plugin is invoked, never at enable time, so + * zero enabled (or zero used) plugins means zero processes. Each child gets a + * V8 heap limit, a minimal environment, and a byte-bounded JSON line channel + * on fd 3; the server decodes everything it reads and kills a child that + * sends anything malformed or oversized. The server stops reading a child + * whose decoded-but-unhandled lines reach `maxMessageBytes`, so a chatty + * plugin is slowed down rather than buffered; the child then drops its logs, + * never its results. + * + * A call that outlives its deadline, or whose caller is interrupted, is + * cancelled cooperatively: the plugin's handler signal aborts and the child + * has `cancelGrace` to answer. A child that cannot answer (a synchronous loop + * never reads the cancel) is killed. Unexpected exits back the plugin off + * with doubling delays; more than `maxRestarts` consecutive failures park it + * in `quarantined` until `resume`. Disabling a plugin revokes its + * registration at once: in-flight calls and calls still waiting for + * activation fail, nothing new is sent, and no result produced after that + * point reaches a caller. The supervisor owns a stopping process until it has + * exited, even if the caller that disabled it goes away. + * + * A plugin can also call the server: capabilities serve host methods (such + * as `settings.get`) with `serveHostMethod`, and the supervisor runs each + * call off the child's read loop, at most `PLUGIN_MAX_HOST_CALLS` at a time + * per child. Host calls belong to the generation that made them: once it is + * revoked or its process exits, new calls are refused, calls being served + * are interrupted (disable returns only after they have ended), and no + * result reaches the child. Answers wait while the child has more than + * `maxMessageBytes` of earlier messages unread, and so does the read loop + * before it takes the next call, so a child that stops reading stops being + * read instead of growing the server's write buffer. + * + * Plugins are trusted OS-user code. The process boundary protects the + * server's availability, not its data. + */ +import * as NodeChildProcess from "node:child_process"; +import type * as NodeStream from "node:stream"; + +import type { PluginHostState, PluginId } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; + +import { + decodePluginChildMessage, + encodePluginHostMessage, + PluginHandlerName, + type PluginHostMessage, + type PluginLogLevel, +} from "./PluginIpc.ts"; +import { + DEFAULT_PLUGIN_IPC_MAX_BYTES, + PLUGIN_IPC_FD, + PLUGIN_IPC_MAX_BYTES_LIMIT, + PLUGIN_MAX_HOST_CALLS, + makeLineDecoder, + makeReadBudget, +} from "./pluginIpcFraming.ts"; +import type { PluginRegistration } from "./PluginManifestLoader.ts"; + +/** Hidden CLI command that bin.ts routes to the plugin child runtime. */ +const PLUGIN_HOST_COMMAND = "__plugin-host"; + +export interface PluginSupervisorOptions { + readonly heapLimitMb: number; + readonly maxMessageBytes: number; + readonly activationTimeout: Duration.Input; + readonly callTimeout: Duration.Input; + readonly cancelGrace: Duration.Input; + readonly stopGrace: Duration.Input; + /** In-flight calls per plugin, counting cancelled calls the plugin has not answered yet. */ + readonly maxConcurrentCalls: number; + /** Plugin processes alive at once across the environment. */ + readonly maxRunningPlugins: number; + /** Consecutive failures that still restart; one more quarantines. */ + readonly maxRestarts: number; + readonly restartBackoff: Duration.Input; + readonly maxRestartBackoff: Duration.Input; + /** A child that ran this long before failing starts a fresh failure count. */ + readonly stableUptime: Duration.Input; +} + +const defaultPluginSupervisorOptions: PluginSupervisorOptions = { + heapLimitMb: 256, + maxMessageBytes: DEFAULT_PLUGIN_IPC_MAX_BYTES, + activationTimeout: Duration.seconds(10), + callTimeout: Duration.seconds(30), + cancelGrace: Duration.seconds(2), + stopGrace: Duration.seconds(2), + maxConcurrentCalls: 16, + maxRunningPlugins: 16, + maxRestarts: 3, + restartBackoff: Duration.seconds(1), + maxRestartBackoff: Duration.seconds(30), + stableUptime: Duration.minutes(1), +}; + +export class PluginAlreadyEnabledError extends Schema.TaggedError()( + "PluginAlreadyEnabledError", + { pluginId: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is already enabled.`; + } +} + +export class PluginNotEnabledError extends Schema.TaggedError()( + "PluginNotEnabledError", + { pluginId: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is not enabled.`; + } +} + +class PluginUnavailableError extends Schema.TaggedError()( + "PluginUnavailableError", + { pluginId: Schema.String, reason: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is unavailable: ${this.reason}`; + } +} + +class PluginCrashedError extends Schema.TaggedError()("PluginCrashedError", { + pluginId: Schema.String, + reason: Schema.String, +}) { + override get message(): string { + return `Plugin ${this.pluginId} stopped unexpectedly: ${this.reason}`; + } +} + +class PluginIncompatibleError extends Schema.TaggedError()( + "PluginIncompatibleError", + { pluginId: Schema.String, reason: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} cannot run on this server: ${this.reason}`; + } +} + +class PluginStoppedError extends Schema.TaggedError()("PluginStoppedError", { + pluginId: Schema.String, +}) { + override get message(): string { + return `Plugin ${this.pluginId} was stopped before the call finished.`; + } +} + +class PluginTimeoutError extends Schema.TaggedError()("PluginTimeoutError", { + pluginId: Schema.String, + handler: Schema.String, + timeoutMs: Schema.Number, +}) { + override get message(): string { + return `Plugin ${this.pluginId} did not answer "${this.handler}" within ${this.timeoutMs}ms.`; + } +} + +class PluginCallFailedError extends Schema.TaggedError()( + "PluginCallFailedError", + { pluginId: Schema.String, handler: Schema.String, reason: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} failed "${this.handler}": ${this.reason}`; + } +} + +class PluginBusyError extends Schema.TaggedError()("PluginBusyError", { + pluginId: Schema.String, + limit: Schema.Number, +}) { + override get message(): string { + return `Plugin ${this.pluginId} already has ${this.limit} calls in flight.`; + } +} + +class PluginPayloadTooLargeError extends Schema.TaggedError()( + "PluginPayloadTooLargeError", + { pluginId: Schema.String, bytes: Schema.Number, limit: Schema.Number }, +) { + override get message(): string { + return `The call to plugin ${this.pluginId} is ${this.bytes} bytes; the limit is ${this.limit}.`; + } +} + +export type PluginInvokeError = + | PluginNotEnabledError + | PluginUnavailableError + | PluginCrashedError + | PluginIncompatibleError + | PluginStoppedError + | PluginTimeoutError + | PluginCallFailedError + | PluginBusyError + | PluginPayloadTooLargeError; + +/** A host method's refusal; `message` reaches the plugin, so it must not carry secrets. */ +export class PluginHostCallError extends Schema.TaggedError()( + "PluginHostCallError", + { message: Schema.String }, +) {} + +/** + * Serves one host method for the plugin whose registration made the call. + * The call is interrupted when that generation is revoked; a method that + * writes runs `admitted` right before committing, after any wait. + */ +export type PluginHostMethod = (call: { + readonly registration: PluginRegistration; + readonly input: Schema.Json; + /** Fails once the calling generation is revoked or its process has exited. */ + readonly admitted: Effect.Effect; +}) => Effect.Effect; + +export type PluginSupervisorEvent = + | { readonly _tag: "StateChanged"; readonly pluginId: PluginId; readonly state: PluginHostState } + | { + readonly _tag: "Log"; + readonly pluginId: PluginId; + readonly level: PluginLogLevel; + readonly message: string; + }; + +type CallOutcome = Exit.Exit; + +interface PendingCall { + readonly handler: string; + readonly deferred: Deferred.Deferred; +} + +type ChildEvent = + | { readonly _tag: "Line"; readonly line: string; readonly bytes: number } + | { readonly _tag: "Overflow" } + | { readonly _tag: "Exited"; readonly code: number | null; readonly signal: string | null } + /** fd 3 and stderr have closed, so every line and the stderr tail have been read. */ + | { readonly _tag: "Drained" }; + +interface Child { + readonly pluginId: PluginId; + readonly process: NodeChildProcess.ChildProcess; + readonly channel: NodeStream.Duplex; + readonly startedAt: number; + readonly ready: Deferred.Deferred< + void, + PluginCrashedError | PluginIncompatibleError | PluginStoppedError + >; + readonly exited: Deferred.Deferred; + readonly pending: Map; + /** Cancelled calls whose answer has not arrived yet. */ + readonly settling: Map>; + nextRequestId: number; + stopping: boolean; + /** The child reported code that cannot load here; set before it is killed. */ + incompatible: string | undefined; + killReason: string | undefined; + stderrTail: string; + /** V8 reported reaching the heap limit; its message can scroll out of the tail. */ + outOfMemory: boolean; + /** Host calls from this child still being served. */ + hostCalls: number; + /** Owns the fibers serving this child's host calls; closed on revocation and exit. */ + readonly hostWork: Scope.Closeable; + /** Set once `hostWork` starts closing; done once it has closed. */ + hostWorkEnded: Deferred.Deferred | undefined; + /** One host-call answer at a time waits for the child to read earlier messages. */ + readonly replies: Semaphore.Semaphore; + /** Set while answers wait for the child to read; logged once per episode. */ + backedUp: boolean; +} + +interface Entry { + readonly registration: PluginRegistration; + readonly pluginId: PluginId; + state: PluginHostState; + /** Set by disable; a removed entry never starts, admits, or reports again. */ + removed: boolean; + failures: number; + child: Child | undefined; + starting: Deferred.Deferred | undefined; +} + +const STDERR_TAIL_BYTES = 4096; +const STOPPED_MESSAGE = "The plugin was stopped."; +// A grandchild that inherited stderr can hold it open after the plugin exits. +const DRAIN_TIMEOUT = Duration.millis(250); + +const LOG_SEVERITY = { debug: "Debug", info: "Info", warn: "Warn", error: "Error" } as const; + +// Enough for a trusted plugin to find its tools and temp space without +// inheriting the server's credentials or Node options. +const CHILD_ENV_KEYS = [ + "PATH", + "Path", + "HOME", + "USERPROFILE", + "TMPDIR", + "TEMP", + "TMP", + "SystemRoot", + "LANG", + "LC_ALL", +]; + +const isHandlerName = Schema.is(PluginHandlerName); + +const isAlive = (child: Child) => + child.process.exitCode === null && child.process.signalCode === null; + +const describeExit = (child: Child, code: number | null, signal: string | null, heapMb: number) => { + if (child.outOfMemory) return `ran out of memory (heap limit ${heapMb} MB).`; + const base = signal ? `was killed by ${signal}` : `exited with code ${code ?? "unknown"}`; + const lastLine = child.stderrTail.trim().split("\n").at(-1)?.slice(0, 300); + return lastLine ? `${base}: ${lastLine}` : `${base}.`; +}; + +export class PluginSupervisor extends Context.Service< + PluginSupervisor, + { + /** Registers a plugin; its process starts on first invoke. */ + readonly enable: ( + registration: PluginRegistration, + ) => Effect.Effect; + /** + * Stops the plugin's process, failing in-flight calls, and forgets it. + * Idempotent: a repeat waits for a stop still in progress. + */ + readonly disable: (pluginId: PluginId) => Effect.Effect; + /** Clears backoff, quarantine, or incompatibility so the next invoke starts a fresh process. */ + readonly resume: (pluginId: PluginId) => Effect.Effect; + readonly invoke: ( + pluginId: PluginId, + handler: string, + input: Schema.Json, + options?: { readonly timeout?: Duration.Input }, + ) => Effect.Effect; + readonly state: (pluginId: PluginId) => Effect.Effect>; + /** Subscribes before returning, so no event after this point is missed. */ + readonly subscribe: Effect.Effect< + PubSub.Subscription, + never, + Scope.Scope + >; + /** Answers plugins' `method` calls with `handler` until the scope closes. One handler per method. */ + readonly serveHostMethod: ( + method: string, + handler: PluginHostMethod, + ) => Effect.Effect; + } +>()("t3/plugins/PluginSupervisor") {} + +export const make = Effect.fn("PluginSupervisor.make")(function* ( + overrides: Partial = {}, +) { + const options = { ...defaultPluginSupervisorOptions, ...overrides }; + const maxMessageBytes = Math.min(options.maxMessageBytes, PLUGIN_IPC_MAX_BYTES_LIMIT); + const activationTimeout = Duration.fromInputUnsafe(options.activationTimeout); + const callTimeout = Duration.fromInputUnsafe(options.callTimeout); + const cancelGrace = Duration.fromInputUnsafe(options.cancelGrace); + const stopGrace = Duration.fromInputUnsafe(options.stopGrace); + const restartBackoffMs = Duration.toMillis(Duration.fromInputUnsafe(options.restartBackoff)); + const maxRestartBackoffMs = Duration.toMillis( + Duration.fromInputUnsafe(options.maxRestartBackoff), + ); + const stableUptimeMs = Duration.toMillis(Duration.fromInputUnsafe(options.stableUptime)); + + const invocation = yield* resolveSelfInvocation(); + const hostEnvironment = yield* HostProcess.Environment; + const heapFlag = `--max-old-space-size=${options.heapLimitMb}`; + const childEnvironment: Record = { ELECTRON_RUN_AS_NODE: "1" }; + for (const key of CHILD_ENV_KEYS) { + const value = hostEnvironment[key]; + if (value !== undefined) childEnvironment[key] = value; + } + // The single executable takes no Node flags on its command line. + const spawnArgs = + invocation.entrypoint === undefined + ? [PLUGIN_HOST_COMMAND] + : [heapFlag, invocation.entrypoint, PLUGIN_HOST_COMMAND]; + if (invocation.entrypoint === undefined) childEnvironment.NODE_OPTIONS = heapFlag; + + const entries = new Map(); + const hostMethods = new Map(); + /** Every child process not yet exited, including ones whose plugin was disabled. */ + const children = new Set(); + /** Completes once a disabled plugin's processes have all exited. */ + const stops = new Map>(); + const events = yield* PubSub.sliding(1024); + // Child readers and timers live here and end after every child has stopped. + const fibers = yield* Scope.make(); + + const setState = (entry: Entry, state: PluginHostState) => + Effect.suspend(() => { + if (entry.removed) return Effect.void; + entry.state = state; + return PubSub.publish(events, { _tag: "StateChanged", pluginId: entry.pluginId, state }); + }); + + const kill = (child: Child, reason: string) => { + child.killReason ??= reason; + if (isAlive(child)) child.process.kill("SIGKILL"); + }; + + /** Sends a message, or says why it cannot be sent. */ + const write = (child: Child, message: PluginHostMessage) => { + const encoded = encodePluginHostMessage(message); + if (Exit.isFailure(encoded)) return { _tag: "invalid" as const }; + const bytes = Buffer.byteLength(encoded.value); + if (bytes > maxMessageBytes) return { _tag: "tooLarge" as const, bytes }; + if (!child.channel.destroyed) child.channel.write(`${encoded.value}\n`); + return undefined; + }; + + const recordFailure = Effect.fnUntraced(function* ( + entry: Entry, + startedAt: number, + reason: string, + ) { + const now = yield* Clock.currentTimeMillis; + entry.failures = now - startedAt >= stableUptimeMs ? 1 : entry.failures + 1; + if (entry.failures > options.maxRestarts) { + yield* Effect.logWarning("Plugin quarantined", { pluginId: entry.pluginId, reason }); + return yield* setState(entry, { + _tag: "quarantined", + failures: entry.failures, + reason: reason.slice(0, 1000), + }); + } + const delay = Math.min(restartBackoffMs * 2 ** (entry.failures - 1), maxRestartBackoffMs); + const backoff: PluginHostState = { + _tag: "backoff", + failures: entry.failures, + reason: reason.slice(0, 1000), + retryAt: DateTime.formatIso(DateTime.makeUnsafe(now + delay)), + }; + yield* Effect.logWarning("Plugin failed; backing off", { + pluginId: entry.pluginId, + reason, + delayMs: delay, + }); + yield* setState(entry, backoff); + yield* Effect.sleep(Duration.millis(delay)).pipe( + Effect.andThen( + Effect.suspend(() => + entry.state === backoff ? setState(entry, { _tag: "idle" }) : Effect.void, + ), + ), + Effect.forkIn(fibers, { startImmediately: true }), + ); + }); + + // Only a write that filled the stream's own buffer is followed by `drain`, so a bound + // below that buffer waits for nothing else. + const hasRoom = (child: Child) => + child.channel.destroyed || + child.channel.writableLength < maxMessageBytes || + !child.channel.writableNeedDrain; + + /** Waits until the child has read enough of what was sent to it, or can no longer read. */ + const awaitRoom = (child: Child) => + Effect.suspend(() => { + if (hasRoom(child) || child.stopping) return Effect.void; + const waiting = Effect.callback((resume) => { + const channel = child.channel; + const done = () => { + cleanup(); + resume(Effect.void); + }; + const cleanup = () => { + channel.off("drain", done); + channel.off("close", done); + }; + channel.on("drain", done); + channel.on("close", done); + if (hasRoom(child)) done(); + return Effect.sync(cleanup); + }).pipe(Effect.raceFirst(Deferred.await(child.exited))); + if (child.backedUp) return waiting; + child.backedUp = true; + return Effect.logWarning("Plugin is not reading the server's answers; waiting", { + pluginId: child.pluginId, + unreadBytes: child.channel.writableLength, + }).pipe(Effect.andThen(waiting)); + }).pipe(Effect.ensuring(Effect.sync(() => (child.backedUp = !hasRoom(child))))); + + const hostCallFailed = (requestId: number, message: string): PluginHostMessage => ({ + _tag: "HostCallFailed", + requestId, + message: message.slice(0, 2000), + }); + + /** Sends a host call's answer to a live child; a revoked generation only learns it was stopped. */ + const sendAnswer = (child: Child, requestId: number, answer: PluginHostMessage) => { + if (!isAlive(child)) return; + if (child.stopping) { + // Never wait for a stopping child; it is killed if it does not exit. + if (hasRoom(child)) write(child, hostCallFailed(requestId, STOPPED_MESSAGE)); + return; + } + const unsent = write(child, answer); + if (unsent) + write( + child, + hostCallFailed( + requestId, + unsent._tag === "tooLarge" + ? `The answer exceeds ${maxMessageBytes} bytes.` + : "The answer is not JSON.", + ), + ); + }; + + const answer = (child: Child, requestId: number, message: PluginHostMessage) => + child.replies.withPermit( + awaitRoom(child).pipe( + Effect.andThen(Effect.sync(() => sendAnswer(child, requestId, message))), + ), + ); + + const outcomeMessage = ( + requestId: number, + outcome: Exit.Exit, + ): PluginHostMessage => + Exit.isSuccess(outcome) + ? { _tag: "HostCallSucceeded", requestId, value: outcome.value } + : hostCallFailed( + requestId, + Exit.findErrorOption(outcome).pipe( + Option.match({ + onNone: () => "The server could not answer.", + onSome: (error) => error.message, + }), + ), + ); + + /** Whether host calls from `child` may still start or commit. */ + const isAdmitted = (entry: Entry, child: Child) => + !entry.removed && !child.stopping && entry.child === child && isAlive(child); + + /** Runs a host call outside the read loop, owned by the child's generation. */ + const serveHostCall = ( + entry: Entry, + child: Child, + requestId: number, + method: string, + input: Schema.Json, + ) => { + const refuse = (message: string) => + answer(child, requestId, hostCallFailed(requestId, message)); + if (!isAdmitted(entry, child)) return refuse(STOPPED_MESSAGE); + const handler = hostMethods.get(method); + if (!handler) return refuse(`This server has no method "${method}".`); + if (child.hostCalls >= PLUGIN_MAX_HOST_CALLS) + return refuse(`${PLUGIN_MAX_HOST_CALLS} calls to the server are already in flight.`); + child.hostCalls++; + const admitted = Effect.suspend(() => + isAdmitted(entry, child) + ? Effect.void + : Effect.fail(new PluginHostCallError({ message: STOPPED_MESSAGE })), + ); + return Effect.suspend(() => + handler({ registration: entry.registration, input, admitted }), + ).pipe( + Effect.exit, + Effect.flatMap((outcome) => answer(child, requestId, outcomeMessage(requestId, outcome))), + Effect.onInterrupt(() => + Effect.sync(() => sendAnswer(child, requestId, hostCallFailed(requestId, STOPPED_MESSAGE))), + ), + Effect.ensuring(Effect.sync(() => child.hostCalls--)), + Effect.forkIn(child.hostWork, { startImmediately: true }), + Effect.asVoid, + ); + }; + + const handleLine = Effect.fnUntraced(function* (entry: Entry, child: Child, line: string) { + const decoded = decodePluginChildMessage(line); + if (Exit.isFailure(decoded)) return kill(child, "sent a malformed IPC message."); + const message = decoded.value; + switch (message._tag) { + case "Ready": + yield* Deferred.succeed(child.ready, undefined); + return; + case "ActivationFailed": + // The exit fails activation, after the state reflects the failure. + return kill(child, `activation failed: ${message.message}`); + case "Incompatible": + child.incompatible = message.message; + return kill(child, message.message); + case "Succeeded": + case "Failed": { + const pending = child.pending.get(message.requestId); + if (pending) { + child.pending.delete(message.requestId); + yield* message._tag === "Succeeded" + ? Deferred.succeed(pending.deferred, message.value) + : Deferred.fail( + pending.deferred, + new PluginCallFailedError({ + pluginId: entry.pluginId, + handler: pending.handler, + reason: message.message, + }), + ); + return; + } + const settling = child.settling.get(message.requestId); + child.settling.delete(message.requestId); + if (settling) yield* Deferred.succeed(settling, undefined); + return; + } + case "Log": + yield* Effect.logWithLevel(LOG_SEVERITY[message.level])(message.message).pipe( + Effect.annotateLogs({ pluginId: entry.pluginId, pluginLogLevel: message.level }), + ); + yield* PubSub.publish(events, { + _tag: "Log", + pluginId: entry.pluginId, + level: message.level, + message: message.message, + }); + return; + case "Deactivated": + return; + case "HostCall": + // A child that does not read its answers is not read either. + yield* awaitRoom(child); + return yield* serveHostCall(entry, child, message.requestId, message.method, message.input); + } + }); + + /** Closes a child's host work; a caller that comes while it is closing waits for the end. */ + const endHostWork = (child: Child) => + Effect.suspend(() => { + if (child.hostWorkEnded) return Deferred.await(child.hostWorkEnded); + const ended = Deferred.makeUnsafe(); + child.hostWorkEnded = ended; + return Scope.close(child.hostWork, Exit.void).pipe( + Effect.ensuring(Deferred.succeed(ended, undefined)), + ); + }); + + const handleExit = Effect.fnUntraced(function* ( + entry: Entry, + child: Child, + code: number | null, + signal: string | null, + ) { + // Host work of a dead process ends before anything else can run for this plugin. + yield* endHostWork(child); + // Anything still unread from the dead process is discarded, including a stderr + // that a process it started still holds open after the drain timeout. + child.channel.destroy(); + child.process.stderr?.destroy(); + const reason = child.killReason ?? describeExit(child, code, signal, options.heapLimitMb); + const crashed = child.stopping + ? new PluginStoppedError({ pluginId: entry.pluginId }) + : child.incompatible !== undefined + ? new PluginIncompatibleError({ pluginId: entry.pluginId, reason: child.incompatible }) + : new PluginCrashedError({ pluginId: entry.pluginId, reason }); + // Settle the state first so a caller woken below already sees it. + if (entry.child === child) entry.child = undefined; + if (!entry.removed) { + if (child.stopping) yield* setState(entry, { _tag: "idle" }); + // Retrying cannot help, so this spends none of the restart budget. + else if (child.incompatible !== undefined) + yield* setState(entry, { _tag: "incompatible", reason: child.incompatible.slice(0, 1000) }); + else yield* recordFailure(entry, child.startedAt, reason); + } + yield* Deferred.fail(child.ready, crashed); + const pending = [...child.pending.values()]; + child.pending.clear(); + for (const call of pending) yield* Deferred.fail(call.deferred, crashed); + const settling = [...child.settling.values()]; + child.settling.clear(); + for (const deferred of settling) yield* Deferred.succeed(deferred, undefined); + }); + + const spawnChild = Effect.fnUntraced(function* (entry: Entry) { + const queue = yield* Queue.unbounded(); + const startedAt = yield* Clock.currentTimeMillis; + const childProcess = NodeChildProcess.spawn(invocation.command, spawnArgs, { + cwd: entry.registration.directory, + env: childEnvironment, + // fd 3 must be overlapped on Windows for the child to open it as a socket. + stdio: ["ignore", "ignore", "pipe", "overlapped"], + windowsHide: true, + }); + const channel = childProcess.stdio[PLUGIN_IPC_FD] as NodeStream.Duplex; + const child: Child = { + pluginId: entry.pluginId, + process: childProcess, + channel, + startedAt, + ready: Deferred.makeUnsafe(), + exited: Deferred.makeUnsafe(), + pending: new Map(), + settling: new Map(), + nextRequestId: 0, + stopping: false, + incompatible: undefined, + killReason: undefined, + stderrTail: "", + outOfMemory: false, + hostCalls: 0, + hostWork: Scope.forkUnsafe(fibers, "parallel"), + hostWorkEnded: undefined, + replies: Semaphore.makeUnsafe(1), + backedUp: false, + }; + // Stream errors follow the child's death; its exit carries the outcome. A + // child that drops fd 3 but lives on is killed when a call cannot settle. + channel.on("error", () => {}); + childProcess.stderr?.on("error", () => {}); + childProcess.stderr?.on("data", (chunk: Buffer) => { + const text = child.stderrTail + chunk.toString("utf8"); + child.outOfMemory ||= /heap limit|heap out of memory/i.test(text); + child.stderrTail = text.slice(-STDERR_TAIL_BYTES); + }); + const budget = makeReadBudget({ + maxBytes: maxMessageBytes, + pause: () => channel.pause(), + resume: () => channel.resume(), + }); + channel.on( + "data", + makeLineDecoder({ + maxBytes: maxMessageBytes, + onLine: (line, bytes) => { + budget.hold(bytes); + Queue.offerUnsafe(queue, { _tag: "Line", line, bytes }); + }, + onOverflow: () => Queue.offerUnsafe(queue, { _tag: "Overflow" }), + }), + ); + let openStreams = 2; + const onStreamClosed = () => { + if (--openStreams === 0) Queue.offerUnsafe(queue, { _tag: "Drained" }); + }; + channel.once("close", onStreamClosed); + if (childProcess.stderr) childProcess.stderr.once("close", onStreamClosed); + else onStreamClosed(); + children.add(child); + const onExit = (code: number | null, signal: string | null) => { + if (!children.delete(child)) return; + Deferred.doneUnsafe(child.exited, Exit.void); + Queue.offerUnsafe(queue, { _tag: "Exited", code, signal }); + }; + childProcess.on("exit", onExit); + childProcess.on("error", (error) => { + child.killReason ??= `could not start: ${error.message}`; + if (childProcess.pid === undefined) onExit(null, null); + }); + + yield* Effect.gen(function* () { + let exit: { readonly code: number | null; readonly signal: string | null } | undefined; + let drained = false; + while (true) { + const event = yield* Queue.take(queue); + if (event._tag === "Line") { + yield* handleLine(entry, child, event.line); + budget.release(event.bytes); + } else if (event._tag === "Overflow") + kill(child, `sent an IPC message larger than ${maxMessageBytes} bytes.`); + else if (event._tag === "Drained") drained = true; + else { + exit = event; + yield* Effect.sleep(DRAIN_TIMEOUT).pipe( + Effect.andThen(Queue.offer(queue, { _tag: "Drained" })), + Effect.forkIn(fibers, { startImmediately: true }), + ); + } + if (exit && drained) return yield* handleExit(entry, child, exit.code, exit.signal); + } + }).pipe(Effect.forkIn(fibers)); + return child; + }); + + /** Marks a disabled plugin's child as stopping and fails everyone waiting on it. */ + const revokeChild = (entry: Entry, child: Child) => { + child.stopping = true; + const stopped = new PluginStoppedError({ pluginId: entry.pluginId }); + Deferred.doneUnsafe(child.ready, Exit.fail(stopped)); + for (const call of child.pending.values()) + Deferred.doneUnsafe(call.deferred, Exit.fail(stopped)); + child.pending.clear(); + }; + + /** + * Starts the plugin's process if needed and waits until it has activated. + * Claiming a start through publishing its outcome is uninterruptible, so an + * interrupted starter never strands the calls waiting on its start. + */ + const ensureChild = Effect.fnUntraced(function* (entry: Entry) { + const claim = yield* Effect.sync(() => { + if (entry.child && !entry.starting) return { _tag: "running" as const, child: entry.child }; + if (entry.starting) return { _tag: "wait" as const, deferred: entry.starting }; + // A disabled plugin's process counts until it has exited. + let running = children.size; + for (const other of entries.values()) if (other.starting && !other.child) running++; + if (running >= options.maxRunningPlugins) return { _tag: "limit" as const }; + for (const other of children) + if (other.pluginId === entry.pluginId) return { _tag: "previous" as const }; + const deferred = Deferred.makeUnsafe(); + entry.starting = deferred; + return { _tag: "start" as const, deferred }; + }); + if (claim._tag === "running") return claim.child; + if (claim._tag === "wait") return yield* Effect.interruptible(Deferred.await(claim.deferred)); + if (claim._tag === "limit") + return yield* new PluginUnavailableError({ + pluginId: entry.pluginId, + reason: `${options.maxRunningPlugins} plugin processes are already running.`, + }); + if (claim._tag === "previous") + return yield* new PluginUnavailableError({ + pluginId: entry.pluginId, + reason: "its previous process is still stopping.", + }); + + const started = yield* Effect.gen(function* () { + yield* setState(entry, { _tag: "starting" }); + if (entry.removed) return yield* new PluginStoppedError({ pluginId: entry.pluginId }); + const child = yield* spawnChild(entry); + entry.child = child; + if (entry.removed) revokeChild(entry, child); + const { manifest, entryPath } = entry.registration; + write(child, { + _tag: "Activate", + pluginId: manifest.id, + version: manifest.version, + apiVersion: manifest.apiVersion, + entryPath, + proposedApi: manifest.proposedApi, + capabilities: manifest.capabilities, + maxMessageBytes, + }); + const activated = yield* Deferred.await(child.ready).pipe( + Effect.timeoutOption(activationTimeout), + ); + if (Option.isNone(activated)) { + kill(child, `did not activate within ${Duration.toMillis(activationTimeout)}ms.`); + yield* Deferred.await(child.exited); + return yield* Deferred.await(child.ready).pipe(Effect.as(child)); + } + // Ready can arrive after disable began; that generation never runs. + if (child.stopping) return yield* new PluginStoppedError({ pluginId: entry.pluginId }); + yield* setState(entry, { _tag: "running" }); + return child; + }).pipe(Effect.exit); + entry.starting = undefined; + yield* Deferred.done(claim.deferred, started); + return yield* started; + }, Effect.uninterruptible); + + const cancel = (entry: Entry, child: Child, requestId: number, handler: string) => + Effect.suspend(() => { + if (!child.pending.delete(requestId)) return Effect.void; + if (!isAlive(child)) return Effect.void; + const settled = Deferred.makeUnsafe(); + child.settling.set(requestId, settled); + write(child, { _tag: "Cancel", requestId }); + return Deferred.await(settled).pipe( + Effect.timeoutOption(cancelGrace), + Effect.flatMap((answered) => + Effect.sync(() => { + if (Option.isNone(answered) && entry.child === child) + kill( + child, + `did not stop "${handler}" within ${Duration.toMillis(cancelGrace)}ms of cancellation.`, + ); + }), + ), + Effect.forkIn(fibers, { startImmediately: true }), + Effect.asVoid, + ); + }); + + const availability = (entry: Entry) => { + const state = entry.state; + if (state._tag === "backoff") + return new PluginUnavailableError({ + pluginId: entry.pluginId, + reason: `restarting after a failure at ${state.retryAt}: ${state.reason}`, + }); + if (state._tag === "quarantined") + return new PluginUnavailableError({ + pluginId: entry.pluginId, + reason: `quarantined after ${state.failures} failures: ${state.reason}`, + }); + if (state._tag === "incompatible") + return new PluginIncompatibleError({ pluginId: entry.pluginId, reason: state.reason }); + return undefined; + }; + + const invoke: PluginSupervisor["Service"]["invoke"] = Effect.fn("PluginSupervisor.invoke")( + function* (pluginId, handler, input, invokeOptions) { + const entry = entries.get(pluginId); + if (!entry) return yield* new PluginNotEnabledError({ pluginId }); + if (!isHandlerName(handler)) + return yield* new PluginCallFailedError({ + pluginId, + handler, + reason: "the handler name is invalid.", + }); + const unavailable = availability(entry); + if (unavailable) return yield* unavailable; + const child = yield* ensureChild(entry); + const timeout = Duration.fromInputUnsafe(invokeOptions?.timeout ?? callTimeout); + + const call = yield* Effect.sync(() => { + if (entry.removed || child.stopping) return new PluginStoppedError({ pluginId }); + if (Deferred.isDoneUnsafe(child.exited)) + return new PluginUnavailableError({ pluginId, reason: "its process just stopped." }); + // A cancelled call holds its slot until the plugin answers it or exits. + if (child.pending.size + child.settling.size >= options.maxConcurrentCalls) + return new PluginBusyError({ pluginId, limit: options.maxConcurrentCalls }); + const requestId = ++child.nextRequestId; + const deferred = Deferred.makeUnsafe(); + child.pending.set(requestId, { handler, deferred }); + const unsent = write(child, { _tag: "Invoke", requestId, handler, input }); + if (unsent) child.pending.delete(requestId); + if (unsent?._tag === "tooLarge") + return new PluginPayloadTooLargeError({ + pluginId, + bytes: unsent.bytes, + limit: maxMessageBytes, + }); + if (unsent) + return new PluginCallFailedError({ pluginId, handler, reason: "the input is not JSON." }); + return { requestId, deferred }; + }); + if (!("requestId" in call)) return yield* call; + + const outcome: Option.Option = yield* Deferred.await(call.deferred).pipe( + Effect.exit, + Effect.timeoutOption(timeout), + Effect.onInterrupt(() => cancel(entry, child, call.requestId, handler)), + ); + if (Option.isNone(outcome)) { + yield* cancel(entry, child, call.requestId, handler); + return yield* new PluginTimeoutError({ + pluginId, + handler, + timeoutMs: Duration.toMillis(timeout), + }); + } + return yield* outcome.value; + }, + ); + + /** Deactivates a removed plugin's child, killing it after `stopGrace`, and waits for its exit. */ + const stopEntry = Effect.fnUntraced(function* (entry: Entry) { + // Only reachable between claiming a start and spawning; the start then fails fast. + if (!entry.child && entry.starting) yield* Deferred.await(entry.starting).pipe(Effect.ignore); + const child = entry.child; + if (!child) return; + // A process that already exited may still be ending its host work. + if (Deferred.isDoneUnsafe(child.exited)) return yield* endHostWork(child); + revokeChild(entry, child); + // The revoked generation's host work ends before its plugin is asked to deactivate. + yield* endHostWork(child); + write(child, { _tag: "Deactivate" }); + const exited = yield* Deferred.await(child.exited).pipe(Effect.timeoutOption(stopGrace)); + if (Option.isNone(exited)) { + kill(child, `did not stop within ${Duration.toMillis(stopGrace)}ms.`); + yield* Deferred.await(child.exited); + } + }); + + const disable = Effect.fn("PluginSupervisor.disable")(function* (pluginId: PluginId) { + const entry = entries.get(pluginId); + const previous = stops.get(pluginId); + if (!entry) { + // A retry after an interrupted disable waits for the same stop. + if (previous) yield* Deferred.await(previous); + return; + } + entries.delete(pluginId); + entry.removed = true; + if (entry.child) revokeChild(entry, entry.child); + // The stop belongs to the supervisor: interrupting this caller only stops the wait. + // It also covers an earlier generation still stopping, so done means every process exited. + const stopped = Deferred.makeUnsafe(); + stops.set(pluginId, stopped); + yield* Effect.all([stopEntry(entry), previous ? Deferred.await(previous) : Effect.void], { + concurrency: "unbounded", + discard: true, + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (stops.get(pluginId) === stopped) stops.delete(pluginId); + Deferred.doneUnsafe(stopped, Exit.void); + }), + ), + Effect.forkIn(fibers, { startImmediately: true, uninterruptible: true }), + ); + yield* Deferred.await(stopped); + }); + + // Closing `fibers` waits for every stop in progress; anything left is killed. + yield* Effect.addFinalizer(() => + Effect.forEach([...entries.keys()], disable, { concurrency: "unbounded", discard: true }).pipe( + Effect.andThen(Scope.close(fibers, Exit.void)), + Effect.andThen( + Effect.forEach( + [...children], + (child) => { + kill(child, "the server stopped."); + return Deferred.await(child.exited); + }, + { discard: true }, + ), + ), + ), + ); + + return PluginSupervisor.of({ + enable: Effect.fn("PluginSupervisor.enable")(function* (registration) { + const pluginId = registration.manifest.id; + if (entries.has(pluginId)) return yield* new PluginAlreadyEnabledError({ pluginId }); + const entry: Entry = { + registration, + pluginId, + state: { _tag: "idle" }, + removed: false, + failures: 0, + child: undefined, + starting: undefined, + }; + entries.set(pluginId, entry); + yield* setState(entry, entry.state); + }), + disable, + resume: Effect.fn("PluginSupervisor.resume")(function* (pluginId) { + const entry = entries.get(pluginId); + if (!entry) return yield* new PluginNotEnabledError({ pluginId }); + if ( + entry.state._tag !== "backoff" && + entry.state._tag !== "quarantined" && + entry.state._tag !== "incompatible" + ) + return; + entry.failures = 0; + yield* setState(entry, { _tag: "idle" }); + }), + invoke, + state: (pluginId) => Effect.sync(() => Option.fromUndefinedOr(entries.get(pluginId)?.state)), + subscribe: PubSub.subscribe(events), + serveHostMethod: (method, handler) => + Effect.acquireRelease( + Effect.sync(() => { + if (hostMethods.has(method)) throw new Error(`Host method ${method} is already served.`); + hostMethods.set(method, handler); + }), + () => Effect.sync(() => hostMethods.delete(method)), + ), + }); +}); + +export const layer = (overrides: Partial = {}) => + Layer.effect(PluginSupervisor, make(overrides)); diff --git a/apps/server/src/plugins/PluginTools.test.ts b/apps/server/src/plugins/PluginTools.test.ts new file mode 100644 index 000000000000..99811ea6fffc --- /dev/null +++ b/apps/server/src/plugins/PluginTools.test.ts @@ -0,0 +1,477 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + PLUGIN_TOOL_LIMITS, + PluginInstallation, + ThreadId, + type PluginToolDeclaration, + type PluginToolError, + type PluginToolsListResult, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as PluginCatalog from "./PluginCatalog.ts"; +import * as PluginSupervisor from "./PluginSupervisor.ts"; +import { jsonBytes } from "./pluginToolDeclarations.ts"; +import * as PluginTools from "./PluginTools.ts"; + +// Children run the real CLI entry, which routes `__plugin-host` to the child runtime. +const BIN_PATH = `${import.meta.dirname}/../bin.ts`; +const FIXTURE = `${import.meta.dirname}/testFixtures/toolsPlugin`; + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const context = { + environmentId: EnvironmentId.make("environment-tools"), + threadId: ThreadId.make("thread-tools"), +}; + +/** A real supervisor, catalogue, and tool service in `scope`, as one server start would run them. */ +const start = Effect.fn("start")(function* (scope: Scope.Scope) { + const supervisor = yield* PluginSupervisor.make({ + heapLimitMb: 64, + activationTimeout: "10 seconds", + stopGrace: "1 second", + }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + Effect.provideService(Scope.Scope, scope), + ); + const catalog = yield* PluginCatalog.make().pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor), + Effect.provideService(Scope.Scope, scope), + ); + const tools = yield* PluginTools.make.pipe( + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + ); + /** Adds, approves, and enables a plugin directory. */ + const install = Effect.fn("install")(function* (directory: string) { + const { installation } = yield* catalog.add({ directory }); + const installationId = installation.installationId; + yield* catalog.consent({ installationId, digest: installation.source!.digest }); + return (yield* catalog.enable({ installationId })).installation; + }); + return { supervisor, catalog, tools, install }; +}); + +/** A scoped copy of the committed fixture, so tests never share a plugin directory. */ +const copyFixture = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = path.join( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-tools-" }), + "plugin", + ); + yield* fs.copy(FIXTURE, directory); + return directory; +}); + +/** A scoped tool plugin that declares `tools` and answers every call with "pong". */ +const declaringPlugin = Effect.fn("declaringPlugin")(function* ( + id: string, + tools: ReadonlyArray>, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = path.join( + yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-tools-" }), + "plugin", + ); + yield* fs.makeDirectory(directory); + yield* fs.writeFileString( + path.join(directory, "main.mjs"), + [ + `export function activate(context) {`, + ...tools.map((tool) => ` context.proposed.handle("t3.tool.${tool.name}", () => "pong");`), + `}`, + ``, + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ + id, + name: id, + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + capabilities: ["tools"], + proposedApi: true, + tools, + }), + ); + return directory; +}); + +const tool = (name: string, description: string) => ({ + name, + description, + inputSchema: { type: "object", additionalProperties: false }, + sideEffect: "read", +}); + +const reasonOf = (error: PluginToolError) => error.reason; + +const withDatabase = (effect: Effect.Effect) => + effect.pipe(Effect.provide(SqlitePersistence.layerMemory)); + +const DIGEST = `sha256:${"0".repeat(64)}`; +const decodeInstallation = Schema.decodeUnknownSync(PluginInstallation); + +/** + * An in-memory catalogue of `count` enabled one-tool plugins. It records which + * plugins had their declarations read and how often the catalogue was listed. + */ +const inventory = (count: number) => { + const prepared = new Set(); + const counts = { lists: 0, revision: 0 }; + const rows = Array.from({ length: count }, (_, index) => { + const id = `test.p${String(index).padStart(4, "0")}`; + const row = decodeInstallation({ + installationId: `installation-${index}`, + generation: 1, + directory: `/plugins/${id}`, + manifest: { id, name: id, version: "1.0.0", capabilities: ["tools"], proposedApi: true }, + source: { digest: DIGEST, files: 1, bytes: 1 }, + problem: null, + inspectedAt: "2026-01-01T00:00:00.000Z", + consent: { digest: DIGEST, capabilities: ["tools"], grantedAt: "2026-01-01T00:00:00.000Z" }, + enabled: true, + addedAt: "2026-01-01T00:00:00.000Z", + }); + const declaration: PluginToolDeclaration = { + name: "ping", + description: "x".repeat(1_500), + sideEffect: "read", + openWorld: false, + get inputSchema() { + prepared.add(id); + return { type: "object", additionalProperties: false }; + }, + }; + return { ...row, manifest: { ...row.manifest!, tools: [declaration] } }; + }); + const unused = () => Effect.die("not used by PluginTools"); + const catalog = PluginCatalog.PluginCatalog.of({ + list: Effect.sync(() => { + counts.lists++; + return { installations: rows }; + }), + revision: Effect.sync(() => counts.revision), + subscribe: Stream.empty, + add: unused, + refresh: unused, + consent: unused, + enable: unused, + disable: unused, + remove: unused, + resume: unused, + replace: unused, + settleReplace: unused, + changeFiles: unused, + invoke: () => Effect.succeed("pong"), + }); + return { rows, prepared, counts, catalog }; +}; + +it.layer(NodeServices.layer)("PluginTools", (it) => { + describe("granted plugins", () => { + it.effect("lists declared tools without starting the plugin and calls them in context", () => + withDatabase( + Effect.gen(function* () { + const { catalog, tools, install } = yield* start(yield* Scope.Scope); + const installation = yield* install(yield* copyFixture); + const grants = yield* tools.grants; + expect(grants).toEqual([{ installationId: installation.installationId, generation: 1 }]); + + const listed = yield* tools.list(grants); + expect(listed.tools.map((tool) => tool.tool)).toEqual([ + "test.tools/word_count", + "test.tools/echo_context", + "test.tools/wait_for_cancel", + "test.tools/big_result", + ]); + expect(listed.tools[0]).toMatchObject({ + tool: "test.tools/word_count", + plugin: { id: "test.tools", name: "Tools fixture" }, + title: "Count words", + description: "Count the words in a text.", + // Exactly what the manifest declares. + inputSchema: { + type: "object", + properties: { text: { type: "string", maxLength: 10000 } }, + required: ["text"], + additionalProperties: false, + }, + sideEffect: "read", + openWorld: false, + }); + expect(listed.tools[2]).toMatchObject({ sideEffect: "write", openWorld: true }); + expect(listed).not.toHaveProperty("nextCursor"); + expect(listed.notInThisSession).toEqual([]); + const hostState = Effect.map( + catalog.list, + (snapshot) => snapshot.installations[0]?.hostState, + ); + // Listing reads the consented manifest; only a call starts the plugin. + expect(yield* hostState).toEqual({ _tag: "idle" }); + + const call = (tool: string, input: unknown) => + tools.call(grants, { tool, input, context }); + expect(yield* call("test.tools/word_count", { text: "one two three" })).toEqual({ + words: 3, + }); + expect((yield* hostState)?._tag).toBe("running"); + // The context comes from the session, never from the input. + expect(yield* call("test.tools/echo_context", {})).toEqual({ input: {}, context }); + + const failures = yield* Effect.forEach( + [ + ["test.tools/word_count", { text: 5 }], + ["test.tools/word_count", {}], + ["test.tools/word_count", { text: "x".repeat(10_001) }], + ["test.tools/word_count", { text: "closed", extra: 1 }], + ["test.tools/echo_context", { context: { threadId: "other" } }], + ["test.tools/nope", {}], + ["word_count", {}], + ["test.tools/big_result", { length: 70_000 }], + ] as const, + ([tool, input]) => call(tool, input).pipe(Effect.flip, Effect.map(reasonOf)), + ); + expect(failures).toEqual([ + "invalid-input", + "invalid-input", + "invalid-input", + "invalid-input", + "invalid-input", + "unknown-tool", + "unknown-tool", + "result-too-large", + ]); + }), + ), + ); + + it.effect("refuses a disabled plugin at once, also mid-call, and its next registration", () => + withDatabase( + Effect.gen(function* () { + const { supervisor, catalog, tools, install } = yield* start(yield* Scope.Scope); + const installation = yield* install(yield* copyFixture); + const installationId = installation.installationId; + const grants = yield* tools.grants; + const events = yield* supervisor.subscribe; + + const waiting = yield* tools + .call(grants, { tool: "test.tools/wait_for_cancel", input: {}, context }) + .pipe(Effect.flip, Effect.forkChild); + yield* Stream.fromSubscription(events).pipe( + Stream.filter((event) => event._tag === "Log" && event.message === "wait-started"), + Stream.runHead, + ); + yield* catalog.disable({ installationId }); + const revoked = yield* Fiber.join(waiting); + expect(revoked.reason).toBe("unavailable"); + + expect(yield* tools.list(grants)).toEqual({ tools: [], notInThisSession: [] }); + const disabled = yield* tools + .call(grants, { tool: "test.tools/word_count", input: { text: "a" }, context }) + .pipe(Effect.flip); + expect(disabled.reason).toBe("unavailable"); + + // A re-enable is a new registration: the old session's grant does not reach it. + yield* catalog.enable({ installationId }); + const stale = yield* tools.list(grants); + expect(stale.tools).toEqual([]); + expect(stale.notInThisSession).toEqual([{ id: "test.tools", name: "Tools fixture" }]); + const notGranted = yield* tools + .call(grants, { tool: "test.tools/word_count", input: { text: "a" }, context }) + .pipe(Effect.flip); + expect(notGranted.reason).toBe("not-granted"); + const fresh = yield* tools.grants; + expect(fresh).toEqual([{ installationId, generation: 2 }]); + expect( + yield* tools.call(fresh, { + tool: "test.tools/word_count", + input: { text: "a b" }, + context, + }), + ).toEqual({ words: 2 }); + }), + ), + ); + it.effect("refuses a plugin once the catalogue finds its files changed", () => + withDatabase( + Effect.gen(function* () { + const { supervisor, catalog, tools, install } = yield* start(yield* Scope.Scope); + const fs = yield* FileSystem.FileSystem; + const directory = yield* copyFixture; + const edit = (note: string) => + fs.writeFileString(`${directory}/main.mjs`, `// ${note}\n`, { flag: "a" }); + const { installationId } = yield* install(directory); + + // Idle: the bytes are checked before a call would start a process. + const grants = yield* tools.grants; + yield* edit("changed while idle"); + const idle = yield* tools + .call(grants, { tool: "test.tools/word_count", input: { text: "a" }, context }) + .pipe(Effect.flip); + expect(idle.reason).toBe("unavailable"); + expect(yield* tools.list(grants)).toEqual({ tools: [], notInThisSession: [] }); + + // Running: a refresh finds the change and cancels the call in flight. + const [changed] = (yield* catalog.list).installations; + yield* catalog.consent({ installationId, digest: changed!.source!.digest }); + yield* catalog.enable({ installationId }); + const fresh = yield* tools.grants; + const events = yield* supervisor.subscribe; + const waiting = yield* tools + .call(fresh, { tool: "test.tools/wait_for_cancel", input: {}, context }) + .pipe(Effect.flip, Effect.forkChild); + yield* Stream.fromSubscription(events).pipe( + Stream.filter((event) => event._tag === "Log" && event.message === "wait-started"), + Stream.runHead, + ); + yield* edit("changed while running"); + yield* catalog.refresh({ installationId }); + expect((yield* Fiber.join(waiting)).reason).toBe("unavailable"); + expect(yield* tools.list(fresh)).toEqual({ tools: [], notInThisSession: [] }); + }), + ), + ); + }); + + describe("declarations", () => { + it.effect("refuses a manifest whose tools the host cannot enforce when it is added", () => + withDatabase( + Effect.gen(function* () { + const { catalog } = yield* start(yield* Scope.Scope); + const refused = (id: string, tools: ReadonlyArray>) => + declaringPlugin(id, tools).pipe( + Effect.flatMap((directory) => catalog.add({ directory })), + Effect.flip, + Effect.map((error) => error.message), + ); + expect( + yield* refused("test.pattern", [ + { + ...tool("ping", "Uses a pattern."), + inputSchema: { + type: "object", + properties: { id: { type: "string", pattern: "^(a+)+$" } }, + }, + }, + ]), + ).toContain("#/properties/id/pattern: this keyword is not supported."); + expect( + yield* refused("test.duplicate", [tool("ping", "One."), tool("ping", "Two.")]), + ).toContain("it declares the tool ping twice."); + expect( + yield* refused("test.scalar", [ + { ...tool("ping", "Takes a string."), inputSchema: { type: "string" } }, + ]), + ).toContain('the root must be "object"'); + }), + ), + ); + + it.effect("pages whole plugins by id within the byte limit and starts none of them", () => + withDatabase( + Effect.gen(function* () { + const { catalog, tools, install } = yield* start(yield* Scope.Scope); + // About 21 KB each to list, so a 64 KiB page holds two of them at most. + const large = (id: string) => + declaringPlugin( + id, + Array.from({ length: 10 }, (_, index) => tool(`tool_${index}`, "é".repeat(1_000))), + ); + for (const id of ["test.large-c", "test.large-a", "test.large-b"]) + yield* install(yield* large(id)); + const grants = yield* tools.grants; + // Enabled after the snapshot: listed by name only, under notInThisSession. + yield* install(yield* declaringPlugin("test.late", [tool("ping", "Late.")])); + + const pages: Array = []; + let cursor: string | undefined; + do { + const page: PluginToolsListResult = yield* tools.list( + grants, + cursor === undefined ? {} : { cursor }, + ); + pages.push(page); + cursor = page.nextCursor; + } while (cursor !== undefined); + + for (const page of pages) + expect(jsonBytes(page)).toBeLessThanOrEqual(PLUGIN_TOOL_LIMITS.maxListBytes); + expect(pages.length).toBeGreaterThan(1); + const order = pages.flatMap((page) => [ + ...new Set(page.tools.map((listing) => listing.plugin.id)), + ...page.notInThisSession.map((plugin) => `late:${plugin.id}`), + ]); + expect(order).toEqual(["test.large-a", "test.large-b", "test.large-c", "late:test.late"]); + expect(pages.flatMap((page) => page.tools)).toHaveLength(30); + + const one = yield* tools.list(grants, { plugin: "test.large-b" }); + expect(one.tools).toHaveLength(10); + expect(one).not.toHaveProperty("nextCursor"); + expect(jsonBytes(one)).toBeLessThanOrEqual(PLUGIN_TOOL_LIMITS.maxListBytes); + + const states = (yield* catalog.list).installations.map((row) => row.hostState?._tag); + expect(states).toEqual(["idle", "idle", "idle", "idle"]); + }), + ), + ); + + it.effect("prepares only the plugins a page shows, however many are installed", () => + Effect.gen(function* () { + const { rows, prepared, counts, catalog } = inventory(1_000); + const tools = yield* PluginTools.make.pipe( + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + ); + const grants = yield* tools.grants; + expect(grants).toHaveLength(1_000); + expect(prepared.size).toBe(0); + + const one = yield* tools.list(grants, { plugin: "test.p0500" }); + expect(one.tools.map((listing) => listing.tool)).toEqual(["test.p0500/ping"]); + expect([...prepared]).toEqual(["test.p0500"]); + + prepared.clear(); + const first = yield* tools.list(grants); + const shown = first.tools.map((listing) => listing.plugin.id); + expect(first.nextCursor).toBe(shown.at(-1)); + expect(shown.length).toBeLessThan(100); + // The page, and the one plugin after it that did not fit. + expect([...prepared].toSorted()).toEqual( + [...shown, `test.p${String(shown.length).padStart(4, "0")}`].toSorted(), + ); + + prepared.clear(); + const second = yield* tools.list(grants, { cursor: first.nextCursor! }); + expect(second.tools[0]?.plugin.id).toBe(`test.p${String(shown.length).padStart(4, "0")}`); + expect(prepared.size).toBe(second.tools.length); + expect(yield* tools.call(grants, { tool: "test.p0999/ping", input: {}, context })).toBe( + "pong", + ); + // Requests read the catalogue only when its records changed. + expect(counts.lists).toBe(1); + + rows[999] = { ...rows[999]!, enabled: false }; + counts.revision++; + const refused = yield* tools + .call(grants, { tool: "test.p0999/ping", input: {}, context }) + .pipe(Effect.flip); + expect(refused.reason).toBe("unavailable"); + expect((yield* tools.list(grants, { plugin: "test.p0999" })).tools).toEqual([]); + expect(counts.lists).toBe(2); + }), + ); + }); +}); diff --git a/apps/server/src/plugins/PluginTools.ts b/apps/server/src/plugins/PluginTools.ts new file mode 100644 index 000000000000..0481b547fcd7 --- /dev/null +++ b/apps/server/src/plugins/PluginTools.ts @@ -0,0 +1,310 @@ +/** + * Tools that enabled plugins offer to agents, behind the two fixed MCP tools + * `plugin_tools_list` and `plugin_tool_call`. + * + * Tools are declared in the consented manifest, so listing them reads the + * catalogue and never starts a plugin; only a call does. A provider session + * holds a snapshot of grants, taken when its MCP credential was prepared: the + * tool plugins enabled at that moment, each with its registration generation. + * Every list and call intersects that snapshot with the catalogue as it is + * now, so a plugin disabled, removed, or re-registered since then is refused + * at once, and a plugin enabled later waits for a new session. Calls are + * pinned to the granted generation all the way into the supervisor, and + * disabling a plugin fails its calls in flight. + */ +import { + PLUGIN_TOOL_LIMITS, + PLUGIN_TOOLS_CAPABILITY, + type PluginInstallation, + type PluginInstallationId, + PluginToolError, + type PluginToolListing, + type PluginToolsListResult, + parseQualifiedPluginToolName, + pluginInstallationStatus, + pluginToolHandlerName, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { PluginCatalog } from "./PluginCatalog.ts"; +import { + jsonBytes, + preparePluginTools, + type PreparedPluginTools, +} from "./pluginToolDeclarations.ts"; + +/** One tool plugin a session may use: the registration that was enabled when it was prepared. */ +export interface PluginToolGrant { + readonly installationId: PluginInstallationId; + readonly generation: number; +} + +type Plugin = PluginToolListing["plugin"]; + +/** A live tool plugin, as the sorted index holds it. */ +interface Indexed { + readonly plugin: Plugin; + readonly installation: PluginInstallation; + /** Its size under notInThisSession, separator included. */ + readonly nameBytes: number; +} + +/** The live tool plugins by id, rebuilt only when the catalogue's records change. */ +interface Index { + readonly revision: number; + readonly ids: ReadonlyArray; + readonly byId: ReadonlyMap; +} + +const MAX_REASON_LENGTH = 500; +/** The page envelope with the longest cursor a plugin id allows. */ +const LIST_ENVELOPE_BYTES = jsonBytes({ + tools: [], + notInThisSession: [], + nextCursor: "x".repeat(128), +}); + +const decodeJsonObject = Schema.decodeUnknownExit(Schema.Record(Schema.String, Schema.Json)); +const cut = (text: string) => + text.length > MAX_REASON_LENGTH ? `${text.slice(0, MAX_REASON_LENGTH)}…` : text; + +const toolError = (reason: string, message: string) => + new PluginToolError({ reason, message: cut(message) }); + +const pluginOf = (installation: PluginInstallation): Plugin | undefined => + installation.manifest === null + ? undefined + : { id: installation.manifest.id, name: installation.manifest.name }; + +/** Enabled now, declaring tools, with consent that covers the tools capability. */ +const offersTools = (installation: PluginInstallation) => + pluginInstallationStatus(installation) === "enabled" && + (installation.manifest?.tools?.length ?? 0) > 0 && + installation.consent?.capabilities.includes(PLUGIN_TOOLS_CAPABILITY) === true; + +interface PluginToolCallRequest { + readonly tool: string; + readonly input: unknown; + readonly context: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId }; +} + +export class PluginTools extends Context.Service< + PluginTools, + { + /** The tool plugins enabled right now, for a session's credential to hold. */ + readonly grants: Effect.Effect>; + /** One page of the tools of granted plugins still enabled under the same registration. */ + readonly list: ( + grants: ReadonlyArray, + options?: { readonly plugin?: string; readonly cursor?: string }, + ) => Effect.Effect; + /** Validates `input` against the tool's schema and calls it under the granted registration. */ + readonly call: ( + grants: ReadonlyArray, + request: PluginToolCallRequest, + ) => Effect.Effect; + } +>()("t3/plugins/PluginTools") {} + +export const make = Effect.gen(function* () { + const catalog = yield* PluginCatalog; + // A registration's manifest cannot change: its bytes are checked before every fresh process. + const prepared = new Map< + PluginInstallationId, + { readonly generation: number; readonly tools: PreparedPluginTools | undefined } + >(); + let index: Index = { revision: -1, ids: [], byId: new Map() }; + // A session's grants are fixed for its credential, so their lookup is built once. + const grantLookups = new WeakMap< + ReadonlyArray, + ReadonlyMap + >(); + + /** + * The live tool plugins. Reading the catalogue and sorting happen once per + * change to its records, never per request, so a request's own work is the + * page it returns. + */ + const current = Effect.gen(function* () { + const revision = yield* catalog.revision; + if (revision === index.revision) return index; + const byId = new Map(); + for (const installation of (yield* catalog.list).installations) { + const plugin = pluginOf(installation); + // Ids are unique among registered plugins; the oldest installation wins otherwise. + if (plugin !== undefined && offersTools(installation) && !byId.has(plugin.id)) + byId.set(plugin.id, { plugin, installation, nameBytes: jsonBytes(plugin) + 1 }); + } + const live = new Map( + [...byId.values()].map(({ installation }) => [installation.installationId, installation]), + ); + for (const [installationId, cached] of prepared) + if (live.get(installationId)?.generation !== cached.generation) + prepared.delete(installationId); + // Reading `revision` first means a change during the read rebuilds again next time. + index = { revision, ids: [...byId.keys()].toSorted(), byId }; + return index; + }); + + const isGranted = (grants: ReadonlyArray, installation: PluginInstallation) => { + let lookup = grantLookups.get(grants); + if (lookup === undefined) { + lookup = new Map(grants.map((grant) => [grant.installationId, grant.generation])); + grantLookups.set(grants, lookup); + } + return lookup.get(installation.installationId) === installation.generation; + }; + + /** The installation's declared tools, prepared once per registration. */ + const toolsOf = Effect.fnUntraced(function* (installation: PluginInstallation, plugin: Plugin) { + const cached = prepared.get(installation.installationId); + if (cached?.generation === installation.generation) return cached.tools; + const result = preparePluginTools(plugin, installation.manifest?.tools ?? []); + // The loader refuses a manifest whose tools do not prepare, so this is not expected. + if ("problem" in result) + yield* Effect.logWarning("Plugin tools could not be prepared", { + installationId: installation.installationId, + problem: result.problem, + }); + const tools = "problem" in result ? undefined : result; + prepared.set(installation.installationId, { generation: installation.generation, tools }); + return tools; + }); + + const grants = current.pipe( + Effect.map(({ byId }) => + [...byId.values()].map(({ installation: { installationId, generation } }) => ({ + installationId, + generation, + })), + ), + ); + + const list = Effect.fn("PluginTools.list")(function* ( + grants: ReadonlyArray, + options?: { readonly plugin?: string; readonly cursor?: string }, + ) { + const { ids, byId } = yield* current; + const cursor = options?.cursor; + const only = options?.plugin; + let from = cursor === undefined ? 0 : firstAfter(ids, cursor); + let to = ids.length; + if (only !== undefined) { + const at = firstAfter(ids, only) - 1; + [from, to] = at >= from && ids[at] === only ? [at, at + 1] : [0, 0]; + } + + // Every plugin fits a page on its own (the loader bounds its listing), so each page + // makes progress and the whole result, envelope included, stays within the limit. + // Only the plugins on the page, and the one after it, are prepared. + const result: { tools: Array; notInThisSession: Array } = { + tools: [], + notInThisSession: [], + }; + let budget = PLUGIN_TOOL_LIMITS.maxListBytes - LIST_ENVELOPE_BYTES; + let last: string | undefined; + for (let position = from; position < to; position++) { + const id = ids[position]!; + const { plugin, installation, nameBytes } = byId.get(id)!; + const granted = isGranted(grants, installation); + const tools = granted ? yield* toolsOf(installation, plugin) : undefined; + if (granted && tools === undefined) continue; + const bytes = tools?.listingBytes ?? nameBytes; + if (bytes > budget) return { ...result, ...(last === undefined ? {} : { nextCursor: last }) }; + budget -= bytes; + last = id; + if (tools === undefined) result.notInThisSession.push(plugin); + else for (const tool of tools.tools.values()) result.tools.push(tool.listing); + } + return result satisfies PluginToolsListResult; + }); + + const call = Effect.fn("PluginTools.call")(function* ( + grants: ReadonlyArray, + request: PluginToolCallRequest, + ) { + const parsed = parseQualifiedPluginToolName(request.tool); + if (Option.isNone(parsed)) + return yield* toolError( + "unknown-tool", + `${request.tool} is not a plugin tool name. Use a tool value from plugin_tools_list.`, + ); + const { pluginId, name } = parsed.value; + const live = (yield* current).byId.get(pluginId); + if (live === undefined) + return yield* toolError("unavailable", `Plugin ${pluginId} is not enabled.`); + const { plugin, installation } = live; + if (!isGranted(grants, installation)) + return yield* toolError( + "not-granted", + `Plugin ${pluginId} was enabled after this session started. Start a new session to use its tools.`, + ); + const tool = (yield* toolsOf(installation, plugin))?.tools.get(name); + if (tool === undefined) + return yield* toolError("unknown-tool", `Plugin ${pluginId} has no tool named ${name}.`); + const validated = tool.validate(request.input ?? {}); + if (Exit.isFailure(validated)) + return yield* toolError( + "invalid-input", + `The input does not match ${request.tool}'s inputSchema: ${Option.match( + Exit.findErrorOption(validated), + { onNone: () => "unknown error", onSome: (error) => error.message }, + )}`, + ); + const input = decodeJsonObject(validated.value); + if (Exit.isFailure(input)) + return yield* toolError("invalid-input", "The input must be a JSON object."); + const value = yield* catalog + .invoke( + installation.installationId, + pluginToolHandlerName(name), + { input: input.value, context: request.context }, + { timeout: `${tool.timeoutSeconds} seconds`, generation: installation.generation }, + ) + .pipe( + Effect.mapError((error) => { + switch (error._tag) { + case "PluginCatalogError": + case "PluginStoppedError": + case "PluginNotEnabledError": + case "PluginUnavailableError": + case "PluginIncompatibleError": + return toolError("unavailable", error.message); + case "PluginTimeoutError": + return toolError("timeout", error.message); + default: + return toolError("failed", error.message); + } + }), + ); + if (jsonBytes(value) > PLUGIN_TOOL_LIMITS.maxResultBytes) + return yield* toolError( + "result-too-large", + `${request.tool} returned more than ${PLUGIN_TOOL_LIMITS.maxResultBytes} bytes.`, + ); + return value; + }); + + return PluginTools.of({ grants, list, call }); +}); + +/** The position of the first id after `cursor` in sorted `ids`. */ +const firstAfter = (ids: ReadonlyArray, cursor: string) => { + let low = 0; + let high = ids.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (ids[middle]! <= cursor) low = middle + 1; + else high = middle; + } + return low; +}; + +export const layer = Layer.effect(PluginTools, make); diff --git a/apps/server/src/plugins/PluginViews.test.ts b/apps/server/src/plugins/PluginViews.test.ts new file mode 100644 index 000000000000..59173b2e4ac3 --- /dev/null +++ b/apps/server/src/plugins/PluginViews.test.ts @@ -0,0 +1,513 @@ +// @effect-diagnostics nodeBuiltinImport:off - the test hashes view bytes the way PluginViews does. +import * as NodeCrypto from "node:crypto"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import type { PluginViewsSnapshot } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +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 Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as PluginCatalog from "./PluginCatalog.ts"; +import * as PluginManifestLoader from "./PluginManifestLoader.ts"; +import * as PluginSupervisor from "./PluginSupervisor.ts"; +import * as PluginViews from "./PluginViews.ts"; + +// Children run the real CLI entry, which routes `__plugin-host` to the child runtime. +const BIN_PATH = `${import.meta.dirname}/../bin.ts`; +/** A views plugin whose `board` view has `view:board:*` handlers; tests never write to it. */ +const BOARD_FIXTURE = `${import.meta.dirname}/testFixtures/views`; + +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const VIEW_SCRIPT = `t3View.ready.then(() => t3View.call("echo", { from: "view" }));\n`; + +/** Starts a catalogue, its supervisor, and the views service in `scope`, as one server start would. */ +const startViews = Effect.fn("startViews")(function* ( + scope: Scope.Scope, + viewFileSystem?: FileSystem.FileSystem, +) { + const supervisor = yield* PluginSupervisor.make({ + heapLimitMb: 64, + activationTimeout: "10 seconds", + stopGrace: "1 second", + }).pipe( + Effect.provideService(HostProcess.Arguments, [process.execPath, BIN_PATH]), + Effect.provideService(Scope.Scope, scope), + ); + const catalog = yield* PluginCatalog.make().pipe( + Effect.provideService(PluginSupervisor.PluginSupervisor, supervisor), + Effect.provideService(Scope.Scope, scope), + ); + const views = yield* PluginViews.make().pipe( + Effect.provideService(PluginCatalog.PluginCatalog, catalog), + Effect.provideService(FileSystem.FileSystem, viewFileSystem ?? (yield* FileSystem.FileSystem)), + Effect.provideService(Scope.Scope, scope), + ); + return { catalog, views }; +}); + +/** Writes a views plugin with a `panel` view, in a directory the test may edit. */ +const preparePlugin = Effect.fn("preparePlugin")(function* (options?: { + readonly script?: string; + readonly views?: unknown; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-views-" }); + const directory = path.join(root, "plugin"); + yield* fs.makeDirectory(path.join(directory, "views"), { recursive: true }); + yield* fs.writeFileString(path.join(directory, "main.mjs"), `export function activate() {}\n`); + yield* fs.writeFileString( + path.join(directory, "views", "panel.js"), + options?.script ?? VIEW_SCRIPT, + ); + yield* fs.writeFileString(path.join(directory, "views", "panel.css"), "body { margin: 0; }\n"); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ + id: "test.views", + name: "Views", + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + capabilities: ["views"], + proposedApi: true, + views: options?.views ?? [ + { + id: "panel", + title: "Panel", + placement: "side-panel", + script: "views/panel.js", + style: "views/panel.css", + }, + ], + }), + ); + return { directory, script: path.join(directory, "views", "panel.js") }; +}); + +type Views = PluginViews.PluginViews["Service"]; + +/** Waits, through the subscription, for a snapshot that satisfies `predicate`. */ +const awaitViews = (views: Views, predicate: (snapshot: PluginViewsSnapshot) => boolean) => + views.subscribe.pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.map((snapshot) => Option.getOrThrow(snapshot)), + ); + +/** Adds, approves, and enables the plugin; returns its installation. */ +const install = Effect.fn("install")(function* ( + catalog: PluginCatalog.PluginCatalog["Service"], + directory: string, +) { + const { installation } = yield* catalog.add({ directory }); + yield* catalog.consent({ + installationId: installation.installationId, + digest: installation.source!.digest, + }); + return (yield* catalog.enable({ installationId: installation.installationId })).installation; +}); + +const withDatabase = (effect: Effect.Effect) => + effect.pipe(Effect.provide(SqlitePersistence.layerMemory)); + +it.layer(NodeServices.layer)("PluginViews", (it) => { + describe("bundles", () => { + it.effect("serves a view's consented bytes only to its current generation", () => + withDatabase( + Effect.gen(function* () { + const { catalog, views } = yield* startViews(yield* Scope.Scope); + const plugin = yield* preparePlugin(); + const { installation: added } = yield* catalog.add({ directory: plugin.directory }); + const installationId = added.installationId; + const notEnabled = yield* views + .readBundle({ installationId, generation: 0, viewId: "panel" }) + .pipe(Effect.flip); + expect(notEnabled.reason).toBe("unavailable"); + + yield* catalog.consent({ installationId, digest: added.source!.digest }); + const enabled = (yield* catalog.enable({ installationId })).installation; + const shown = yield* awaitViews(views, (snapshot) => snapshot.views.length > 0); + expect(shown).toEqual({ + views: [ + { + installationId, + generation: enabled.generation, + pluginId: "test.views", + pluginName: "Views", + viewId: "panel", + title: "Panel", + placement: "side-panel", + }, + ], + problems: [], + }); + + const bundle = yield* views.readBundle({ + installationId, + generation: enabled.generation, + viewId: "panel", + }); + expect(bundle).toEqual({ + installationId, + generation: enabled.generation, + viewId: "panel", + sourceDigest: enabled.consent!.digest, + script: { + text: VIEW_SCRIPT, + sha256: NodeCrypto.createHash("sha256").update(VIEW_SCRIPT).digest("base64"), + }, + style: { + text: "body { margin: 0; }\n", + sha256: NodeCrypto.createHash("sha256") + .update("body { margin: 0; }\n") + .digest("base64"), + }, + }); + const missing = yield* views + .readBundle({ installationId, generation: enabled.generation, viewId: "other" }) + .pipe(Effect.flip); + expect(missing.reason).toBe("not-found"); + + // Disable revokes: the views leave the snapshot and the generation serves nothing. + yield* catalog.disable({ installationId }); + yield* awaitViews(views, (snapshot) => snapshot.views.length === 0); + const disabled = yield* views + .readBundle({ installationId, generation: enabled.generation, viewId: "panel" }) + .pipe(Effect.flip); + expect(disabled.reason).toBe("unavailable"); + + // A re-enable is a new generation; the old one stays refused. + const again = (yield* catalog.enable({ installationId })).installation; + expect(again.generation).toBe(enabled.generation + 1); + yield* awaitViews( + views, + (snapshot) => snapshot.views[0]?.generation === again.generation, + ); + const stale = yield* views + .readBundle({ installationId, generation: enabled.generation, viewId: "panel" }) + .pipe(Effect.flip); + expect(stale.reason).toBe("generation-changed"); + + yield* catalog.remove({ installationId }); + yield* awaitViews(views, (snapshot) => snapshot.views.length === 0); + const removed = yield* views + .readBundle({ installationId, generation: again.generation, viewId: "panel" }) + .pipe(Effect.flip); + expect(removed.reason).toBe("not-found"); + }), + ), + ); + + it.effect.each([ + { + name: "bytes change", + change: (fs: FileSystem.FileSystem, plugin: { directory: string; script: string }) => + fs.writeFileString(plugin.script, `${VIEW_SCRIPT}// edited\n`), + }, + { + // The read fails before the digest, which still finds the edit. + name: "bytes change into an invalid view", + change: (fs: FileSystem.FileSystem, plugin: { directory: string; script: string }) => + fs.writeFileString(plugin.script, `document.write("");\n`), + }, + { + // The final digest refuses a symlinked tree instead of computing a different digest. + name: "directory stops being digestible", + change: (fs: FileSystem.FileSystem, plugin: { directory: string; script: string }) => + fs.symlink("main.mjs", `${plugin.directory}/late-link`), + }, + ])("disables a plugin whose $name while its views are read", ({ change }) => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const plugin = yield* preparePlugin(); + // The views service reads the script through a file system that stops at it once. + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + let held = false; + const holding: FileSystem.FileSystem = { + ...fs, + readFile: (target) => + Effect.suspend(() => { + if (held || !target.endsWith("panel.js")) return fs.readFile(target); + held = true; + return Deferred.succeed(reached, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(fs.readFile(target)), + ); + }), + }; + const { catalog, views } = yield* startViews(yield* Scope.Scope, holding); + const installation = yield* install(catalog, plugin.directory); + + yield* Deferred.await(reached); + const target = { + installationId: installation.installationId, + generation: installation.generation, + viewId: "panel", + }; + // A fetch waiting on that read is answered from the same check. + const waiting = yield* views + .readBundle(target) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* change(fs, plugin); + yield* Deferred.succeed(release, undefined); + + expect((yield* Fiber.join(waiting)).reason).toBe("source-changed"); + // The catalogue re-inspects and disables it; nothing is shown or served. + yield* catalog.subscribe.pipe( + Stream.filter((snapshot) => snapshot.installations[0]?.enabled === false), + Stream.runHead, + ); + const shown = yield* awaitViews(views, (snapshot) => snapshot.problems.length === 0); + expect(shown.views).toEqual([]); + const refused = yield* views.readBundle(target).pipe(Effect.flip); + expect(refused.reason).toBe("unavailable"); + }), + ), + ); + + it.effect("serves nothing when the bytes read differ from the bytes digested", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const plugin = yield* preparePlugin(); + // As if the script changed for the read and was restored before the digest. + const swapped: FileSystem.FileSystem = { + ...fs, + readFile: (target) => + target.endsWith("panel.js") + ? Effect.succeed(new TextEncoder().encode(`${VIEW_SCRIPT}// swapped\n`)) + : fs.readFile(target), + }; + const { catalog, views } = yield* startViews(yield* Scope.Scope, swapped); + const installation = yield* install(catalog, plugin.directory); + const shown = yield* awaitViews( + views, + (snapshot) => snapshot.views.length > 0 || snapshot.problems.length > 0, + ); + expect(shown.views).toEqual([]); + expect(shown.problems[0]?.message).toBe( + "The plugin's files changed while its views were read.", + ); + const refused = yield* views + .readBundle({ + installationId: installation.installationId, + generation: installation.generation, + viewId: "panel", + }) + .pipe(Effect.flip); + expect(refused.reason).toBe("source-changed"); + }), + ), + ); + + it.effect("serves nothing when views sharing a script read it differently", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const plugin = yield* preparePlugin({ + views: [ + { id: "panel", title: "Panel", placement: "side-panel", script: "views/panel.js" }, + { id: "other", title: "Other", placement: "side-panel", script: "views/panel.js" }, + ], + }); + // As if the script changed for the first view's read and was restored before the second. + let reads = 0; + const swapped: FileSystem.FileSystem = { + ...fs, + readFile: (target) => + target.endsWith("panel.js") && reads++ === 0 + ? Effect.succeed(new TextEncoder().encode(`${VIEW_SCRIPT}// swapped\n`)) + : fs.readFile(target), + }; + const { catalog, views } = yield* startViews(yield* Scope.Scope, swapped); + const installation = yield* install(catalog, plugin.directory); + const shown = yield* awaitViews( + views, + (snapshot) => snapshot.views.length > 0 || snapshot.problems.length > 0, + ); + expect(shown.views).toEqual([]); + expect(shown.problems[0]?.message).toBe( + "The plugin's files changed while its views were read.", + ); + const refused = yield* views + .readBundle({ + installationId: installation.installationId, + generation: installation.generation, + viewId: "panel", + }) + .pipe(Effect.flip); + expect(refused.reason).toBe("source-changed"); + }), + ), + ); + + it.effect("reports view files that cannot be inlined exactly instead of serving them", () => + withDatabase( + Effect.gen(function* () { + const { catalog, views } = yield* startViews(yield* Scope.Scope); + const unsafe = yield* preparePlugin({ script: `document.write("");\n` }); + const installation = yield* install(catalog, unsafe.directory); + const shown = yield* awaitViews(views, (snapshot) => snapshot.problems.length > 0); + expect(shown.views).toEqual([]); + expect(shown.problems).toEqual([ + { + installationId: installation.installationId, + generation: installation.generation, + message: expect.stringContaining("views/panel.js contains CR, NUL"), + }, + ]); + const refused = yield* views + .readBundle({ + installationId: installation.installationId, + generation: installation.generation, + viewId: "panel", + }) + .pipe(Effect.flip); + expect(refused.reason).toBe("invalid-view"); + yield* catalog.disable({ installationId: installation.installationId }); + yield* awaitViews(views, (snapshot) => snapshot.problems.length === 0); + + const escaping = yield* preparePlugin({ + views: [ + { id: "panel", title: "Panel", placement: "side-panel", script: "../outside.js" }, + ], + }); + const second = yield* install(catalog, escaping.directory); + const invalid = yield* awaitViews(views, (snapshot) => + snapshot.problems.some((problem) => problem.installationId === second.installationId), + ); + expect(invalid.problems[0]?.message).toContain("The views in t3-plugin.json are invalid"); + yield* catalog.disable({ installationId: second.installationId }); + yield* awaitViews(views, (snapshot) => snapshot.problems.length === 0); + + // Under the raw size limit, but each control character escapes to six bytes of JSON. + const escaped = yield* preparePlugin({ + script: `//${"\u0001".repeat(400 * 1024)}\n`, + }); + const third = yield* install(catalog, escaped.directory); + const oversized = yield* awaitViews(views, (snapshot) => + snapshot.problems.some((problem) => problem.installationId === third.installationId), + ); + expect(oversized.problems[0]?.message).toContain("once encoded for delivery"); + }), + ), + ); + + it.effect("serves an asset whose name only starts with two dots", () => + withDatabase( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { catalog, views } = yield* startViews(yield* Scope.Scope); + const plugin = yield* preparePlugin({ + views: [{ id: "panel", title: "Panel", placement: "side-panel", script: "..board.js" }], + }); + yield* fs.writeFileString(path.join(plugin.directory, "..board.js"), VIEW_SCRIPT); + const installation = yield* install(catalog, plugin.directory); + const shown = yield* awaitViews( + views, + (snapshot) => snapshot.views.length > 0 || snapshot.problems.length > 0, + ); + expect(shown.problems).toEqual([]); + const bundle = yield* views.readBundle({ + installationId: installation.installationId, + generation: installation.generation, + viewId: "panel", + }); + expect(bundle.script.text).toBe(VIEW_SCRIPT); + }), + ), + ); + }); + + describe("calls", () => { + it.effect("refuses the views capability without the proposed API opt-in", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-plugin-views-" }); + yield* fs.writeFileString( + path.join(directory, "main.mjs"), + "export function activate() {}\n", + ); + yield* fs.writeFileString( + path.join(directory, "t3-plugin.json"), + toJson({ + id: "test.views-no-opt-in", + name: "test.views-no-opt-in", + version: "1.0.0", + apiVersion: 1, + entry: "main.mjs", + capabilities: ["views"], + }), + ); + const error = yield* PluginManifestLoader.loadPluginDirectory(directory).pipe(Effect.flip); + expect(error.reason).toContain('"views" capability needs "proposedApi": true'); + }).pipe(Effect.scoped), + ); + it.effect("reaches only the view's own handlers of the current generation", () => + withDatabase( + Effect.gen(function* () { + const { catalog, views } = yield* startViews(yield* Scope.Scope); + const installation = yield* install(catalog, BOARD_FIXTURE); + const target = { + installationId: installation.installationId, + generation: installation.generation, + viewId: "board", + }; + + const echoed = yield* views.call({ ...target, handler: "echo", input: { n: 1 } }); + expect(echoed).toEqual({ value: { echo: { n: 1 } } }); + // `ping` exists, but not under the view's prefix. + const foreign = yield* views + .call({ ...target, handler: "ping", input: null }) + .pipe(Effect.flip); + expect(foreign.reason).toBe("call-failed"); + const big = yield* views + .call({ ...target, handler: "big", input: null }) + .pipe(Effect.flip); + expect(big.reason).toBe("too-large"); + const tooLarge = yield* views + .call({ ...target, handler: "echo", input: "x".repeat(70 * 1024) }) + .pipe(Effect.flip); + expect(tooLarge.reason).toBe("too-large"); + const stale = yield* views + .call({ ...target, generation: target.generation + 1, handler: "echo", input: null }) + .pipe(Effect.flip); + expect(stale.reason).toBe("generation-changed"); + + // A call in flight when the plugin is disabled fails instead of answering later. + const hanging = yield* views + .call({ ...target, handler: "hang", input: null }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* catalog.subscribe.pipe( + Stream.filter((snapshot) => + snapshot.installations.some((row) => row.hostState?._tag === "running"), + ), + Stream.runHead, + ); + yield* catalog.disable({ installationId: installation.installationId }); + const revoked = yield* Fiber.join(hanging); + expect(revoked.reason).toBe("unavailable"); + const after = yield* views + .call({ ...target, handler: "echo", input: null }) + .pipe(Effect.flip); + expect(after.reason).toBe("unavailable"); + }), + ), + ); + }); +}); diff --git a/apps/server/src/plugins/PluginViews.ts b/apps/server/src/plugins/PluginViews.ts new file mode 100644 index 000000000000..bb8876139a36 --- /dev/null +++ b/apps/server/src/plugins/PluginViews.ts @@ -0,0 +1,459 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Isolated plugin views: the views enabled plugins declare, the exact bytes + * a client host runs them from, and the calls a mounted view makes into its + * own plugin. + * + * A view's files are read once per installation generation, between the + * catalogue's consent and a fresh digest of the whole directory that must + * still equal the consented one. A mismatch serves nothing and asks the + * catalogue to re-inspect, which disables the installation. Everything a + * client gets is bound to `(installationId, generation)`: disable, remove, a + * byte change, or a re-enable drops the views from the next snapshot at once, + * and later fetches and calls for the old generation are refused. + */ +import * as NodeCrypto from "node:crypto"; + +import { + PLUGIN_MANIFEST_FILE, + PLUGIN_VIEW_BUNDLE_MAX_BYTES, + PLUGIN_VIEW_BUNDLE_MAX_ENCODED_BYTES, + PLUGIN_VIEW_CALL_TIMEOUT_MS, + PLUGIN_VIEW_MESSAGE_MAX_BYTES, + PLUGIN_VIEW_SCRIPT_MAX_BYTES, + PLUGIN_VIEW_STYLE_MAX_BYTES, + PLUGIN_VIEWS_CAPABILITY, + PluginViewError, + PluginViewsManifest, + pluginInstallationStatus, + pluginViewHandler, + type PluginCatalogSnapshot, + type PluginInstallation, + type PluginInstallationId, + type PluginView, + type PluginViewAsset, + type PluginViewBundle, + type PluginViewBundleInput, + type PluginViewCallInput, + type PluginViewCallResult, + type PluginViewDeclaration, + type PluginViewProblem, + type PluginViewsSnapshot, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { PluginCatalog } from "./PluginCatalog.ts"; +import { digestPluginSourceFiles } from "./pluginSource.ts"; + +const MAX_MANIFEST_BYTES = 64 * 1024; + +const decodeViewsManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginViewsManifest)); + +// The HTML parser rewrites CR and NUL, ends a script at ` Buffer.byteLength(encodeJson(value)); + +const viewError = (reason: string, message: string) => new PluginViewError({ reason, message }); + +interface LoadedView { + readonly declaration: PluginViewDeclaration; + readonly bundle: PluginViewBundle; +} + +/** One generation's views, loaded at most once. */ +interface Entry { + readonly generation: number; + readonly digest: string; + readonly loaded: Deferred.Deferred, PluginViewError>; + outcome: + | { readonly _tag: "loading" } + | { readonly _tag: "ready"; readonly views: ReadonlyMap } + | { readonly _tag: "failed"; readonly message: string }; +} + +/** The consented digest an installation's views are served under, if it may show views now. */ +const servingDigest = (installation: PluginInstallation) => + pluginInstallationStatus(installation) === "enabled" && + installation.consent?.capabilities.includes(PLUGIN_VIEWS_CAPABILITY) + ? installation.consent.digest + : undefined; + +export class PluginViews extends Context.Service< + PluginViews, + { + /** The current views, then a fresh snapshot after every change. */ + readonly subscribe: Stream.Stream; + /** The consented bytes of one view of the given generation. */ + readonly readBundle: ( + input: PluginViewBundleInput, + ) => Effect.Effect; + /** Calls the plugin handler `view::` for a view of the given generation. */ + readonly call: ( + input: PluginViewCallInput, + ) => Effect.Effect; + } +>()("t3/plugins/PluginViews") {} + +export const make = Effect.fn("PluginViews.make")(function* () { + const catalog = yield* PluginCatalog; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const scope = yield* Effect.scope; + + const entries = new Map(); + let installations: ReadonlyArray = []; + const snapshot = yield* SubscriptionRef.make({ views: [], problems: [] }); + + /** Reads one asset as strict UTF-8 text from inside `directory`, with its `/` path and hex hash. */ + const readAsset = Effect.fnUntraced(function* ( + directory: string, + relative: string, + kind: "script" | "style", + ) { + const fail = (detail: string) => viewError("invalid-view", `${relative} ${detail}`); + const maxBytes = kind === "script" ? PLUGIN_VIEW_SCRIPT_MAX_BYTES : PLUGIN_VIEW_STYLE_MAX_BYTES; + const real = yield* fs + .realPath(path.resolve(directory, relative)) + .pipe(Effect.mapError(() => fail("does not exist."))); + const inside = path.relative(directory, real); + // `..board.js` stays inside; only a whole `..` segment leaves. + if ( + inside === "" || + inside === ".." || + inside.startsWith(`..${path.sep}`) || + path.isAbsolute(inside) + ) + return yield* fail("resolves outside the plugin directory."); + const info = yield* fs.stat(real).pipe(Effect.mapError(() => fail("is not readable."))); + if (info.type !== "File") return yield* fail("is not a file."); + if (Number(info.size) > maxBytes) return yield* fail(`is larger than ${maxBytes} bytes.`); + const bytes = yield* fs.readFile(real).pipe(Effect.mapError(() => fail("is not readable."))); + if (bytes.length > maxBytes) return yield* fail(`is larger than ${maxBytes} bytes.`); + const text = yield* Effect.try({ + try: () => utf8.decode(bytes), + catch: () => fail("is not valid UTF-8."), + }); + if (text.charCodeAt(0) === 0xfeff) return yield* fail("starts with a byte order mark."); + if ((kind === "script" ? UNSAFE_SCRIPT : UNSAFE_STYLE).test(text)) + return yield* fail( + kind === "script" + ? "contains CR, NUL, `