diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index aec3a2d5b45c0..4af9b94e8670e 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -99,7 +99,7 @@ describe("ElectronProtocol", () => { assert.equal(yield* Effect.promise(() => response.text()), "ok"); assert.include( response.headers.get("content-security-policy") ?? "", - "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://clerk.t3.codes https://challenges.cloudflare.com", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob: https://clerk.t3.codes https://challenges.cloudflare.com", ); assert.include( response.headers.get("content-security-policy") ?? "", diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 27af3f2cfd8ae..8cf179ec37f7f 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -74,6 +74,8 @@ function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrationInpu "'self'", "'unsafe-inline'", "'wasm-unsafe-eval'", + // Web plugins run from object URLs: their bundles are fetched with the session's credentials. + "blob:", ...(clerkOrigin ? [clerkOrigin] : []), "https://challenges.cloudflare.com", ]; diff --git a/apps/server/package.json b/apps/server/package.json index 0dacbbe90720c..a06e2346d534c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -77,6 +77,7 @@ "@effect/vitest": "catalog:", "@opencode-ai/sdk": "^1.3.15", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk": "workspace:*", "@t3tools/provider-testing": "workspace:*", "@t3tools/secret-provider-core": "workspace:*", "@t3tools/secret-provider-definitions": "workspace:*", diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 8044740a7e2ee..d43b5a5c2f734 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -68,6 +68,16 @@ describe("RPC authorization scopes", () => { } }); + it("keeps enabling, reloading and configuring plugins, which runs their code, behind settings write", () => { + for (const method of [ + WS_METHODS.pluginsSetEnabled, + WS_METHODS.pluginsReload, + WS_METHODS.pluginsSettingsUpdate, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthSettingsWriteScope); + } + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6049121b41a5d..0fb91a610c9e1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -205,6 +205,13 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.deviceDetail]: AuthOrchestrationReadScope, [WS_METHODS.deviceAction]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope, + // `plugins.invoke` also needs the operate scope for methods a plugin registered as writes; + // PluginHost checks that against the plugin's own declaration. Channels are reads. + [WS_METHODS.pluginsList]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSubscribeStates]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSettingsGet]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsInvoke]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSubscribe]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope, [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 72d7f0c2de7ef..b2b2d6956398d 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -243,6 +243,7 @@ export const make = Effect.gen(function* () { threadSnooze: true, threadForceStop: true, environmentThemes: true, + plugins: true, usageLimitSources: true, providerWorkspaceSnapshotsOnDemand: true, usagePriceOverrides: true, diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts index e6b2e1bc67921..ac7c9c8634fc1 100644 --- a/apps/server/src/environmentTheme.test.ts +++ b/apps/server/src/environmentTheme.test.ts @@ -90,6 +90,41 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ), ); + it.effect("adds plugin themes after the environment's own, which win on the same id", () => + withEnvironmentThemes( + { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, + Effect.gen(function* () { + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const pluginThemes = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-plugin-themes-" }); + yield* fs.writeFileString( + path.join(pluginThemes, "nightfall.json"), + encodeThemeFile({ ...NIGHTFALL_THEME, name: "Plugin Nightfall" }), + ); + yield* fs.writeFileString( + path.join(pluginThemes, "shared-light.json"), + encodeThemeFile(SHARED_THEME), + ); + + yield* environmentTheme.setPluginThemeDirectories([pluginThemes]); + assert.deepEqual( + (yield* currentThemes).map((theme) => [theme.id, theme.name]), + [ + ["nightfall", "Nightfall"], + ["shared-light", "Shared Light"], + ], + ); + + yield* environmentTheme.setPluginThemeDirectories([]); + assert.deepEqual( + (yield* currentThemes).map((theme) => theme.id), + ["nightfall"], + ); + }), + ), + ); + it.effect("never replays a set older than the snapshot it started from", () => withEnvironmentThemes( { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index f0fb44b2bfda7..21966b7c1d75a 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -50,6 +50,12 @@ export class EnvironmentThemeService extends Context.Service< /** Subscribes before reading to retain changes during connection. */ readonly streamChanges: Stream.Stream>; + + /** + * Theme folders of active plugins, read after the environment's own folder. The first theme + * with an id wins, so a plugin cannot replace a theme the user published. + */ + readonly setPluginThemeDirectories: (directories: ReadonlyArray) => Effect.Effect; } >()("t3/environmentTheme/EnvironmentThemeService") {} @@ -158,6 +164,7 @@ const make = Effect.gen(function* () { // Sequence numbers let subscribers drop queued events older than their initial snapshot. const changes = yield* PubSub.sliding(1); const published = yield* Ref.make({ seq: 0, themes: [] }); + const pluginDirectories = yield* Ref.make>([]); // Serialize read/compare/publish so slow older reads cannot publish with newer sequence numbers. const refreshSemaphore = yield* Semaphore.make(1); const watcherScope = yield* Scope.make("sequential"); @@ -165,9 +172,18 @@ const make = Effect.gen(function* () { const refresh = refreshSemaphore.withPermits(1)( Effect.gen(function* () { - const themes = yield* readPublishedThemes(environmentThemesDir).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - ); + const themes: Array = []; + const ids = new Set(); + for (const directory of [environmentThemesDir, ...(yield* Ref.get(pluginDirectories))]) { + const found = yield* readPublishedThemes(directory).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ); + for (const theme of found) { + if (ids.has(theme.id)) continue; + ids.add(theme.id); + themes.push(theme); + } + } // Compare the whole value so new fields cannot silently miss republishes. const [changed, next] = yield* Ref.modify( published, @@ -198,6 +214,13 @@ const make = Effect.gen(function* () { return { current: Effect.map(refresh, (state) => state.themes), + setPluginThemeDirectories: Effect.fn("EnvironmentThemeService.setPluginThemeDirectories")( + function* (directories) { + const previous = yield* Ref.getAndSet(pluginDirectories, directories); + if (Equal.equals(previous, directories)) return; + yield* refresh; + }, + ), get streamChanges() { return Stream.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index e792a233c6f06..a92d035e85c45 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -288,7 +288,7 @@ function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { return redirectUrl.toString(); } -const authenticateRawRouteWithScope = +export const authenticateRawRouteWithScope = /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* ( scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index e684316f48d9a..8b14678789b60 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -31,6 +31,7 @@ import * as HtmlRender from "../htmlRender/HtmlRender.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as PluginMcpTools from "../plugins/PluginMcpTools.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { type AgentThread, @@ -1140,6 +1141,53 @@ export const layerDeviceToolkit = Layer.mergeAll( layerDeviceScreenshotRegistration, ); +const isJsonObject = Schema.is(Schema.JsonObject); + +const pluginToolResult = (output: Schema.Json) => + new McpSchema.CallToolResult({ + isError: false, + ...(isJsonObject(output) ? { structuredContent: output } : {}), + content: [{ type: "text", text: JSON.stringify(output) }], + }); + +const pluginToolFailure = (error: { readonly message: string }) => + new McpSchema.CallToolResult({ isError: true, content: [{ type: "text", text: error.message }] }); + +/** + * Plugins add tools while the server runs, so they register here directly instead of through + * a toolkit. Kept out of `layerTools`: with no plugin installed there are none to measure. + */ +export const layerPluginTools = Layer.effectDiscard( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const plugins = yield* PluginMcpTools.PluginMcpTools; + const threads = yield* ThreadManagementService.ThreadManagementService; + yield* plugins.attach((entry) => { + const call = McpToolAccess.dynamic(entry.access, entry.call); + return server.addTool({ + tool: new McpSchema.Tool({ + name: entry.name, + description: entry.description, + inputSchema: entry.inputSchema, + annotations: { readOnlyHint: entry.access === "read" }, + }), + annotations: Context.empty(), + handle: (payload) => + Effect.withFiber((fiber) => + call(isJsonObject(payload) ? payload : {}).pipe( + Effect.provideService( + McpInvocationContext.McpInvocationContext, + Context.getUnsafe(fiber.context, McpInvocationContext.McpInvocationContext), + ), + Effect.provideService(ThreadManagementService.ThreadManagementService, threads), + Effect.match({ onFailure: pluginToolFailure, onSuccess: pluginToolResult }), + ), + ), + }); + }); + }), +).pipe(Layer.provide(McpServer.McpServer.layer)); + export const layerMcpTransport = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, @@ -1148,7 +1196,7 @@ export const layerMcpTransport = McpServer.layerHttp({ allowSessionTermination: true, }).pipe(Layer.provide(layerMcpAuthMiddleware)); -/** Every tool the T3 MCP server advertises. AgentContextBudget.test.ts measures this set. */ +/** Every built-in tool the T3 MCP server advertises. AgentContextBudget.test.ts measures this set. */ export const layerTools = Layer.mergeAll( layerPreviewToolkit, layerOrchestratorToolkit, @@ -1163,4 +1211,6 @@ export const layerTools = Layer.mergeAll( layerHtmlToolkit, ); -export const layer = layerTools.pipe(Layer.provideMerge(layerMcpTransport)); +export const layer = Layer.merge(layerTools, layerPluginTools).pipe( + Layer.provideMerge(layerMcpTransport), +); diff --git a/apps/server/src/mcp/McpToolAccess.ts b/apps/server/src/mcp/McpToolAccess.ts index b82d9bb5ae59c..ff491aa184d8d 100644 --- a/apps/server/src/mcp/McpToolAccess.ts +++ b/apps/server/src/mcp/McpToolAccess.ts @@ -173,6 +173,23 @@ export const writesWithCapability = (handle: (params: P) => Effect.E () => writingCaller, ); +/** + * For tools whose access is data, registered while the server runs (plugin tools): `read` + * checks like `reads`; `write` like `writesEnvironment`, because a plugin tool can do anything + * the plugin can, and its handler never sees the caller's limits. Returns the checked handler. + */ +export const dynamic = ( + access: "read" | "write", + handle: (params: P) => Effect.Effect, +) => { + const declaration: Declaration< + (params: P) => Effect.Effect + > = access === "read" ? reads(handle) : writesEnvironment((params: P) => handle(params)); + return checkedHandler(declaration); +}; + +type WritingServices = Effect.Services; + export const writesThreads = ( threads: (params: P) => ReadonlyArray, handle: (params: P) => Effect.Effect, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 1e3388e4136ad..12a39030adaf2 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -87,6 +87,17 @@ const RPC_AGGREGATES = { [WS_METHODS.serverRefreshUsageRates]: "server", [WS_METHODS.serverSignalProcess]: "server", [WS_METHODS.serverReportClientActivity]: "server", + [WS_METHODS.pluginsList]: "plugins", + [WS_METHODS.pluginsSubscribeStates]: "plugins", + [WS_METHODS.pluginsSetEnabled]: "plugins", + [WS_METHODS.pluginsReload]: "plugins", + [WS_METHODS.pluginsInstall]: "plugins", + [WS_METHODS.pluginsUninstall]: "plugins", + [WS_METHODS.pluginsUpdate]: "plugins", + [WS_METHODS.pluginsSettingsGet]: "plugins", + [WS_METHODS.pluginsSettingsUpdate]: "plugins", + [WS_METHODS.pluginsInvoke]: "plugins", + [WS_METHODS.pluginsSubscribe]: "plugins", [WS_METHODS.scheduledTasksSubscribe]: "scheduledTasks", [WS_METHODS.scheduledTasksUpsert]: "scheduledTasks", [WS_METHODS.scheduledTasksSetEnabled]: "scheduledTasks", diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 61515a053ac36..dcb52548b7c8e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -260,6 +260,63 @@ it.effect("marks a never-visited thread's first completion unread", () => }).pipe(Effect.provide(layerTest)), ); +it.effect("files a plugin note under the latest finished run without starting a run", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:plugin-note"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-plugin-note"), + threadId, + projectId: ProjectId.make("project:plugin-note"), + title: "Plugin note", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const dispatch = (id: string) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId, + messageId: MessageId.make(id), + text: "Run in the background", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "agent", + creationSource: "mcp", + }); + yield* dispatch("plugin-note-finished"); + const finished = (yield* projections.getThreadProjection(threadId)).runs[0]!; + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("interrupt-plugin-note"), + threadId, + runId: finished.id, + }); + // A later run that hasn't finished must not receive the note. + yield* dispatch("plugin-note-pending"); + yield* orchestrator.dispatch({ + type: "thread.notification.record", + commandId: CommandId.make("record-plugin-note"), + threadId, + turnItemId: TurnItemId.make("plugin-note"), + notification: { source: { kind: "system" }, outcome: "updated", summary: "Digest: done" }, + }); + const projection = yield* projections.getThreadProjection(threadId); + assert.equal(projection.runs.length, 2); + const finishedItems = projection.turnItems.filter((item) => item.runId === finished.id); + const note = finishedItems.find((item) => item.id === "plugin-note"); + assert.equal(note?.type, "notification"); + assert.equal(note?.ordinal, Math.max(...finishedItems.map((item) => item.ordinal))); + }).pipe(Effect.provide(layerTest)), +); + const completeAndSnoozeThread = Effect.fn("test.completeAndSnoozeThread")(function* ( name: string, snoozeMinutes: number, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 0a5e56d98d639..855209bd93743 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -559,6 +559,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { return command.parentThreadId; case "secret_request.record": case "link_request.record": + case "thread.notification.record": return command.threadId; case "thread.fork": case "thread.merge_back": @@ -8502,6 +8503,82 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ); + /** + * Files the note under the latest settled run so it sorts after that run's output: run-less items + * sort before every run, and a note inside a running turn would split its live output. + */ + const dispatchThreadNotificationRecord = Effect.fn( + "orchestrationV2.dispatch.threadNotificationRecord", + )(function* ( + command: Extract< + OrchestrationV2InternalCommand, + { readonly type: "thread.notification.record" } + >, + events: Ref.Ref>, + ) { + const projection = yield* projectionStore + .getThreadRecords(command.threadId, ["runs", "turnItems", "attempts", "providerTurns"], { + turnItemTypes: [], + messageRoles: [], + }) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + if (projection.thread.deletedAt !== null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is deleted.`, + }); + } + const run = projection.runs + .filter((candidate) => + ["completed", "failed", "cancelled", "interrupted"].includes(candidate.status), + ) + .reduce( + (latest, candidate) => + latest === undefined || candidate.ordinal > latest.ordinal ? candidate : latest, + undefined, + ); + const now = yield* DateTime.now; + const turnItem: OrchestrationV2TurnItem = { + id: command.turnItemId, + threadId: command.threadId, + runId: run?.id ?? null, + nodeId: run?.rootNodeId ?? null, + providerThreadId: run?.providerThreadId ?? null, + providerTurnId: run === undefined ? null : (providerTurnForRun(projection, run)?.id ?? null), + nativeItemRef: null, + parentItemId: null, + ordinal: yield* nextTurnItemOrdinal(projection), + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "notification", + ...command.notification, + }; + yield* emit( + events, + command, + )({ + type: "turn-item.updated", + threadId: command.threadId, + ...(run === undefined + ? {} + : { + runId: run.id, + ...(run.rootNodeId === null ? {} : { nodeId: run.rootNodeId }), + providerInstanceId: run.providerInstanceId, + }), + occurredAt: now, + payload: turnItem, + }); + }); + const dispatchSecretRequestRecord = Effect.fn("orchestrationV2.dispatch.secretRequestRecord")( function* ( command: Extract, @@ -13226,6 +13303,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "link_request.record": yield* dispatchLinkRequestRecord(command, events, effects); break; + case "thread.notification.record": + yield* dispatchThreadNotificationRecord(command, events); + break; default: return yield* dispatchUnsupported(command); } diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 9285f72d46f56..a80aa86e084fd 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -4,7 +4,9 @@ import { EnvironmentId, MessageId, type OrchestrationV2Command, + type OrchestrationV2Notification, type OrchestrationV2Run, + type OrchestrationV2ServerCommand, type OrchestrationV2ProviderThread, ProviderThreadId, ProviderDriverKind, @@ -29,6 +31,7 @@ import * as TestClock from "effect/testing/TestClock"; import { AgentScope } from "@t3tools/shared/AgentScope"; +import * as PluginHooks from "../plugins/PluginHooks.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; @@ -81,7 +84,8 @@ const layerReceiptsTest = CommandReceiptStore.layer.pipe( ); const layerDispatchChokepointTest = - (settingsLayer: ReturnType) => (dispatched: Array) => + (settingsLayer: ReturnType) => + (dispatched: Array, texts: Array = []) => ThreadManagementService.layer.pipe( Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( @@ -101,6 +105,7 @@ const layerDispatchChokepointTest = dispatch: (command) => Effect.sync(() => { dispatched.push(command.type); + if (command.type === "message.dispatch") texts.push(command.text); return { sequence: 1, storedEvents: [] } as never; }), }), @@ -151,6 +156,56 @@ it.effect( }, ); +it.effect("plugin hooks append to user messages but not to notifications or slash commands", () => { + const threadId = ThreadId.make("thread:thread-management:chokepoint"); + const dispatched: Array = []; + const texts: Array = []; + const instanceId = ProviderInstanceId.make("codex_work"); + const layerTest = layerDispatchChokepointTest( + ServerSettings.layerTest({ + providerInstances: { [instanceId]: { driver: "codex", enabled: true } }, + }), + )(dispatched, texts).pipe(Layer.provideMerge(PluginHooks.layer)); + const message = ( + id: string, + notification?: OrchestrationV2Notification, + text = "Fix the build.", + ): OrchestrationV2ServerCommand => ({ + type: "message.dispatch", + commandId: CommandId.make(`command:thread-management:hooks-${id}`), + threadId, + messageId: MessageId.make(`message:thread-management:hooks-${id}`), + text, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + ...(notification === undefined ? {} : { notification }), + }); + + return Effect.gen(function* () { + const hooks = yield* PluginHooks.PluginHooks; + yield* hooks.addBeforeDispatch(() => Effect.succeed("Project uses pnpm.")).pipe(Effect.asVoid); + const threads = yield* ThreadManagementService.ThreadManagementService; + yield* threads.dispatch(message("user")); + yield* threads.dispatch( + message("notification", { + source: { kind: "system" }, + outcome: "updated", + summary: "Restarted", + }), + ); + + yield* threads.dispatch(message("command", undefined, "/goal clear")); + + expect(texts).toEqual([ + "Fix the build.\n\nProject uses pnpm.", + "Fix the build.", + "/goal clear", + ]); + }).pipe(Effect.provide(layerTest)); +}); + it.effect( "a replay of an already-accepted command id dispatches despite the instance now being disabled", () => { diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 276d673a39d48..78dba1c21b65f 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -58,6 +58,7 @@ import * as Stream from "effect/Stream"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; +import * as PluginHooks from "../plugins/PluginHooks.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import { projectedRowEncodedBytes } from "./threadHistoryPaging.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; @@ -569,6 +570,7 @@ const isForceStopThreadError = Schema.is(OrchestrationV2ForceStopThreadError); const make = Effect.gen(function* () { const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor; const orchestrator = yield* Orchestrator.OrchestratorV2; + const pluginHooks = yield* PluginHooks.PluginHooks; const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const layerScope = yield* Effect.scope; const agentScope = yield* AgentScope; @@ -746,10 +748,44 @@ const make = Effect.gen(function* () { } }; + // Plugins see user and agent messages before orchestration records them. Notifications and + // guest messages are not the sender's own text, and an appended paragraph would change what a + // slash command such as `/goal clear` means, so all three skip the hooks. + const withPluginHooks = (command: OrchestrationV2ServerCommand) => + command.type === "message.dispatch" && + command.notification === undefined && + command.guestMessageId === undefined && + command.createdBy !== "system" && + !command.text.trimStart().startsWith("/") + ? pluginHooks + .beforeDispatch({ + threadId: command.threadId, + messageId: command.messageId, + text: command.text, + createdBy: command.createdBy, + attachments: command.attachments.map((attachment) => ({ + type: attachment.type, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + })), + ...(command.modelSelection === undefined + ? {} + : { + model: { + instanceId: command.modelSelection.instanceId, + model: command.modelSelection.model, + }, + }), + }) + .pipe(Effect.map((text) => (text === command.text ? command : { ...command, text }))) + : Effect.succeed(command); + const dispatch: ThreadManagementServiceShape["dispatch"] = (command) => ensureCommandTranscripts(command).pipe( Effect.andThen(assertDispatchTargetEnabled(command)), - Effect.andThen(orchestrator.dispatch(command)), + Effect.andThen(withPluginHooks(command)), + Effect.flatMap((next) => orchestrator.dispatch(next)), ); const getProjectThreadRecords: ThreadManagementServiceShape["getProjectThreadRecords"] = ( diff --git a/apps/server/src/plugins/PluginApi.ts b/apps/server/src/plugins/PluginApi.ts new file mode 100644 index 0000000000000..7e1476a649352 --- /dev/null +++ b/apps/server/src/plugins/PluginApi.ts @@ -0,0 +1,188 @@ +import { PluginRpcName, type PluginId } from "@t3tools/contracts"; +import type { PluginApi, PluginRpcAccess, PluginRpcHandler } from "@t3tools/plugin-sdk/server"; +import type * as Context from "effect/Context"; +import type * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; + +import type * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import type * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import type * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import type * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import type * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; +import type * as ServerSettings from "../serverSettings.ts"; +import { makeEventsApi, storedEvents } from "./api/events.ts"; +import { makeFacadeContext } from "./api/facade.ts"; +import { makeHttpApi } from "./api/http.ts"; +import { makeMcpApi } from "./api/mcp.ts"; +import { makeScheduleApi } from "./api/schedule.ts"; +import { makeServerApi } from "./api/server.ts"; +import { makeProjectsApi, makeThreadsApi } from "./api/threads.ts"; +import { makeTurnsApi } from "./api/turns.ts"; +import type * as PluginHooks from "./PluginHooks.ts"; +import type * as PluginHttpRoutes from "./PluginHttpRoutes.ts"; +import type * as PluginMcpTools from "./PluginMcpTools.ts"; +import type * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import type * as PluginStorage from "./PluginStorage.ts"; + +const isJson = Schema.is(Schema.Json); +const isPluginRpcName = Schema.is(PluginRpcName); +const isPluginRpcAccess = Schema.is(Schema.Literals(["read", "write"])); + +export interface PluginPublication { + readonly channel: string; + readonly value: Schema.Json; +} + +/** Everything one activation registered. The host routes calls through it and tears it down. */ +export interface PluginActivation { + readonly methods: Map< + string, + { readonly handler: PluginRpcHandler; readonly access: PluginRpcAccess } + >; + readonly cleanups: Array<() => void | Promise>; + /** Aborted when deactivation starts; handler signals, iterators and timers follow it. */ + readonly controller: AbortController; + /** Aborted once the cleanups have run; API calls are refused from then on. */ + readonly ended: AbortController; + readonly publications: PubSub.PubSub; +} + +export const makePluginActivation = ( + publications: PubSub.PubSub, +): PluginActivation => ({ + methods: new Map(), + cleanups: [], + controller: new AbortController(), + ended: new AbortController(), + publications, +}); + +const assertActive = (activation: PluginActivation) => { + if (activation.controller.signal.aborted) { + throw new Error("This plugin activation has ended."); + } +}; + +/** The host services the facade reaches. The host builds this once and shares it. */ +export interface PluginApiServices { + readonly version: string; + readonly settings: PluginSettingsStore.PluginSettingsStore["Service"]; + readonly storage: PluginStorage.PluginStorage["Service"]; + readonly orchestrator: Orchestrator.OrchestratorV2["Service"]; + readonly eventStore: OrchestrationEventStore.OrchestrationEventStore["Service"]; + readonly threads: ThreadManagementService.ThreadManagementService["Service"]; + readonly projects: ProjectStore.ProjectStoreV2["Service"]; + readonly hooks: PluginHooks.PluginHooksRegistry; + readonly mcpTools: PluginMcpTools.PluginMcpTools["Service"]; + readonly httpRoutes: PluginHttpRoutes.PluginHttpRoutes["Service"]; + readonly environment: ServerEnvironment.ServerEnvironment["Service"]; + readonly serverSettings: ServerSettings.ServerSettingsService["Service"]; + readonly crypto: Crypto.Crypto; +} + +/** + * Builds the object a plugin's `activate` receives. Effect stays on this side: every call runs + * with the host's services and settles as a plain Promise that rejects with an `Error`. + */ +export const makePluginApi = (input: { + readonly id: PluginId; + readonly name: string; + readonly rootDir: string; + readonly settingsSchema: Schema.JsonObject | undefined; + readonly activation: PluginActivation; + readonly context: Context.Context; + readonly services: PluginApiServices; +}): PluginApi => { + const { id, activation, context, services } = input; + const facade = makeFacadeContext({ pluginId: id, activation, context }); + const { call } = facade; + const log = + (level: "Debug" | "Info" | "Warn" | "Error") => + (message: string, fields?: Schema.JsonObject) => { + Effect.runForkWith(context)( + Effect.logWithLevel(level)(message, fields ?? {}).pipe( + Effect.annotateLogs({ pluginId: id }), + ), + ); + }; + + const storage: PluginApi["storage"] = { + get: (key) => call(services.storage.get(id, key)).then(Option.getOrUndefined), + set: (key, value) => { + if (!isJson(value)) return Promise.reject(new TypeError("Storage values must be JSON.")); + return call(services.storage.set(id, key, value)); + }, + delete: (key) => call(services.storage.remove(id, key)), + list: (prefix) => call(services.storage.list(id, prefix)), + }; + const events = makeEventsApi(facade, services); + + return { + version: 1, + id, + rootDir: input.rootDir, + log: { debug: log("Debug"), info: log("Info"), warn: log("Warn"), error: log("Error") }, + settings: { + get: () => call(services.settings.get(id, input.settingsSchema)), + changes: () => facade.iterate(services.settings.changes(id)), + }, + storage, + rpc: { + register: (method, handler, options) => { + assertActive(activation); + if (!isPluginRpcName(method)) + throw new TypeError(`"${method}" is not a valid method name.`); + // Plugins are untyped JavaScript, and a misspelled access level must not open a write. + if (!isPluginRpcAccess(options.access)) { + throw new TypeError(`Method "${method}" needs access "read" or "write".`); + } + if (activation.methods.has(method)) { + throw new Error(`Method "${method}" is already registered.`); + } + activation.methods.set(method, { handler, access: options.access }); + }, + publish: (channel, value) => { + assertActive(activation); + if (!isPluginRpcName(channel)) { + throw new TypeError(`"${channel}" is not a valid channel name.`); + } + if (!isJson(value)) throw new TypeError("Published values must be JSON."); + PubSub.publishUnsafe(activation.publications, { channel, value }); + }, + }, + // Something created after deactivation has nothing left to wait for, so it cleans up now. + onDeactivate: (cleanup) => { + if (activation.controller.signal.aborted) { + void Promise.resolve() + .then(cleanup) + .catch(() => undefined); + return; + } + activation.cleanups.push(cleanup); + }, + events, + threads: makeThreadsApi(facade, services, input.name), + projects: makeProjectsApi(facade, services), + schedule: makeScheduleApi(facade), + turns: makeTurnsApi(facade, services, { + storage, + runEvents: (afterSequence) => + storedEvents( + facade, + services, + { types: ["run.updated"], ...(afterSequence === undefined ? {} : { afterSequence }) }, + "suspend", + ), + }), + mcp: makeMcpApi(facade, services), + http: makeHttpApi(facade, services), + server: makeServerApi(facade, { + version: services.version, + environment: services.environment, + settings: services.serverSettings, + }), + }; +}; diff --git a/apps/server/src/plugins/PluginHooks.ts b/apps/server/src/plugins/PluginHooks.ts new file mode 100644 index 0000000000000..cfa57325b979f --- /dev/null +++ b/apps/server/src/plugins/PluginHooks.ts @@ -0,0 +1,72 @@ +import type { ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +export interface PluginOutgoingMessage { + readonly threadId: ThreadId; + readonly messageId: string; + readonly text: string; + readonly createdBy: "user" | "agent"; + readonly attachments: ReadonlyArray<{ + readonly type: string; + readonly name: string; + readonly mimeType: string; + readonly sizeBytes: number; + }>; + /** The model the message asked for; absent when the thread keeps its current one. */ + readonly model?: { readonly instanceId: string; readonly model: string }; +} + +/** Returns a paragraph to append, or nothing. The host makes hooks safe: bounded and fail-open. */ +type BeforeDispatchHook = (message: PluginOutgoingMessage) => Effect.Effect; + +/** The registry behind the `PluginHooks` reference. */ +export interface PluginHooksRegistry { + /** Adds a hook and returns the effect that removes it. */ + readonly addBeforeDispatch: (hook: BeforeDispatchHook) => Effect.Effect>; + /** The text to send after every hook has seen the message. */ + readonly beforeDispatch: (message: PluginOutgoingMessage) => Effect.Effect; +} + +// A reference with an inert default, so orchestration and its tests need no plugin wiring; the +// server provides the real registry to both the dispatch path and the plugin host. +export class PluginHooks extends Context.Reference("t3/plugins/PluginHooks", { + defaultValue: () => ({ + addBeforeDispatch: () => Effect.succeed(Effect.void), + beforeDispatch: (message) => Effect.succeed(message.text), + }), +}) {} + +const make = Effect.gen(function* () { + const hooks = yield* Ref.make>([]); + return { + addBeforeDispatch: (hook) => + Effect.sync(() => ({ run: hook })).pipe( + Effect.tap((entry) => Ref.update(hooks, (all) => [...all, entry])), + Effect.map((entry) => Ref.update(hooks, (all) => all.filter((other) => other !== entry))), + ), + beforeDispatch: (message) => + Ref.get(hooks).pipe( + Effect.flatMap((all) => + all.length === 0 + ? Effect.succeed(message.text) + : Effect.reduce( + all, + () => message.text, + (text, entry) => + entry + .run({ ...message, text }) + .pipe( + Effect.map((appended) => + appended === undefined ? text : `${text}\n\n${appended}`, + ), + ), + ), + ), + ), + } satisfies PluginHooksRegistry; +}); + +export const layer = Layer.effect(PluginHooks, make); diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts new file mode 100644 index 0000000000000..764b6b587f24a --- /dev/null +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -0,0 +1,685 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + EnvironmentId, + PluginId, + ProjectId, + ThreadId, + type OrchestrationV2ServerCommand, + type PluginState, +} from "@t3tools/contracts"; +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 type * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as EnvironmentTheme from "../environmentTheme.ts"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import packageJson from "../../package.json" with { type: "json" }; +import { gzipTarball, tarEntry } from "./testTarball.ts"; +import * as PluginHooks from "./PluginHooks.ts"; +import * as PluginHost from "./PluginHost.ts"; +import * as PluginHttpRoutes from "./PluginHttpRoutes.ts"; +import * as PluginMcpTools from "./PluginMcpTools.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import * as PluginStorage from "./PluginStorage.ts"; + +const fixturesDir = new URL("../../../../packages/plugin-sdk/fixtures/", import.meta.url).pathname; +const hello = PluginId.make("hello-plugin"); +const broken = PluginId.make("broken-plugin"); +const operate = [AuthOrchestrationReadScope, AuthOrchestrationOperateScope] as const; + +const layerConfig = () => + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-host-test-" })); + +// Orchestration services the lifecycle tests never reach. +const layerUnusedServices = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({}), + Layer.mock(OrchestrationEventStore.OrchestrationEventStore)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(ProjectStore.ProjectStoreV2)({}), + Layer.mock(ServerEnvironment.ServerEnvironment)({}), + Layer.mock(ServerSettings.ServerSettingsService)({}), +); + +// The last theme folders the host published, for the web files case. +let publishedThemeDirectories: ReadonlyArray = []; +const layerThemes = Layer.mock(EnvironmentTheme.EnvironmentThemeService)({ + setPluginThemeDirectories: (directories) => + Effect.sync(() => { + publishedThemeDirectories = directories; + }), +}); + +// A registry with one good package and one whose integrity hash does not match its tarball. +const npmTarball = gzipTarball( + tarEntry( + "package/t3-plugin.json", + JSON.stringify({ + id: "npm-hello", + name: "npm hello", + version: "1.0.0", + engines: { t3: ">=0.1.0" }, + server: "./server.cjs", + }), + ), + tarEntry("package/server.cjs", "module.exports = { activate() {} };"), +); +const npmIntegrity = `sha512-${NodeCrypto.createHash("sha512").update(npmTarball).digest("base64")}`; +const npmDocument = (name: string, integrity: string) => ({ + "dist-tags": { latest: "1.0.0" }, + versions: { "1.0.0": { dist: { tarball: `https://registry.test/${name}.tgz`, integrity } } }, +}); +const layerNpmRegistry = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + const url = request.url; + const body = url.endsWith(".tgz") + ? new Response(npmTarball) + : url.endsWith("/npm-hello") + ? Response.json(npmDocument("npm-hello", npmIntegrity)) + : url.endsWith("/npm-tampered") + ? Response.json(npmDocument("npm-tampered", "sha512-AAAA")) + : new Response("not found", { status: 404 }); + return Effect.succeed(HttpClientResponse.fromWeb(request, body)); + }), +); + +const layerHost = (services: typeof layerUnusedServices = layerUnusedServices) => + Layer.fresh( + PluginHost.layer.pipe( + Layer.provideMerge(Layer.mergeAll(PluginSettingsStore.layer, PluginStorage.layer)), + Layer.provide(ServerSecretStore.layer), + Layer.provide(services), + Layer.provide(layerThemes), + Layer.provide(ProcessRunner.layer), + Layer.provide(layerNpmRegistry), + Layer.provideMerge( + Layer.mergeAll(PluginHooks.layer, PluginMcpTools.layer, PluginHttpRoutes.layer), + ), + ), + ); + +const writeFile = Effect.fn(function* (filePath: string, contents: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, contents); +}); + +const writePluginsConfig = Effect.fn(function* (config: { + readonly enabled?: Record; + readonly devPaths?: ReadonlyArray; +}) { + const { stateDir } = yield* ServerConfig.ServerConfig; + yield* writeFile(`${stateDir}/plugins.json`, JSON.stringify(config)); +}); + +/** A plugin written for one test, under `/plugins/`. */ +const writeInstalledPlugin = Effect.fn(function* (id: string, serverSource: string) { + const { stateDir } = yield* ServerConfig.ServerConfig; + const rootDir = `${stateDir}/plugins/${id}`; + yield* writeFile( + `${rootDir}/t3-plugin.json`, + JSON.stringify({ + id, + name: id, + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + server: "./server.cjs", + }), + ); + yield* writeFile(`${rootDir}/server.cjs`, serverSource); + return rootDir; +}); + +const stateOf = (states: ReadonlyArray, id: string) => + states.find((state) => state.id === id); + +/** Resolves with the first list where every listed plugin has its expected status. */ +const waitForStatuses = ( + host: PluginHost.PluginHost["Service"], + expected: Readonly>, +) => + host.streamStates.pipe( + Stream.filter((states) => + Object.entries(expected).every(([id, status]) => stateOf(states, id)?.status === status), + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + +it.layer(NodeServices.layer)("PluginHost", (it) => { + it.effect("activates an enabled plugin and serves its methods, settings and storage", () => + Effect.gen(function* () { + yield* writePluginsConfig({ + enabled: { [hello]: true }, + devPaths: [`${fixturesDir}hello-plugin`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { [hello]: "active" }); + assert.include(stateOf(states, hello), { source: "dev", hasServer: true, enabled: true }); + + const ping = (input: string) => + host.invoke({ pluginId: hello, method: "ping", input, scopes: operate }); + assert.deepStrictEqual(yield* ping("a"), { pong: "a", greeting: "hello" }); + const changed = yield* host + .subscribe({ pluginId: hello, channel: "settings" }) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + yield* host.updateSettings(hello, { greeting: "hey" }); + assert.deepStrictEqual(yield* Fiber.join(changed), Option.some({ greeting: "hey" })); + assert.deepStrictEqual(yield* ping("b"), { pong: "b", greeting: "hey" }); + + const counted = yield* host + .subscribe({ pluginId: hello, channel: "counted" }) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + assert.strictEqual( + yield* host.invoke({ + pluginId: hello, + method: "count", + input: undefined, + scopes: operate, + }), + 1, + ); + assert.deepStrictEqual(yield* Fiber.join(counted), Option.some(1)); + + const denied = yield* host + .invoke({ + pluginId: hello, + method: "count", + input: undefined, + scopes: [AuthOrchestrationReadScope], + }) + .pipe(Effect.flip); + assert.strictEqual(denied._tag, "PluginAccessDeniedError"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("marks a plugin that throws in activate as failed and keeps the others running", () => + Effect.gen(function* () { + yield* writePluginsConfig({ + enabled: { [hello]: true, [broken]: true }, + devPaths: [`${fixturesDir}broken-plugin`, `${fixturesDir}hello-plugin`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { [broken]: "failed", [hello]: "active" }); + assert.deepStrictEqual(stateOf(states, broken)?.failure, { + stage: "activate", + message: "broken-plugin refuses to start", + }); + const missing = yield* host + .invoke({ pluginId: broken, method: "ping", input: undefined, scopes: operate }) + .pipe(Effect.flip); + assert.strictEqual(missing._tag, "PluginNotActiveError"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("runs cleanup on disable and stops serving the plugin", () => + Effect.gen(function* () { + yield* writePluginsConfig({ + enabled: { [hello]: true }, + devPaths: [`${fixturesDir}hello-plugin`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const storage = yield* PluginStorage.PluginStorage; + yield* waitForStatuses(host, { [hello]: "active" }); + + const disabled = yield* host.setEnabled(hello, false); + assert.include(disabled, { status: "disabled", enabled: false }); + assert.deepStrictEqual(yield* storage.get(hello, "deactivated"), Option.some(true)); + const error = yield* host + .invoke({ pluginId: hello, method: "ping", input: undefined, scopes: operate }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PluginNotActiveError"); + + const { stateDir } = yield* ServerConfig.ServerConfig; + const saved = yield* FileSystem.FileSystem.use((fs) => + fs.readFileString(`${stateDir}/plugins.json`), + ); + assert.deepStrictEqual(JSON.parse(saved).enabled, { [hello]: false }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("reload picks up a changed manifest and changed code", () => + Effect.gen(function* () { + const rootDir = yield* writeInstalledPlugin( + "reloading", + `module.exports = { activate(api) { api.rpc.register("which", () => "v1", { access: "read" }); } };`, + ); + yield* writePluginsConfig({ enabled: { reloading: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("reloading"); + yield* waitForStatuses(host, { reloading: "active" }); + const which = host.invoke({ + pluginId: id, + method: "which", + input: undefined, + scopes: operate, + }); + assert.strictEqual(yield* which, "v1"); + + yield* writeFile( + `${rootDir}/t3-plugin.json`, + JSON.stringify({ + id: "reloading", + name: "Reloaded", + version: "0.2.0", + engines: { t3: ">=0.1.0" }, + server: "./server.cjs", + }), + ); + yield* writeFile( + `${rootDir}/server.cjs`, + `module.exports = { activate(api) { api.rpc.register("which", () => "v2", { access: "read" }); } };`, + ); + const reloaded = yield* host.reload(id); + assert.include(reloaded, { status: "active", version: "0.2.0", name: "Reloaded" }); + assert.strictEqual(yield* which, "v2"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("fails a plugin that registers a method with an unknown access level", () => + Effect.gen(function* () { + yield* writeInstalledPlugin( + "typo", + `module.exports = { activate(api) { api.rpc.register("go", () => 1, { access: "Write" }); } };`, + ); + yield* writePluginsConfig({ enabled: { typo: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { typo: "failed" }); + assert.deepStrictEqual(stateOf(states, "typo")?.failure, { + stage: "activate", + message: 'Method "go" needs access "read" or "write".', + }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("reload stops a plugin whose folder was removed", () => + Effect.gen(function* () { + const stays = yield* writeInstalledPlugin("stays", `module.exports = { activate() {} };`); + const goes = yield* writeInstalledPlugin( + "goes", + `const marker = require("node:path").join(__dirname, "..", "..", "goes-cleaned"); +module.exports = { activate(api) { api.onDeactivate(() => require("node:fs").writeFileSync(marker, "")); } };`, + ); + yield* writePluginsConfig({ enabled: { stays: true, goes: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const fs = yield* FileSystem.FileSystem; + yield* waitForStatuses(host, { stays: "active", goes: "active" }); + yield* fs.remove(goes, { recursive: true }); + + yield* host.reload(PluginId.make("stays")); + + assert.isTrue(yield* fs.exists(`${stays}/../../goes-cleaned`)); + assert.isUndefined(stateOf(yield* host.list, "goes")); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("reload runs new code from a symlinked plugin folder", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const realDir = `${stateDir}/checkout/linked`; + const plugin = (version: string) => + `module.exports = { activate(api) { api.rpc.register("which", () => "${version}", { access: "read" }); } };`; + yield* writeFile( + `${realDir}/t3-plugin.json`, + JSON.stringify({ + id: "linked", + name: "Linked", + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + server: "./server.cjs", + }), + ); + yield* writeFile(`${realDir}/server.cjs`, plugin("v1")); + yield* fs.makeDirectory(`${stateDir}/plugins`, { recursive: true }); + yield* fs.symlink(realDir, `${stateDir}/plugins/linked`); + yield* writePluginsConfig({ enabled: { linked: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("linked"); + const which = host.invoke({ + pluginId: id, + method: "which", + input: undefined, + scopes: operate, + }); + yield* waitForStatuses(host, { linked: "active" }); + assert.strictEqual(yield* which, "v1"); + + yield* writeFile(`${realDir}/server.cjs`, plugin("v2")); + yield* host.reload(id); + assert.strictEqual(yield* which, "v2"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("finishes disabling a plugin when the caller gives up during its cleanup", () => + Effect.gen(function* () { + const started = Promise.withResolvers(); + const gate = Promise.withResolvers(); + Reflect.set(globalThis, "__t3PluginHostCleanupStarted", started.resolve); + Reflect.set(globalThis, "__t3PluginHostCleanupGate", gate.promise); + yield* writeInstalledPlugin( + "stubborn", + `module.exports = { activate(api) { + api.rpc.register("ping", () => "pong", { access: "read" }); + api.onDeactivate(() => { globalThis.__t3PluginHostCleanupStarted(); return globalThis.__t3PluginHostCleanupGate; }); +} };`, + ); + yield* writePluginsConfig({ enabled: { stubborn: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("stubborn"); + yield* waitForStatuses(host, { stubborn: "active" }); + + const disabling = yield* host + .setEnabled(id, false) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => started.promise); + yield* Fiber.interrupt(disabling); + gate.resolve(); + + yield* waitForStatuses(host, { stubborn: "disabled" }); + const error = yield* host + .invoke({ pluginId: id, method: "ping", input: undefined, scopes: operate }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PluginNotActiveError"); + }).pipe(Effect.provide(layerHost())); + Reflect.deleteProperty(globalThis, "__t3PluginHostCleanupStarted"); + Reflect.deleteProperty(globalThis, "__t3PluginHostCleanupGate"); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("gives a plugin threads, projects, server info, schedules and send hooks", () => + Effect.gen(function* () { + const workbench = PluginId.make("workbench-plugin"); + const threadId = ThreadId.make("thread:plugin-workbench"); + const dispatched: Array = []; + const services = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + streamStoredEventsFrom: () => Stream.never, + }), + Layer.mock(OrchestrationEventStore.OrchestrationEventStore)({ + latestAgentSequence: () => Effect.succeed(0), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({ + listProjectThreads: () => Effect.succeed([]), + getThreadShell: () => + Effect.succeed({ + id: threadId, + projectId: ProjectId.make("project:plugin-workbench"), + } as never), + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: 1, storedEvents: [] } as never; + }), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ listShells: () => Effect.succeed([]) }), + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getDescriptor: Effect.succeed({ + environmentId: EnvironmentId.make("environment:plugin-workbench"), + label: "Workbench host", + platform: { os: "linux", arch: "x64" }, + serverVersion: packageJson.version, + capabilities: { repositoryIdentity: false, plugins: true }, + }), + }), + Layer.mock(ServerSettings.ServerSettingsService)({}), + ); + yield* writePluginsConfig({ + enabled: { [workbench]: true }, + devPaths: [`${fixturesDir}workbench-plugin`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const hooks = yield* PluginHooks.PluginHooks; + yield* waitForStatuses(host, { [workbench]: "active" }); + const call = (method: string, input?: Schema.Json) => + host.invoke({ pluginId: workbench, method, input, scopes: operate }); + + assert.deepStrictEqual(yield* call("overview"), { + version: packageJson.version, + environment: "Workbench host", + threads: 0, + projects: 0, + firstThreadId: null, + }); + + yield* call("renameThread", { threadId, title: "Renamed by a plugin" }); + assert.deepInclude(dispatched[0], { + type: "thread.metadata.update", + threadId, + title: "Renamed by a plugin", + }); + + yield* call("noteThread", { threadId }); + assert.deepInclude(dispatched[1], { + type: "thread.notification.record", + threadId, + notification: { + source: { kind: "system" }, + outcome: "completed", + summary: "Workbench: Checked", + }, + }); + + assert.strictEqual( + yield* hooks.beforeDispatch({ + threadId, + messageId: "message:context", + text: "Ship it #context", + createdBy: "user", + attachments: [], + }), + "Ship it #context\n\nContext from workbench-plugin.", + ); + + const tick = yield* host + .subscribe({ pluginId: workbench, channel: "ticks" }) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + assert.deepStrictEqual(yield* Fiber.join(tick), Option.some(1)); + }).pipe(Effect.provide(layerHost(services))); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("serves an active plugin's web files and themes, and stops when disabled", () => + Effect.gen(function* () { + const helloWeb = PluginId.make("hello-web"); + yield* writePluginsConfig({ + enabled: { [helloWeb]: true }, + devPaths: [`${fixturesDir}hello-web`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const routes = yield* PluginHttpRoutes.PluginHttpRoutes; + yield* waitForStatuses(host, { [helloWeb]: "active" }); + const request = { method: "GET", path: "/web.js", query: {}, headers: {}, body: "" }; + + const response = yield* routes.handle({ pluginId: helloWeb, request }); + assert.strictEqual(response.status, 200); + assert.strictEqual(response.headers["content-type"], "text/javascript; charset=utf-8"); + assert.deepStrictEqual(publishedThemeDirectories, [`${fixturesDir}hello-web/themes`]); + const asset = yield* routes.handle({ + pluginId: helloWeb, + request: { ...request, path: "/assets/hello.txt" }, + }); + assert.strictEqual(asset.headers["content-type"], "text/plain; charset=utf-8"); + const escaped = yield* routes + .handle({ pluginId: helloWeb, request: { ...request, path: "/assets/..%2Fserver.cjs" } }) + .pipe(Effect.flip); + assert.strictEqual(escaped._tag, "PluginHttpHandlerError"); + + yield* host.setEnabled(helloWeb, false); + const missing = yield* routes.handle({ pluginId: helloWeb, request }).pipe(Effect.flip); + assert.strictEqual(missing._tag, "PluginHttpRouteNotFoundError"); + assert.deepStrictEqual(publishedThemeDirectories, []); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect( + "installs from a folder, keeps the old version when a reinstall fails, and uninstalls", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const broken = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-plugin-broken-" }); + yield* writeFile(`${broken}/t3-plugin.json`, JSON.stringify({ id: "hello-plugin" })); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, {}); + + const source = { type: "path" as const, path: `${fixturesDir}hello-plugin` }; + const installed = yield* host.install(source); + assert.deepStrictEqual(installed.installSource, source); + const updated = yield* host.update(hello); + assert.include(updated, { status: "active" }); + assert.notStrictEqual(updated.revision, installed.revision); + assert.include(installed, { + id: hello, + status: "active", + enabled: true, + source: "installed", + }); + assert.isTrue(yield* fs.exists(`${stateDir}/plugins/hello-plugin/server.cjs`)); + + const failed = yield* host.install({ type: "path", path: broken }).pipe(Effect.flip); + assert.strictEqual(failed._tag, "PluginInstallError"); + assert.include(stateOf(yield* host.list, hello), { status: "active" }); + assert.isTrue(yield* fs.exists(`${stateDir}/plugins/hello-plugin/server.cjs`)); + + yield* host.uninstall(hello, true); + assert.isUndefined(stateOf(yield* host.list, hello)); + assert.isFalse(yield* fs.exists(`${stateDir}/plugins/hello-plugin`)); + + // A plugin linked into the plugins folder: removing it removes the link, not its source. + yield* fs.symlink(`${fixturesDir}hello-plugin`, `${stateDir}/plugins/hello-plugin`); + yield* host.reload(hello).pipe(Effect.ignore); + yield* host.uninstall(hello, false); + assert.isFalse(yield* fs.exists(`${stateDir}/plugins/hello-plugin`)); + assert.isTrue(yield* fs.exists(`${fixturesDir}hello-plugin/server.cjs`)); + + const fromNpm = yield* host.install({ type: "npm", name: "npm-hello" }); + assert.include(fromNpm, { id: PluginId.make("npm-hello"), status: "active" }); + // Another source claiming an installed id is refused before anything moves. + const impostor = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-plugin-impostor-" }); + yield* writeFile( + `${impostor}/t3-plugin.json`, + JSON.stringify({ + id: "npm-hello", + name: "Impostor", + version: "9.0.0", + engines: { t3: ">=0.1.0" }, + }), + ); + const takeover = yield* host.install({ type: "path", path: impostor }).pipe(Effect.flip); + assert.match(takeover.message, /already installed from somewhere else/); + // Updating a disabled plugin keeps it disabled. + yield* host.setEnabled(PluginId.make("npm-hello"), false); + assert.include(yield* host.update(PluginId.make("npm-hello")), { enabled: false }); + const tampered = yield* host + .install({ type: "npm", name: "npm-tampered" }) + .pipe(Effect.flip); + assert.match(tampered.message, /integrity/); + assert.deepStrictEqual((yield* fs.readDirectory(`${stateDir}/plugins`)).toSorted(), [ + "npm-hello", + ]); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("restores a version an interrupted install left in staging", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const previous = `${stateDir}/plugin-staging/crashed/previous`; + yield* writeFile( + `${previous}/t3-plugin.json`, + JSON.stringify({ + id: "stranded", + name: "Stranded", + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + }), + ); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { stranded: "disabled" }); + assert.isTrue(yield* fs.exists(`${stateDir}/plugins/stranded/t3-plugin.json`)); + assert.isFalse(yield* fs.exists(`${stateDir}/plugin-staging/crashed`)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("does not load a disabled plugin at startup", () => + Effect.gen(function* () { + const rootDir = yield* writeInstalledPlugin( + "dormant", + `require("node:fs").writeFileSync(__dirname + "/loaded", ""); +module.exports = { activate() {} };`, + ); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { dormant: "disabled" }); + assert.include(stateOf(states, "dormant"), { enabled: false, hasServer: true }); + const fs = yield* FileSystem.FileSystem; + assert.isFalse(yield* fs.exists(`${rootDir}/loaded`)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("finishes starting before a slow plugin finishes activating", () => + Effect.gen(function* () { + const gate = Promise.withResolvers(); + Reflect.set(globalThis, "__t3PluginHostTestGate", gate.promise); + yield* writeInstalledPlugin( + "slow", + `module.exports = { activate() { return globalThis.__t3PluginHostTestGate; } };`, + ); + yield* writePluginsConfig({ enabled: { slow: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { slow: "loading" }); + gate.resolve(); + yield* waitForStatuses(host, { slow: "active" }); + }).pipe(Effect.provide(layerHost())); + Reflect.deleteProperty(globalThis, "__t3PluginHostTestGate"); + }).pipe(Effect.provide(layerConfig())), + ); +}); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts new file mode 100644 index 0000000000000..050fcc8eeef04 --- /dev/null +++ b/apps/server/src/plugins/PluginHost.ts @@ -0,0 +1,1110 @@ +// @effect-diagnostics nodeBuiltinImport:off - plugins load through Node's CommonJS loader, which +// the FileSystem service does not wrap. +import * as NodeModule from "node:module"; + +import { + AuthEnvironmentScope, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + type PluginFailure, + PluginId, + PluginManifest, + PluginInstallSource, + type PluginState, + type PluginStatus, +} from "@t3tools/contracts"; +import type { PluginDefinition } from "@t3tools/plugin-sdk/server"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Fiber from "effect/Fiber"; +import * as FiberMap from "effect/FiberMap"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/http"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as EnvironmentTheme from "../environmentTheme.ts"; +import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; +import * as OrchestrationEventStore from "../persistence/OrchestrationEventStore.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { forkParked } from "../serverActivation.ts"; +import { + makePluginActivation, + makePluginApi, + type PluginActivation, + type PluginApiServices, + type PluginPublication, +} from "./PluginApi.ts"; +import { discoverPlugins, type DiscoveredPlugin } from "./pluginDiscovery.ts"; +import { fetchNpmPackage } from "./pluginNpm.ts"; +import * as PluginHooks from "./PluginHooks.ts"; +import * as PluginHttpRoutes from "./PluginHttpRoutes.ts"; +import * as PluginMcpTools from "./PluginMcpTools.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import * as PluginStorage from "./PluginStorage.ts"; + +const MAX_FAILURE_MESSAGE_CHARS = 500; +const WEB_CONTENT_TYPES: Readonly> = { + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".woff2": "font/woff2", + ".txt": "text/plain; charset=utf-8", +}; +// Plugin code that never settles must not keep a plugin loading, or Disable and Reload waiting, +// forever. The cleanup deadline covers all of a plugin's cleanups together. +const ACTIVATE_DEADLINE = Duration.seconds(30); +const CLEANUP_DEADLINE = Duration.seconds(5); +const PUBLICATION_BUFFER = 64; + +export class PluginNotFoundError extends Schema.TaggedError()( + "PluginNotFoundError", + { pluginId: PluginId }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is not installed.`; + } +} + +export class PluginNotActiveError extends Schema.TaggedError()( + "PluginNotActiveError", + { pluginId: PluginId, status: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is ${this.status}.`; + } +} + +export class PluginMethodNotFoundError extends Schema.TaggedError()( + "PluginMethodNotFoundError", + { pluginId: PluginId, method: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} has no method ${this.method}.`; + } +} + +export class PluginAccessDeniedError extends Schema.TaggedError()( + "PluginAccessDeniedError", + { pluginId: PluginId, method: Schema.String, requiredScope: AuthEnvironmentScope }, +) { + override get message(): string { + return `Calling ${this.method} on plugin ${this.pluginId} needs the ${this.requiredScope} scope.`; + } +} +export const isPluginAccessDeniedError = Schema.is(PluginAccessDeniedError); + +// `reason` is the plugin's own error text, bounded. Plugins are trusted code, and their UI needs +// to show why its own call failed. +export class PluginHandlerError extends Schema.TaggedError()( + "PluginHandlerError", + { pluginId: PluginId, method: Schema.String, reason: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Plugin ${this.pluginId} failed in ${this.method}: ${this.reason}`; + } +} + +/** Plugin code threw or rejected while loading, activating, or cleaning up. */ +class PluginCodeError extends Schema.TaggedError()("PluginCodeError", { + cause: Schema.Defect(), +}) { + override get message(): string { + return errorText(this.cause); + } +} + +export class PluginConfigError extends Schema.TaggedError()( + "PluginConfigError", + { path: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not update ${this.path}. If it was edited by hand, fix or delete it.`; + } +} + +// `reason` is shown to the user as is, so it says what to fix. +export class PluginInstallError extends Schema.TaggedError()( + "PluginInstallError", + { reason: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return this.reason; + } +} + +export class PluginHost extends Context.Service< + PluginHost, + { + readonly list: Effect.Effect>; + /** The current list, then the whole list after every change. */ + readonly streamStates: Stream.Stream>; + /** Persists the flag, then waits for activation or deactivation to finish. */ + readonly setEnabled: ( + pluginId: PluginId, + enabled: boolean, + ) => Effect.Effect; + /** Rescans the plugin folders and restarts this plugin with fresh code. */ + readonly reload: ( + pluginId: PluginId, + ) => Effect.Effect; + readonly getSettings: ( + pluginId: PluginId, + ) => Effect.Effect< + Schema.JsonObject, + PluginNotFoundError | PluginNotActiveError | PluginSettingsError + >; + readonly updateSettings: ( + pluginId: PluginId, + patch: Schema.JsonObject, + ) => Effect.Effect< + Schema.JsonObject, + PluginNotFoundError | PluginNotActiveError | PluginSettingsError + >; + /** `scopes` are the caller's; methods the plugin registered as writes need the operate scope. */ + /** + * Copies or clones a plugin into the plugins folder, enables it and starts it. It is staged + * first and swapped in with a rename, so a failed install leaves the previous version. + */ + readonly install: ( + source: PluginInstallSource, + ) => Effect.Effect; + /** Installs a plugin again from the source it was installed from. */ + readonly update: ( + pluginId: PluginId, + ) => Effect.Effect; + /** Stops an installed plugin and deletes its folder; `removeData` also deletes its settings. */ + readonly uninstall: ( + pluginId: PluginId, + removeData: boolean, + ) => Effect.Effect; + readonly invoke: (input: { + readonly pluginId: PluginId; + readonly method: string; + readonly input: Schema.Json | undefined; + readonly scopes: ReadonlyArray; + }) => Effect.Effect< + Schema.Json, + | PluginNotFoundError + | PluginNotActiveError + | PluginMethodNotFoundError + | PluginAccessDeniedError + | PluginHandlerError + >; + /** Values the plugin publishes on `channel` until it deactivates. */ + readonly subscribe: (input: { + readonly pluginId: PluginId; + readonly channel: string; + }) => Stream.Stream; + } +>()("t3/plugins/PluginHost") {} + +type PluginSettingsError = + | PluginSettingsStore.PluginSettingsPersistenceError + | PluginSettingsStore.PluginSettingsSchemaError + | PluginSettingsStore.PluginSettingsValidationError; + +// Unknown keys survive a rewrite, so a newer build's additions outlive a toggle by an older one. +const PluginsConfigFile = Schema.StructWithRest( + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)), + devPaths: Schema.optionalKey(Schema.Array(Schema.String)), + /** Where each plugin installed through `install` came from, so `update` can fetch it again. */ + sources: Schema.optionalKey(Schema.Record(Schema.String, PluginInstallSource)), + }), + [Schema.Record(Schema.String, Schema.Json)], +); +type PluginsConfigFile = typeof PluginsConfigFile.Type; +const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginsConfigFile)); +const encodeConfig = Schema.encodeEffect(fromJsonStringPretty(PluginsConfigFile)); +const isJson = Schema.is(Schema.Json); + +interface PluginRecord { + readonly plugin: DiscoveredPlugin; + readonly status: PluginStatus; + readonly failure?: PluginFailure; + readonly activation?: PluginActivation; + readonly revision?: number; +} + +const errorText = (cause: unknown) => { + let text = "Unknown error"; + try { + text = + cause instanceof Error && typeof cause.message === "string" ? cause.message : String(cause); + } catch { + // Plugin code can throw values whose conversion to a string throws again. + } + return text.slice(0, MAX_FAILURE_MESSAGE_CHARS); +}; + +const manifestOf = (plugin: DiscoveredPlugin): PluginManifest | undefined => + "manifest" in plugin ? plugin.manifest : undefined; + +// Git URLs can carry credentials, and plugin state reaches read-only clients and agents. +const shownSource = (source: PluginInstallSource): PluginInstallSource => { + if (source.type !== "git" || !URL.canParse(source.url)) return source; + const url = new URL(source.url); + if (url.username === "" && url.password === "") return source; + url.username = "***"; + url.password = ""; + return { ...source, url: url.toString() }; +}; + +const toState = ( + record: PluginRecord, + enabled: boolean, + installSource: PluginInstallSource | undefined, +): PluginState => { + const manifest = manifestOf(record.plugin); + return { + id: record.plugin.id, + name: manifest?.name ?? record.plugin.id, + ...(manifest === undefined + ? {} + : { + version: manifest.version, + ...(manifest.description === undefined ? {} : { description: manifest.description }), + ...(manifest.settingsSchema === undefined + ? {} + : { settingsSchema: manifest.settingsSchema }), + ...(manifest.contributes === undefined ? {} : { contributes: manifest.contributes }), + }), + permissions: manifest?.permissions ?? [], + source: record.plugin.source, + enabled, + status: record.status, + ...(record.failure === undefined ? {} : { failure: record.failure }), + ...(record.revision === undefined ? {} : { revision: record.revision }), + ...(installSource === undefined || record.plugin.source !== "installed" + ? {} + : { installSource: shownSource(installSource) }), + hasServer: manifest?.server !== undefined, + hasWeb: manifest?.web !== undefined, + }; +}; + +// One loader for the packaged single executable and plain Node: a require created from an +// absolute path loads files from disk in both, and plugin code resolves its own imports. +const requireFromDisk = NodeModule.createRequire(process.execPath); + +const isPluginDefinition = (value: unknown): value is PluginDefinition => + typeof value === "object" && + value !== null && + "activate" in value && + typeof value.activate === "function"; + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const context = yield* Effect.context(); + const services: PluginApiServices = { + version: packageJson.version, + settings: yield* PluginSettingsStore.PluginSettingsStore, + storage: yield* PluginStorage.PluginStorage, + orchestrator: yield* Orchestrator.OrchestratorV2, + eventStore: yield* OrchestrationEventStore.OrchestrationEventStore, + threads: yield* ThreadManagementService.ThreadManagementService, + projects: yield* ProjectStore.ProjectStoreV2, + hooks: yield* PluginHooks.PluginHooks, + mcpTools: yield* PluginMcpTools.PluginMcpTools, + httpRoutes: yield* PluginHttpRoutes.PluginHttpRoutes, + environment: yield* ServerEnvironment.ServerEnvironment, + serverSettings: yield* ServerSettings.ServerSettingsService, + crypto: yield* Crypto.Crypto, + }; + const settingsStore = services.settings; + const environmentThemes = yield* EnvironmentTheme.EnvironmentThemeService; + const processRunner = yield* ProcessRunner.ProcessRunner; + const http = yield* HttpClient.HttpClient; + + const pluginsDir = path.join(config.stateDir, "plugins"); + const configPath = path.join(config.stateDir, "plugins.json"); + + const records = yield* Ref.make>([]); + // Changes on every start, so a client can tell a restarted plugin from one that kept running. + // Seeded with the clock so a restarted server never repeats a revision a client already saw. + let revision = yield* Clock.currentTimeMillis; + const nextRevision = () => ++revision; + const pluginsConfig = yield* Ref.make({}); + const changes = yield* PubSub.sliding>(1); + const lifecycle = yield* Semaphore.make(1); + const publishLock = yield* Semaphore.make(1); + const activations = yield* FiberMap.make(); + const hostScope = yield* Effect.scope; + + const currentStates = Effect.gen(function* () { + const config = yield* Ref.get(pluginsConfig); + const enabled = config.enabled ?? {}; + return (yield* Ref.get(records)).map((record) => + toState(record, enabled[record.plugin.id] === true, config.sources?.[record.plugin.id]), + ); + }); + // Reading and publishing under one lock, so a slower fiber cannot publish an older list last. + const publishStates = publishLock.withPermits(1)( + Effect.gen(function* () { + const all = yield* Ref.get(records); + const states = yield* currentStates; + yield* services.mcpTools.setInstalledPlugins(states); + yield* environmentThemes.setPluginThemeDirectories( + all.flatMap((record, index) => + states[index]?.status === "active" ? [path.join(record.plugin.rootDir, "themes")] : [], + ), + ); + yield* PubSub.publish(changes, states); + }), + ); + const findRecord = (pluginId: string) => + Ref.get(records).pipe(Effect.map((all) => all.find((record) => record.plugin.id === pluginId))); + const stateOf = (pluginId: PluginId) => + currentStates.pipe( + Effect.flatMap((states) => { + const state = states.find((candidate) => candidate.id === pluginId); + return state === undefined + ? Effect.fail(new PluginNotFoundError({ pluginId })) + : Effect.succeed(state); + }), + ); + // Records are replaced by id; a shadowed duplicate keeps its own failure entry. + const setRecord = (pluginId: PluginId, update: Omit) => + Ref.update(records, (all) => { + const index = all.findIndex((record) => record.plugin.id === pluginId); + if (index === -1) return all; + const next = [...all]; + next[index] = { plugin: all[index]!.plugin, ...update }; + return next; + }).pipe(Effect.andThen(publishStates)); + + const readConfig = Effect.gen(function* () { + const exists = yield* fs.exists(configPath); + if (!exists) { + const emptyConfig: PluginsConfigFile = {}; + return emptyConfig; + } + const raw = yield* fs.readFileString(configPath); + return yield* decodeConfig(raw); + }).pipe(Effect.mapError((cause) => new PluginConfigError({ path: configPath, cause }))); + + const writeConfig = Effect.fnUntraced(function* ( + update: (current: PluginsConfigFile) => PluginsConfigFile, + ) { + const next = update(yield* readConfig); + const contents = yield* encodeConfig(next).pipe( + Effect.mapError((cause) => new PluginConfigError({ path: configPath, cause })), + ); + yield* writeFileStringAtomically({ filePath: configPath, contents: `${contents}\n` }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError((cause) => new PluginConfigError({ path: configPath, cause })), + ); + yield* Ref.set(pluginsConfig, next); + }); + + const writeEnabled = (pluginId: PluginId, enabled: boolean) => + writeConfig((current) => ({ + ...current, + enabled: { ...current.enabled, [pluginId]: enabled }, + })); + + const removeFromConfig = (pluginId: PluginId) => + writeConfig((current) => { + const { [pluginId]: _removed, ...enabled } = current.enabled ?? {}; + const { [pluginId]: _source, ...sources } = current.sources ?? {}; + return { ...current, enabled, sources }; + }); + + const runCleanup = (pluginId: PluginId, cleanup: () => void | Promise) => + Effect.tryPromise({ + try: () => Promise.resolve().then(cleanup), + catch: (cause) => new PluginCodeError({ cause }), + }).pipe( + Effect.catchTags({ + PluginCodeError: (error) => + Effect.logWarning("plugin cleanup failed", { pluginId, reason: error.message }), + }), + ); + + // Aborts first so handlers and iterators stop, then runs cleanups newest first. Teardown also + // runs as an interrupt finalizer, so the deadline gets an interruptible region to cut. + const teardown = Effect.fnUntraced(function* (pluginId: PluginId, activation: PluginActivation) { + activation.controller.abort(); + const timedOut = yield* Effect.forEach( + activation.cleanups.toReversed(), + (cleanup) => runCleanup(pluginId, cleanup), + { discard: true }, + ).pipe(Effect.interruptible, Effect.timeoutOption(CLEANUP_DEADLINE), Effect.map(Option.isNone)); + if (timedOut) yield* Effect.logWarning("plugin cleanup timed out", { pluginId }); + activation.ended.abort(); + yield* PubSub.shutdown(activation.publications); + }); + + const loadDefinition = (plugin: DiscoveredPlugin, entry: string) => + Effect.try({ + try: () => { + const entryPath = path.join(plugin.rootDir, entry); + // Drop every cached module under the plugin so reload runs current code. Node still + // holds the old instances until the server restarts. + const prefix = `${plugin.rootDir}${path.sep}`; + for (const cached of Object.keys(requireFromDisk.cache)) { + if (cached.startsWith(prefix)) Reflect.deleteProperty(requireFromDisk.cache, cached); + } + const exported: unknown = requireFromDisk(entryPath); + // Bundlers that compile `export default` to CommonJS put the definition on `default`. + const definition = + typeof exported === "object" && exported !== null && "default" in exported + ? exported.default + : exported; + if (!isPluginDefinition(definition)) { + throw new Error("The server entry does not export a plugin with an activate function."); + } + return definition; + }, + catch: (cause) => new PluginCodeError({ cause }), + }); + + // Web files are served from the plugin's real folder; a path or symlink out of `within` is refused. + const readWebFile = Effect.fnUntraced(function* (within: string, file: string) { + const resolved = yield* fs.realPath(file).pipe(Effect.option); + if (Option.isNone(resolved)) return { status: 404, body: "Not found." }; + const real = resolved.value; + if (!real.startsWith(`${within}${path.sep}`)) { + return yield* new PluginCodeError({ cause: "The file resolves outside the plugin folder." }); + } + return { + status: 200, + headers: { + "content-type": + WEB_CONTENT_TYPES[path.extname(real).toLowerCase()] ?? "application/octet-stream", + "cache-control": "no-store", + }, + body: yield* fs.readFile(real), + }; + }); + + const serveWebFiles = Effect.fnUntraced(function* (plugin: DiscoveredPlugin, entry: string) { + const register = (routePath: string, read: (requestPath: string) => string, within: string) => + services.httpRoutes + .register({ + pluginId: plugin.id, + method: "GET", + path: routePath, + handler: (request) => + Effect.runPromiseWith(context)(readWebFile(within, read(request.path))), + }) + .pipe(Effect.orDie); + yield* register("/web.js", () => path.join(plugin.rootDir, entry), plugin.rootDir); + const assetsDir = yield* fs.realPath(path.join(plugin.rootDir, "assets")).pipe(Effect.option); + if (Option.isSome(assetsDir)) { + yield* register( + "/assets/*", + (requestPath) => + path.join(assetsDir.value, decodeURIComponent(requestPath.slice("/assets/".length))), + assetsDir.value, + ); + } + }); + + const activatePlugin = Effect.fnUntraced(function* (plugin: DiscoveredPlugin) { + const manifest = manifestOf(plugin); + if (manifest === undefined) return; + if (!satisfiesSemverRange(packageJson.version, manifest.engines.t3)) { + return yield* setRecord(plugin.id, { + status: "failed", + failure: { + stage: "compatibility", + message: `Needs T3 Code ${manifest.engines.t3}; this server is ${packageJson.version}.`, + }, + }); + } + if (manifest.web !== undefined) yield* serveWebFiles(plugin, manifest.web); + if (manifest.server === undefined) { + return yield* setRecord(plugin.id, { status: "active", revision: nextRevision() }); + } + yield* setRecord(plugin.id, { status: "loading" }); + const definition = yield* Effect.exit(loadDefinition(plugin, manifest.server)); + if (definition._tag === "Failure") { + return yield* setRecord(plugin.id, { + status: "failed", + failure: { stage: "load", message: errorText(Cause.squash(definition.cause)) }, + }); + } + const activation = makePluginActivation( + yield* PubSub.sliding(PUBLICATION_BUFFER), + ); + const api = makePluginApi({ + id: plugin.id, + name: manifest.name, + rootDir: plugin.rootDir, + settingsSchema: manifest.settingsSchema, + activation, + context, + services, + }); + const activated = yield* Effect.tryPromise({ + try: () => Promise.resolve().then(() => definition.value.activate(api)), + catch: (cause) => new PluginCodeError({ cause }), + }).pipe( + Effect.timeoutOption(ACTIVATE_DEADLINE), + Effect.onInterrupt(() => teardown(plugin.id, activation)), + Effect.exit, + ); + if (activated._tag === "Failure" || Option.isNone(activated.value)) { + yield* teardown(plugin.id, activation); + return yield* setRecord(plugin.id, { + status: "failed", + failure: { + stage: "activate", + message: + activated._tag === "Failure" + ? errorText(Cause.squash(activated.cause)) + : `activate did not finish within ${Duration.toSeconds(ACTIVATE_DEADLINE)} seconds.`, + }, + }); + } + yield* setRecord(plugin.id, { status: "active", activation, revision: nextRevision() }); + yield* Effect.logInfo("plugin activated", { pluginId: plugin.id }); + }); + + /** + * Interrupts a pending activation, then tears down a finished one. The record stops routing + * calls before cleanups run; the caller sets the plugin's next status. + */ + const stopPlugin = Effect.fnUntraced(function* (pluginId: PluginId) { + yield* FiberMap.remove(activations, pluginId); + // Web-only plugins have no activation but still serve their bundle. + yield* services.httpRoutes.unregisterPlugin(pluginId); + const record = yield* findRecord(pluginId); + if (record?.activation === undefined) return; + yield* setRecord(pluginId, { status: "disabled" }); + yield* teardown(pluginId, record.activation); + }); + + const startPlugin = (plugin: DiscoveredPlugin) => + FiberMap.run(activations, plugin.id, activatePlugin(plugin)); + + const initialRecord = (plugin: DiscoveredPlugin, enabled: boolean): PluginRecord => + "failure" in plugin + ? { plugin, status: "failed", failure: plugin.failure } + : { plugin, status: enabled ? "loading" : "disabled" }; + + // Startup tolerates an unreadable plugins.json; an explicit reload reports it instead. + const discover = Effect.fnUntraced(function* (lenient: boolean) { + const emptyConfig: PluginsConfigFile = {}; + const current = lenient + ? yield* readConfig.pipe( + Effect.catchTags({ + PluginConfigError: (error) => + Effect.logWarning("ignoring unreadable plugins.json", { path: error.path }).pipe( + Effect.as(emptyConfig), + ), + }), + ) + : yield* readConfig; + yield* Ref.set(pluginsConfig, current); + const plugins = yield* discoverPlugins({ pluginsDir, devPaths: current.devPaths ?? [] }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + return { plugins, enabled: current.enabled ?? {} }; + }); + + const stagingRoot = path.join(config.stateDir, "plugin-staging"); + const decodeManifestJson = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); + + // An install interrupted mid-swap (a crash) can leave the old version only in staging. Put it + // back if nothing replaced it, then drop every leftover staging folder. + // Listed while the layer builds, before the server serves requests, so recovery never touches + // the staging folder of an install that starts after that. + const leftoverStaging = yield* fs + .readDirectory(stagingRoot) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + const recoverStaging = Effect.gen(function* () { + const entries = leftoverStaging; + for (const entry of entries) { + const previous = path.join(stagingRoot, entry, "previous"); + const manifest = yield* fs + .readFileString(path.join(previous, "t3-plugin.json")) + .pipe(Effect.flatMap(decodeManifestJson), Effect.option); + if (Option.isSome(manifest)) { + const target = path.join(pluginsDir, manifest.value.id); + const replaced = yield* fs.exists(target).pipe(Effect.orElseSucceed(() => true)); + if (!replaced) { + yield* fs.makeDirectory(pluginsDir, { recursive: true }).pipe(Effect.ignore); + yield* fs.rename(previous, target).pipe(Effect.ignore); + } + } + yield* fs + .remove(path.join(stagingRoot, entry), { recursive: true, force: true }) + .pipe(Effect.ignore); + } + }); + + const startup = lifecycle.withPermits(1)( + Effect.gen(function* () { + yield* recoverStaging; + const { plugins, enabled } = yield* discover(true); + yield* Ref.set( + records, + plugins.map((plugin) => initialRecord(plugin, enabled[plugin.id] === true)), + ); + yield* publishStates; + // Each plugin activates in its own fiber, so a slow one delays nobody else. + for (const plugin of plugins) { + if (enabled[plugin.id] === true && "manifest" in plugin) yield* startPlugin(plugin); + } + }), + ); + + // Lifecycle changes run in the host's scope, so a caller that disconnects only stops waiting + // and never leaves a plugin half torn down. + const runLifecycle = (body: Effect.Effect) => + lifecycle.withPermits(1)(body).pipe(Effect.forkIn(hostScope), Effect.flatMap(Fiber.join)); + + const awaitActivation = (pluginId: PluginId) => + FiberMap.get(activations, pluginId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: (fiber) => Effect.asVoid(Fiber.await(fiber)), + }), + ), + ); + + const setEnabled: PluginHost["Service"]["setEnabled"] = Effect.fn("PluginHost.setEnabled")( + function* (pluginId, enabled) { + yield* runLifecycle( + Effect.gen(function* () { + const record = yield* findRecord(pluginId); + if (record === undefined) return yield* new PluginNotFoundError({ pluginId }); + yield* writeEnabled(pluginId, enabled); + if ("failure" in record.plugin) return yield* publishStates; + // Enabling a plugin that is running or starting keeps it; a failed one is retried. + if (enabled && (record.status === "active" || record.status === "loading")) { + return yield* publishStates; + } + yield* stopPlugin(pluginId); + if (enabled) { + yield* startPlugin(record.plugin); + } else { + yield* setRecord(pluginId, { status: "disabled" }); + } + }), + ); + yield* awaitActivation(pluginId); + return yield* stateOf(pluginId); + }, + ); + + const reload: PluginHost["Service"]["reload"] = Effect.fn("PluginHost.reload")( + function* (pluginId) { + yield* runLifecycle( + Effect.gen(function* () { + const { plugins, enabled } = yield* discover(false); + const fresh = plugins.find((plugin) => plugin.id === pluginId); + yield* stopPlugin(pluginId); + // Plugins whose folders are gone are stopped too, or they would keep running unlisted. + const vanished = (yield* Ref.get(records)).filter( + (record) => !plugins.some((plugin) => plugin.id === record.plugin.id), + ); + for (const record of vanished) yield* stopPlugin(record.plugin.id); + const known = yield* Ref.get(records); + const kept = known.filter( + (record) => + record.plugin.id !== pluginId && plugins.some((p) => p.id === record.plugin.id), + ); + const added = plugins.filter( + (plugin) => + plugin.id === pluginId || !known.some((record) => record.plugin.id === plugin.id), + ); + yield* Ref.set(records, [ + ...kept, + ...added.map((plugin) => initialRecord(plugin, enabled[plugin.id] === true)), + ]); + yield* publishStates; + if (fresh === undefined) return yield* new PluginNotFoundError({ pluginId }); + for (const plugin of added) { + if (enabled[plugin.id] === true && "manifest" in plugin) yield* startPlugin(plugin); + } + }), + ); + yield* awaitActivation(pluginId); + return yield* stateOf(pluginId); + }, + ); + + const installFailed = (reason: string) => (cause: unknown) => + new PluginInstallError({ reason, cause }); + + // Plugins run with T3's own access, so install takes code only from the sources the user named. + const fetchSource = Effect.fnUntraced(function* (source: PluginInstallSource, into: string) { + if (source.type === "path") { + const from = path.resolve(source.path); + const info = yield* fs + .stat(from) + .pipe(Effect.mapError(installFailed(`${source.path} does not exist on the server.`))); + if (info.type !== "Directory") { + return yield* new PluginInstallError({ reason: `${source.path} is not a folder.` }); + } + // Copied rather than linked: the installed plugin never changes when the source does. + return yield* fs + .copy(from, into) + .pipe(Effect.mapError(installFailed(`Could not copy ${source.path}.`))); + } + if (source.type === "npm") { + const files = yield* fetchNpmPackage(source.name, source.version).pipe( + Effect.provideService(HttpClient.HttpClient, http), + Effect.mapError((error) => new PluginInstallError({ reason: error.reason, cause: error })), + ); + for (const file of files) { + const target = path.join(into, ...file.path.split("/")); + yield* fs + .makeDirectory(path.dirname(target), { recursive: true }) + .pipe( + Effect.andThen(fs.writeFile(target, file.contents)), + Effect.mapError(installFailed(`Could not write ${file.path}.`)), + ); + } + return; + } + const result = yield* processRunner + .run({ + command: "git", + args: [ + "clone", + "--depth", + "1", + ...(source.ref === undefined ? [] : ["--branch", source.ref]), + "--", + source.url, + into, + ], + timeout: "2 minutes", + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_ALLOW_PROTOCOL: "https:http:ssh:git:file", + }, + }) + .pipe(Effect.mapError(installFailed("Could not run git."))); + if (result.code !== 0) { + return yield* new PluginInstallError({ + reason: `git clone failed: ${result.stderr.trim().slice(-300) || `exit ${result.code}`}`, + }); + } + yield* fs.remove(path.join(into, ".git"), { recursive: true, force: true }).pipe(Effect.ignore); + }); + + const installInHost = Effect.fnUntraced(function* ( + source: PluginInstallSource, + /** Set by `update`: the id the source must still provide, checked before anything moves. */ + expectedId: PluginId | undefined, + ) { + const id = yield* services.crypto.randomUUIDv4().pipe(Effect.orDie); + const stagingDir = path.join(stagingRoot, id); + const staged = path.join(stagingDir, "plugin"); + yield* fs + .makeDirectory(stagingDir, { recursive: true }) + .pipe(Effect.mapError(installFailed("Could not create the staging folder."))); + const manifest = yield* Effect.gen(function* () { + yield* fetchSource(source, staged); + const raw = yield* fs + .readFileString(path.join(staged, "t3-plugin.json")) + .pipe(Effect.mapError(installFailed("The source has no t3-plugin.json."))); + return yield* decodeManifestJson(raw).pipe( + Effect.mapError(installFailed("The source's t3-plugin.json is not a valid manifest.")), + ); + }).pipe( + Effect.tapError(() => + fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + const existing = yield* findRecord(manifest.id); + const pluginsFile = yield* Ref.get(pluginsConfig); + const recorded = pluginsFile.sources?.[manifest.id]; + // A source may only replace the plugin it installed: another package claiming an installed + // id would take over that plugin's folder, settings, secrets and storage. + const refusal = + existing?.plugin.source === "dev" + ? `A dev folder already provides ${manifest.id}. Remove it from plugins.json first.` + : expectedId !== undefined && manifest.id !== expectedId + ? `The source now provides ${manifest.id}, not ${expectedId}.` + : existing !== undefined && (recorded === undefined || !Equal.equals(recorded, source)) + ? `${manifest.id} is already installed from somewhere else. Remove it first to replace it.` + : undefined; + if (refusal !== undefined) { + yield* fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore); + return yield* new PluginInstallError({ reason: refusal }); + } + // An update keeps a disabled plugin disabled; a new install is enabled. + const enabled = existing === undefined ? true : pluginsFile.enabled?.[manifest.id] === true; + const target = path.join(pluginsDir, manifest.id); + const previous = path.join(stagingDir, "previous"); + yield* runLifecycle( + Effect.gen(function* () { + yield* stopPlugin(manifest.id); + yield* fs + .makeDirectory(pluginsDir, { recursive: true }) + .pipe(Effect.mapError(installFailed("Could not create the plugins folder."))); + const hadPrevious = yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false)); + if (hadPrevious) { + yield* fs + .rename(target, previous) + .pipe(Effect.mapError(installFailed("Could not move the installed version aside."))); + } + yield* fs.rename(staged, target).pipe( + Effect.mapError(installFailed("Could not move the new version into place.")), + Effect.tapError(() => + hadPrevious ? fs.rename(previous, target).pipe(Effect.ignore) : Effect.void, + ), + ); + yield* writeConfig((current) => ({ + ...current, + enabled: { ...current.enabled, [manifest.id]: enabled }, + sources: { ...current.sources, [manifest.id]: source }, + })); + }), + ).pipe( + Effect.ensuring(fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore)), + // Whatever happened, the plugin folder now holds one version; start it again. + Effect.ensuring(reload(manifest.id).pipe(Effect.ignore)), + ); + return yield* stateOf(manifest.id); + }); + + // Like lifecycle changes, an install runs in the host's scope: a caller that disconnects stops + // waiting but cannot interrupt the swap or the staging cleanup. + const install: PluginHost["Service"]["install"] = Effect.fn("PluginHost.install")((source) => + installInHost(source, undefined).pipe(Effect.forkIn(hostScope), Effect.flatMap(Fiber.join)), + ); + + const update: PluginHost["Service"]["update"] = Effect.fn("PluginHost.update")( + function* (pluginId) { + const source = (yield* Ref.get(pluginsConfig)).sources?.[pluginId]; + if (source === undefined) { + return yield* new PluginInstallError({ + reason: `${pluginId} was not installed from Settings, so it has no source to update from.`, + }); + } + return yield* installInHost(source, pluginId).pipe( + Effect.forkIn(hostScope), + Effect.flatMap(Fiber.join), + ); + }, + ); + + const uninstall: PluginHost["Service"]["uninstall"] = Effect.fn("PluginHost.uninstall")( + function* (pluginId, removeData) { + const record = yield* findRecord(pluginId); + if (record === undefined) return yield* new PluginNotFoundError({ pluginId }); + if (record.plugin.source === "dev") { + return yield* new PluginInstallError({ + reason: `${pluginId} is loaded from a dev folder. Remove it from plugins.json instead.`, + }); + } + const settingsSchema = manifestOf(record.plugin)?.settingsSchema; + yield* runLifecycle( + Effect.gen(function* () { + yield* stopPlugin(pluginId); + // The entry in the plugins folder, not `rootDir`: that is the real path, so a symlinked + // plugin would delete the user's own folder behind the link. + const entry = path.join(pluginsDir, pluginId); + yield* fs + .remove(entry, { recursive: true, force: true }) + .pipe(Effect.mapError(installFailed(`Could not delete ${entry}.`))); + yield* removeFromConfig(pluginId); + // Stopping replaces the record, so match it by id and source rather than identity. + yield* Ref.update(records, (all) => + all.filter( + (entry) => entry.plugin.id !== pluginId || entry.plugin.source !== "installed", + ), + ); + yield* publishStates; + }), + ); + if (removeData) { + yield* settingsStore + .clear(pluginId, settingsSchema) + .pipe(Effect.mapError(installFailed("The plugin is removed, but its settings are not."))); + const keys = yield* services.storage + .list(pluginId) + .pipe(Effect.mapError(installFailed("The plugin is removed, but its storage is not."))); + for (const key of keys) { + yield* services.storage + .remove(pluginId, key) + .pipe(Effect.mapError(installFailed("The plugin is removed, but its storage is not."))); + } + } + }, + ); + + const manifestFor = Effect.fnUntraced(function* (pluginId: PluginId) { + const record = yield* findRecord(pluginId); + if (record === undefined) return yield* new PluginNotFoundError({ pluginId }); + const manifest = manifestOf(record.plugin); + if (manifest === undefined) { + return yield* new PluginNotActiveError({ pluginId, status: record.status }); + } + return manifest; + }); + + const activeRecord = Effect.fnUntraced(function* (pluginId: PluginId) { + const record = yield* findRecord(pluginId); + if (record === undefined) return yield* new PluginNotFoundError({ pluginId }); + if (record.status !== "active" || record.activation === undefined) { + return yield* new PluginNotActiveError({ pluginId, status: record.status }); + } + return record.activation; + }); + + yield* forkParked(startup); + yield* Effect.addFinalizer(() => + Ref.get(records).pipe( + Effect.flatMap((all) => + Effect.forEach(all, (record) => + record.activation === undefined + ? Effect.void + : teardown(record.plugin.id, record.activation), + ), + ), + ), + ); + + const service = PluginHost.of({ + list: currentStates, + streamStates: Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + const snapshot = yield* currentStates; + return Stream.concat(Stream.make(snapshot), Stream.fromSubscription(subscription)); + }), + ), + setEnabled, + reload, + install, + update, + uninstall, + getSettings: Effect.fn("PluginHost.getSettings")(function* (pluginId) { + const manifest = yield* manifestFor(pluginId); + return yield* settingsStore.getForClient(pluginId, manifest.settingsSchema); + }), + updateSettings: Effect.fn("PluginHost.updateSettings")(function* (pluginId, patch) { + const manifest = yield* manifestFor(pluginId); + return yield* settingsStore.update(pluginId, manifest.settingsSchema, patch); + }), + invoke: Effect.fn("PluginHost.invoke")(function* (request) { + yield* Effect.annotateCurrentSpan({ + "plugin.id": request.pluginId, + "plugin.method": request.method, + }); + const activation = yield* activeRecord(request.pluginId); + const registered = activation.methods.get(request.method); + if (registered === undefined) { + return yield* new PluginMethodNotFoundError({ + pluginId: request.pluginId, + method: request.method, + }); + } + if ( + registered.access === "write" && + !request.scopes.includes(AuthOrchestrationOperateScope) + ) { + return yield* new PluginAccessDeniedError({ + pluginId: request.pluginId, + method: request.method, + requiredScope: AuthOrchestrationOperateScope, + }); + } + const handlerError = (cause: unknown) => + new PluginHandlerError({ + pluginId: request.pluginId, + method: request.method, + reason: errorText(cause), + cause, + }); + const deactivated = activation.controller.signal; + const output = yield* Effect.raceFirst( + Effect.tryPromise({ + try: (signal) => + Promise.resolve().then(() => + registered.handler(request.input, { + signal: AbortSignal.any([signal, deactivated]), + }), + ), + catch: handlerError, + }), + // A handler that ignores its abort signal must not hold the call open after disable. + Effect.callback((resume) => { + const onAbort = () => + resume( + Effect.fail( + new PluginNotActiveError({ pluginId: request.pluginId, status: "disabled" }), + ), + ); + if (deactivated.aborted) return onAbort(); + deactivated.addEventListener("abort", onAbort, { once: true }); + return Effect.sync(() => deactivated.removeEventListener("abort", onAbort)); + }), + ); + if (output === undefined) return null; + if (!isJson(output)) { + return yield* handlerError(new TypeError("The method returned a value that is not JSON.")); + } + return output; + }), + subscribe: (request) => + Stream.unwrap( + activeRecord(request.pluginId).pipe( + Effect.map((activation) => + Stream.fromPubSub(activation.publications).pipe( + Stream.filter((publication) => publication.channel === request.channel), + Stream.map((publication) => publication.value), + ), + ), + ), + ), + }); + // Agents reach these through the `t3_plugins_*` tools, which MCP already gates as writes, so + // the call carries operate access. + yield* services.mcpTools.setHostActions({ + invoke: (pluginId, method, input) => + service.invoke({ + pluginId, + method, + input, + scopes: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope], + }), + setEnabled: service.setEnabled, + install: service.install, + uninstall: service.uninstall, + }); + return service; +}); + +export const layer = Layer.effect(PluginHost, make); diff --git a/apps/server/src/plugins/PluginHttpRoutes.test.ts b/apps/server/src/plugins/PluginHttpRoutes.test.ts new file mode 100644 index 0000000000000..f77380746f148 --- /dev/null +++ b/apps/server/src/plugins/PluginHttpRoutes.test.ts @@ -0,0 +1,168 @@ +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + type AuthEnvironmentScope, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + AuthSessionId, + PluginId, +} from "@t3tools/contracts"; +import type { PluginApi, PluginDefinition } from "@t3tools/plugin-sdk/server"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import { HttpClient, HttpClientRequest, HttpRouter } from "effect/http"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import { makeFacadeContext } from "./api/facade.ts"; +import { makeHttpApi } from "./api/http.ts"; +import { makePluginActivation, type PluginPublication } from "./PluginApi.ts"; +import * as PluginHttpRoutes from "./PluginHttpRoutes.ts"; + +const fixturesDir = new URL("../../../../packages/plugin-sdk/fixtures/", import.meta.url).pathname; +const httpPlugin = PluginId.make("http-plugin"); + +const scopesByToken: Record> = { + "Bearer read": [AuthOrchestrationReadScope], + "Bearer operate": [AuthOrchestrationReadScope, AuthOrchestrationOperateScope], +}; +const layerAuth = Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + authenticateHttpRequest: (request) => { + const scopes = scopesByToken[request.headers.authorization ?? ""]; + return scopes === undefined + ? Effect.fail(new EnvironmentAuth.ServerAuthMissingCredentialError()) + : Effect.succeed({ + sessionId: AuthSessionId.make("session"), + subject: "test", + method: "bearer-access-token", + scopes, + }); + }, +}); + +const isDefinition = (value: unknown): value is PluginDefinition => + typeof value === "object" && + value !== null && + "activate" in value && + typeof value.activate === "function"; + +/** Serves the route with the fixture activated through the `http` group, the one it uses. */ +const serveFixture = Effect.fn(function* () { + const services = yield* Layer.build( + HttpRouter.serve(PluginHttpRoutes.layerRoute, { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provideMerge(PluginHttpRoutes.layer), + Layer.provideMerge(layerAuth), + Layer.provideMerge(NodeHttpServer.layerTest), + ), + ); + const loaded: unknown = yield* Effect.promise( + () => import(`${fixturesDir}http-plugin/server.cjs`), + ); + const definition = + typeof loaded === "object" && loaded !== null && "default" in loaded + ? loaded.default + : undefined; + assert(isDefinition(definition)); + const facade = makeFacadeContext({ + pluginId: httpPlugin, + activation: makePluginActivation(yield* PubSub.sliding(1)), + context: yield* Effect.context(), + }); + const routes = Context.get(services, PluginHttpRoutes.PluginHttpRoutes); + const api: Pick = { http: makeHttpApi(facade, { httpRoutes: routes }) }; + yield* Effect.promise(() => Promise.resolve(definition.activate(api as PluginApi))); + const client = Context.get(services, HttpClient.HttpClient); + const send = Effect.fn(function* ( + method: "GET" | "POST", + path: string, + options: { readonly token?: string; readonly body?: string } = {}, + ) { + const response = yield* client.execute( + HttpClientRequest.make(method)(path).pipe( + options.token === undefined + ? (request) => request + : HttpClientRequest.setHeader("authorization", options.token), + options.body === undefined + ? (request) => request + : HttpClientRequest.bodyText(options.body, "application/json"), + ), + ); + return { + status: response.status, + contentType: response.headers["content-type"], + body: yield* response.text, + }; + }); + return { api, routes, send }; +}); + +it.effect("serves a plugin's routes and answers 404 and 500 for the rest", () => + Effect.gen(function* () { + const { api, routes, send } = yield* serveFixture(); + api.http.route("GET", "/fail", () => { + throw new Error("boom"); + }); + api.http.route("GET", "/files/*", (request) => ({ body: `any ${request.path}` })); + api.http.route("GET", "/files/deep/*", (request) => ({ body: `deep ${request.path}` })); + + assert.deepStrictEqual( + yield* send("GET", "/api/plugins/http-plugin/hello?name=ash", { token: "Bearer read" }), + { status: 200, contentType: "text/plain", body: "hello ash" }, + ); + assert.deepStrictEqual( + yield* send("POST", "/api/plugins/http-plugin/echo", { + token: "Bearer operate", + body: '{"ping":1}', + }), + { status: 201, contentType: "application/json", body: '{"ping":1}' }, + ); + assert.strictEqual( + (yield* send("GET", "/api/plugins/http-plugin/files/a/b.txt", { token: "Bearer read" })).body, + "any /files/a/b.txt", + ); + assert.strictEqual( + (yield* send("GET", "/api/plugins/http-plugin/files/deep/c.txt", { token: "Bearer read" })) + .body, + "deep /files/deep/c.txt", + ); + assert.strictEqual( + (yield* send("GET", "/api/plugins/other-plugin/hello", { token: "Bearer read" })).status, + 404, + ); + assert.strictEqual( + (yield* send("GET", "/api/plugins/http-plugin/missing", { token: "Bearer read" })).status, + 404, + ); + assert.deepStrictEqual( + yield* send("GET", "/api/plugins/http-plugin/fail", { token: "Bearer read" }), + { + status: 500, + contentType: "text/plain", + body: "Plugin http-plugin failed to handle the request.", + }, + ); + + yield* routes.unregisterPlugin(httpPlugin); + assert.strictEqual( + (yield* send("GET", "/api/plugins/http-plugin/hello", { token: "Bearer read" })).status, + 404, + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("signs in like the rest of /api: reads need read, other methods operate", () => + Effect.gen(function* () { + const { send } = yield* serveFixture(); + assert.strictEqual((yield* send("GET", "/api/plugins/http-plugin/hello")).status, 401); + assert.strictEqual( + (yield* send("POST", "/api/plugins/http-plugin/echo", { token: "Bearer read", body: "{}" })) + .status, + 403, + ); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/plugins/PluginHttpRoutes.ts b/apps/server/src/plugins/PluginHttpRoutes.ts new file mode 100644 index 0000000000000..088ff467f4b77 --- /dev/null +++ b/apps/server/src/plugins/PluginHttpRoutes.ts @@ -0,0 +1,234 @@ +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + PluginId, +} from "@t3tools/contracts"; +import type { PluginHttpRequest, PluginHttpResponse } from "@t3tools/plugin-sdk/server"; +import * as ByteSize from "effect/ByteSize"; +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 Schema from "effect/Schema"; +import { + HttpRouter, + HttpServerRequest, + HttpServerRespondable, + HttpServerResponse, +} from "effect/http"; +import * as HttpIncomingMessage from "effect/http/HttpIncomingMessage"; + +import { authenticateRawRouteWithScope } from "../http.ts"; + +const ROUTE_PREFIX = "/api/plugins"; +const MAX_BODY_BYTES = 1024 * 1024; +const MAX_PATH_CHARS = 1024; +const MAX_REASON_CHARS = 500; +const METHODS = ["GET", "POST", "PUT", "DELETE"] as const; +type PluginHttpMethod = (typeof METHODS)[number]; +// The caller's credentials stay with the server; a plugin that logs headers cannot leak them. +const WITHHELD_HEADERS = new Set(["authorization", "cookie"]); + +const isMethod = Schema.is(Schema.Literals(METHODS)); +// Plugins are untyped JavaScript, so their responses are checked before they reach the socket. +const isPluginResponse = Schema.is( + Schema.Struct({ + status: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 200, maximum: 599 }))), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + body: Schema.optional(Schema.Union([Schema.String, Schema.Uint8Array])), + }), +); + +export class PluginHttpRouteInvalidError extends Schema.TaggedError()( + "PluginHttpRouteInvalidError", + { pluginId: PluginId }, +) { + override get message(): string { + return `Routes need method GET, POST, PUT or DELETE and a path that starts with "/" and has no "?", "#" or spaces.`; + } +} +export const isPluginHttpRouteInvalidError = Schema.is(PluginHttpRouteInvalidError); + +export class PluginHttpRouteNotFoundError extends Schema.TaggedError()( + "PluginHttpRouteNotFoundError", + { pluginId: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} has no route for this request.`; + } +} + +// `reason` is the plugin's own error text, bounded; it goes to the log, not the response. +export class PluginHttpHandlerError extends Schema.TaggedError()( + "PluginHttpHandlerError", + { pluginId: Schema.String, reason: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Plugin ${this.pluginId} failed to handle the request.`; + } +} + +type PluginHttpRouteHandler = ( + request: PluginHttpRequest, + signal: AbortSignal, +) => Promise; + +export class PluginHttpRoutes extends Context.Service< + PluginHttpRoutes, + { + /** + * Serves `method path` under `/api/plugins/`, replacing a route of that method and + * path. A path ending in `/*` answers every path below it; the longest match wins. + */ + readonly register: (input: { + readonly pluginId: PluginId; + readonly method: PluginHttpMethod; + readonly path: string; + /** `signal` aborts when the client goes away. */ + readonly handler: PluginHttpRouteHandler; + }) => Effect.Effect; + readonly unregisterPlugin: (pluginId: PluginId) => Effect.Effect; + /** Answers a request already signed in and read; `path` is the part after the plugin id. */ + readonly handle: (input: { + readonly pluginId: string; + readonly request: PluginHttpRequest; + }) => Effect.Effect< + HttpServerResponse.HttpServerResponse, + PluginHttpRouteNotFoundError | PluginHttpHandlerError + >; + } +>()("t3/plugins/PluginHttpRoutes") {} + +const errorText = (cause: unknown) => { + let text = "Unknown error"; + try { + text = + cause instanceof Error && typeof cause.message === "string" ? cause.message : String(cause); + } catch { + // Plugin code can throw values whose conversion to a string throws again. + } + return text.slice(0, MAX_REASON_CHARS); +}; + +const isValidPath = (path: string) => + path.startsWith("/") && path.length <= MAX_PATH_CHARS && !/[?#\s]/.test(path); + +const findHandler = ( + pluginRoutes: ReadonlyMap | undefined, + method: string, + path: string, +) => { + if (pluginRoutes === undefined) return undefined; + const exact = pluginRoutes.get(`${method} ${path}`); + if (exact !== undefined) return exact; + // Paths start with "/", so the walk ends at index 0 with the `/*` catch-all. + for (let end = path.lastIndexOf("/"); end >= 0; end = path.lastIndexOf("/", end - 1)) { + const prefixed = pluginRoutes.get(`${method} ${path.slice(0, end)}/*`); + if (prefixed !== undefined) return prefixed; + if (end === 0) break; + } + return undefined; +}; + +const make = () => { + // pluginId -> "METHOD /path" -> handler + const routes = new Map>(); + return PluginHttpRoutes.of({ + register: Effect.fn("PluginHttpRoutes.register")(function* ({ + pluginId, + method, + path, + handler, + }) { + if (!isMethod(method) || !isValidPath(path)) { + return yield* new PluginHttpRouteInvalidError({ pluginId }); + } + const pluginRoutes = routes.get(pluginId) ?? new Map(); + routes.set(pluginId, pluginRoutes.set(`${method} ${path}`, handler)); + }), + unregisterPlugin: Effect.fn("PluginHttpRoutes.unregisterPlugin")((pluginId) => + Effect.sync(() => { + routes.delete(pluginId); + }), + ), + handle: Effect.fn("PluginHttpRoutes.handle")(function* ({ pluginId, request }) { + const handler = findHandler(routes.get(pluginId), request.method, request.path); + if (handler === undefined) { + return yield* new PluginHttpRouteNotFoundError({ pluginId }); + } + const failed = (cause: unknown) => + new PluginHttpHandlerError({ pluginId, reason: errorText(cause), cause }); + const response: unknown = yield* Effect.tryPromise({ + try: (signal) => handler(request, signal), + catch: failed, + }); + if (!isPluginResponse(response)) { + return yield* failed(new TypeError("The handler returned an invalid response.")); + } + const { body, ...options } = response; + const withStatus = { ...options, status: options.status ?? 200 }; + return body === undefined + ? HttpServerResponse.empty(withStatus) + : typeof body === "string" + ? HttpServerResponse.text(body, withStatus) + : HttpServerResponse.uint8Array(body, withStatus); + }), + }); +}; + +export const layer = Layer.sync(PluginHttpRoutes, make); + +/** Same sign-in as the rest of `/api`: reads need `orchestration:read`, everything else operate. */ +const authenticate = (request: HttpServerRequest.HttpServerRequest) => + authenticateRawRouteWithScope( + request.method === "GET" ? AuthOrchestrationReadScope : AuthOrchestrationOperateScope, + ); + +const tooLarge = HttpServerResponse.text("The request body is over 1 MiB.", { status: 413 }); + +/** `/api/plugins//`, answered by the route the plugin registered. */ +export const layerRoute = HttpRouter.add( + "*", + `${ROUTE_PREFIX}/*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + yield* authenticate(request); + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 }); + const rest = url.value.pathname.slice(`${ROUTE_PREFIX}/`.length); + const slash = rest.indexOf("/"); + const pluginId = slash === -1 ? rest : rest.slice(0, slash); + if (Number(request.headers["content-length"] ?? "0") > MAX_BODY_BYTES) return tooLarge; + const body = yield* request.text.pipe( + Effect.provideService(HttpIncomingMessage.MaxBodySize, ByteSize.bytes(MAX_BODY_BYTES)), + Effect.option, + ); + if (Option.isNone(body)) return tooLarge; + const routes = yield* PluginHttpRoutes; + return yield* routes.handle({ + pluginId, + request: { + method: request.method, + path: slash === -1 ? "/" : rest.slice(slash), + query: Object.fromEntries(url.value.searchParams), + headers: Object.fromEntries( + Object.entries(request.headers).filter(([name]) => !WITHHELD_HEADERS.has(name)), + ), + body: body.value, + }, + }); + }).pipe( + Effect.catchTags({ + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, + PluginHttpRouteNotFoundError: () => + Effect.succeed(HttpServerResponse.text("Not Found", { status: 404 })), + PluginHttpHandlerError: (error) => + Effect.logWarning("plugin http handler failed", { + pluginId: error.pluginId, + reason: error.reason, + }).pipe(Effect.as(HttpServerResponse.text(error.message, { status: 500 }))), + }), + ), +); diff --git a/apps/server/src/plugins/PluginMcpTools.test.ts b/apps/server/src/plugins/PluginMcpTools.test.ts new file mode 100644 index 0000000000000..fc21534b31810 --- /dev/null +++ b/apps/server/src/plugins/PluginMcpTools.test.ts @@ -0,0 +1,226 @@ +import { assert, it } from "@effect/vitest"; +import { + EnvironmentId, + PluginId, + type PluginState, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import type { PluginApi, PluginDefinition } from "@t3tools/plugin-sdk/server"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import { McpSchema, McpServer } from "effect/ai"; + +import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import { liveThreadsLayer } from "../mcp/McpToolAccess.testkit.ts"; +import { makeFacadeContext } from "./api/facade.ts"; +import { makeMcpApi } from "./api/mcp.ts"; +import { makePluginActivation, type PluginPublication } from "./PluginApi.ts"; +import * as PluginMcpTools from "./PluginMcpTools.ts"; + +const fixturesDir = new URL("../../../../packages/plugin-sdk/fixtures/", import.meta.url).pathname; +const mcpPlugin = PluginId.make("mcp-plugin"); + +const layerTest = McpHttpServer.layerPluginTools.pipe( + Layer.provideMerge(PluginMcpTools.layer), + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(liveThreadsLayer), +); + +const mcpClient = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "mcp-test", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "mcp-test", version: "1" }, + }, + getClient: Effect.die("unused"), +}); + +const threadCaller: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment"), + requestNamespace: "provider:thread", + thread: { + threadId: ThreadId.make("thread:caller"), + providerSessionId: "provider:thread", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(), + issuedAt: 0, +}; + +// Plugin write tools need what environment writes need: a full-access caller. +const fullAccessClient: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment"), + requestNamespace: "client:full", + thread: undefined, + client: { sessionId: "full", label: "Claude Code", access: "full-access" }, + capabilities: new Set(["orchestration"]), + issuedAt: 0, +}; + +const readOnlyClient: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment"), + requestNamespace: "client:session", + thread: undefined, + client: { sessionId: "session", label: "Claude Code", access: "read-only" }, + capabilities: new Set(), + issuedAt: 0, +}; + +const call = ( + name: string, + scope: McpInvocationContext.McpInvocationScope, + args: Record = {}, +) => + McpServer.McpServer.pipe( + Effect.flatMap((server) => server.callTool({ name, arguments: args })), + Effect.map((result) => ({ + isError: result.isError === true, + text: result.content.map((part) => (part.type === "text" ? part.text : "")).join(""), + })), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, mcpClient), + ); + +const toolNames = McpServer.McpServer.pipe( + Effect.map((server) => server.tools.map(({ tool }) => tool.name)), +); + +const isDefinition = (value: unknown): value is PluginDefinition => + typeof value === "object" && + value !== null && + "activate" in value && + typeof value.activate === "function"; + +/** Activates the fixture with only the `mcp` group, the one it uses. */ +const activateFixture = Effect.fn(function* () { + const loaded: unknown = yield* Effect.promise( + () => import(`${fixturesDir}mcp-plugin/server.cjs`), + ); + const definition = + typeof loaded === "object" && loaded !== null && "default" in loaded + ? loaded.default + : undefined; + assert(isDefinition(definition)); + const activation = makePluginActivation(yield* PubSub.sliding(1)); + const facade = makeFacadeContext({ + pluginId: mcpPlugin, + activation, + context: yield* Effect.context(), + }); + const api: Pick = { + mcp: makeMcpApi(facade, { mcpTools: yield* PluginMcpTools.PluginMcpTools }), + }; + yield* Effect.promise(() => Promise.resolve(definition.activate(api as PluginApi))); + return api; +}); + +const installed: PluginState = { + id: mcpPlugin, + name: "MCP tools", + version: "0.1.0", + permissions: [], + source: "dev", + enabled: true, + status: "active", + hasServer: true, + hasWeb: false, +}; + +it.effect("answer under plg_ names with access checks, then answer disabled", () => + Effect.gen(function* () { + const api = yield* activateFixture(); + api.mcp.registerTool( + { name: "fail", description: "Throws.", inputSchema: { type: "object" }, access: "read" }, + () => { + throw new Error("boom"); + }, + ); + assert.throws( + () => + api.mcp.registerTool( + { name: "no spaces", description: "", inputSchema: {}, access: "read" }, + () => null, + ), + TypeError, + ); + + const names = yield* toolNames; + assert.includeMembers(names, ["plg_mcp-plugin_echo", "plg_mcp-plugin_touch"]); + assert.deepStrictEqual(yield* call("plg_mcp-plugin_echo", readOnlyClient, { text: "hi" }), { + isError: false, + text: '{"echoed":"hi"}', + }); + const refused = yield* call("plg_mcp-plugin_touch", readOnlyClient); + assert.isTrue(refused.isError); + assert.include(refused.text, "read-only"); + assert.deepStrictEqual(yield* call("plg_mcp-plugin_touch", fullAccessClient), { + isError: false, + text: '{"touches":1}', + }); + assert.deepStrictEqual(yield* call("plg_mcp-plugin_fail", threadCaller), { + isError: true, + text: "Plugin mcp-plugin failed in fail: boom", + }); + + const tools = yield* PluginMcpTools.PluginMcpTools; + yield* tools.unregisterPlugin(mcpPlugin); + assert.deepStrictEqual(yield* call("plg_mcp-plugin_echo", threadCaller, { text: "hi" }), { + isError: true, + text: "Plugin mcp-plugin is disabled.", + }); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("offers the plugin management tools only once a plugin is installed", () => + Effect.gen(function* () { + const tools = yield* PluginMcpTools.PluginMcpTools; + const invoked: Array = []; + yield* tools.setHostActions({ + invoke: (pluginId, method, input) => + Effect.sync(() => { + invoked.push({ pluginId, method, input }); + return "pong"; + }), + setEnabled: (pluginId, enabled) => Effect.succeed({ ...installed, id: pluginId, enabled }), + install: () => Effect.succeed(installed), + uninstall: () => Effect.void, + }); + yield* tools.setInstalledPlugins([]); + assert.notInclude(yield* toolNames, "t3_plugins_list"); + + yield* tools.setInstalledPlugins([installed]); + assert.includeMembers(yield* toolNames, [ + "t3_plugins_list", + "t3_plugins_invoke", + "t3_plugins_set_enabled", + "t3_plugins_install", + "t3_plugins_uninstall", + ]); + assert.deepStrictEqual(yield* call("t3_plugins_list", readOnlyClient), { + isError: false, + text: '{"plugins":[{"id":"mcp-plugin","name":"MCP tools","version":"0.1.0","status":"active","enabled":true}]}', + }); + const ping = { pluginId: "mcp-plugin", method: "ping", input: { n: 1 } }; + assert.isTrue((yield* call("t3_plugins_invoke", readOnlyClient, ping)).isError); + assert.deepStrictEqual(yield* call("t3_plugins_invoke", fullAccessClient, ping), { + isError: false, + text: '"pong"', + }); + assert.deepStrictEqual(invoked, [ping]); + assert.deepStrictEqual( + yield* call("t3_plugins_set_enabled", fullAccessClient, { + pluginId: "mcp-plugin", + enabled: false, + }), + { isError: false, text: '{"id":"mcp-plugin","status":"active","enabled":false}' }, + ); + }).pipe(Effect.provide(layerTest)), +); diff --git a/apps/server/src/plugins/PluginMcpTools.ts b/apps/server/src/plugins/PluginMcpTools.ts new file mode 100644 index 0000000000000..8bf9b1cb69cb1 --- /dev/null +++ b/apps/server/src/plugins/PluginMcpTools.ts @@ -0,0 +1,373 @@ +import { PluginId, PluginInstallSource, type PluginState } from "@t3tools/contracts"; +import type { PluginMcpTool, PluginRpcAccess } from "@t3tools/plugin-sdk/server"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; + +// MCP clients reject longer tool names. +const MAX_TOOL_NAME_CHARS = 64; +const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; +const MAX_REASON_CHARS = 500; +const PLUGINS_LIST_TOOL = "t3_plugins_list"; +const PLUGINS_INVOKE_TOOL = "t3_plugins_invoke"; +const PLUGINS_SET_ENABLED_TOOL = "t3_plugins_set_enabled"; +const PLUGINS_INSTALL_TOOL = "t3_plugins_install"; +const PLUGINS_UNINSTALL_TOOL = "t3_plugins_uninstall"; + +const isJson = Schema.is(Schema.Json); + +export class PluginMcpToolNameError extends Schema.TaggedError()( + "PluginMcpToolNameError", + { pluginId: PluginId }, +) { + override get message(): string { + return `Tool names use letters, digits, "_" and "-", and plg_${this.pluginId}_ must fit in ${MAX_TOOL_NAME_CHARS} characters.`; + } +} +export const isPluginMcpToolNameError = Schema.is(PluginMcpToolNameError); + +// `reason` is the plugin's own error text, bounded, so the agent can see why the tool failed. +export class PluginMcpToolError extends Schema.TaggedError()( + "PluginMcpToolError", + { pluginId: PluginId, tool: Schema.String, reason: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Plugin ${this.pluginId} failed in ${this.tool}: ${this.reason}`; + } +} + +export class PluginMcpToolDisabledError extends Schema.TaggedError()( + "PluginMcpToolDisabledError", + { pluginId: PluginId }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is disabled.`; + } +} + +export class PluginMcpManagementError extends Schema.TaggedError()( + "PluginMcpManagementError", + { reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} + +/** What the management tools do. The plugin host supplies them, since it depends on this service. */ +export interface PluginMcpHostActions { + readonly invoke: ( + pluginId: PluginId, + method: string, + input: Schema.Json | undefined, + ) => Effect.Effect; + readonly setEnabled: ( + pluginId: PluginId, + enabled: boolean, + ) => Effect.Effect; + readonly install: ( + source: PluginInstallSource, + ) => Effect.Effect; + readonly uninstall: ( + pluginId: PluginId, + removeData: boolean, + ) => Effect.Effect; +} + +const decodeInvokeInput = Schema.decodeUnknownEffect( + Schema.Struct({ + pluginId: PluginId, + method: Schema.String, + input: Schema.optional(Schema.Json), + }), +); +const decodeSetEnabledInput = Schema.decodeUnknownEffect( + Schema.Struct({ pluginId: PluginId, enabled: Schema.Boolean }), +); +const decodeInstallInput = Schema.decodeUnknownEffect( + Schema.Struct({ source: PluginInstallSource }), +); +const decodeUninstallInput = Schema.decodeUnknownEffect( + Schema.Struct({ pluginId: PluginId, removeData: Schema.optional(Schema.Boolean) }), +); + +/** One tool as `/mcp` advertises it. `call` runs it; McpHttpServer checks `access` first. */ +export interface PluginMcpToolEntry { + readonly name: string; + readonly description: string; + readonly inputSchema: Schema.JsonObject; + readonly access: PluginRpcAccess; + readonly call: ( + input: Schema.JsonObject, + ) => Effect.Effect< + Schema.Json, + PluginMcpToolError | PluginMcpToolDisabledError | PluginMcpManagementError + >; +} + +export class PluginMcpTools extends Context.Service< + PluginMcpTools, + { + /** Offers `tool` to agents as `plg__`, replacing a tool of that name. */ + readonly register: (input: { + readonly pluginId: PluginId; + readonly tool: PluginMcpTool; + /** `signal` aborts when the agent cancels the call. */ + readonly handler: (input: Schema.JsonObject, signal: AbortSignal) => Promise; + }) => Effect.Effect; + /** MCP has no tool removal, so each of the plugin's tools answers "disabled" instead. */ + readonly unregisterPlugin: (pluginId: PluginId) => Effect.Effect; + /** + * Offers the `t3_plugins_*` management tools once a plugin is installed; agents without + * plugins never pay for them. + */ + readonly setInstalledPlugins: (plugins: ReadonlyArray) => Effect.Effect; + /** Connects the management tools to the plugin host. */ + readonly setHostActions: (actions: PluginMcpHostActions) => Effect.Effect; + /** Hands `publish` every tool now and each change until the scope closes. */ + readonly attach: ( + publish: (tool: PluginMcpToolEntry) => Effect.Effect, + ) => Effect.Effect; + } +>()("t3/plugins/PluginMcpTools") {} + +const errorText = (cause: unknown) => { + let text = "Unknown error"; + try { + text = + cause instanceof Error && typeof cause.message === "string" ? cause.message : String(cause); + } catch { + // Plugin code can throw values whose conversion to a string throws again. + } + return text.slice(0, MAX_REASON_CHARS); +}; + +const make = () => { + // Every change runs synchronously, so plugin activation can register a tool and return. + const tools = new Map(); + const toolsByPlugin = new Map>(); + const publishers = new Set<(tool: PluginMcpToolEntry) => Effect.Effect>(); + let installed: ReadonlyArray = []; + let hostActions: PluginMcpHostActions | undefined; + const withHost = ( + run: (actions: PluginMcpHostActions) => Effect.Effect, + ) => + hostActions === undefined + ? Effect.fail(new PluginMcpManagementError({ reason: "Plugins are still starting." })) + : run(hostActions).pipe( + Effect.mapError((error) => new PluginMcpManagementError({ reason: error.message })), + ); + const invalidInput = (cause: { readonly message: string }) => + new PluginMcpManagementError({ reason: `Invalid input: ${cause.message}` }); + + const publish = Effect.fnUntraced(function* (entry: PluginMcpToolEntry) { + tools.set(entry.name, entry); + for (const publisher of publishers) yield* publisher(entry); + }); + + const pluginsList: PluginMcpToolEntry = { + name: PLUGINS_LIST_TOOL, + description: + "List the plugins installed in this T3 Code environment, with each one's status and whether it is enabled.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + access: "read", + call: () => + Effect.sync(() => ({ + plugins: installed.map((plugin) => ({ + id: plugin.id, + name: plugin.name, + version: plugin.version ?? null, + status: plugin.status, + enabled: plugin.enabled, + })), + })), + }; + + const pluginsInvoke: PluginMcpToolEntry = { + name: PLUGINS_INVOKE_TOOL, + description: + "Call a method an installed plugin registered, with optional JSON input, and return its result. Use t3_plugins_list to see plugins.", + inputSchema: { + type: "object", + properties: { + pluginId: { type: "string" }, + method: { type: "string" }, + input: { description: "JSON passed to the method." }, + }, + required: ["pluginId", "method"], + additionalProperties: false, + }, + access: "write", + call: (input) => + decodeInvokeInput(input).pipe( + Effect.mapError(invalidInput), + Effect.flatMap((request) => + withHost((actions) => actions.invoke(request.pluginId, request.method, request.input)), + ), + ), + }; + + const pluginsSetEnabled: PluginMcpToolEntry = { + name: PLUGINS_SET_ENABLED_TOOL, + description: "Turn an installed plugin on or off and return its new status.", + inputSchema: { + type: "object", + properties: { pluginId: { type: "string" }, enabled: { type: "boolean" } }, + required: ["pluginId", "enabled"], + additionalProperties: false, + }, + access: "write", + call: (input) => + decodeSetEnabledInput(input).pipe( + Effect.mapError(invalidInput), + Effect.flatMap((request) => + withHost((actions) => actions.setEnabled(request.pluginId, request.enabled)), + ), + Effect.map((state) => ({ id: state.id, status: state.status, enabled: state.enabled })), + ), + }; + + const pluginsInstall: PluginMcpToolEntry = { + name: PLUGINS_INSTALL_TOOL, + description: + "Install a plugin into this T3 Code environment from a folder on the server, a git repository or npm, then enable it. Plugins run with T3 Code's own access, so only install what the user asked for.", + inputSchema: { + type: "object", + properties: { + source: { + oneOf: [ + { + type: "object", + properties: { type: { const: "path" }, path: { type: "string" } }, + required: ["type", "path"], + }, + { + type: "object", + properties: { + type: { const: "git" }, + url: { type: "string" }, + ref: { type: "string" }, + }, + required: ["type", "url"], + }, + { + type: "object", + properties: { + type: { const: "npm" }, + name: { type: "string" }, + version: { type: "string" }, + }, + required: ["type", "name"], + }, + ], + }, + }, + required: ["source"], + additionalProperties: false, + }, + access: "write", + call: (input) => + decodeInstallInput(input).pipe( + Effect.mapError(invalidInput), + Effect.flatMap((request) => withHost((actions) => actions.install(request.source))), + Effect.map((state) => ({ id: state.id, status: state.status, enabled: state.enabled })), + ), + }; + + const pluginsUninstall: PluginMcpToolEntry = { + name: PLUGINS_UNINSTALL_TOOL, + description: + "Remove an installed plugin. Set removeData to also delete its settings and stored data.", + inputSchema: { + type: "object", + properties: { pluginId: { type: "string" }, removeData: { type: "boolean" } }, + required: ["pluginId"], + additionalProperties: false, + }, + access: "write", + call: (input) => + decodeUninstallInput(input).pipe( + Effect.mapError(invalidInput), + Effect.flatMap((request) => + withHost((actions) => actions.uninstall(request.pluginId, request.removeData === true)), + ), + Effect.as({ removed: true }), + ), + }; + + return PluginMcpTools.of({ + register: Effect.fn("PluginMcpTools.register")(function* ({ pluginId, tool, handler }) { + const name = `plg_${pluginId}_${tool.name}`; + if (!TOOL_NAME_PATTERN.test(tool.name) || name.length > MAX_TOOL_NAME_CHARS) { + return yield* new PluginMcpToolNameError({ pluginId }); + } + const failed = (cause: unknown) => + new PluginMcpToolError({ pluginId, tool: tool.name, reason: errorText(cause), cause }); + const names = toolsByPlugin.get(pluginId) ?? new Set(); + toolsByPlugin.set(pluginId, names.add(name)); + yield* publish({ + name, + description: tool.description, + inputSchema: tool.inputSchema, + access: tool.access, + call: (input) => + Effect.tryPromise({ try: (signal) => handler(input, signal), catch: failed }).pipe( + Effect.flatMap((output: unknown) => + output === undefined + ? Effect.succeed(null) + : isJson(output) + ? Effect.succeed(output) + : Effect.fail( + failed(new TypeError("The tool returned a value that is not JSON.")), + ), + ), + ), + }); + }), + unregisterPlugin: Effect.fn("PluginMcpTools.unregisterPlugin")(function* (pluginId) { + const names = toolsByPlugin.get(pluginId); + toolsByPlugin.delete(pluginId); + for (const name of names ?? []) { + const entry = tools.get(name); + if (entry === undefined) continue; + yield* publish({ + ...entry, + call: () => Effect.fail(new PluginMcpToolDisabledError({ pluginId })), + }); + } + }), + setInstalledPlugins: Effect.fn("PluginMcpTools.setInstalledPlugins")(function* (plugins) { + installed = plugins; + // Once advertised it stays: the list then reads empty when the last plugin is removed. + if (plugins.length > 0 && !tools.has(PLUGINS_LIST_TOOL)) { + for (const entry of [ + pluginsList, + pluginsInvoke, + pluginsSetEnabled, + pluginsInstall, + pluginsUninstall, + ]) { + yield* publish(entry); + } + } + }), + setHostActions: Effect.fn("PluginMcpTools.setHostActions")((actions) => + Effect.sync(() => { + hostActions = actions; + }), + ), + attach: Effect.fn("PluginMcpTools.attach")((publisher) => + Effect.acquireRelease( + Effect.gen(function* () { + publishers.add(publisher); + for (const entry of tools.values()) yield* publisher(entry); + }), + () => Effect.sync(() => publishers.delete(publisher)), + ), + ), + }); +}; + +export const layer = Layer.sync(PluginMcpTools, make); diff --git a/apps/server/src/plugins/PluginSettingsStore.test.ts b/apps/server/src/plugins/PluginSettingsStore.test.ts new file mode 100644 index 0000000000000..f9546057b9436 --- /dev/null +++ b/apps/server/src/plugins/PluginSettingsStore.test.ts @@ -0,0 +1,126 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PluginId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { SECRET_REDACTED } from "../settingsSecrets.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; + +const hello = PluginId.make("hello"); + +const settingsSchema: Schema.JsonObject = { + type: "object", + properties: { + count: { type: "integer", minimum: 0 }, + name: { type: "string" }, + token: { type: "string", format: "t3-secret" }, + }, + additionalProperties: false, +}; + +const layerTest = () => + Layer.mergeAll(PluginSettingsStore.layer, ServerSettings.layer).pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-settings-test-" }), + ), + ), + ); + +const readSettingsFile = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fileSystem.readFileString( + path.join(stateDir, "plugin-data", hello, "settings.json"), + ); +}); + +it.layer(NodeServices.layer)("PluginSettingsStore", (it) => { + it.effect("rejects values outside the schema without writing them", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + yield* store.update(hello, settingsSchema, { count: 1 }); + + const error = yield* Effect.flip( + store.update(hello, settingsSchema, { count: -1, extra: true }), + ); + assert.strictEqual(error._tag, "PluginSettingsValidationError"); + assert.include(error.message, 'at ["count"]: Expected a value greater than or equal to 0'); + assert.include(error.message, 'at ["extra"]: Expected no excess property'); + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { count: 1 }); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("keeps secrets out of settings.json and redacts them for clients", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const { secretsDir } = yield* ServerConfig.ServerConfig; + + assert.deepStrictEqual( + yield* store.update(hello, settingsSchema, { name: "a", token: "s3cret" }), + { name: "a", token: SECRET_REDACTED }, + ); + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { + name: "a", + token: "s3cret", + }); + assert.deepStrictEqual(JSON.parse(yield* readSettingsFile), { name: "a" }); + + yield* store.update(hello, settingsSchema, { name: "b", token: SECRET_REDACTED }); + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { + name: "b", + token: "s3cret", + }); + + yield* store.update(hello, settingsSchema, { token: null }); + assert.deepStrictEqual(yield* store.getForClient(hello, settingsSchema), { name: "b" }); + const secretNames = yield* FileSystem.FileSystem.use((fileSystem) => + fileSystem.readDirectory(secretsDir), + ); + assert.isFalse(secretNames.some((name) => name.startsWith(`plugin-${hello}-`))); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("keeps plugin settings when the server saves its own settings", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const serverSettings = yield* ServerSettings.ServerSettingsService; + yield* store.update(hello, settingsSchema, { count: 2, token: "s3cret" }); + + yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/next" }); + + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { + count: 2, + token: "s3cret", + }); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("emits the plugin view after an update of that plugin", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const next = yield* store + .changes(hello) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + + yield* store.update(PluginId.make("other"), undefined, { count: 9 }); + yield* store.update(hello, settingsSchema, { count: 3, token: "s3cret" }); + + assert.deepStrictEqual(yield* Fiber.join(next), Option.some({ count: 3, token: "s3cret" })); + }).pipe(Effect.provide(layerTest())), + ); +}); diff --git a/apps/server/src/plugins/PluginSettingsStore.ts b/apps/server/src/plugins/PluginSettingsStore.ts new file mode 100644 index 0000000000000..fcb004dc994f2 --- /dev/null +++ b/apps/server/src/plugins/PluginSettingsStore.ts @@ -0,0 +1,364 @@ +import { PluginId } from "@t3tools/contracts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as JsonSchema from "effect/JsonSchema"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Predicate from "effect/Predicate"; +import * as PubSub from "effect/PubSub"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; +import * as SchemaRepresentation from "effect/SchemaRepresentation"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { SECRET_REDACTED } from "../settingsSecrets.ts"; + +// The Settings page shows the summary, so it stays readable when a patch breaks many fields. +const MAX_VALIDATION_SUMMARY_LENGTH = 1000; + +const persistenceOperations = { + read: "read the settings", + write: "save the settings", + readSecret: "read a secret setting", + writeSecret: "save a secret setting", +} as const; + +export class PluginSettingsPersistenceError extends Schema.TaggedError()( + "PluginSettingsPersistenceError", + { + pluginId: PluginId, + operation: Schema.Literals(["read", "write", "readSecret", "writeSecret"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${persistenceOperations[this.operation]} of plugin ${this.pluginId}.`; + } +} + +export class PluginSettingsSchemaError extends Schema.TaggedError()( + "PluginSettingsSchemaError", + { + pluginId: PluginId, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Plugin ${this.pluginId} has a settings schema T3 Code cannot validate.`; + } +} + +export class PluginSettingsValidationError extends Schema.TaggedError()( + "PluginSettingsValidationError", + { + pluginId: PluginId, + summary: Schema.String, + }, +) { + override get message(): string { + return `Invalid settings for plugin ${this.pluginId}: ${this.summary}`; + } +} + +export class PluginSettingsStore extends Context.Service< + PluginSettingsStore, + { + /** Stored settings with secret values, for the plugin. JSON Schema defaults are not applied. */ + readonly get: ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) => Effect.Effect; + /** Stored settings for clients. A secret that has a value reads as `SECRET_REDACTED`. */ + readonly getForClient: ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) => Effect.Effect; + /** + * Merges `patch` over the stored settings at the top level, where `null` removes a key and + * `SECRET_REDACTED` keeps a secret, validates the result, saves it and returns the client view. + */ + readonly update: ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + patch: Schema.JsonObject, + ) => Effect.Effect< + Schema.JsonObject, + PluginSettingsPersistenceError | PluginSettingsSchemaError | PluginSettingsValidationError + >; + /** The plugin view after each successful `update` of that plugin. */ + readonly changes: (pluginId: PluginId) => Stream.Stream; + /** Deletes the plugin's settings file and the secrets its schema declares. */ + readonly clear: ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) => Effect.Effect; + } +>()("t3/plugins/PluginSettingsStore") {} + +interface StoredSettings { + readonly values: Schema.JsonObject; + readonly secrets: Readonly>; +} + +interface SettingsChange { + readonly pluginId: PluginId; + readonly settings: Schema.JsonObject; +} + +const SettingsJson = fromJsonStringPretty(Schema.JsonObject); +const decodeSettingsJson = Schema.decodeUnknownEffect(SettingsJson); +const encodeSettingsJson = Schema.encodeEffect(SettingsJson); +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +// Top-level string fields with `format: "t3-secret"` live in ServerSecretStore, never settings.json. +const secretFields = (settingsSchema: Schema.JsonObject | undefined): ReadonlySet => { + const properties = settingsSchema?.properties; + if (!Predicate.isObject(properties)) return new Set(); + return new Set( + Object.entries(properties).flatMap(([field, property]) => + Predicate.isObject(property) && property.type === "string" && property.format === "t3-secret" + ? [field] + : [], + ), + ); +}; + +// Secret names become file names, so the field is base64url encoded. +const secretName = (pluginId: PluginId, field: string) => + `plugin-${pluginId}-${Buffer.from(field, "utf8").toString("base64url")}`; + +const formatPathSegment = (segment: PropertyKey | { readonly key: PropertyKey }) => { + const key = Predicate.isObject(segment) ? segment.key : segment; + return typeof key === "number" ? `[${key}]` : `["${String(key)}"]`; +}; + +// Issues never carry input values because decoding does not set `reportInput`. +const summarizeIssue = (issue: SchemaIssue.Issue) => { + const summary = SchemaIssue.makeFormatterStandardSchemaV1()(issue) + .issues.map(({ path, message }) => + path === undefined || path.length === 0 + ? message + : `at ${path.map(formatPathSegment).join("")}: ${message}`, + ) + .join("; "); + return summary.length > MAX_VALIDATION_SUMMARY_LENGTH + ? `${summary.slice(0, MAX_VALIDATION_SUMMARY_LENGTH - 1)}…` + : summary; +}; + +const compileSettingsSchema = (settingsSchema: Schema.JsonObject) => + Result.try(() => + Schema.decodeUnknownResult( + // Imported JSON Schemas need no decoding services, which `Schema.Top` cannot express. + Schema.make>( + SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12(settingsSchema), + // Plugins are trusted code, so their patterns are applied rather than rejected. + { patterns: "apply" }, + ).ast, + ), + { onExcessProperty: "error", errors: "all" }, + ), + ); + +const make = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const writeSemaphore = yield* Semaphore.make(1); + // Sliding, so a subscriber that stops pulling drops old changes instead of blocking `update`. + const changesPubSub = yield* PubSub.sliding(64); + // Keyed by schema identity: a reloaded plugin brings a new manifest object. + const validators = new WeakMap>(); + + const settingsPath = (pluginId: PluginId) => + path.join(stateDir, "plugin-data", pluginId, "settings.json"); + + const validatorFor = (pluginId: PluginId, settingsSchema: Schema.JsonObject) => { + let validator = validators.get(settingsSchema); + if (validator === undefined) { + validator = compileSettingsSchema(settingsSchema); + validators.set(settingsSchema, validator); + } + return Result.isSuccess(validator) + ? Effect.succeed(validator.success) + : Effect.fail(new PluginSettingsSchemaError({ pluginId, cause: validator.failure })); + }; + + // Shares the span of the public operation that reads. + const load = Effect.fnUntraced( + function* (pluginId: PluginId, fields: ReadonlySet) { + const raw = yield* fileSystem.readFileString(settingsPath(pluginId)).pipe( + Effect.asSome, + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeedNone, + ), + ); + const stored = Option.isSome(raw) ? yield* decodeSettingsJson(raw.value) : {}; + const values = Object.fromEntries(Object.entries(stored).filter(([key]) => !fields.has(key))); + const secrets = yield* Effect.forEach( + fields, + (field) => + secretStore.get(secretName(pluginId, field)).pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ pluginId, operation: "readSecret", cause }), + ), + Effect.map(Option.map((bytes) => [field, textDecoder.decode(bytes)] as const)), + ), + { concurrency: "unbounded" }, + ); + return { + values, + secrets: Object.fromEntries(secrets.flatMap((secret) => Option.toArray(secret))), + } satisfies StoredSettings; + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + PlatformError: (cause) => + Effect.fail(new PluginSettingsPersistenceError({ pluginId, operation: "read", cause })), + SchemaError: (cause) => + Effect.fail(new PluginSettingsPersistenceError({ pluginId, operation: "read", cause })), + }), + ), + ); + + const clientView = ({ values, secrets }: StoredSettings): Schema.JsonObject => ({ + ...values, + ...Object.fromEntries(Object.keys(secrets).map((field) => [field, SECRET_REDACTED])), + }); + + const get = Effect.fn("PluginSettingsStore.get")(function* ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) { + const { values, secrets } = yield* load(pluginId, secretFields(settingsSchema)); + return { ...values, ...secrets } satisfies Schema.JsonObject; + }); + + const getForClient = Effect.fn("PluginSettingsStore.getForClient")(function* ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) { + return clientView(yield* load(pluginId, secretFields(settingsSchema))); + }); + + const update = Effect.fn("PluginSettingsStore.update")(function* ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + patch: Schema.JsonObject, + ) { + const fields = secretFields(settingsSchema); + const validate = + settingsSchema === undefined ? undefined : yield* validatorFor(pluginId, settingsSchema); + return yield* writeSemaphore.withPermits(1)( + Effect.gen(function* () { + const current = yield* load(pluginId, fields); + const merged: Record = { ...current.values, ...current.secrets }; + for (const [key, value] of Object.entries(patch)) { + if (fields.has(key) && value === SECRET_REDACTED) continue; + if (value === null) delete merged[key]; + else merged[key] = value; + } + + // Secrets are validated with their real values so `required` and type checks hold. + const validation = validate?.(merged); + if (validation !== undefined && Result.isFailure(validation)) { + return yield* new PluginSettingsValidationError({ + pluginId, + summary: summarizeIssue(validation.failure.issue), + }); + } + + const next: StoredSettings = { + values: Object.fromEntries(Object.entries(merged).filter(([key]) => !fields.has(key))), + secrets: Object.fromEntries( + Object.entries(merged).filter( + (entry): entry is [string, string] => + fields.has(entry[0]) && typeof entry[1] === "string", + ), + ), + }; + yield* Effect.forEach( + fields, + (field) => { + const value = next.secrets[field]; + if (value === current.secrets[field]) return Effect.void; + const name = secretName(pluginId, field); + return ( + value === undefined + ? secretStore.remove(name) + : secretStore.set(name, textEncoder.encode(value)) + ).pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ pluginId, operation: "writeSecret", cause }), + ), + ); + }, + { discard: true }, + ); + yield* encodeSettingsJson(next.values).pipe( + Effect.flatMap((contents) => + writeFileStringAtomically({ filePath: settingsPath(pluginId), contents }), + ), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => new PluginSettingsPersistenceError({ pluginId, operation: "write", cause }), + ), + ); + + yield* PubSub.publish(changesPubSub, { + pluginId, + settings: { ...next.values, ...next.secrets }, + }); + return clientView(next); + }), + ); + }); + + const changes = (pluginId: PluginId) => + Stream.fromPubSub(changesPubSub).pipe( + Stream.filter((change) => change.pluginId === pluginId), + Stream.map((change) => change.settings), + ); + + const clear: PluginSettingsStore["Service"]["clear"] = Effect.fn("PluginSettingsStore.clear")( + function* (pluginId, settingsSchema) { + for (const field of secretFields(settingsSchema)) { + yield* secretStore + .remove(secretName(pluginId, field)) + .pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ pluginId, operation: "writeSecret", cause }), + ), + ); + } + yield* fileSystem + .remove(path.dirname(settingsPath(pluginId)), { recursive: true, force: true }) + .pipe( + Effect.mapError( + (cause) => new PluginSettingsPersistenceError({ pluginId, operation: "write", cause }), + ), + ); + }, + ); + + return PluginSettingsStore.of({ get, getForClient, update, changes, clear }); +}); + +export const layer = Layer.effect(PluginSettingsStore, make); diff --git a/apps/server/src/plugins/PluginStorage.test.ts b/apps/server/src/plugins/PluginStorage.test.ts new file mode 100644 index 0000000000000..022fa4fa13d5f --- /dev/null +++ b/apps/server/src/plugins/PluginStorage.test.ts @@ -0,0 +1,110 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PluginId } from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/sql/SqlClient"; + +import * as ServerConfig from "../config.ts"; +import * as PluginStorage from "./PluginStorage.ts"; + +const hello = PluginId.make("hello"); +const other = PluginId.make("other"); + +const layerConfig = () => + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-storage-test-" })); + +// A fresh layer per call, so closing it closes its SQLite connection. +const withStorage = ( + use: (storage: PluginStorage.PluginStorage["Service"]) => Effect.Effect, +) => PluginStorage.PluginStorage.use(use).pipe(Effect.provide(Layer.fresh(PluginStorage.layer))); + +it.layer(NodeServices.layer)("PluginStorage", (it) => { + it.effect("creates its own plugins.sqlite on first use", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const filename = path.join(stateDir, "plugins.sqlite"); + + yield* withStorage((storage) => + Effect.gen(function* () { + assert.isFalse(yield* fileSystem.exists(filename)); + yield* storage.set(hello, "greeting", { text: "hi" }); + }), + ); + + const rows = yield* SqlClient.SqlClient.use( + (sql) => sql<{ readonly key: string }>`SELECT key FROM plugin_storage`, + ).pipe(Effect.provide(NodeSqliteClient.layer({ filename, readonly: true }))); + assert.deepStrictEqual( + rows.map((row) => row.key), + ["greeting"], + ); + assert.isFalse(yield* fileSystem.exists(path.join(stateDir, "statev2.sqlite"))); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("round-trips values and keeps them after the layer closes", () => + Effect.gen(function* () { + yield* withStorage((storage) => + Effect.gen(function* () { + yield* storage.set(hello, "b", [1, "two", null]); + yield* storage.set(hello, "a", { nested: { ok: true } }); + yield* storage.set(hello, "gone", 1); + yield* storage.set(other, "a", "other plugin"); + yield* storage.set(hello, "a", { nested: { ok: false } }); + yield* storage.remove(hello, "gone"); + + assert.deepStrictEqual(yield* storage.list(hello), ["a", "b"]); + assert.deepStrictEqual(yield* storage.get(hello, "gone"), Option.none()); + }), + ); + + yield* withStorage((storage) => + Effect.gen(function* () { + assert.deepStrictEqual( + yield* storage.get(hello, "a"), + Option.some({ nested: { ok: false } }), + ); + assert.deepStrictEqual(yield* storage.get(hello, "b"), Option.some([1, "two", null])); + assert.deepStrictEqual(yield* storage.get(other, "a"), Option.some("other plugin")); + }), + ); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("matches list prefixes literally", () => + withStorage((storage) => + Effect.gen(function* () { + for (const key of ["a_b", "axb", "A_b", "a%c", "abc"]) { + yield* storage.set(hello, key, true); + } + yield* storage.set(other, "a_other", true); + + assert.deepStrictEqual(yield* storage.list(hello, "a_"), ["a_b"]); + assert.deepStrictEqual(yield* storage.list(hello, "a%"), ["a%c"]); + }), + ).pipe(Effect.provide(layerConfig())), + ); + + it.effect("rejects oversize values and keys", () => + withStorage((storage) => + Effect.gen(function* () { + const tooLarge = yield* Effect.flip(storage.set(hello, "big", "x".repeat(1024 * 1024))); + assert.strictEqual(tooLarge._tag, "PluginStorageValueTooLargeError"); + + const emptyKey = yield* Effect.flip(storage.set(hello, "", 1)); + assert.strictEqual(emptyKey._tag, "PluginStorageKeyError"); + const longKey = yield* Effect.flip(storage.get(hello, "k".repeat(513))); + assert.strictEqual(longKey._tag, "PluginStorageKeyError"); + + assert.deepStrictEqual(yield* storage.list(hello), []); + }), + ).pipe(Effect.provide(layerConfig())), + ); +}); diff --git a/apps/server/src/plugins/PluginStorage.ts b/apps/server/src/plugins/PluginStorage.ts new file mode 100644 index 0000000000000..e5bb5b498df29 --- /dev/null +++ b/apps/server/src/plugins/PluginStorage.ts @@ -0,0 +1,237 @@ +import { PluginId } from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as SqlClient from "effect/sql/SqlClient"; + +import * as ServerConfig from "../config.ts"; + +const MAX_KEY_LENGTH = 512; +const MAX_VALUE_BYTES = 1024 * 1024; + +export class PluginStoragePersistenceError extends Schema.TaggedError()( + "PluginStoragePersistenceError", + { + pluginId: PluginId, + operation: Schema.Literals(["open", "get", "set", "remove", "list"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Plugin storage ${this.operation} failed for plugin ${this.pluginId}.`; + } +} + +export class PluginStorageKeyError extends Schema.TaggedError()( + "PluginStorageKeyError", + { + pluginId: PluginId, + length: Schema.Number, + }, +) { + override get message(): string { + return `Plugin storage keys must be 1 to ${MAX_KEY_LENGTH} characters long, got ${this.length}.`; + } +} + +export class PluginStorageInvalidValueError extends Schema.TaggedError()( + "PluginStorageInvalidValueError", + { + pluginId: PluginId, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Plugin storage values must be JSON."; + } +} + +export class PluginStorageValueTooLargeError extends Schema.TaggedError()( + "PluginStorageValueTooLargeError", + { + pluginId: PluginId, + bytes: Schema.Number, + }, +) { + override get message(): string { + return `Plugin storage values must be at most ${MAX_VALUE_BYTES} bytes of JSON, got ${this.bytes}.`; + } +} + +export class PluginStorage extends Context.Service< + PluginStorage, + { + readonly get: ( + pluginId: PluginId, + key: string, + ) => Effect.Effect< + Option.Option, + PluginStoragePersistenceError | PluginStorageKeyError + >; + readonly set: ( + pluginId: PluginId, + key: string, + value: Schema.Json, + ) => Effect.Effect< + void, + | PluginStoragePersistenceError + | PluginStorageKeyError + | PluginStorageInvalidValueError + | PluginStorageValueTooLargeError + >; + readonly remove: ( + pluginId: PluginId, + key: string, + ) => Effect.Effect; + /** Keys that start with `prefix`, sorted. */ + readonly list: ( + pluginId: PluginId, + prefix?: string, + ) => Effect.Effect, PluginStoragePersistenceError>; + } +>()("t3/plugins/PluginStorage") {} + +const ValueJson = Schema.fromJsonString(Schema.Json); +const encodeValue = Schema.encodeUnknownEffect(ValueJson); +const decodeValue = Schema.decodeUnknownEffect(ValueJson); + +const checkKey = (pluginId: PluginId, key: string) => + key.length >= 1 && key.length <= MAX_KEY_LENGTH + ? Effect.void + : Effect.fail(new PluginStorageKeyError({ pluginId, length: key.length })); + +const make = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; + const layerScope = yield* Effect.scope; + const filename = path.join(stateDir, "plugins.sqlite"); + + // Opened on first use so a server without plugins never creates the file. A failed open is + // retried on the next call; a successful one stays open until the layer closes. + const sqlClient = yield* Effect.cachedWithTTL( + Effect.gen(function* () { + const scope = yield* Scope.fork(layerScope); + return yield* Layer.buildWithScope(NodeSqliteClient.layer({ filename }), scope).pipe( + Effect.map((context) => Context.get(context, SqlClient.SqlClient)), + Effect.tap( + (sql) => sql` + CREATE TABLE IF NOT EXISTS plugin_storage ( + plugin_id TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (plugin_id, key) + ) + `, + ), + Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))), + ); + }), + (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), + ); + + const open = (pluginId: PluginId) => + sqlClient.pipe( + Effect.mapError( + (cause) => new PluginStoragePersistenceError({ pluginId, operation: "open", cause }), + ), + ); + + const get = Effect.fn("PluginStorage.get")( + function* (pluginId: PluginId, key: string) { + yield* checkKey(pluginId, key); + const sql = yield* open(pluginId); + const rows = yield* sql<{ readonly value_json: string }>` + SELECT value_json FROM plugin_storage WHERE plugin_id = ${pluginId} AND key = ${key} + `; + const row = rows[0]; + return row === undefined ? Option.none() : Option.some(yield* decodeValue(row.value_json)); + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "get", cause })), + SchemaError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "get", cause })), + }), + ), + ); + + const set = Effect.fn("PluginStorage.set")( + function* (pluginId: PluginId, key: string, value: Schema.Json) { + yield* checkKey(pluginId, key); + const valueJson = yield* encodeValue(value).pipe( + Effect.mapError((cause) => new PluginStorageInvalidValueError({ pluginId, cause })), + ); + const bytes = Buffer.byteLength(valueJson, "utf8"); + if (bytes > MAX_VALUE_BYTES) { + return yield* new PluginStorageValueTooLargeError({ pluginId, bytes }); + } + const sql = yield* open(pluginId); + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* sql` + INSERT INTO plugin_storage (plugin_id, key, value_json, updated_at) + VALUES (${pluginId}, ${key}, ${valueJson}, ${updatedAt}) + ON CONFLICT (plugin_id, key) + DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at + `; + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "set", cause })), + }), + ), + ); + + const remove = Effect.fn("PluginStorage.remove")( + function* (pluginId: PluginId, key: string) { + yield* checkKey(pluginId, key); + const sql = yield* open(pluginId); + yield* sql`DELETE FROM plugin_storage WHERE plugin_id = ${pluginId} AND key = ${key}`; + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail( + new PluginStoragePersistenceError({ pluginId, operation: "remove", cause }), + ), + }), + ), + ); + + const list = Effect.fn("PluginStorage.list")( + function* (pluginId: PluginId, prefix = "") { + const sql = yield* open(pluginId); + // Not LIKE: it treats `%` and `_` as wildcards and ignores ASCII case. + const rows = yield* sql<{ readonly key: string }>` + SELECT key FROM plugin_storage + WHERE plugin_id = ${pluginId} AND substr(key, 1, length(${prefix})) = ${prefix} + ORDER BY key + `; + return rows.map((row) => row.key); + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "list", cause })), + }), + ), + ); + + return PluginStorage.of({ get, set, remove, list }); +}); + +export const layer = Layer.effect(PluginStorage, make); diff --git a/apps/server/src/plugins/api/events.ts b/apps/server/src/plugins/api/events.ts new file mode 100644 index 0000000000000..c7f19d3c9ede5 --- /dev/null +++ b/apps/server/src/plugins/api/events.ts @@ -0,0 +1,78 @@ +import { OrchestrationV2DomainEvent, type OrchestrationV2StoredEvent } from "@t3tools/contracts"; +import type { PluginApi, PluginStoredEvent } from "@t3tools/plugin-sdk/server"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import type * as Orchestrator from "../../orchestration-v2/Orchestrator.ts"; +import type * as OrchestrationEventStore from "../../persistence/OrchestrationEventStore.ts"; +import type { FacadeContext } from "./facade.ts"; + +// A plugin that stops reading must not hold back event delivery for the rest of the server. +const PLUGIN_EVENT_BUFFER = 1024; + +const encodeEvent = Schema.encodeUnknownSync(Schema.toCodecJson(OrchestrationV2DomainEvent)); +const isJsonObject = Schema.is(Schema.JsonObject); + +const toPluginEvent = (stored: OrchestrationV2StoredEvent): PluginStoredEvent => { + const event = encodeEvent(stored.event); + return { + sequence: stored.sequence, + type: stored.event.type, + event: isJsonObject(event) ? event : {}, + }; +}; + +interface EventServices { + readonly orchestrator: Orchestrator.OrchestratorV2["Service"]; + readonly eventStore: OrchestrationEventStore.OrchestrationEventStore["Service"]; +} + +/** + * Stored events for a plugin. `sliding` drops the oldest when the plugin falls behind, so it can + * never hold the server back; `suspend` waits instead, for host features that must see every event. + */ +export const storedEvents = ( + facade: FacadeContext, + services: EventServices, + options: Parameters[0], + strategy: "sliding" | "suspend", +) => { + if ( + options.afterSequence !== undefined && + (!Number.isSafeInteger(options.afterSequence) || options.afterSequence < 0) + ) { + throw new RangeError("afterSequence must be a whole number of 0 or more."); + } + const types = new Set(options.types); + const [onlyType] = types; + const start = + options.afterSequence === undefined + ? services.eventStore.latestAgentSequence() + : Effect.succeed(options.afterSequence); + // One type is filtered by the event sink before anything is decoded for the plugin. Several + // types share one unfiltered stream, so events still arrive in sequence order. + const stream = Stream.unwrap( + start.pipe( + Effect.map((afterSequence) => + types.size === 1 && onlyType !== undefined + ? services.orchestrator.streamStoredEventsFrom({ + afterSequence, + // An unknown type matches no stored event. + eventType: onlyType as OrchestrationV2DomainEvent["type"], + }) + : services.orchestrator + .streamStoredEventsFrom({ afterSequence }) + .pipe(Stream.filter((stored) => types.has(stored.event.type))), + ), + ), + ).pipe(Stream.map(toPluginEvent), Stream.buffer({ capacity: PLUGIN_EVENT_BUFFER, strategy })); + return facade.iterate(stream, options.signal); +}; + +export const makeEventsApi = ( + facade: FacadeContext, + services: EventServices, +): PluginApi["events"] => ({ + stored: (options) => storedEvents(facade, services, options, "sliding"), +}); diff --git a/apps/server/src/plugins/api/facade.ts b/apps/server/src/plugins/api/facade.ts new file mode 100644 index 0000000000000..ac54038d1cd72 --- /dev/null +++ b/apps/server/src/plugins/api/facade.ts @@ -0,0 +1,91 @@ +import type { PluginId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import type * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Stream from "effect/Stream"; + +import type { PluginActivation } from "../PluginApi.ts"; + +/** What every facade group needs from the activation it belongs to. */ +export interface FacadeContext { + readonly pluginId: PluginId; + readonly activation: PluginActivation; + readonly context: Context.Context; + /** Runs an effect as a Promise that rejects with an `Error` carrying the failure's `_tag` as `code`. */ + readonly call: (effect: Effect.Effect) => Promise; + /** Runs an effect in the background for the plugin; failures are logged, not thrown. */ + readonly fork: (effect: Effect.Effect) => void; + /** Exposes a stream as an iterable that ends when the plugin deactivates or `signal` aborts. */ + readonly iterate: (stream: Stream.Stream, signal?: AbortSignal) => AsyncIterable; +} + +class PluginApiCallError extends Error { + readonly code: string; + constructor(code: string, message: string) { + super(message); + this.name = "PluginApiError"; + this.code = code; + } +} + +const abortEffect = (signal: AbortSignal) => + Effect.callback((resume) => { + const onAbort = () => resume(Effect.void); + if (signal.aborted) return onAbort(); + signal.addEventListener("abort", onAbort, { once: true }); + return Effect.sync(() => signal.removeEventListener("abort", onAbort)); + }); + +export const makeFacadeContext = (input: { + readonly pluginId: PluginId; + readonly activation: PluginActivation; + readonly context: Context.Context; +}): FacadeContext => { + const { activation, context, pluginId } = input; + const runExit = Effect.runPromiseExitWith(context); + return { + ...input, + // Cleanups may still save state; afterwards a disabled plugin's leftovers cannot act. + call: (effect) => + activation.ended.signal.aborted + ? Promise.reject( + new PluginApiCallError("PluginDeactivated", "This plugin activation has ended."), + ) + : runExit(effect).then((exit) => { + if (Exit.isSuccess(exit)) return exit.value; + const error = Cause.squash(exit.cause); + const code = + typeof error === "object" && + error !== null && + "_tag" in error && + typeof error._tag === "string" + ? error._tag + : "PluginApiFailure"; + throw new PluginApiCallError( + code, + error instanceof Error ? error.message : "The plugin API call failed.", + ); + }), + fork: (effect) => { + Effect.runForkWith(context)( + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("plugin background work failed", { + pluginId, + cause: Cause.pretty(cause).slice(0, 500), + }), + ), + ), + ); + }, + iterate: (stream, signal) => + Stream.toAsyncIterableWith( + stream.pipe( + Stream.interruptWhen(abortEffect(activation.controller.signal)), + signal === undefined ? (self) => self : Stream.interruptWhen(abortEffect(signal)), + ), + context, + ), + }; +}; diff --git a/apps/server/src/plugins/api/http.ts b/apps/server/src/plugins/api/http.ts new file mode 100644 index 0000000000000..ebf277a595131 --- /dev/null +++ b/apps/server/src/plugins/api/http.ts @@ -0,0 +1,47 @@ +import type { PluginApi } from "@t3tools/plugin-sdk/server"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +import * as PluginHttpRoutes from "../PluginHttpRoutes.ts"; +import type { FacadeContext } from "./facade.ts"; + +export const makeHttpApi = ( + facade: FacadeContext, + services: { readonly httpRoutes: PluginHttpRoutes.PluginHttpRoutes["Service"] }, +): PluginApi["http"] => { + const { activation, pluginId } = facade; + const deactivated = activation.controller.signal; + let unregistersOnAbort = false; + return { + route: (method, path, handler) => { + if (deactivated.aborted) throw new Error("This plugin activation has ended."); + const registered = Effect.runSyncExitWith(facade.context)( + services.httpRoutes.register({ + pluginId, + method, + path, + handler: (request, signal) => + Promise.resolve().then(() => + handler(request, { signal: AbortSignal.any([signal, deactivated]) }), + ), + }), + ); + if (Exit.isFailure(registered)) { + const error = Cause.squash(registered.cause); + throw PluginHttpRoutes.isPluginHttpRouteInvalidError(error) + ? new TypeError(`"${method} ${path}" is not a valid route. ${error.message}`) + : error; + } + // Abort comes before any cleanup on deactivate, so a cleanup that hangs cannot keep routes live. + if (!unregistersOnAbort) { + unregistersOnAbort = true; + deactivated.addEventListener( + "abort", + () => facade.fork(services.httpRoutes.unregisterPlugin(pluginId)), + { once: true }, + ); + } + }, + }; +}; diff --git a/apps/server/src/plugins/api/mcp.ts b/apps/server/src/plugins/api/mcp.ts new file mode 100644 index 0000000000000..751baaf642981 --- /dev/null +++ b/apps/server/src/plugins/api/mcp.ts @@ -0,0 +1,64 @@ +import type { PluginApi } from "@t3tools/plugin-sdk/server"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import * as PluginMcpTools from "../PluginMcpTools.ts"; +import type { FacadeContext } from "./facade.ts"; + +const isAccess = Schema.is(Schema.Literals(["read", "write"])); +const isJsonObject = Schema.is(Schema.JsonObject); + +export const makeMcpApi = ( + facade: FacadeContext, + services: { readonly mcpTools: PluginMcpTools.PluginMcpTools["Service"] }, +): PluginApi["mcp"] => { + const { activation, pluginId } = facade; + const deactivated = activation.controller.signal; + let unregistersOnAbort = false; + return { + registerTool: (tool, handler) => { + if (deactivated.aborted) throw new Error("This plugin activation has ended."); + // Plugins are untyped JavaScript, and a misspelled access level must not open a write. + if (!isAccess(tool.access)) { + throw new TypeError(`Tool "${tool.name}" needs access "read" or "write".`); + } + // Model APIs reject a tool whose schema is not an object, which would fail every turn. + if ( + typeof tool.description !== "string" || + !isJsonObject(tool.inputSchema) || + tool.inputSchema.type !== "object" + ) { + throw new TypeError( + `Tool "${tool.name}" needs a description and an object JSON Schema for its input.`, + ); + } + const registered = Effect.runSyncExitWith(facade.context)( + services.mcpTools.register({ + pluginId, + tool, + handler: (input, signal) => + Promise.resolve().then(() => + handler(input, { signal: AbortSignal.any([signal, deactivated]) }), + ), + }), + ); + if (Exit.isFailure(registered)) { + const error = Cause.squash(registered.cause); + throw PluginMcpTools.isPluginMcpToolNameError(error) + ? new TypeError(`"${tool.name}" is not a valid tool name. ${error.message}`) + : error; + } + // Abort comes before any cleanup on deactivate, so a cleanup that hangs cannot keep tools live. + if (!unregistersOnAbort) { + unregistersOnAbort = true; + deactivated.addEventListener( + "abort", + () => facade.fork(services.mcpTools.unregisterPlugin(pluginId)), + { once: true }, + ); + } + }, + }; +}; diff --git a/apps/server/src/plugins/api/schedule.ts b/apps/server/src/plugins/api/schedule.ts new file mode 100644 index 0000000000000..fcd33e0ecafa0 --- /dev/null +++ b/apps/server/src/plugins/api/schedule.ts @@ -0,0 +1,37 @@ +import type { PluginApi } from "@t3tools/plugin-sdk/server"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; + +import type { FacadeContext } from "./facade.ts"; + +const MIN_INTERVAL_MS = 1000; + +export const makeScheduleApi = (facade: FacadeContext): PluginApi["schedule"] => ({ + every: (everyMs, handler) => { + if (facade.activation.controller.signal.aborted) { + throw new Error("This plugin activation has ended."); + } + if (!Number.isFinite(everyMs) || everyMs < MIN_INTERVAL_MS) { + throw new RangeError(`schedule.every needs an interval of at least ${MIN_INTERVAL_MS} ms.`); + } + // Runs never overlap: the next wait starts when a run finishes. + const tick = Effect.tryPromise(() => Promise.resolve().then(handler)).pipe( + Effect.catchCause((cause) => + Effect.logWarning("plugin scheduled work failed", { + pluginId: facade.pluginId, + failure: String(cause).slice(0, 500), + }), + ), + ); + const fiber = Effect.runForkWith(facade.context)( + Effect.sleep(Duration.millis(everyMs)).pipe(Effect.andThen(tick), Effect.forever), + ); + const cancel = () => { + Effect.runForkWith(facade.context)(Fiber.interrupt(fiber)); + }; + // Abort runs before cleanups, so a hung cleanup cannot keep a disabled plugin's timer alive. + facade.activation.controller.signal.addEventListener("abort", cancel, { once: true }); + return cancel; + }, +}); diff --git a/apps/server/src/plugins/api/server.ts b/apps/server/src/plugins/api/server.ts new file mode 100644 index 0000000000000..77d1aaa271494 --- /dev/null +++ b/apps/server/src/plugins/api/server.ts @@ -0,0 +1,41 @@ +import { ExecutionEnvironmentDescriptor, ServerSettings } from "@t3tools/contracts"; +import type { PluginApi } from "@t3tools/plugin-sdk/server"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; +import * as ServerSettingsModule from "../../serverSettings.ts"; +import type { FacadeContext } from "./facade.ts"; + +const encodeDescriptor = Schema.encodeUnknownEffect( + Schema.toCodecJson(ExecutionEnvironmentDescriptor), +); +const encodeSettings = Schema.encodeUnknownEffect(Schema.toCodecJson(ServerSettings)); +const isJsonObject = Schema.is(Schema.JsonObject); +const asJsonObject = (value: unknown): Schema.JsonObject => (isJsonObject(value) ? value : {}); + +export const makeServerApi = ( + facade: FacadeContext, + services: { + readonly version: string; + readonly environment: ServerEnvironment.ServerEnvironment["Service"]; + readonly settings: ServerSettingsModule.ServerSettingsService["Service"]; + }, +): PluginApi["server"] => ({ + version: services.version, + environment: () => + facade.call( + services.environment.getDescriptor.pipe( + Effect.flatMap(encodeDescriptor), + Effect.map(asJsonObject), + ), + ), + settings: () => + facade.call( + services.settings.getSettings.pipe( + Effect.map(ServerSettingsModule.redactServerSettingsForClient), + Effect.flatMap(encodeSettings), + Effect.map(asJsonObject), + ), + ), +}); diff --git a/apps/server/src/plugins/api/threads.ts b/apps/server/src/plugins/api/threads.ts new file mode 100644 index 0000000000000..ba1fe01cddf54 --- /dev/null +++ b/apps/server/src/plugins/api/threads.ts @@ -0,0 +1,170 @@ +import { + CommandId, + MessageId, + OrchestrationProjectShell, + OrchestrationV2Notification, + OrchestrationV2ThreadShell, + ProjectId, + ThreadId, + TurnItemId, +} from "@t3tools/contracts"; +import type { PluginApi } from "@t3tools/plugin-sdk/server"; +import type * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type * as ProjectStore from "../../orchestration-v2/ProjectStore.ts"; +import type * as ThreadManagementService from "../../orchestration-v2/ThreadManagementService.ts"; +import type { FacadeContext } from "./facade.ts"; + +const encodeThreadShell = Schema.encodeUnknownEffect( + Schema.toCodecJson(OrchestrationV2ThreadShell), +); +const encodeProjectShell = Schema.encodeUnknownEffect( + Schema.toCodecJson(OrchestrationProjectShell), +); +const decodeThreadId = Schema.decodeUnknownEffect(ThreadId); +const decodeNotification = Schema.decodeUnknownEffect(OrchestrationV2Notification); +const decodeProjectId = Schema.decodeUnknownEffect(ProjectId); +const isJsonObject = Schema.is(Schema.JsonObject); + +const asJsonObject = (value: unknown): Schema.JsonObject => (isJsonObject(value) ? value : {}); + +export class PluginThreadNotFoundError extends Schema.TaggedError()( + "PluginThreadNotFoundError", + { threadId: Schema.String }, +) { + override get message(): string { + return `Thread ${this.threadId} does not exist.`; + } +} + +export const makeThreadsApi = ( + facade: FacadeContext, + services: { + readonly threads: ThreadManagementService.ThreadManagementService["Service"]; + readonly crypto: Crypto.Crypto; + }, + pluginName: string, +): PluginApi["threads"] => { + const { threads, crypto } = services; + const newId = crypto.randomUUIDv4().pipe(Effect.orDie); + const shellOf = Effect.fnUntraced(function* (threadId: string) { + const id = yield* decodeThreadId(threadId); + const shell = yield* threads.getThreadShell(id); + if (shell === null) return yield* new PluginThreadNotFoundError({ threadId }); + return shell; + }); + return { + list: (options) => + facade.call( + Effect.gen(function* () { + const projectId = + options?.projectId === undefined + ? undefined + : yield* decodeProjectId(options.projectId); + const shells = yield* threads.listProjectThreads({ projectId, includeSubagents: false }); + return yield* Effect.forEach(shells, (shell) => + encodeThreadShell(shell).pipe(Effect.map(asJsonObject)), + ); + }), + ), + read: (threadId) => + facade.call( + shellOf(threadId).pipe(Effect.flatMap(encodeThreadShell), Effect.map(asJsonObject)), + ), + sendMessage: (input) => + facade.call( + Effect.gen(function* () { + const shell = yield* shellOf(input.threadId); + yield* threads.sendToThread({ + projectId: shell.projectId, + commandId: CommandId.make(`command:plugin:${facade.pluginId}:${yield* newId}`), + threadId: shell.id, + messageId: MessageId.make(yield* newId), + text: input.text, + attachments: [], + mode: input.mode ?? "auto", + createdBy: "agent", + creationSource: "server", + }); + }), + ), + notify: (input) => + facade.call( + Effect.gen(function* () { + const shell = yield* shellOf(input.threadId); + const notification = yield* decodeNotification({ + source: { kind: "system" }, + outcome: input.outcome ?? "updated", + summary: input.summary, + ...(input.detail === undefined ? {} : { detail: input.detail }), + }); + yield* threads.dispatch({ + type: "thread.notification.record", + commandId: CommandId.make(`command:plugin:${facade.pluginId}:${yield* newId}`), + threadId: shell.id, + turnItemId: TurnItemId.make(`turn-item:plugin:${facade.pluginId}:${yield* newId}`), + notification: { ...notification, summary: `${pluginName}: ${notification.summary}` }, + }); + }), + ), + interrupt: (threadId) => + facade.call( + Effect.gen(function* () { + const shell = yield* shellOf(threadId); + yield* threads.interruptThread({ + projectId: shell.projectId, + commandId: CommandId.make(`command:plugin:${facade.pluginId}:${yield* newId}`), + threadId: shell.id, + }); + }), + ), + rename: (threadId, title) => + facade.call( + Effect.gen(function* () { + const shell = yield* shellOf(threadId); + yield* threads.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`command:plugin:${facade.pluginId}:${yield* newId}`), + threadId: shell.id, + title, + }); + }), + ), + }; +}; + +export const makeProjectsApi = ( + facade: FacadeContext, + services: { readonly projects: ProjectStore.ProjectStoreV2["Service"] }, +): PluginApi["projects"] => ({ + list: () => + facade.call( + services.projects + .listShells() + .pipe( + Effect.flatMap((shells) => + Effect.forEach(shells, (shell) => + encodeProjectShell(shell).pipe(Effect.map(asJsonObject)), + ), + ), + ), + ), + read: (projectId) => + facade + .call( + decodeProjectId(projectId).pipe( + Effect.flatMap(services.projects.getShell), + Effect.flatMap((shell) => + Option.isNone(shell) + ? Effect.succeedNone + : encodeProjectShell(shell.value).pipe( + Effect.map((encoded) => Option.some(asJsonObject(encoded))), + ), + ), + ), + ) + .then(Option.getOrUndefined), +}); diff --git a/apps/server/src/plugins/api/turns.test.ts b/apps/server/src/plugins/api/turns.test.ts new file mode 100644 index 0000000000000..7e1cf8f22c6c0 --- /dev/null +++ b/apps/server/src/plugins/api/turns.test.ts @@ -0,0 +1,120 @@ +import { assert, it } from "@effect/vitest"; +import { PluginId, ThreadId } from "@t3tools/contracts"; +import type { PluginApi, PluginStoredEvent } from "@t3tools/plugin-sdk/server"; +import type { JsonValue } from "@t3tools/plugin-sdk/shared"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as PubSub from "effect/PubSub"; +import * as TestClock from "effect/testing/TestClock"; + +import { makePluginActivation, type PluginPublication } from "../PluginApi.ts"; +import * as PluginHooks from "../PluginHooks.ts"; +import { makeFacadeContext } from "./facade.ts"; +import { makeTurnsApi } from "./turns.ts"; + +const unusedStorage = {} as PluginApi["storage"]; +const unusedRunEvents = () => { + throw new Error("run events are not used here"); +}; + +it.effect("skips a beforeDispatch hook that throws or misses its deadline", () => + Effect.gen(function* () { + const hooks = yield* PluginHooks.PluginHooks; + const activation = makePluginActivation(yield* PubSub.sliding(1)); + const turns = makeTurnsApi( + makeFacadeContext({ + pluginId: PluginId.make("hooked"), + activation, + context: yield* Effect.context(), + }), + { hooks }, + { storage: unusedStorage, runEvents: unusedRunEvents }, + ); + turns.beforeDispatch(() => ({ appendText: "first" })); + turns.beforeDispatch(() => { + throw new Error("broken hook"); + }); + turns.beforeDispatch(() => new Promise(() => undefined)); + turns.beforeDispatch(async () => ({ appendText: "last" })); + + const sending = yield* hooks + .beforeDispatch({ + threadId: ThreadId.make("thread:hooks"), + messageId: "message:hooks", + text: "Go", + createdBy: "user", + attachments: [], + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("2 seconds"); + + assert.strictEqual(yield* Fiber.join(sending), "Go\n\nfirst\n\nlast"); + }).pipe(Effect.provide(PluginHooks.layer)), +); + +it.effect("delivers each ended run once and resumes after the last one it handled", () => + Effect.gen(function* () { + const saved = new Map(); + const requests: Array = []; + const runUpdate = (sequence: number, status: string): PluginStoredEvent => ({ + sequence, + type: "run.updated", + event: { type: "run.updated", payload: { id: `run:${sequence}`, threadId: "t", status } }, + }); + const runUpdateOf = (sequence: number, runId: string, status: string): PluginStoredEvent => ({ + sequence, + type: "run.updated", + event: { type: "run.updated", payload: { id: runId, threadId: "t", status } }, + }); + // Activates a plugin over `stored` and resolves once it has handled every event. + const activate = (stored: ReadonlyArray) => + Effect.gen(function* () { + const hooks = yield* PluginHooks.PluginHooks; + const activation = makePluginActivation(yield* PubSub.sliding(1)); + const facade = makeFacadeContext({ + pluginId: PluginId.make("runs"), + activation, + context: yield* Effect.context(), + }); + const drained = Promise.withResolvers(); + const storage = { + get: async (key: string) => saved.get(key), + set: async (key: string, value: JsonValue) => { + saved.set(key, value); + }, + } as PluginApi["storage"]; + const runEvents = (afterSequence: number | undefined) => { + requests.push(afterSequence); + return (async function* () { + try { + yield* stored; + } finally { + drained.resolve(); + } + })(); + }; + const seen: Array = []; + makeTurnsApi(facade, { hooks }, { storage, runEvents }).afterRunCompleted((run) => { + seen.push(`${run.runId}:${run.status}`); + }); + yield* Effect.promise(() => drained.promise); + return seen; + }); + + assert.deepStrictEqual( + yield* activate([ + runUpdate(10, "completed"), + runUpdate(11, "running"), + runUpdate(12, "failed"), + ]), + ["run:10:completed", "run:12:failed"], + ); + // A checkpoint landing on an ended run updates it again, after a restart too. + assert.deepStrictEqual( + yield* activate([runUpdate(13, "interrupted"), runUpdateOf(14, "run:13", "interrupted")]), + ["run:13:interrupted"], + ); + assert.deepStrictEqual(yield* activate([runUpdateOf(15, "run:12", "failed")]), []); + assert.deepStrictEqual(requests, [undefined, 12, 13]); + }).pipe(Effect.provide(PluginHooks.layer)), +); diff --git a/apps/server/src/plugins/api/turns.ts b/apps/server/src/plugins/api/turns.ts new file mode 100644 index 0000000000000..c38547b4702d7 --- /dev/null +++ b/apps/server/src/plugins/api/turns.ts @@ -0,0 +1,162 @@ +import type { + PluginApi, + PluginBeforeDispatchResult, + PluginCompletedRun, + PluginStoredEvent, +} from "@t3tools/plugin-sdk/server"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import type * as PluginHooks from "../PluginHooks.ts"; +import type { FacadeContext } from "./facade.ts"; + +// A hook sits on the send path of every message, so it gets a short deadline and fails open. +const BEFORE_DISPATCH_DEADLINE = Duration.seconds(2); +const MAX_APPENDED_CHARS = 20_000; + +const appendedText = (result: PluginBeforeDispatchResult) => + typeof result === "object" && + result !== null && + "appendText" in result && + typeof result.appendText === "string" && + result.appendText.trim().length > 0 + ? result.appendText.slice(0, MAX_APPENDED_CHARS) + : undefined; + +const TERMINAL_RUN_STATUSES = new Set([ + "completed", + "interrupted", + "failed", + "cancelled", + "rolled_back", +]); + +const isJsonObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +// Ended runs remembered with the cursor: a run can be updated again after it ends (a checkpoint +// lands on an interrupted run), and those later updates must not deliver it twice. +const REMEMBERED_RUNS = 256; +const RESUME_DELAY_MS = 1000; + +interface RunCursor { + readonly sequence: number; + readonly runIds: ReadonlyArray; +} + +const readCursor = (saved: unknown): RunCursor | undefined => { + if (typeof saved === "number") return { sequence: saved, runIds: [] }; + if (!isJsonObject(saved) || typeof saved.sequence !== "number") return undefined; + const runIds = Array.isArray(saved.runIds) + ? saved.runIds.filter((id): id is string => typeof id === "string") + : []; + return { sequence: saved.sequence, runIds }; +}; + +export const makeTurnsApi = ( + facade: FacadeContext, + services: { readonly hooks: PluginHooks.PluginHooksRegistry }, + api: { + readonly storage: PluginApi["storage"]; + /** Every `run.updated` event after `afterSequence`, or from now; never drops events. */ + readonly runEvents: (afterSequence: number | undefined) => AsyncIterable; + }, +): PluginApi["turns"] => { + let runHandlers = 0; + return { + afterRunCompleted: (handler) => { + const { activation, pluginId } = facade; + if (activation.controller.signal.aborted) { + throw new Error("This plugin activation has ended."); + } + runHandlers += 1; + const cursorKey = `t3.turns.afterRunCompleted.${runHandlers}`; + void (async () => { + const cursor = readCursor(await api.storage.get(cursorKey)); + let sequence = cursor?.sequence; + const handled = new Set(cursor?.runIds ?? []); + // The stream can end with an error when the reader falls far behind; resume from the + // saved position instead of going quiet until the plugin restarts. + while (!activation.controller.signal.aborted) { + try { + for await (const stored of api.runEvents(sequence)) { + const run = stored.event.payload; + if (!isJsonObject(run) || typeof run.status !== "string") continue; + if (!TERMINAL_RUN_STATUSES.has(run.status)) continue; + const runId = String(run.id); + if (handled.has(runId)) continue; + try { + await handler({ + runId, + threadId: String(run.threadId), + status: run.status, + run: stored.event.payload as PluginCompletedRun["run"], + }); + } catch (error) { + Effect.runForkWith(facade.context)( + Effect.logWarning("plugin afterRunCompleted handler failed", { + pluginId, + failure: String(error).slice(0, 500), + }), + ); + } + handled.add(runId); + const [oldest] = handled; + if (handled.size > REMEMBERED_RUNS && oldest !== undefined) handled.delete(oldest); + sequence = stored.sequence; + await api.storage.set(cursorKey, { sequence, runIds: [...handled] }); + } + return; + } catch (error) { + Effect.runForkWith(facade.context)( + Effect.logWarning("plugin afterRunCompleted stream failed; resuming", { + pluginId, + failure: String(error).slice(0, 500), + }), + ); + await Effect.runPromiseWith(facade.context)(Effect.sleep(RESUME_DELAY_MS)); + } + } + })().catch(() => undefined); + }, + beforeDispatch: (hook) => { + const { activation, pluginId } = facade; + if (activation.controller.signal.aborted) { + throw new Error("This plugin activation has ended."); + } + const remove = Effect.runSyncWith(facade.context)( + services.hooks.addBeforeDispatch((message) => + activation.controller.signal.aborted + ? Effect.sync(() => undefined) + : Effect.tryPromise(() => + Promise.resolve().then(() => + hook({ + threadId: message.threadId, + messageId: message.messageId, + text: message.text, + createdBy: message.createdBy, + attachments: message.attachments, + ...(message.model === undefined ? {} : { model: message.model }), + }), + ), + ).pipe( + Effect.timeoutOption(BEFORE_DISPATCH_DEADLINE), + Effect.map(Option.match({ onNone: () => undefined, onSome: appendedText })), + Effect.catchCause((cause) => + Effect.logWarning("plugin beforeDispatch hook failed", { + pluginId, + failure: String(cause).slice(0, 500), + }).pipe(Effect.as(undefined)), + ), + ), + ), + ); + activation.controller.signal.addEventListener( + "abort", + () => Effect.runForkWith(facade.context)(remove), + { once: true }, + ); + }, + }; +}; diff --git a/apps/server/src/plugins/pluginDiscovery.test.ts b/apps/server/src/plugins/pluginDiscovery.test.ts new file mode 100644 index 0000000000000..de9c55acbf1f3 --- /dev/null +++ b/apps/server/src/plugins/pluginDiscovery.test.ts @@ -0,0 +1,57 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverPlugins } from "./pluginDiscovery.ts"; + +const manifest = (id: string) => + JSON.stringify({ id, name: id, version: "1.0.0", engines: { t3: ">=0.1.0" } }); + +it.layer(NodeServices.layer)("discoverPlugins", (it) => { + it.effect("reports valid, missing, invalid, mismatched and duplicate plugins", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-plugin-discovery-" }); + const pluginsDir = path.join(root, "plugins"); + const write = Effect.fn(function* (dir: string, contents: string | undefined) { + yield* fs.makeDirectory(dir, { recursive: true }); + if (contents !== undefined) { + yield* fs.writeFileString(path.join(dir, "t3-plugin.json"), contents); + } + }); + yield* write(path.join(pluginsDir, "valid"), manifest("valid")); + yield* write(path.join(pluginsDir, "empty"), undefined); + yield* write( + path.join(pluginsDir, "invalid"), + JSON.stringify({ ...JSON.parse(manifest("invalid")), version: "1" }), + ); + yield* write(path.join(pluginsDir, "renamed"), manifest("other-name")); + yield* write(path.join(pluginsDir, "Not_An_Id"), manifest("ignored")); + const devCopy = path.join(root, "dev", "valid"); + yield* write(devCopy, manifest("valid")); + const devOnly = path.join(root, "dev", "workbench"); + yield* write(devOnly, manifest("dev-only")); + + const plugins = yield* discoverPlugins({ pluginsDir, devPaths: [devCopy, devOnly] }); + const summary = plugins.map((plugin) => ({ + id: plugin.id, + source: plugin.source, + stage: "failure" in plugin ? plugin.failure.stage : "ok", + })); + + assert.deepStrictEqual(summary, [ + { id: "empty", source: "installed", stage: "discovery" }, + { id: "invalid", source: "installed", stage: "manifest" }, + { id: "renamed", source: "installed", stage: "manifest" }, + { id: "valid", source: "installed", stage: "ok" }, + { id: "valid", source: "dev", stage: "discovery" }, + { id: "dev-only", source: "dev", stage: "ok" }, + ]); + const invalid = plugins.find((plugin) => plugin.id === "invalid"); + assert.include(invalid && "failure" in invalid ? invalid.failure.message : "", "version"); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/plugins/pluginDiscovery.ts b/apps/server/src/plugins/pluginDiscovery.ts new file mode 100644 index 0000000000000..9e48f8a8968de --- /dev/null +++ b/apps/server/src/plugins/pluginDiscovery.ts @@ -0,0 +1,180 @@ +import { + PluginId, + PluginManifest, + type PluginFailure, + type PluginSource, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +const PLUGIN_MANIFEST_FILE = "t3-plugin.json"; + +// Discovery runs at startup and on every reload, so a plugins folder full of unrelated +// directories or an oversized manifest must not stall the server. +const MAX_PLUGIN_DIRECTORIES = 64; +const MAX_MANIFEST_BYTES = 64 * 1024; +const MAX_FAILURE_MESSAGE_CHARS = 500; + +const decodeManifest = Schema.decodeUnknownExit(Schema.fromJsonString(PluginManifest)); +const decodeLooseId = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ id: PluginId })), +); +const isPluginId = Schema.is(PluginId); + +const causeMessage = (cause: Cause.Cause) => { + const error = Cause.squash(cause); + return error instanceof Error ? error.message : String(error); +}; + +interface PluginLocation { + readonly id: PluginId; + readonly rootDir: string; + readonly source: PluginSource; +} + +/** A plugin directory, with its manifest when it could be read and decoded. */ +export type DiscoveredPlugin = PluginLocation & + ({ readonly manifest: PluginManifest } | { readonly failure: PluginFailure }); + +const failed = (location: PluginLocation, failure: PluginFailure): DiscoveredPlugin => ({ + ...location, + failure: { ...failure, message: failure.message.slice(0, MAX_FAILURE_MESSAGE_CHARS) }, +}); + +/** + * Lists installed plugins (`//t3-plugin.json`) and dev plugins (`devPaths`). + * An installed plugin wins over a dev plugin with the same id, which is reported as failed. + */ +export const discoverPlugins = Effect.fn("PluginDiscovery.discover")(function* (input: { + readonly pluginsDir: string; + readonly devPaths: ReadonlyArray; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const readManifest = Effect.fnUntraced(function* (rootDir: string) { + const manifestPath = path.join(rootDir, PLUGIN_MANIFEST_FILE); + const info = yield* fs.stat(manifestPath).pipe(Effect.option); + if (info._tag === "None" || info.value.type !== "File") { + return { _tag: "Missing" } as const; + } + if (Number(info.value.size) > MAX_MANIFEST_BYTES) { + return { _tag: "TooLarge" } as const; + } + const raw = yield* fs.readFileString(manifestPath).pipe(Effect.option); + return raw._tag === "None" + ? ({ _tag: "Missing" } as const) + : ({ _tag: "Read", raw: raw.value } as const); + }); + + // Node caches modules by real path, so reload can only find a plugin's modules by its real root. + const inspect = Effect.fnUntraced(function* ( + location: string, + source: PluginSource, + fallbackId: PluginId | undefined, + ) { + const rootDir = yield* fs.realPath(location).pipe(Effect.orElseSucceed(() => location)); + const manifest = yield* readManifest(rootDir); + if (manifest._tag !== "Read") { + if (fallbackId === undefined) return undefined; + return failed( + { id: fallbackId, rootDir, source }, + { + stage: "discovery", + message: + manifest._tag === "Missing" + ? `No ${PLUGIN_MANIFEST_FILE} in the plugin folder.` + : `${PLUGIN_MANIFEST_FILE} is larger than ${MAX_MANIFEST_BYTES} bytes.`, + }, + ); + } + const decoded = decodeManifest(manifest.raw); + if (decoded._tag === "Success") { + return { id: decoded.value.id, rootDir, source, manifest: decoded.value } as const; + } + const looseId = decodeLooseId(manifest.raw); + const id = looseId._tag === "Some" ? looseId.value.id : fallbackId; + if (id === undefined) return undefined; + return failed( + { id, rootDir, source }, + { + stage: "manifest", + message: `Invalid ${PLUGIN_MANIFEST_FILE}: ${causeMessage(decoded.cause)}`, + }, + ); + }); + + const plugins: Array = []; + const seen = new Set(); + let examined = 0; + const underLimit = Effect.fnUntraced(function* (location: string) { + examined += 1; + if (examined <= MAX_PLUGIN_DIRECTORIES) return true; + yield* Effect.logWarning("ignoring plugin folders past the limit", { + path: location, + limit: MAX_PLUGIN_DIRECTORIES, + }); + return false; + }); + + const entries = yield* fs + .readDirectory(input.pluginsDir) + .pipe(Effect.orElseSucceed((): Array => [])); + for (const entry of entries.toSorted()) { + if (entry.startsWith(".")) continue; + const rootDir = path.join(input.pluginsDir, entry); + const info = yield* fs.stat(rootDir).pipe(Effect.option); + if (info._tag === "None" || info.value.type !== "Directory") continue; + if (!isPluginId(entry)) { + yield* Effect.logWarning("ignoring plugin folder whose name is not a plugin id", { + path: rootDir, + }); + continue; + } + if (!(yield* underLimit(input.pluginsDir))) break; + const plugin = yield* inspect(rootDir, "installed", entry); + if (plugin === undefined) continue; + seen.add(entry); + plugins.push( + "manifest" in plugin && plugin.manifest.id !== entry + ? failed( + { id: entry, rootDir, source: "installed" }, + { + stage: "manifest", + message: `The manifest id "${plugin.manifest.id}" does not match the folder name "${entry}".`, + }, + ) + : plugin, + ); + } + + for (const devPath of input.devPaths) { + const rootDir = path.resolve(devPath); + if (!(yield* underLimit(rootDir))) break; + const basename = path.basename(rootDir); + const plugin = yield* inspect(rootDir, "dev", isPluginId(basename) ? basename : undefined); + if (plugin === undefined) { + yield* Effect.logWarning("ignoring dev plugin path without a usable plugin id", { + path: rootDir, + }); + continue; + } + if (seen.has(plugin.id)) { + plugins.push( + failed(plugin, { + stage: "discovery", + message: + "Another plugin with this id is already loaded from the plugins folder or an earlier dev path.", + }), + ); + continue; + } + seen.add(plugin.id); + plugins.push(plugin); + } + + return plugins; +}); diff --git a/apps/server/src/plugins/pluginNpm.ts b/apps/server/src/plugins/pluginNpm.ts new file mode 100644 index 0000000000000..68acd26ba25f4 --- /dev/null +++ b/apps/server/src/plugins/pluginNpm.ts @@ -0,0 +1,103 @@ +// @effect-diagnostics nodeBuiltinImport:off - npm integrity is a sha512 digest of the raw tarball. +import * as NodeCrypto from "node:crypto"; + +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; + +import { type TarballFile, unpackNpmTarball } from "./pluginTarball.ts"; + +const REGISTRY = "https://registry.npmjs.org"; +const MAX_TARBALL_BYTES = 32 * 1024 * 1024; +// Install runs in the host's scope where no client can cancel it, so every request has a limit. +const REQUEST_TIMEOUT = Duration.seconds(60); + +export class PluginNpmError extends Schema.TaggedError()("PluginNpmError", { + reason: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return this.reason; + } +} + +const PackageDocument = Schema.Struct({ + "dist-tags": Schema.optional(Schema.Record(Schema.String, Schema.String)), + versions: Schema.Record( + Schema.String, + Schema.Struct({ + dist: Schema.Struct({ tarball: Schema.String, integrity: Schema.optional(Schema.String) }), + }), + ), +}); +const decodePackageDocument = Schema.decodeUnknownEffect(PackageDocument); + +const failed = (reason: string) => (cause: unknown) => new PluginNpmError({ reason, cause }); + +/** + * Downloads `name@version` (the `latest` tag when no version is given) from the npm registry, + * checks the tarball against the registry's sha512 integrity, and returns its files. + */ +export const fetchNpmPackage = Effect.fn("PluginNpm.fetch")(function* ( + name: string, + version: string | undefined, +) { + const http = yield* HttpClient.HttpClient; + // Scoped names keep their `@`; only the slash is encoded, as the registry expects. + const metadataUrl = `${REGISTRY}/${name.replace("/", "%2F")}`; + // The abbreviated document lists only what installing needs, a fraction of the full one. + const metadataRequest = HttpClientRequest.get(metadataUrl).pipe( + HttpClientRequest.setHeader("accept", "application/vnd.npm.install-v1+json"), + ); + const document = yield* http.execute(metadataRequest).pipe( + Effect.timeout(REQUEST_TIMEOUT), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.flatMap(decodePackageDocument), + Effect.mapError(failed(`Could not read ${name} from the npm registry.`)), + ); + const wanted = version ?? document["dist-tags"]?.latest; + const release = wanted === undefined ? undefined : document.versions[wanted]; + if (wanted === undefined || release === undefined) { + return yield* new PluginNpmError({ reason: `${name}@${version ?? "latest"} does not exist.` }); + } + const integrity = release.dist.integrity; + if (integrity?.startsWith("sha512-") !== true) { + return yield* new PluginNpmError({ + reason: `${name}@${wanted} has no sha512 integrity hash to check it against.`, + }); + } + const response = yield* http + .execute(HttpClientRequest.get(release.dist.tarball)) + .pipe( + Effect.timeout(REQUEST_TIMEOUT), + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.mapError(failed(`Could not download ${name}@${wanted}.`)), + ); + if (Number(response.headers["content-length"] ?? 0) > MAX_TARBALL_BYTES) { + return yield* new PluginNpmError({ reason: `${name}@${wanted} is larger than 32 MiB.` }); + } + const tarball = yield* response.arrayBuffer.pipe( + Effect.timeout(REQUEST_TIMEOUT), + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.mapError(failed(`Could not download ${name}@${wanted}.`)), + ); + if (tarball.byteLength > MAX_TARBALL_BYTES) { + return yield* new PluginNpmError({ reason: `${name}@${wanted} is larger than 32 MiB.` }); + } + const digest = NodeCrypto.createHash("sha512").update(tarball).digest("base64"); + if (`sha512-${digest}` !== integrity) { + return yield* new PluginNpmError({ + reason: `${name}@${wanted} does not match the registry's integrity hash.`, + }); + } + return yield* Effect.try({ + try: (): ReadonlyArray => unpackNpmTarball(tarball), + catch: (cause) => + new PluginNpmError({ + reason: cause instanceof Error ? cause.message : `Could not unpack ${name}@${wanted}.`, + cause, + }), + }); +}); diff --git a/apps/server/src/plugins/pluginRpcHandlers.ts b/apps/server/src/plugins/pluginRpcHandlers.ts new file mode 100644 index 0000000000000..94e65f01e2427 --- /dev/null +++ b/apps/server/src/plugins/pluginRpcHandlers.ts @@ -0,0 +1,83 @@ +import { + type AuthEnvironmentScope, + PluginError, + type PluginId, + type PluginInstallInput, + type PluginInvokeInput, + type PluginListInput, + type PluginReloadInput, + type PluginSetEnabledInput, + type PluginSettingsGetInput, + type PluginSettingsUpdateInput, + type PluginSubscribeInput, + type PluginUninstallInput, + type PluginUpdateInput, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import { rpcAuthorizationError } from "../auth/RpcAuthorization.ts"; +import * as PluginHost from "./PluginHost.ts"; + +const toRpcError = (pluginId: PluginId) => (error: { readonly message: string }) => + PluginHost.isPluginAccessDeniedError(error) + ? rpcAuthorizationError(error.requiredScope) + : new PluginError({ message: error.message, pluginId }); + +/** The `plugins.*` WebSocket handlers. `scopes` are the connected session's. */ +export const makePluginRpcHandlers = ( + pluginHost: PluginHost.PluginHost["Service"], + scopes: ReadonlyArray, +) => ({ + [WS_METHODS.pluginsList]: (_input: PluginListInput) => + pluginHost.list.pipe(Effect.map((plugins) => ({ plugins }))), + [WS_METHODS.pluginsSubscribeStates]: (_input: PluginListInput) => + pluginHost.streamStates.pipe(Stream.map((plugins) => ({ plugins }))), + [WS_METHODS.pluginsSetEnabled]: (input: PluginSetEnabledInput) => + pluginHost.setEnabled(input.pluginId, input.enabled).pipe( + Effect.map((plugin) => ({ plugin })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsReload]: (input: PluginReloadInput) => + pluginHost.reload(input.pluginId).pipe( + Effect.map((plugin) => ({ plugin })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsInstall]: (input: PluginInstallInput) => + pluginHost.install(input.source).pipe( + Effect.map((plugin) => ({ plugin })), + Effect.mapError((error) => new PluginError({ message: error.message })), + ), + [WS_METHODS.pluginsUpdate]: (input: PluginUpdateInput) => + pluginHost.update(input.pluginId).pipe( + Effect.map((plugin) => ({ plugin })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsUninstall]: (input: PluginUninstallInput) => + pluginHost + .uninstall(input.pluginId, input.removeData === true) + .pipe(Effect.as({ pluginId: input.pluginId }), Effect.mapError(toRpcError(input.pluginId))), + [WS_METHODS.pluginsSettingsGet]: (input: PluginSettingsGetInput) => + pluginHost.getSettings(input.pluginId).pipe( + Effect.map((settings) => ({ pluginId: input.pluginId, settings })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsSettingsUpdate]: (input: PluginSettingsUpdateInput) => + pluginHost.updateSettings(input.pluginId, input.patch).pipe( + Effect.map((settings) => ({ pluginId: input.pluginId, settings })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsInvoke]: (input: PluginInvokeInput) => + pluginHost + .invoke({ pluginId: input.pluginId, method: input.method, input: input.input, scopes }) + .pipe( + Effect.map((output) => ({ output })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsSubscribe]: (input: PluginSubscribeInput) => + pluginHost.subscribe(input).pipe( + Stream.map((value) => ({ value })), + Stream.mapError(toRpcError(input.pluginId)), + ), +}); diff --git a/apps/server/src/plugins/pluginTarball.test.ts b/apps/server/src/plugins/pluginTarball.test.ts new file mode 100644 index 0000000000000..7cb06c2360ce2 --- /dev/null +++ b/apps/server/src/plugins/pluginTarball.test.ts @@ -0,0 +1,43 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeZlib from "node:zlib"; + +import { describe, expect, it } from "vite-plus/test"; + +import { unpackNpmTarball } from "./pluginTarball.ts"; +import { gzipTarball as tarball, tarEntry as entry } from "./testTarball.ts"; + +describe("unpackNpmTarball", () => { + it("returns the package's files relative to its folder", () => { + const files = unpackNpmTarball( + tarball( + entry("package/", "", "5"), + entry("package/t3-plugin.json", '{"id":"hello"}'), + entry("package/dist/web.js", "export default {}"), + entry("PaxHeader", "30 path=package/a-long-name.txt\n", "x"), + entry("ignored", "long name"), + ), + ); + expect(files.map((file) => [file.path, new TextDecoder().decode(file.contents)])).toEqual([ + ["t3-plugin.json", '{"id":"hello"}'], + ["dist/web.js", "export default {}"], + ["a-long-name.txt", "long name"], + ]); + }); + + it.each([ + ["a path that leaves the package", entry("package/../escape.js", "x"), /unsafe path/], + ["a file outside the package folder", entry("other/file.js", "x"), /outside its package/], + ["a symlink", entry("package/link", "", "2"), /not a plain file/], + ["a hard link", entry("package/hard", "", "1"), /not a plain file/], + ["a duplicate path", entry("package/ok.js", "other"), /twice/], + ])("refuses %s", (_label, bad, message) => { + expect(() => unpackNpmTarball(tarball(entry("package/ok.js", "ok"), bad))).toThrow(message); + }); + + it("refuses a header whose checksum does not match", () => { + const archive = tarball(entry("package/ok.js", "ok")); + const raw = new Uint8Array(NodeZlib.gunzipSync(archive)); + raw[0] = "q".charCodeAt(0); + expect(() => unpackNpmTarball(NodeZlib.gzipSync(raw))).toThrow(/malformed/); + }); +}); diff --git a/apps/server/src/plugins/pluginTarball.ts b/apps/server/src/plugins/pluginTarball.ts new file mode 100644 index 0000000000000..e9c7092a1133d --- /dev/null +++ b/apps/server/src/plugins/pluginTarball.ts @@ -0,0 +1,114 @@ +// @effect-diagnostics nodeBuiltinImport:off - gunzip needs node:zlib; the archive is in memory. +import * as NodeZlib from "node:zlib"; + +const BLOCK = 512; +// npm packs every file under `package/`; plugins are small, so anything bigger is refused. +const PACKAGE_PREFIX = "package/"; +const MAX_UNPACKED_BYTES = 64 * 1024 * 1024; +const MAX_ENTRIES = 4096; + +export interface TarballFile { + /** Relative to the package root, with `/` separators and no `.` or `..` segments. */ + readonly path: string; + readonly contents: Uint8Array; +} + +const text = (block: Uint8Array, start: number, length: number) => { + const field = block.subarray(start, start + length); + const end = field.indexOf(0); + return new TextDecoder().decode(end === -1 ? field : field.subarray(0, end)); +}; + +const octal = (block: Uint8Array, start: number, length: number) => { + const value = text(block, start, length).trim(); + return value === "" ? 0 : Number.parseInt(value, 8); +}; + +// The header checksum treats its own 8 bytes as spaces. node-tar skips a header that fails it, so +// accepting one here could install a file that npm's own tools never show. +const checksumMatches = (header: Uint8Array) => { + let sum = 0; + for (let index = 0; index < BLOCK; index += 1) { + sum += index >= 148 && index < 156 ? 0x20 : (header[index] ?? 0); + } + return sum === octal(header, 148, 8); +}; + +const isUstar = (header: Uint8Array) => text(header, 257, 5) === "ustar"; + +const isSafeRelativePath = (path: string) => + path.length > 0 && + !path.startsWith("/") && + !path.includes("\\") && + !path.includes("\0") && + path.split("/").every((segment) => segment !== "" && segment !== "." && segment !== ".."); + +/** + * The regular files of an npm package tarball (gzipped ustar), relative to its `package/` folder. + * Throws on anything but plain files and folders (links, devices), on paths that would leave the + * package, and past the size and entry caps, so nothing from the archive is written unchecked. + */ +export function unpackNpmTarball(gzipped: Uint8Array): ReadonlyArray { + const archive = NodeZlib.gunzipSync(gzipped, { maxOutputLength: MAX_UNPACKED_BYTES }); + const files: Array = []; + const paths = new Set(); + let offset = 0; + let longName: string | undefined; + let paxSize: number | undefined; + while (offset + BLOCK <= archive.length) { + const header = archive.subarray(offset, offset + BLOCK); + if (header.every((byte) => byte === 0)) { + // The archive ends with 2 zero blocks; a lone one followed by more data is malformed. + const next = archive.subarray(offset + BLOCK, offset + 2 * BLOCK); + if (next.every((byte) => byte === 0)) break; + throw new Error("The package archive is truncated or malformed."); + } + if (files.length >= MAX_ENTRIES) throw new Error("The package has too many files."); + if (!checksumMatches(header)) throw new Error("The package archive is truncated or malformed."); + const size = paxSize ?? octal(header, 124, 12); + paxSize = undefined; + const type = String.fromCharCode(header[156] ?? 0); + // Only ustar headers have a prefix field; GNU headers keep other data there. + const prefix = isUstar(header) ? text(header, 345, 155) : ""; + const name = + longName ?? (prefix === "" ? text(header, 0, 100) : `${prefix}/${text(header, 0, 100)}`); + longName = undefined; + const dataStart = offset + BLOCK; + const dataEnd = dataStart + size; + if (!Number.isSafeInteger(size) || size < 0 || dataEnd > archive.length) { + throw new Error("The package archive is truncated or malformed."); + } + offset = dataStart + Math.ceil(size / BLOCK) * BLOCK; + // GNU long names and pax headers describe the next entry; pax "path" overrides its name. + if (type === "L") { + longName = text(archive, dataStart, size); + continue; + } + if (type === "x") { + const records = new TextDecoder() + .decode(archive.subarray(dataStart, dataEnd)) + .split("\n") + .map((line) => line.slice(line.indexOf(" ") + 1)); + const pathRecord = records.find((record) => record.startsWith("path=")); + const sizeRecord = records.find((record) => record.startsWith("size=")); + if (pathRecord !== undefined) longName = pathRecord.slice("path=".length); + if (sizeRecord !== undefined) paxSize = Number(sizeRecord.slice("size=".length)); + continue; + } + if (type === "g") continue; + if (type === "5") continue; + if (type !== "0" && type !== "\0") { + throw new Error(`The package contains ${name}, which is not a plain file.`); + } + if (!name.startsWith(PACKAGE_PREFIX)) { + throw new Error(`The package contains ${name} outside its package folder.`); + } + const path = name.slice(PACKAGE_PREFIX.length); + if (!isSafeRelativePath(path)) throw new Error(`The package contains an unsafe path: ${name}.`); + // npm keeps the last copy of a duplicated path; refusing makes the installed file unambiguous. + if (paths.has(path)) throw new Error(`The package contains ${name} twice.`); + paths.add(path); + files.push({ path, contents: archive.slice(dataStart, dataEnd) }); + } + return files; +} diff --git a/apps/server/src/plugins/testTarball.ts b/apps/server/src/plugins/testTarball.ts new file mode 100644 index 0000000000000..9f2b5e6c2a28d --- /dev/null +++ b/apps/server/src/plugins/testTarball.ts @@ -0,0 +1,34 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeZlib from "node:zlib"; + +const encoder = new TextEncoder(); + +/** A ustar entry with only the fields the extractor reads, for tests. */ +export const tarEntry = (name: string, contents: string, type = "0") => { + const data = encoder.encode(contents); + const header = new Uint8Array(512); + header.set(encoder.encode(name).subarray(0, 100), 0); + header.set(encoder.encode(`${data.length.toString(8).padStart(11, "0")}\0`), 124); + header.set(encoder.encode(type), 156); + header.set(encoder.encode("ustar\0"), 257); + header.set(encoder.encode("00"), 263); + // The checksum counts its own field as spaces, then is stored as 6 octal digits, NUL, space. + header.fill(0x20, 148, 156); + const sum = header.reduce((total, byte) => total + byte, 0); + header.set(encoder.encode(`${sum.toString(8).padStart(6, "0")}\0 `), 148); + const padded = new Uint8Array(Math.ceil(data.length / 512) * 512); + padded.set(data); + return [header, padded]; +}; + +/** A gzipped tarball of `entries`, the way npm serves packages. */ +export const gzipTarball = (...entries: ReadonlyArray>) => { + const blocks = [...entries.flat(), new Uint8Array(1024)]; + const archive = new Uint8Array(blocks.reduce((total, block) => total + block.length, 0)); + let offset = 0; + for (const block of blocks) { + archive.set(block, offset); + offset += block.length; + } + return new Uint8Array(NodeZlib.gzipSync(archive)); +}; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 452addfb4a667..a8840aec9db32 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -88,6 +88,12 @@ import * as PreviewBrowser from "./preview/PreviewBrowser.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; +import * as PluginHooks from "./plugins/PluginHooks.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; +import * as PluginHttpRoutes from "./plugins/PluginHttpRoutes.ts"; +import * as PluginMcpTools from "./plugins/PluginMcpTools.ts"; +import * as PluginSettingsStore from "./plugins/PluginSettingsStore.ts"; +import * as PluginStorage from "./plugins/PluginStorage.ts"; import * as Keybindings from "./keybindings.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerOwnership from "./serverOwnership.ts"; @@ -681,6 +687,10 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)), Layer.provideMerge(layerRepositoryIdentityResolver), Layer.provideMerge(CodexManagedTokenBroker.layer), + // One registry each for the dispatch path, MCP and HTTP below and the plugin host above. + Layer.provideMerge( + Layer.mergeAll(PluginHooks.layer, PluginMcpTools.layer, PluginHttpRoutes.layer), + ), Layer.provideMerge(layerServerEnvironment), Layer.provideMerge(layerAuth), Layer.provideMerge(ServerSecretStore.layer), @@ -709,6 +719,14 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( Layer.provide(NetService.layer), ); +// The plugin host sits on top of the runtime so plugins can reach orchestration. +const layerRuntimeDependenciesWithPlugins = PluginHost.layer.pipe( + Layer.provide( + Layer.mergeAll(PluginSettingsStore.layer, PluginStorage.layer, ProcessRunner.layer), + ), + Layer.provideMerge(layerRuntimeDependencies), +); + const layerCommandReadiness = HttpRouter.middleware( (httpEffect) => Effect.flatMap(ServerRuntimeStartup.ServerRuntimeStartup, (startup) => @@ -734,6 +752,7 @@ const layerMakeRoutes = Layer.mergeAll( ServerHttp.layerAssetRoute, ServerHttp.layerAttachmentUploadRoute, GuestHttp.layer, + PluginHttpRoutes.layerRoute, DeviceHubProxy.layer, ServerBrowserStream.routeLayer, ServerHttp.layerStaticAndDevRoute, @@ -1072,7 +1091,7 @@ const layerMakeServer = Layer.unwrap( ], { concurrency: "unbounded" }, ).pipe(Effect.asVoid), - }).pipe(Layer.provideMerge(layerRuntimeDependencies), Layer.provide(layerLauncher)); + }).pipe(Layer.provideMerge(layerRuntimeDependenciesWithPlugins), Layer.provide(layerLauncher)); const layerRoutes = HttpRouter.serve(layerMakeRoutes.pipe(Layer.provide(layerLauncher)), { disableLogger: !config.logWebSocketEvents, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 641845f322ea6..c10d7ef9965cb 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -79,6 +79,7 @@ import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolve import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -529,6 +530,7 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ResourceTelemetry.ResourceTelemetry)({}), Layer.mock(ReviewService.ReviewService)({}), Layer.mock(ScheduledTasks.ScheduledTaskService)({}), + Layer.mock(PluginHost.PluginHost)({}), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(SecretResolver.SecretResolver)({}), Layer.mock(GuestShareService.GuestShareService)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4781fcf78dde6..f7b7390cbcacd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -121,6 +121,8 @@ import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts" import * as DocumentText from "./provider/DocumentText.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; +import { makePluginRpcHandlers } from "./plugins/pluginRpcHandlers.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; import { @@ -1273,6 +1275,7 @@ export const layerWsRpc = ( const commandOutputHub = yield* CommandOutputHub.CommandOutputHub; const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; + const pluginHost = yield* PluginHost.PluginHost; const secretRequests = yield* SecretRequests.SecretRequests; const guestShares = yield* GuestShareService.GuestShareService; const pullRequests = yield* PullRequestService.PullRequestService; @@ -1867,6 +1870,7 @@ export const layerWsRpc = ( }); const handlers = ServerWsRpcGroup.of({ + ...makePluginRpcHandlers(pluginHost, currentSession.scopes), [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, diff --git a/apps/web/package.json b/apps/web/package.json index 4407645ea143f..351c71a0e0735 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "@pierre/trees": "1.0.0-beta.6", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/secret-provider-definitions": "workspace:*", "@t3tools/secret-provider-onepassword": "workspace:*", diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index e4dfceefd91da..8c3872fb4927a 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -11,6 +11,8 @@ import { filesystemEnvironment } from "../state/filesystem"; import { useRightPanelStore } from "../rightPanelStore"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; import { HostImageUrlContext } from "./chat/hostImageUrl"; +import { registerPluginCodeBlock } from "../plugins/pluginRegistry"; +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; const { workspaceSearch, filesystemMetadata } = vi.hoisted(() => ({ workspaceSearch: vi.fn(), @@ -250,6 +252,26 @@ describe("ChatMarkdown streaming", () => { } }); + it("lets a plugin draw closed code fences of its language", async () => { + setup(); + using _renderer = { + [Symbol.dispose]: registerPluginCodeBlock("hello-chart", (props) => ( +

Chart of {props.code}

+ )), + }; + const message = (text: string, isStreaming = false) => ( + + + + ); + await using screen = await render(message("```hello-chart\n1,2,3", true)); + expect(screen.container.textContent).toContain("1,2,3"); + expect(screen.container.textContent).not.toContain("Chart of"); + + await screen.rerender(message("```hello-chart\n1,2,3\n```")); + await expect.element(page.getByText(/Chart of 1,2,3/)).toBeVisible(); + }); + it("edits the current task text and marker after reusing a renderer", async () => { setup(); let editedText: string | undefined; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9413b13a088cf..9fcd3838b1719 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -192,6 +192,7 @@ import { MarkdownCodeBlock, SuspenseShikiCodeBlock } from "./chat/MarkdownCodeBl import { createMarkdownRenderer } from "./chat/markdownRenderer"; import { splitMarkdownBlocks } from "./chat/markdownStreamBlocks"; import { MarkdownMermaidCodeBlock } from "./chat/MarkdownMermaidCodeBlock"; +import { pluginCodeBlockRenderersAtom } from "../plugins/pluginRegistry"; import { MarkdownTable } from "./chat/MarkdownTable"; import { MarkdownDetails } from "./chat/MarkdownDetails"; import { @@ -1570,6 +1571,7 @@ const CHAT_MARKDOWN_COMPONENTS = { mathEnabled, } = use(ChatMarkdownRendererContext); const text = use(ChatMarkdownTextContext); + const pluginCodeBlocks = useAtomValue(pluginCodeBlockRenderersAtom); const codeBlock = extractCodeBlock(children); if (!codeBlock) { return
{children}
; @@ -1620,7 +1622,7 @@ const CHAT_MARKDOWN_COMPONENTS = { ); } - return ( + const genericCodeBlock = ( ); + // A plugin draws only closed fences, so streaming text renders exactly as before. + const PluginCodeBlock = pluginCodeBlocks.get(language); + if (PluginCodeBlock && (!isStreaming || isClosedCodeFence(node, text, codeBlock.code))) { + return ( + + + + ); + } + return genericCodeBlock; }, } satisfies Components; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 486d80a988666..c2377871bef2c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -73,6 +73,7 @@ import { SquarePenIcon, TextSearchIcon, UserPlusIcon, + PuzzleIcon, } from "lucide-react"; import { openThreadFind } from "./chat/threadFindStore"; import { @@ -86,6 +87,7 @@ import { } from "react"; import type { LegendListRef } from "@legendapp/list/react"; import { useAtomValue } from "@effect/atom-react"; +import { pluginCommandsAtom } from "../plugins/pluginRegistry"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; @@ -441,6 +443,7 @@ function OpenCommandPaletteDialog(props: { const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const pluginCommands = useAtomValue(pluginCommandsAtom(primaryEnvironmentId ?? "")); const availableSettingsSearchItems = useAvailableSettingsSearchItems(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); @@ -1647,6 +1650,19 @@ function OpenCommandPaletteDialog(props: { }); } + for (const command of pluginCommands) { + actionItems.push({ + kind: "action", + value: `action:${command.command}`, + searchTerms: [command.pluginName, ...command.keywords], + title: command.title, + description: command.pluginName, + icon: , + shortcutCommand: command.command, + run: async () => command.run(), + }); + } + const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); // Every setting goes to the palette's own matcher, so settings get the same // word starts and typo tolerance as threads. diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 596bdee96ad1f..4c194606cce61 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -46,7 +46,9 @@ import { MessagesSquare, Plus, TerminalSquare, + Puzzle, } from "lucide-react"; +import { PluginPanelLauncherItems, PluginPanelMenuItems } from "../plugins/PluginPanelMenuItems"; import { Volume2, VolumeOff } from "lucide"; import { type KeyboardEvent as ReactKeyboardEvent, @@ -313,6 +315,7 @@ function SurfaceMenuItem(props: { } function RightPanelEmptyState(props: { + environmentId: EnvironmentId | null; onAddBrowser: () => void; onAddBrowserInProfile: (profileId: string) => void; browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; @@ -561,6 +564,7 @@ function RightPanelEmptyState(props: { /> ), )} + @@ -594,6 +598,8 @@ function surfaceTitle( return surface.title ?? surface.target?.name ?? "Device"; case "side-chat": return "Side chat"; + case "plugin": + return surface.title; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -692,6 +698,8 @@ function SurfaceIcon({ ) : ( ); + case "plugin": + return ; } } @@ -1461,6 +1469,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ); })} + setAddSurfaceMenuOpen(false)} + /> ) : null} @@ -1524,6 +1536,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
{props.activeSurfaceId === null ? ( void; + label: string; + description: string; + } | { id: string; type: "skill"; diff --git a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx index 6ca50c824cb45..9a3cfb1fb625f 100644 --- a/apps/web/src/components/chat/RightPanelSurfaceContent.tsx +++ b/apps/web/src/components/chat/RightPanelSurfaceContent.tsx @@ -33,6 +33,11 @@ const DevicePanel = lazy(() => import("../device/DevicePanel").then((module) => ({ default: module.DevicePanel })), ); const FilePreviewPanel = lazy(() => import("../files/FilePreviewPanel")); +const PluginPanelSurface = lazy(() => + import("../../plugins/PluginPanelSurface").then((module) => ({ + default: module.PluginPanelSurface, + })), +); type RightPanelTerminalProps = Omit< PersistentThreadTerminalPanelProps, @@ -179,6 +184,18 @@ export function RightPanelSurfaceContent(props: { onClose={() => props.onCloseSurface(surface)} /> ); + case "plugin": + return ( + + props.onCloseSurface(surface)} + /> + + ); case "device": return ( diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index ee6072b926b7e..b88bacc975a9f 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -22,6 +22,8 @@ import { Button } from "../ui/button"; import ChatMarkdown from "../ChatMarkdown"; import { ChatMarkdownAssetImage } from "./ChatMarkdownMedia"; import { RenderErrorBoundary } from "../RenderErrorBoundary"; +import { PluginToolBody } from "../../plugins/PluginToolBody"; +import { pluginToolNameOf } from "../../plugins/pluginRegistry"; import { CommandOutputPanel, useCommandOutputStreaming } from "./CommandOutputPanel"; import { resolveExternalWebLinkHref } from "./externalLinkContextMenu"; import type { ExpandedImagePreview } from "./ExpandedImagePreview"; @@ -379,12 +381,30 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} {item.type === "dynamic_tool" ? ( - + pluginToolNameOf(item.toolName) !== undefined && item.toolName !== null ? ( + + } + /> + ) : ( + + ) ) : null} {item.type === "approval_request" ? : null} diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.ts b/apps/web/src/components/chat/composerSlashCommandSearch.ts index 0c5c04e28caef..4b64eefe5b7d2 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.ts @@ -9,7 +9,7 @@ import { scoreProviderSkill } from "../../providerSkillSearch"; type SlashSearchItem = Extract< ComposerCommandItem, - { type: "slash-command" | "provider-slash-command" | "skill" } + { type: "slash-command" | "provider-slash-command" | "plugin-slash-command" | "skill" } >; /** Provider commands, `/side` and `/btw` work only at the message start. */ @@ -103,7 +103,9 @@ export function searchSlashCommandItems( ? `0\u0000${item.command}` : item.type === "provider-slash-command" ? `1\u0000${item.command.name}\u0000${item.provider}` - : `2\u0000${item.skill.name}\u0000${item.provider}`, + : item.type === "plugin-slash-command" + ? `2\u0000${item.command.name}` + : `3\u0000${item.skill.name}\u0000${item.provider}`, }, Number.POSITIVE_INFINITY, ); diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 3ae947ff897f9..f77c4a0094a2d 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -31,6 +31,7 @@ import { searchProviderSkills } from "../../providerSkillSearch"; import { useThreadShells } from "~/state/entities"; import { useEnvironmentIdentities, useEnvironmentMachines } from "~/state/environments"; import { offersLinkedEnvironmentsAtom } from "~/state/peerLinks"; +import { pluginSlashCommandsAtom } from "~/plugins/pluginRegistry"; import { pullRequestEnvironment, usePullRequestList, @@ -114,6 +115,7 @@ export function useComposerMenuItems({ const environmentIdentities = useEnvironmentIdentities(); const environmentMachines = useEnvironmentMachines(); const offersMachines = useAtomValue(offersLinkedEnvironmentsAtom(environmentId)); + const pluginSlashCommands = useAtomValue(pluginSlashCommandsAtom(environmentId)); const mentionableEnvironments = environmentIdentities.map((identity) => ({ ...identity, machine: environmentMachines.get(identity.environmentId) ?? ("server" as const), @@ -289,8 +291,21 @@ export function useComposerMenuItems({ const visibleProviderSlashCommandItems = providerSlashCommandItems.filter( (item) => item.command.name !== "compact" || compactSlashCommandAvailable, ); + const pluginSlashCommandItems = pluginSlashCommands.map((command) => ({ + id: `plugin-slash-command:${command.pluginId}:${command.name}`, + type: "plugin-slash-command" as const, + command: { name: command.name }, + run: command.run, + label: `/${command.name}`, + description: `${command.description} · ${command.pluginName}`, + })); const slashCommandItems = slashCommandItemsForPromptPosition( - [...builtInSlashCommandItems, ...visibleProviderSlashCommandItems, ...skillItems], + [ + ...builtInSlashCommandItems, + ...visibleProviderSlashCommandItems, + ...pluginSlashCommandItems, + ...skillItems, + ], composerTrigger.rangeStart === 0, ); return searchSlashCommandItems(slashCommandItems, query); diff --git a/apps/web/src/components/chat/useSystemComposerBanners.tsx b/apps/web/src/components/chat/useSystemComposerBanners.tsx index 1d29dfcb19ea1..947f795aa5682 100644 --- a/apps/web/src/components/chat/useSystemComposerBanners.tsx +++ b/apps/web/src/components/chat/useSystemComposerBanners.tsx @@ -8,7 +8,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { WifiOffIcon } from "lucide-react"; +import { PuzzleIcon, WifiOffIcon } from "lucide-react"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { environmentCatalog } from "../../connection/catalog"; import { ConnectionDiagnosisHeadline } from "../settings/ConnectionDiagnosisDetails"; @@ -25,6 +25,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { Button } from "../ui/button"; import { ServerUpdateAction, serverUpdateStageLabel } from "../ServerUpdateAction"; import { useAutoBalanceUpdateBanner } from "./useAutoBalanceUpdateBanner"; +import { pluginBannersAtom } from "../../plugins/pluginRegistry"; import { ComposerServerUpdateIcon } from "./ComposerServerUpdateStatus"; import { dismissServerUpdateFailure, @@ -56,6 +57,7 @@ interface SystemComposerBannersInput { * a server update, and auto-balanced machines that need one. */ export function useSystemComposerBanners(input: SystemComposerBannersInput) { + const pluginBanners = useAtomValue(pluginBannersAtom(input.activeThreadEnvironmentId ?? "")); const { activeEnvironment, activeEnvironmentUnavailable, @@ -260,6 +262,16 @@ export function useSystemComposerBanners(input: SystemComposerBannersInput) { }); } if (autoBalanceUpdateBanner) items.push(autoBalanceUpdateBanner); + for (const banner of pluginBanners) { + items.push({ + id: `plugin:${banner.key}`, + variant: banner.variant, + icon: , + title: banner.title, + ...(banner.description === undefined ? {} : { description: banner.description }), + onDismiss: banner.dismiss, + }); + } return items; })() as ComposerBannerStackItem[]; } diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index fe9b47b4dcadc..22693ac89b58a 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -219,6 +219,7 @@ const KEYBINDING_GROUPS = [ { id: "preview", title: "Preview & diff", prefixes: ["preview", "diff"] }, { id: "appearance", title: "Appearance", prefixes: ["theme", "appearance", "themeEditor"] }, { id: "scripts", title: "Project scripts", prefixes: ["script"] }, + { id: "plugins", title: "Plugins", prefixes: ["plugin"] }, ] as const; const OTHER_KEYBINDING_GROUP = { id: "other", title: "Other" } as const; @@ -359,8 +360,9 @@ export function buildWhenVariableOptions(): ReadonlyArray { export function buildKeybindingCommandOptions( keybindings: ResolvedKeybindingsConfig, + pluginCommands: ReadonlyArray = [], ): ReadonlyArray { - const commands = new Set(STATIC_KEYBINDING_COMMANDS); + const commands = new Set([...STATIC_KEYBINDING_COMMANDS, ...pluginCommands]); for (const binding of keybindings) { commands.add(binding.command); } diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index f6298d778073e..24db64f29eb0c 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -1,3 +1,5 @@ +import { useAtomValue } from "@effect/atom-react"; +import { pluginCommandsAtom } from "../../plugins/pluginRegistry"; import { useEnvironmentScope } from "../../state/session"; import { useEnvironmentsWithScope, readEnvironmentScope } from "../../state/session"; import { @@ -1283,10 +1285,14 @@ function keybindingAnchorIds(rows: ReturnType) { // Cache rows in a separate hook so the compiler does not rebuild them per search keystroke. function useKeybindingCatalog() { - const serverKeybindings = useSettingsScope().environment?.serverConfig?.keybindings; - const merged = mergeWithDefaultKeybindings(serverKeybindings ?? []); + const { environment } = useSettingsScope(); + const pluginCommands = useAtomValue(pluginCommandsAtom(environment?.environmentId ?? "")); + const merged = mergeWithDefaultKeybindings(environment?.serverConfig?.keybindings ?? []); return { - commandOptions: buildKeybindingCommandOptions(merged), + commandOptions: buildKeybindingCommandOptions( + merged, + pluginCommands.map((entry) => entry.command), + ), allRows: buildKeybindingRows(merged), }; } diff --git a/apps/web/src/components/settings/PluginsSettings.browser.test.tsx b/apps/web/src/components/settings/PluginsSettings.browser.test.tsx new file mode 100644 index 0000000000000..ec31f6b0d85ed --- /dev/null +++ b/apps/web/src/components/settings/PluginsSettings.browser.test.tsx @@ -0,0 +1,129 @@ +import { render } from "../../../test/render"; +import { PluginId, type PluginState } from "@t3tools/contracts"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +const { state, setEnabled, setEnabledCommand } = vi.hoisted(() => ({ + state: { plugins: [] as ReadonlyArray }, + setEnabled: vi.fn(async (_request: unknown) => ({ _tag: "Success" as const, value: {} })), + setEnabledCommand: { permissionAtom: (): unknown => null }, +})); +vi.mock(import("../../state/use-atom-command"), async (original) => ({ + ...(await original()), + useAtomCommand: ((command: unknown) => + command === setEnabledCommand ? setEnabled : vi.fn()) as never, +})); +vi.mock(import("../../state/plugins"), () => ({ + pluginEnvironment: { + pluginsLive: () => "plugins-live", + setPluginEnabled: setEnabledCommand, + // Install and removal share the enable permission here; the test exercises neither. + installPlugin: { permissionAtom: () => setEnabledCommand.permissionAtom() }, + uninstallPlugin: { permissionAtom: () => setEnabledCommand.permissionAtom() }, + updatePlugin: { permissionAtom: () => setEnabledCommand.permissionAtom() }, + } as never, +})); +vi.mock(import("../../state/query"), async (original) => ({ + ...(await original()), + useEnvironmentQuery: ((atom: unknown) => ({ + data: atom === "plugins-live" ? { plugins: state.plugins } : null, + error: null, + })) as never, +})); +// The real row and page wire in scope and inheritance UI this page does not use. +vi.mock(import("./settingsLayout"), async (original) => ({ + ...(await original()), + SettingsPageContainer: (({ children }: { children: ReactNode }) => ( +
{children}
+ )) as never, + SettingsRow: (({ + title, + description, + status, + control, + children, + }: { + title: ReactNode; + description?: ReactNode; + status?: ReactNode; + control?: ReactNode; + children?: ReactNode; + }) => ( +
+

{title}

+ {description} + {status} + {control} + {children} +
+ )) as never, +})); +vi.mock(import("./SettingsScopeContext"), async (original) => ({ + ...(await original()), + useSettingsScope: (() => ({ + scope: { kind: "environment" }, + environment: { + environmentId: "env", + label: "MAC1", + connection: { phase: "connected" }, + serverConfig: { environment: { capabilities: { plugins: true } } }, + }, + })) as never, +})); + +import { Atom } from "effect/reactivity"; + +import { PluginsSettings } from "./PluginsSettings"; + +const allowed = Atom.make(true); + +const disabledPlugin: Omit = { + version: "0.1.0", + permissions: [], + source: "installed", + enabled: false, + status: "disabled", + hasServer: true, + hasWeb: false, +}; + +function setup(plugins: ReadonlyArray) { + setEnabled.mockClear(); + setEnabledCommand.permissionAtom = () => allowed; + state.plugins = plugins; +} + +describe("PluginsSettings", () => { + it("enables a plugin through the command and shows why another failed", async () => { + setup([ + { ...disabledPlugin, id: PluginId.make("hello-plugin"), name: "Hello" }, + { + ...disabledPlugin, + id: PluginId.make("broken-plugin"), + name: "Broken", + enabled: true, + status: "failed", + failure: { stage: "activate", message: "broken-plugin refuses to start" }, + }, + ]); + await using _screen = await render(); + + // The settings row lays the switch out past the test frame's viewport, so click it directly. + page + .getByRole("switch", { name: "Enable Hello" }) + .first() + .element() + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await vi.waitFor(() => + expect(setEnabled).toHaveBeenCalledExactlyOnceWith({ + environmentId: "env", + input: { pluginId: "hello-plugin", enabled: true }, + }), + ); + await expect + .element(page.getByText("Failed to start: broken-plugin refuses to start")) + .toBeVisible(); + }); +}); diff --git a/apps/web/src/components/settings/PluginsSettings.tsx b/apps/web/src/components/settings/PluginsSettings.tsx new file mode 100644 index 0000000000000..24395f0286cb7 --- /dev/null +++ b/apps/web/src/components/settings/PluginsSettings.tsx @@ -0,0 +1,663 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + type PluginSettingsField, + pluginSettingsForm, +} from "@t3tools/client-runtime/plugins/settings-form"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, PluginInstallSource, PluginState } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + DownloadIcon, + PuzzleIcon, + RotateCwIcon, + SlidersHorizontalIcon, + Trash2Icon, +} from "lucide-react"; +import { type ComponentType, useState } from "react"; + +import type { EnvironmentPresentation } from "../../state/environments"; +import { + pluginContributionKey, + pluginSettingsPanelAtom, + pluginWebFailureAtom, +} from "../../plugins/pluginRegistry"; +import { pluginEnvironment } from "../../state/plugins"; +import { RenderErrorBoundary } from "../RenderErrorBoundary"; +import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Badge } from "../ui/badge"; +import { Checkbox } from "../ui/checkbox"; +import { Button } from "../ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Switch } from "../ui/switch"; +import { Textarea } from "../ui/textarea"; +import { useSettingsScope } from "./SettingsScopeContext"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; + +const STATUS_BADGES = { + active: { variant: "success", label: "Active" }, + loading: { variant: "info", label: "Starting" }, + disabled: { variant: "secondary", label: "Disabled" }, + failed: { variant: "error", label: "Failed" }, +} as const; + +const FAILURE_STAGES = { + discovery: "Not found", + manifest: "Invalid manifest", + compatibility: "Incompatible", + load: "Failed to load", + activate: "Failed to start", +} as const; + +export function PluginsSettings() { + const { scope, environment } = useSettingsScope(); + return ( + + + {scope.kind === "unavailable" ? ( + + ) : environment === null ? ( + + + + + + No environment selected + Connect an environment to manage its plugins. + + + ) : ( + + )} + + + ); +} + +function EnvironmentPlugins({ environment }: { readonly environment: EnvironmentPresentation }) { + const connected = + environment.connection.phase === "connected" && environment.serverConfig !== null; + const supported = environment.serverConfig?.environment.capabilities.plugins === true; + const query = useEnvironmentQuery( + connected && supported + ? pluginEnvironment.pluginsLive({ environmentId: environment.environmentId, input: {} }) + : null, + ); + if (!connected) { + return ( + + ); + } + if (!supported) { + return ( + + ); + } + if (query.error) return ; + const plugins = query.data?.plugins; + if (!plugins) return ; + return ( + <> + + {plugins.length === 0 ? ( + + ) : ( + // A shadowed copy of a plugin shares its id and is listed as failed during discovery. + plugins.map((plugin) => ( + + )) + )} + + ); +} + +const installSourceLabel = (source: PluginInstallSource) => + source.type === "path" ? source.path : source.type === "git" ? source.url : `npm ${source.name}`; + +const INSTALL_SOURCES = { + path: { label: "Folder on the server", placeholder: "/home/me/my-plugin" }, + git: { label: "Git repository", placeholder: "https://github.com/me/my-plugin.git" }, + npm: { label: "npm package", placeholder: "t3-plugin-example" }, +} as const; + +/** Installs a plugin into this environment; it runs with the same access as T3 Code itself. */ +function AddPlugin({ environmentId }: { readonly environmentId: EnvironmentId }) { + const canInstall = useAtomValue(pluginEnvironment.installPlugin.permissionAtom(environmentId)); + const install = useAtomCommand(pluginEnvironment.installPlugin, { + label: "plugin install", + reportFailure: false, + }); + const [open, setOpen] = useState(false); + const [kind, setKind] = useState("path"); + const [location, setLocation] = useState(""); + const [ref, setRef] = useState(""); + const [busy, setBusy] = useState(false); + const [problem, setProblem] = useState(null); + if (!open) { + return ( + setOpen(true)}> + Add plugin + + } + /> + ); + } + const submit = async () => { + setBusy(true); + setProblem(null); + const extra = ref.trim(); + const source = + kind === "path" + ? { type: "path" as const, path: location.trim() } + : kind === "git" + ? { type: "git" as const, url: location.trim(), ...(extra === "" ? {} : { ref: extra }) } + : { + type: "npm" as const, + name: location.trim(), + ...(extra === "" ? {} : { version: extra }), + }; + const result = await install({ environmentId, input: { source } }); + setBusy(false); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + setProblem(error instanceof Error ? error.message : "Could not install the plugin."); + return; + } + setOpen(false); + setLocation(""); + setRef(""); + }; + return ( + +
{ + event.preventDefault(); + void submit(); + }} + > +
+ + +
+
+ + setLocation(event.target.value)} + /> +
+ {kind === "path" ? null : ( +
+ + setRef(event.target.value)} + /> +
+ )} + {problem === null ? null :

{problem}

} +
+ + +
+
+
+ ); +} + +function PluginRow({ + environmentId, + plugin, +}: { + readonly environmentId: EnvironmentId; + readonly plugin: PluginState; +}) { + const canManage = useAtomValue(pluginEnvironment.setPluginEnabled.permissionAtom(environmentId)); + const setEnabled = useAtomCommand(pluginEnvironment.setPluginEnabled, "plugin enabled"); + const reload = useAtomCommand(pluginEnvironment.reloadPlugin, "plugin reload"); + const uninstall = useAtomCommand(pluginEnvironment.uninstallPlugin, "plugin removal"); + const update = useAtomCommand(pluginEnvironment.updatePlugin, "plugin update"); + const [busy, setBusy] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [removing, setRemoving] = useState(false); + const [removeData, setRemoveData] = useState(false); + const contributionKey = pluginContributionKey(environmentId, plugin.id); + const customSettings = useAtomValue(pluginSettingsPanelAtom(contributionKey)); + const webFailure = useAtomValue(pluginWebFailureAtom(contributionKey)); + const hasSettings = + customSettings !== undefined || pluginSettingsForm(plugin.settingsSchema).kind !== "none"; + const run = async (action: () => Promise) => { + setBusy(true); + await action(); + setBusy(false); + }; + const badge = STATUS_BADGES[plugin.status]; + return ( + + {plugin.description} + {plugin.permissions.length > 0 ? ( + Permissions: {plugin.permissions.join(", ")} + ) : null} + + } + status={ +
+ {badge.label} + + {plugin.version === undefined ? plugin.id : `${plugin.id} ${plugin.version}`} + {plugin.source === "dev" ? " · dev folder" : ""} + + {plugin.failure ? ( + + {FAILURE_STAGES[plugin.failure.stage]}: {plugin.failure.message} + + ) : null} + {webFailure === undefined ? null : ( + Web part failed: {webFailure} + )} +
+ } + control={ + <> + {hasSettings ? ( + + ) : null} + + {plugin.installSource === undefined ? null : ( + + )} + {plugin.source === "installed" ? ( + + ) : null} + + void run(() => setEnabled({ environmentId, input: { pluginId: plugin.id, enabled } })) + } + /> + + } + > + {removing ? ( +
+
+ setRemoveData(checked === true)} + /> + +
+ + +
+ ) : null} + {settingsOpen ? ( + customSettings === undefined ? ( + + ) : ( + + ) + ) : null} +
+ ); +} + +/** A settings panel the plugin's web bundle drew instead of the generated form. */ +function PluginCustomSettings(props: { readonly panel: ComponentType }) { + const Panel = props.panel; + return ( +
+ + This plugin's settings panel stopped with an error. +

+ } + > + +
+
+ ); +} + +function PluginSettingsEditor({ + environmentId, + plugin, +}: { + readonly environmentId: EnvironmentId; + readonly plugin: PluginState; +}) { + const query = useEnvironmentQuery( + pluginEnvironment.pluginSettings({ environmentId, input: { pluginId: plugin.id } }), + ); + if (query.error) return

{query.error}

; + if (!query.data) return

Loading settings…

; + return ( + + ); +} + +type Draft = Readonly>; + +const draftValue = (field: PluginSettingsField, value: Schema.Json | undefined) => { + switch (field.kind) { + case "boolean": + return typeof value === "boolean" ? value : (field.defaultValue ?? false); + case "secret": + return ""; + case "list": + return Array.isArray(value) ? value.join("\n") : ""; + default: + return typeof value === "string" || typeof value === "number" ? String(value) : ""; + } +}; + +/** The changed fields as a settings patch, or the first problem a user can fix before saving. */ +const toPatch = ( + fields: ReadonlyArray, + saved: Draft, + draft: Draft, +): { readonly patch: Schema.JsonObject } | { readonly problem: string } => { + const patch: Record = {}; + for (const field of fields) { + const value = draft[field.key]; + if (value === saved[field.key] || value === undefined) continue; + if (typeof value === "boolean") { + patch[field.key] = value; + } else if (field.kind === "secret") { + if (value !== "") patch[field.key] = value; + } else if (value.trim() === "") { + patch[field.key] = null; + } else if (field.kind === "number") { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return { problem: `${field.label} must be a number.` }; + patch[field.key] = parsed; + } else if (field.kind === "select") { + patch[field.key] = field.options.find((option) => String(option) === value) ?? value; + } else if (field.kind === "list") { + patch[field.key] = value + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + } else { + patch[field.key] = value; + } + } + return { patch }; +}; + +const parseJsonObject = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.JsonObject)); + +function PluginSettingsFormView({ + environmentId, + plugin, + initialSettings, +}: { + readonly environmentId: EnvironmentId; + readonly plugin: PluginState; + readonly initialSettings: Schema.JsonObject; +}) { + const form = pluginSettingsForm(plugin.settingsSchema); + const fields = form.kind === "fields" ? form.fields : []; + const update = useAtomCommand(pluginEnvironment.updatePluginSettings, { + label: "plugin settings", + reportFailure: false, + }); + const canSave = useAtomValue( + pluginEnvironment.updatePluginSettings.permissionAtom(environmentId), + ); + const toDraft = (settings: Schema.JsonObject): Draft => + Object.fromEntries(fields.map((field) => [field.key, draftValue(field, settings[field.key])])); + const [savedSettings, setSavedSettings] = useState(initialSettings); + const [saved, setSaved] = useState(() => toDraft(initialSettings)); + const [draft, setDraft] = useState(saved); + const [json, setJson] = useState(() => JSON.stringify(initialSettings, null, 2)); + const [problem, setProblem] = useState(null); + const [busy, setBusy] = useState(false); + + const save = async () => { + const next = + form.kind === "json" + ? (() => { + const parsed = parseJsonObject(json); + return Option.isSome(parsed) + ? { patch: parsed.value } + : { problem: "Settings must be a JSON object." }; + })() + : toPatch(fields, saved, draft); + if ("problem" in next) return setProblem(next.problem); + setBusy(true); + const result = await update({ + environmentId, + input: { pluginId: plugin.id, patch: next.patch }, + }); + setBusy(false); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + return setProblem(error instanceof Error ? error.message : "Could not save the settings."); + } + setProblem(null); + setSavedSettings(result.value.settings); + const savedDraft = toDraft(result.value.settings); + setSaved(savedDraft); + setDraft(savedDraft); + setJson(JSON.stringify(result.value.settings, null, 2)); + }; + + return ( +
+ {form.kind === "json" ? ( +