History

refactor(core): generic executor-wrapper seam; move toolkit narrowing into the plugin

Core no longer contains any toolkit vocabulary. The MCP narrowing seam (toolkit-scope.ts: ResolvedToolkitScope/applyToolkitScope/EMPTY_TOOLKIT_SCOPE) moves out of @executor-js/sdk into the toolkits plugin, where 'toolkit' naming belongs. In its place core gains a domain-agnostic hook: a plugin may expose a `wrapExecutor` method on its extension; the assembler collects these into the executor and the execution stack runs each before the engine is built, threading a per-request `selector` string. Core knows nothing about what a wrapper narrows to. The toolkits plugin contributes the wrapper (its resolveScope + applyToolkitScope, fail-closed to the empty slice). - removes the hardcoded `base.toolkits` plugin-id lookup in execution-stack - renames the core/host build option toolkitId -> selector (neutral) - ExecutorWrapper exported from @executor-js/sdk Behavior unchanged: full repo typecheck green; all 12 toolkit e2e files (13 tests) pass.

Rhys Sullivan committed 4 months ago

Commit ef950471 parent 30c6c278 files · +971 −381Browse files

Unified · Split

8 files
  1. packages/core/api/src/server/execution-stack.ts +56 −31
  2. packages/core/api/src/server/mcp-build.ts +18 −7
  3. packages/core/sdk/src/executor.ts +632 −209
  4. packages/core/sdk/src/index.ts +21 −14
  5. packages/core/sdk/src/tool.ts +10 −4
  6. packages/hosts/mcp/src/in-memory-session-store.ts +84 −30
  7. packages/plugins/toolkits/src/server.ts +110 −58
  8. packages/plugins/toolkits/src/toolkit-scope.ts +40 −28
packages/core/api/src/server/execution-stack.ts+56−31
@@ -27,12 +27,10 @@ import { Context, Effect, Layer } from "effect"; · 26 unmodified lines
27import type * as Cause from "effect/Cause";27import type * as Cause from "effect/Cause";
28 28
29import {29import {
30 applyToolkitScope,
31 EMPTY_TOOLKIT_SCOPE,
32 type AnyPlugin,30 type AnyPlugin,
33 type Executor,31 type Executor,
32 type ExecutorWrapper,
34 type StorageFailure,33 type StorageFailure,
35 type ToolkitResolver,
36} from "@executor-js/sdk";34} from "@executor-js/sdk";
37import {35import {
38 createExecutionEngine,36 createExecutionEngine,
@@ -41,7 +39,11 @@ import { · 2 unmodified lines
41} from "@executor-js/execution";39} from "@executor-js/execution";
42 40
43import { DbProvider } from "./executor-fuma-db";41import { DbProvider } from "./executor-fuma-db";
44import { HostConfig, PluginsProvider, makeScopedExecutor } from "./scoped-executor";42import {
43 HostConfig,
44 PluginsProvider,
45 makeScopedExecutor,
46} from "./scoped-executor";
45 47
46// ---------------------------------------------------------------------------48// ---------------------------------------------------------------------------
47// CodeExecutorProvider seam — the host's code-execution substrate. Typed to the49// CodeExecutorProvider seam — the host's code-execution substrate. Typed to the
@@ -50,11 +52,13 @@ import { HostConfig, PluginsProvider, makeScopedExecutor } from "./scoped-execut · 2 unmodified lines
50// assigns structurally.52// assigns structurally.
51// ---------------------------------------------------------------------------53// ---------------------------------------------------------------------------
52 54
53export type CodeExecutor = ExecutionEngineConfig<Cause.YieldableError>["codeExecutor"];55export type CodeExecutor =
56 ExecutionEngineConfig<Cause.YieldableError>["codeExecutor"];
54 57
55export class CodeExecutorProvider extends Context.Service<CodeExecutorProvider, CodeExecutor>()(58export class CodeExecutorProvider extends Context.Service<
56 "@executor-js/api/CodeExecutorProvider",59 CodeExecutorProvider,
57) {}60 CodeExecutor
61>()("@executor-js/api/CodeExecutorProvider") {}
58 62
59// ---------------------------------------------------------------------------63// ---------------------------------------------------------------------------
60// EngineDecorator seam — wrap the freshly built engine (e.g. with usage64// EngineDecorator seam — wrap the freshly built engine (e.g. with usage
@@ -78,12 +82,15 @@ export interface EngineDecoratorShape { · 17 unmodified lines
78 ) => ExecutionEngine<E>;82 ) => ExecutionEngine<E>;
79}83}
80 84
81export class EngineDecorator extends Context.Service<EngineDecorator, EngineDecoratorShape>()(85export class EngineDecorator extends Context.Service<
82 "@executor-js/api/EngineDecorator",86 EngineDecorator,
83) {}87 EngineDecoratorShape
88>()("@executor-js/api/EngineDecorator") {}
84 89
85/** No-op decorator: the engine passes through unchanged. */90/** No-op decorator: the engine passes through unchanged. */
86export const EngineDecoratorNoop: Layer.Layer<EngineDecorator> = Layer.succeed(EngineDecorator)({91export const EngineDecoratorNoop: Layer.Layer<EngineDecorator> = Layer.succeed(
92 EngineDecorator,
93)({
87 decorate: (engine) => engine,94 decorate: (engine) => engine,
88});95});
89 96
@@ -102,20 +109,34 @@ export const makeExecutionStack = < · 12 unmodified lines
102 accountId: string,109 accountId: string,
103 organizationId: string,110 organizationId: string,
104 organizationName: string,111 organizationName: string,
105 /** Optional toolkit selector (slug or id). When set, the executor is narrowed112 /** Optional per-request narrowing selector (e.g. an MCP `?toolkit=` value).
106 * to that toolkit's slice before the engine is built — so listing, search,113 * When set, the executor is run through every plugin-contributed
107 * description, core-tools, AND execute all see only the slice. Resolves via114 * `ExecutorWrapper` before the engine is built — so listing, search,
108 * the `toolkits` plugin extension; an unknown/unauthorized selector applies115 * description, core-tools, AND execute all see only the narrowed view. Core
109 * an EMPTY slice (fail-closed — never the full account). */116 * is agnostic about what a wrapper narrows to; a plugin (e.g. toolkits) owns
110 toolkitId?: string,117 * that, including fail-closed behavior for an unknown selector. */
118 selector?: string,
111): Effect.Effect<119): Effect.Effect<
112 { readonly executor: Executor<TPlugins>; readonly engine: ExecutionEngine<Cause.YieldableError> },120 {
121 readonly executor: Executor<TPlugins>;
122 readonly engine: ExecutionEngine<Cause.YieldableError>;
123 },
113 StorageFailure,124 StorageFailure,
114 DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator125 | DbProvider
126 | PluginsProvider
127 | HostConfig
128 | CodeExecutorProvider
129 | EngineDecorator
115> =>130> =>
116 Effect.gen(function* () {131 Effect.gen(function* () {
117 const base = yield* makeScopedExecutor<TPlugins>(accountId, organizationId, organizationName);132 const base = yield* makeScopedExecutor<TPlugins>(
118 const executor = toolkitId ? yield* narrowToToolkit(base, toolkitId) : base;133 accountId,
134 organizationId,
135 organizationName,
136 );
137 const executor = selector
138 ? yield* applyExecutorWrappers(base, selector)
139 : base;
119 const codeExecutor = yield* CodeExecutorProvider;140 const codeExecutor = yield* CodeExecutorProvider;
120 const { decorate } = yield* EngineDecorator;141 const { decorate } = yield* EngineDecorator;
121 const engine = decorate(createExecutionEngine({ executor, codeExecutor }), {142 const engine = decorate(createExecutionEngine({ executor, codeExecutor }), {
@@ -126,16 +147,20 @@ export const makeExecutionStack = < · 4 unmodified lines
126 return { executor, engine };147 return { executor, engine };
127 });148 });
128 149
129// Resolve the toolkit slice via the `toolkits` plugin extension (stamped on the150// Run every plugin-contributed executor wrapper (collected on the executor at
130// executor) and wrap the executor. Fail-closed: no extension or no match -> an151// assembly) with the request selector. Core does not know what any wrapper
131// empty slice, so a bad/cross-tenant selector exposes only static tools, never152// narrows to — the toolkits plugin's wrapper, for instance, fails closed to an
132// the full account.153// empty slice for an unknown/cross-tenant selector. With no wrapper registered,
133const narrowToToolkit = <TPlugins extends readonly AnyPlugin[]>(154// the executor passes through unchanged.
155const applyExecutorWrappers = <TPlugins extends readonly AnyPlugin[]>(
134 base: Executor<TPlugins>,156 base: Executor<TPlugins>,
135 toolkitId: string,157 selector: string,
136): Effect.Effect<Executor<TPlugins>, StorageFailure> =>158): Effect.Effect<Executor<TPlugins>, StorageFailure> =>
137 Effect.gen(function* () {159 Effect.gen(function* () {
138 const resolver = (base as { toolkits?: ToolkitResolver }).toolkits;160 const wrappers = (base as { executorWrappers?: readonly ExecutorWrapper[] })
139 const scope = resolver?.resolveScope ? yield* resolver.resolveScope(toolkitId) : null;161 .executorWrappers;
140 return yield* applyToolkitScope(base, scope ?? EMPTY_TOOLKIT_SCOPE);162 let executor = base;
163 for (const wrap of wrappers ?? [])
164 executor = yield* wrap(executor, selector);
165 return executor;
141 });166 });
packages/core/api/src/server/mcp-build.ts+18−7
@@ -9,7 +9,11 @@ import { · 8 unmodified lines
9import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";9import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
10 10
11import { ErrorCapture } from "../observability";11import { ErrorCapture } from "../observability";
12import { CodeExecutorProvider, EngineDecorator, makeExecutionStack } from "./execution-stack";12import {
13 CodeExecutorProvider,
14 EngineDecorator,
15 makeExecutionStack,
16} from "./execution-stack";
13import { DbProvider } from "./executor-fuma-db";17import { DbProvider } from "./executor-fuma-db";
14import { HostConfig, PluginsProvider } from "./scoped-executor";18import { HostConfig, PluginsProvider } from "./scoped-executor";
15 19
@@ -28,7 +32,11 @@ import { HostConfig, PluginsProvider } from "./scoped-executor"; · 12 unmodified lines
28 32
29/** The five execution-stack seams a host fully provides (no residual). */33/** The five execution-stack seams a host fully provides (no residual). */
30export type McpExecutionStackLayer = Layer.Layer<34export type McpExecutionStackLayer = Layer.Layer<
31 DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator35 | DbProvider
36 | PluginsProvider
37 | HostConfig
38 | CodeExecutorProvider
39 | EngineDecorator
32>;40>;
33 41
34/**42/**
@@ -39,14 +47,15 @@ export type McpExecutionStackLayer = Layer.Layer< · 4 unmodified lines
39export const makeMcpBuildServer =47export const makeMcpBuildServer =
40 (executionStack: McpExecutionStackLayer): McpBuildServer =>48 (executionStack: McpExecutionStackLayer): McpBuildServer =>
41 (principal: Principal, options?: McpBuildServerOptions) => {49 (principal: Principal, options?: McpBuildServerOptions) => {
42 // `toolkitId` narrows the engine in `makeExecutionStack`; the rest of the50 // `selector` narrows the engine in `makeExecutionStack` (a plugin's
43 // options configure the MCP server (elicitation/approval).51 // executor wrapper applies it); the rest configure the MCP server
44 const { toolkitId, ...serverOptions } = options ?? {};52 // (elicitation/approval).
53 const { selector, ...serverOptions } = options ?? {};
45 return makeExecutionStack(54 return makeExecutionStack(
46 principal.accountId,55 principal.accountId,
47 principal.organizationId,56 principal.organizationId,
48 principal.organizationName,57 principal.organizationName,
49 toolkitId,58 selector,
50 ).pipe(59 ).pipe(
51 Effect.map(({ engine }) => engine),60 Effect.map(({ engine }) => engine),
52 Effect.provide(executionStack),61 Effect.provide(executionStack),
@@ -72,6 +81,8 @@ export const makeConsoleMcpErrorReporter = ( · 19 unmodified lines
72 McpErrorReporter,81 McpErrorReporter,
73 Effect.gen(function* () {82 Effect.gen(function* () {
74 const capture = yield* ErrorCapture;83 const capture = yield* ErrorCapture;
75 return { report: (cause) => Effect.asVoid(capture.captureException(cause)) };84 return {
85 report: (cause) => Effect.asVoid(capture.captureException(cause)),
86 };
76 }),87 }),
77 ).pipe(Layer.provide(errorCapture));88 ).pipe(Layer.provide(errorCapture));
packages/core/sdk/src/executor.ts+632−209
@@ -2,8 +2,15 @@ import { Effect, Inspectable, Layer, Option, Predicate, Schema } from "effect"; · 1 unmodified line
2import { FetchHttpClient, type HttpClient } from "effect/unstable/http";2import { FetchHttpClient, type HttpClient } from "effect/unstable/http";
3import { fumadb } from "@executor-js/fumadb";3import { fumadb } from "@executor-js/fumadb";
4import { memoryAdapter } from "@executor-js/fumadb/adapters/memory";4import { memoryAdapter } from "@executor-js/fumadb/adapters/memory";
5import { withQueryContext, type Condition, type ConditionBuilder } from "@executor-js/fumadb/query";5import {
6import { schema as fumaSchema, type RelationsMap } from "@executor-js/fumadb/schema";6 withQueryContext,
7 type Condition,
8 type ConditionBuilder,
9} from "@executor-js/fumadb/query";
10import {
11 schema as fumaSchema,
12 type RelationsMap,
13} from "@executor-js/fumadb/schema";
7import type { AnyColumn } from "@executor-js/fumadb/schema";14import type { AnyColumn } from "@executor-js/fumadb/schema";
8import { generateKeyBetween } from "fractional-indexing";15import { generateKeyBetween } from "fractional-indexing";
9 16
@@ -16,7 +23,12 @@ import { · 6 unmodified lines
16 type FumaTables,23 type FumaTables,
17 type StorageFailure,24 type StorageFailure,
18} from "./fuma-runtime";25} from "./fuma-runtime";
19import { makeFumaBlobStore, pluginBlobStore, type BlobStore, type OwnerPartitions } from "./blob";26import {
27 makeFumaBlobStore,
28 pluginBlobStore,
29 type BlobStore,
30 type OwnerPartitions,
31} from "./blob";
20import { coreToolsPlugin } from "./core-tools";32import { coreToolsPlugin } from "./core-tools";
21import type {33import type {
22 Connection,34 Connection,
@@ -85,7 +97,10 @@ import type { · 62 unmodified lines
85 IntegrationConfig,97 IntegrationConfig,
86 RegisterIntegrationInput,98 RegisterIntegrationInput,
87} from "./integration";99} from "./integration";
88import { makeOAuthService, type MintOAuthConnectionInput } from "./oauth-service";100import {
101 makeOAuthService,
102 type MintOAuthConnectionInput,
103} from "./oauth-service";
89import type { OAuthService } from "./oauth-client";104import type { OAuthService } from "./oauth-client";
90import {105import {
91 comparePolicyRow,106 comparePolicyRow,
@@ -130,7 +145,12 @@ import { · 38 unmodified lines
130 type ExecutorOwnerPolicyContext,145 type ExecutorOwnerPolicyContext,
131} from "./owner-policy";146} from "./owner-policy";
132import { ToolSchemaView, type IntegrationDetectionResult } from "./types";147import { ToolSchemaView, type IntegrationDetectionResult } from "./types";
133import { type Tool, type ToolAnnotations, type ToolDef, type ToolListFilter } from "./tool";148import {
149 type Tool,
150 type ToolAnnotations,
151 type ToolDef,
152 type ToolListFilter,
153} from "./tool";
134import { buildToolTypeScriptPreview } from "./schema-types";154import { buildToolTypeScriptPreview } from "./schema-types";
135import { collectReferencedDefinitions } from "./schema-refs";155import { collectReferencedDefinitions } from "./schema-refs";
136import {156import {
@@ -155,7 +175,9 @@ const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000; · 18 unmodified lines
155const acceptAllHandler: ElicitationHandler = () =>175const acceptAllHandler: ElicitationHandler = () =>
156 Effect.succeed(ElicitationResponse.make({ action: "accept" }));176 Effect.succeed(ElicitationResponse.make({ action: "accept" }));
157 177
158const resolveElicitationHandler = (onElicitation: OnElicitation): ElicitationHandler =>178const resolveElicitationHandler = (
179 onElicitation: OnElicitation,
180): ElicitationHandler =>
159 onElicitation === "accept-all" ? acceptAllHandler : onElicitation;181 onElicitation === "accept-all" ? acceptAllHandler : onElicitation;
160 182
161// ---------------------------------------------------------------------------183// ---------------------------------------------------------------------------
@@ -171,7 +193,8 @@ export interface ParsedToolAddress { · 9 unmodified lines
171 readonly tool: ToolName;193 readonly tool: ToolName;
172}194}
173 195
174const isOwner = (value: string): value is Owner => value === "org" || value === "user";196const isOwner = (value: string): value is Owner =>
197 value === "org" || value === "user";
175 198
176/** Parse a callable address; null when it's not a well-formed199/** Parse a callable address; null when it's not a well-formed
177 * `tools.<integration>.<owner>.<connection>.<tool>`.200 * `tools.<integration>.<owner>.<connection>.<tool>`.
@@ -190,16 +213,17 @@ export const parseToolAddress = (address: string): ParsedToolAddress | null => { · 12 unmodified lines
190 cut = address.indexOf(".", cut + 1);213 cut = address.indexOf(".", cut + 1);
191 if (cut === -1) return null;214 if (cut === -1) return null;
192 }215 }
193 const [prefix, integration, owner, connection] = address.slice(0, cut).split(".") as [216 const [prefix, integration, owner, connection] = address
194 string,217 .slice(0, cut)
195 string,218 .split(".") as [string, string, string, string];
196 string,
197 string,
198 ];
199 const tool = address.slice(cut + 1);219 const tool = address.slice(cut + 1);
200 if (prefix !== ADDRESS_PREFIX) return null;220 if (prefix !== ADDRESS_PREFIX) return null;
201 if (!isOwner(owner)) return null;221 if (!isOwner(owner)) return null;
202 if (integration.length === 0 || connection.length === 0 || tool.length === 0) {222 if (
223 integration.length === 0 ||
224 connection.length === 0 ||
225 tool.length === 0
226 ) {
203 return null;227 return null;
204 }228 }
205 return {229 return {
@@ -215,7 +239,9 @@ export const connectionAddress = ( · 9 unmodified lines
215 integration: IntegrationSlug,239 integration: IntegrationSlug,
216 connection: ConnectionName,240 connection: ConnectionName,
217): ConnectionAddress =>241): ConnectionAddress =>
218 ConnectionAddress.make(`${ADDRESS_PREFIX}.${integration}.${owner}.${connection}`);242 ConnectionAddress.make(
243 `${ADDRESS_PREFIX}.${integration}.${owner}.${connection}`,
244 );
219 245
220export const toolAddress = (246export const toolAddress = (
221 owner: Owner,247 owner: Owner,
@@ -223,7 +249,9 @@ export const toolAddress = ( · 1 unmodified line
223 connection: ConnectionName,249 connection: ConnectionName,
224 tool: ToolName,250 tool: ToolName,
225): ToolAddress =>251): ToolAddress =>
226 ToolAddress.make(`${ADDRESS_PREFIX}.${integration}.${owner}.${connection}.${tool}`);252 ToolAddress.make(
253 `${ADDRESS_PREFIX}.${integration}.${owner}.${connection}.${tool}`,
254 );
227 255
228// ---------------------------------------------------------------------------256// ---------------------------------------------------------------------------
229// Owner key helpers — every owned-row write stamps `tenant`, `owner`,257// Owner key helpers — every owned-row write stamps `tenant`, `owner`,
@@ -241,17 +269,34 @@ interface OwnedKeys { · 11 unmodified lines
241// core-table query unioned with the in-memory static pool.269// core-table query unioned with the in-memory static pool.
242// ---------------------------------------------------------------------------270// ---------------------------------------------------------------------------
243 271
272// ExecutorWrapper — a plugin-contributed executor middleware. Given the
273// assembled executor and the per-request selector string (e.g. an MCP
274// `?toolkit=` value), it returns a possibly-narrowed executor. A plugin
275// contributes one by exposing a `wrapExecutor` method on its extension; the
276// assembler collects them and the execution stack runs each before the engine
277// is built. Core knows nothing about what a wrapper narrows to — that domain
278// (toolkits, etc.) lives entirely in the contributing plugin.
279export type ExecutorWrapper = <TPlugins extends readonly AnyPlugin[]>(
280 base: Executor<TPlugins>,
281 selector: string,
282) => Effect.Effect<Executor<TPlugins>, StorageFailure>;
283
244export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = {284export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = {
245 readonly integrations: {285 readonly integrations: {
246 readonly list: () => Effect.Effect<readonly Integration[], StorageFailure>;286 readonly list: () => Effect.Effect<readonly Integration[], StorageFailure>;
247 readonly get: (slug: IntegrationSlug) => Effect.Effect<Integration | null, StorageFailure>;287 readonly get: (
288 slug: IntegrationSlug,
289 ) => Effect.Effect<Integration | null, StorageFailure>;
248 readonly update: (290 readonly update: (
249 slug: IntegrationSlug,291 slug: IntegrationSlug,
250 patch: { readonly name?: string; readonly description?: string },292 patch: { readonly name?: string; readonly description?: string },
251 ) => Effect.Effect<void, IntegrationNotFoundError | StorageFailure>;293 ) => Effect.Effect<void, IntegrationNotFoundError | StorageFailure>;
252 readonly remove: (294 readonly remove: (
253 slug: IntegrationSlug,295 slug: IntegrationSlug,
254 ) => Effect.Effect<void, IntegrationRemovalNotAllowedError | StorageFailure>;296 ) => Effect.Effect<
297 void,
298 IntegrationRemovalNotAllowedError | StorageFailure
299 >;
255 readonly detect: (300 readonly detect: (
256 url: string,301 url: string,
257 ) => Effect.Effect<readonly IntegrationDetectionResult[], StorageFailure>;302 ) => Effect.Effect<readonly IntegrationDetectionResult[], StorageFailure>;
@@ -271,7 +316,9 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 13 unmodified lines
271 readonly integration?: IntegrationSlug;316 readonly integration?: IntegrationSlug;
272 readonly owner?: Owner;317 readonly owner?: Owner;
273 }) => Effect.Effect<readonly Connection[], StorageFailure>;318 }) => Effect.Effect<readonly Connection[], StorageFailure>;
274 readonly get: (ref: ConnectionRef) => Effect.Effect<Connection | null, StorageFailure>;319 readonly get: (
320 ref: ConnectionRef,
321 ) => Effect.Effect<Connection | null, StorageFailure>;
275 /** Edit user-curated metadata (description, identityLabel). Credentials and322 /** Edit user-curated metadata (description, identityLabel). Credentials and
276 * OAuth lifecycle fields are not editable here. */323 * OAuth lifecycle fields are not editable here. */
277 readonly update: (324 readonly update: (
@@ -294,21 +341,35 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 16 unmodified lines
294 readonly oauth: OAuthService;341 readonly oauth: OAuthService;
295 342
296 readonly tools: {343 readonly tools: {
297 readonly list: (filter?: ToolListFilter) => Effect.Effect<readonly Tool[], StorageFailure>;344 readonly list: (
298 readonly schema: (address: ToolAddress) => Effect.Effect<ToolSchemaView | null, StorageFailure>;345 filter?: ToolListFilter,
346 ) => Effect.Effect<readonly Tool[], StorageFailure>;
347 readonly schema: (
348 address: ToolAddress,
349 ) => Effect.Effect<ToolSchemaView | null, StorageFailure>;
299 };350 };
300 351
301 readonly providers: {352 readonly providers: {
302 readonly list: () => Effect.Effect<readonly ProviderKey[]>;353 readonly list: () => Effect.Effect<readonly ProviderKey[]>;
303 readonly items: (key: ProviderKey) => Effect.Effect<readonly ProviderEntry[], StorageFailure>;354 readonly items: (
355 key: ProviderKey,
356 ) => Effect.Effect<readonly ProviderEntry[], StorageFailure>;
304 };357 };
305 358
306 readonly policies: {359 readonly policies: {
307 readonly list: () => Effect.Effect<readonly ToolPolicy[], StorageFailure>;360 readonly list: () => Effect.Effect<readonly ToolPolicy[], StorageFailure>;
308 readonly create: (input: CreateToolPolicyInput) => Effect.Effect<ToolPolicy, StorageFailure>;361 readonly create: (
309 readonly update: (input: UpdateToolPolicyInput) => Effect.Effect<ToolPolicy, StorageFailure>;362 input: CreateToolPolicyInput,
310 readonly remove: (input: RemoveToolPolicyInput) => Effect.Effect<void, StorageFailure>;363 ) => Effect.Effect<ToolPolicy, StorageFailure>;
311 readonly resolve: (address: ToolAddress) => Effect.Effect<EffectivePolicy, StorageFailure>;364 readonly update: (
365 input: UpdateToolPolicyInput,
366 ) => Effect.Effect<ToolPolicy, StorageFailure>;
367 readonly remove: (
368 input: RemoveToolPolicyInput,
369 ) => Effect.Effect<void, StorageFailure>;
370 readonly resolve: (
371 address: ToolAddress,
372 ) => Effect.Effect<EffectivePolicy, StorageFailure>;
312 };373 };
313 374
314 readonly execute: (375 readonly execute: (
@@ -322,7 +383,10 @@ export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = { · 7 unmodified lines
322 383
323export interface ExecutorDb {384export interface ExecutorDb {
324 readonly db: FumaDb<any>;385 readonly db: FumaDb<any>;
325 readonly close?: () => Effect.Effect<void, StorageFailure> | Promise<void> | void;386 readonly close?: () =>
387 | Effect.Effect<void, StorageFailure>
388 | Promise<void>
389 | void;
326}390}
327 391
328export type ExecutorDbInput = FumaDb<any> | ExecutorDb;392export type ExecutorDbInput = FumaDb<any> | ExecutorDb;
@@ -331,7 +395,9 @@ export type ExecutorDbFactory = (config: { · 2 unmodified lines
331 readonly tables: FumaTables;395 readonly tables: FumaTables;
332}) => ExecutorDbInput | Effect.Effect<ExecutorDbInput, StorageFailure>;396}) => ExecutorDbInput | Effect.Effect<ExecutorDbInput, StorageFailure>;
333 397
334export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly []> {398export interface ExecutorConfig<
399 TPlugins extends readonly AnyPlugin[] = readonly [],
400> {
335 /** The org / workspace this executor is bound to. `owner: "org"` rows file401 /** The org / workspace this executor is bound to. `owner: "org"` rows file
336 * here. */402 * here. */
337 readonly tenant: Tenant;403 readonly tenant: Tenant;
@@ -392,7 +458,10 @@ const validateExecutorOwnerPolicyTables = (tables: FumaTables): void => { · 54 unmodified lines
392 }458 }
393};459};
394 460
395const validateExecutorDbTables = (required: FumaTables, actual: FumaTables): void => {461const validateExecutorDbTables = (
462 required: FumaTables,
463 actual: FumaTables,
464): void => {
396 const missing = Object.keys(required)465 const missing = Object.keys(required)
397 .filter((tableName) => !actual[tableName])466 .filter((tableName) => !actual[tableName])
398 .sort();467 .sort();
@@ -408,18 +477,27 @@ const validateExecutorDbTables = (required: FumaTables, actual: FumaTables): voi · 9 unmodified lines
408 });477 });
409};478};
410 479
411const storageFailureFromUnknown = (message: string, cause: unknown): StorageFailure =>480const storageFailureFromUnknown = (
481 message: string,
482 cause: unknown,
483): StorageFailure =>
412 isStorageFailure(cause) ? cause : new StorageError({ message, cause });484 isStorageFailure(cause) ? cause : new StorageError({ message, cause });
413 485
414const pluginStorageFailure = (pluginId: string, hook: string, cause: unknown): StorageFailure =>486const pluginStorageFailure = (
487 pluginId: string,
488 hook: string,
489 cause: unknown,
490): StorageFailure =>
415 storageFailureFromUnknown(`${hook} failed for plugin ${pluginId}`, cause);491 storageFailureFromUnknown(`${hook} failed for plugin ${pluginId}`, cause);
416 492
417const createDefaultMemoryDb = (tables: FumaTables): ExecutorDb => {493const createDefaultMemoryDb = (tables: FumaTables): ExecutorDb => {
418 const version = "1.0.0";494 const version = "1.0.0";
419 const latestSchema = fumaSchema<string, FumaTables, RelationsMap<FumaTables>>({495 const latestSchema = fumaSchema<string, FumaTables, RelationsMap<FumaTables>>(
420 version,496 {
421 tables,497 version,
422 });498 tables,
499 },
500 );
423 const factory = fumadb({501 const factory = fumadb({
424 namespace: "executor_memory",502 namespace: "executor_memory",
425 schemas: [latestSchema],503 schemas: [latestSchema],
@@ -434,7 +512,9 @@ const createDefaultMemoryDb = (tables: FumaTables): ExecutorDb => { · 8 unmodified lines
434// JSON helpers + row → public projection conversions512// JSON helpers + row → public projection conversions
435// ---------------------------------------------------------------------------513// ---------------------------------------------------------------------------
436 514
437const decodeJsonFromString = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);515const decodeJsonFromString = Schema.decodeUnknownOption(
516 Schema.UnknownFromJsonString,
517);
438 518
439const decodeJsonColumn = (value: unknown): unknown => {519const decodeJsonColumn = (value: unknown): unknown => {
440 if (value === null || value === undefined) return undefined;520 if (value === null || value === undefined) return undefined;
@@ -482,9 +562,14 @@ const rowToConnection = (row: ConnectionRow): Connection => { · 41 unmodified lines
482 identityLabel: row.identity_label ?? null,562 identityLabel: row.identity_label ?? null,
483 description: row.description ?? null,563 description: row.description ?? null,
484 expiresAt: row.expires_at == null ? null : Number(row.expires_at),564 expiresAt: row.expires_at == null ? null : Number(row.expires_at),
485 oauthClient: row.oauth_client == null ? null : OAuthClientSlug.make(String(row.oauth_client)),565 oauthClient:
566 row.oauth_client == null
567 ? null
568 : OAuthClientSlug.make(String(row.oauth_client)),
486 oauthClientOwner:569 oauthClientOwner:
487 row.oauth_client_owner == null ? null : (String(row.oauth_client_owner) as Owner),570 row.oauth_client_owner == null
571 ? null
572 : (String(row.oauth_client_owner) as Owner),
488 oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope),573 oauthScope: row.oauth_scope == null ? null : String(row.oauth_scope),
489 };574 };
490};575};
@@ -505,7 +590,10 @@ const normalizeConnectionInputs = ( · 14 unmodified lines
505 input: ConnectionValueInput,590 input: ConnectionValueInput,
506): readonly NormalizedConnectionInput[] => {591): readonly NormalizedConnectionInput[] => {
507 if ("inputs" in input) {592 if ("inputs" in input) {
508 return Object.entries(input.inputs).map(([variable, origin]) => ({ variable, origin }));593 return Object.entries(input.inputs).map(([variable, origin]) => ({
594 variable,
595 origin,
596 }));
509 }597 }
510 if ("values" in input) {598 if ("values" in input) {
511 return Object.entries(input.values).map(([variable, value]) => ({599 return Object.entries(input.values).map(([variable, value]) => ({
@@ -532,7 +620,8 @@ const connectionItemIds = (row: ConnectionRow): Record<string, string> => { · 20 unmodified lines
532// schema columns); `Tool.inputSchema`/`outputSchema` are optional and stay620// schema columns); `Tool.inputSchema`/`outputSchema` are optional and stay
533// absent for those callers — `tools.schema` is the schema-bearing surface.621// absent for those callers — `tools.schema` is the schema-bearing surface.
534const rowToTool = (622const rowToTool = (
535 row: ToolInvocationRow & Partial<Pick<ToolRow, "input_schema" | "output_schema">>,623 row: ToolInvocationRow &
624 Partial<Pick<ToolRow, "input_schema" | "output_schema">>,
536 annotations?: ToolAnnotations,625 annotations?: ToolAnnotations,
537): Tool => {626): Tool => {
538 const owner = row.owner as Owner;627 const owner = row.owner as Owner;
@@ -549,7 +638,9 @@ const rowToTool = ( · 10 unmodified lines
549 description: row.description,638 description: row.description,
550 inputSchema: decodeJsonColumn(row.input_schema),639 inputSchema: decodeJsonColumn(row.input_schema),
551 outputSchema: decodeJsonColumn(row.output_schema),640 outputSchema: decodeJsonColumn(row.output_schema),
552 annotations: annotations ?? (decodeJsonColumn(row.annotations) as ToolAnnotations | undefined),641 annotations:
642 annotations ??
643 (decodeJsonColumn(row.annotations) as ToolAnnotations | undefined),
553 };644 };
554};645};
555 646
@@ -580,7 +671,10 @@ type CoreFindFirstOptions<TName extends CoreTableName = CoreTableName> = Omit< · 24 unmodified lines
580>;671>;
581/** The narrowed row a projected query returns: the selected columns keep672/** The narrowed row a projected query returns: the selected columns keep
582 * their types, everything else is absent. */673 * their types, everything else is absent. */
583type CoreProjectedRow<TName extends CoreTableName, TSelect> = TSelect extends readonly (infer K)[]674type CoreProjectedRow<
675 TName extends CoreTableName,
676 TSelect,
677> = TSelect extends readonly (infer K)[]
584 ? Pick<CoreRow<TName>, Extract<K, keyof CoreRow<TName>>>678 ? Pick<CoreRow<TName>, Extract<K, keyof CoreRow<TName>>>
585 : CoreRow<TName>;679 : CoreRow<TName>;
586 680
@@ -613,7 +707,9 @@ const makeCoreDb = (fuma: ReturnType<typeof makeFumaClient>) => ({ · 26 unmodified lines
613 tableName: TName,707 tableName: TName,
614 options?: { readonly where?: CoreWhere },708 options?: { readonly where?: CoreWhere },
615 ): Effect.Effect<number, StorageFailure> =>709 ): Effect.Effect<number, StorageFailure> =>
616 fuma.use(`${tableName}.count`, (db) => asLooseStorageDb(db).count(tableName, options)),710 fuma.use(`${tableName}.count`, (db) =>
711 asLooseStorageDb(db).count(tableName, options),
712 ),
617 create: <TName extends CoreTableName>(713 create: <TName extends CoreTableName>(
618 tableName: TName,714 tableName: TName,
619 row: Record<string, unknown>,715 row: Record<string, unknown>,
@@ -628,7 +724,9 @@ const makeCoreDb = (fuma: ReturnType<typeof makeFumaClient>) => ({ · 8 unmodified lines
628 rows.length === 0724 rows.length === 0
629 ? Effect.void725 ? Effect.void
630 : fuma726 : fuma
631 .use(`${tableName}.createMany`, (db) => asLooseStorageDb(db).createMany(tableName, rows))727 .use(`${tableName}.createMany`, (db) =>
728 asLooseStorageDb(db).createMany(tableName, rows),
729 )
632 .pipe(Effect.asVoid),730 .pipe(Effect.asVoid),
633 deleteMany: <TName extends CoreTableName>(731 deleteMany: <TName extends CoreTableName>(
634 tableName: TName,732 tableName: TName,
@@ -637,20 +735,38 @@ const makeCoreDb = (fuma: ReturnType<typeof makeFumaClient>) => ({ · 2 unmodified lines
637 fuma.use(`${tableName}.deleteMany`, (db) =>735 fuma.use(`${tableName}.deleteMany`, (db) =>
638 asLooseStorageDb(db).deleteMany(tableName, options),736 asLooseStorageDb(db).deleteMany(tableName, options),
639 ),737 ),
640 findFirst: <TName extends CoreTableName, const TOptions extends CoreFindFirstOptions<TName>>(738 findFirst: <
739 TName extends CoreTableName,
740 const TOptions extends CoreFindFirstOptions<TName>,
741 >(
641 tableName: TName,742 tableName: TName,
642 options: TOptions,743 options: TOptions,
643 ): Effect.Effect<CoreProjectedRow<TName, TOptions["select"]> | null, StorageFailure> =>744 ): Effect.Effect<
745 CoreProjectedRow<TName, TOptions["select"]> | null,
746 StorageFailure
747 > =>
644 fuma.use(`${tableName}.findFirst`, (db) =>748 fuma.use(`${tableName}.findFirst`, (db) =>
645 asLooseStorageDb(db).findFirst(tableName, options),749 asLooseStorageDb(db).findFirst(tableName, options),
646 ) as Effect.Effect<CoreProjectedRow<TName, TOptions["select"]> | null, StorageFailure>,750 ) as Effect.Effect<
647 findMany: <TName extends CoreTableName, const TOptions extends CoreFindManyOptions<TName>>(751 CoreProjectedRow<TName, TOptions["select"]> | null,
752 StorageFailure
753 >,
754 findMany: <
755 TName extends CoreTableName,
756 const TOptions extends CoreFindManyOptions<TName>,
757 >(
648 tableName: TName,758 tableName: TName,
649 options: TOptions = {} as TOptions,759 options: TOptions = {} as TOptions,
650 ): Effect.Effect<readonly CoreProjectedRow<TName, TOptions["select"]>[], StorageFailure> =>760 ): Effect.Effect<
761 readonly CoreProjectedRow<TName, TOptions["select"]>[],
762 StorageFailure
763 > =>
651 fuma.use(`${tableName}.findMany`, (db) =>764 fuma.use(`${tableName}.findMany`, (db) =>
652 asLooseStorageDb(db).findMany(tableName, options),765 asLooseStorageDb(db).findMany(tableName, options),
653 ) as Effect.Effect<readonly CoreProjectedRow<TName, TOptions["select"]>[], StorageFailure>,766 ) as Effect.Effect<
767 readonly CoreProjectedRow<TName, TOptions["select"]>[],
768 StorageFailure
769 >,
654 updateMany: <TName extends CoreTableName>(770 updateMany: <TName extends CoreTableName>(
655 tableName: TName,771 tableName: TName,
656 options: {772 options: {
@@ -670,7 +786,9 @@ type CoreDb = ReturnType<typeof makeCoreDb>; · 13 unmodified lines
670// [user, org]; writes/deletes name an explicit owner.786// [user, org]; writes/deletes name an explicit owner.
671// ---------------------------------------------------------------------------787// ---------------------------------------------------------------------------
672 788
673const pluginStorageEntryFromRow = <T>(row: CoreRow<"plugin_storage">): PluginStorageEntry<T> => ({789const pluginStorageEntryFromRow = <T>(
790 row: CoreRow<"plugin_storage">,
791): PluginStorageEntry<T> => ({
674 id: pluginStorageId({792 id: pluginStorageId({
675 pluginId: row.plugin_id,793 pluginId: row.plugin_id,
676 collection: row.collection,794 collection: row.collection,
@@ -681,21 +799,28 @@ const pluginStorageEntryFromRow = <T>(row: CoreRow<"plugin_storage">): PluginSto · 4 unmodified lines
681 collection: row.collection,799 collection: row.collection,
682 key: row.key,800 key: row.key,
683 data: row.data as T,801 data: row.data as T,
684 createdAt: row.created_at instanceof Date ? row.created_at : new Date(row.created_at),802 createdAt:
685 updatedAt: row.updated_at instanceof Date ? row.updated_at : new Date(row.updated_at),803 row.created_at instanceof Date ? row.created_at : new Date(row.created_at),
804 updatedAt:
805 row.updated_at instanceof Date ? row.updated_at : new Date(row.updated_at),
686});806});
687 807
688const pluginStorageIndexSpecFields = (spec: PluginStorageRuntimeIndexSpec): readonly string[] =>808const pluginStorageIndexSpecFields = (
689 typeof spec === "string" ? [spec] : spec;809 spec: PluginStorageRuntimeIndexSpec,
810): readonly string[] => (typeof spec === "string" ? [spec] : spec);
690 811
691const pluginStorageCollectionIndexedFields = (812const pluginStorageCollectionIndexedFields = (
692 definition: PluginStorageRuntimeCollectionDefinition,813 definition: PluginStorageRuntimeCollectionDefinition,
693): ReadonlySet<string> =>814): ReadonlySet<string> =>
694 new Set(definition.indexes.flatMap((spec) => pluginStorageIndexSpecFields(spec)));815 new Set(
816 definition.indexes.flatMap((spec) => pluginStorageIndexSpecFields(spec)),
817 );
695 818
696const pluginStorageQueryValidationError = (819const pluginStorageQueryValidationError = (
697 definition: PluginStorageRuntimeCollectionDefinition,820 definition: PluginStorageRuntimeCollectionDefinition,
698 query: PluginStorageCollectionQueryInput<PluginStorageCollectionDefinition> | undefined,821 query:
822 | PluginStorageCollectionQueryInput<PluginStorageCollectionDefinition>
823 | undefined,
699): StorageError | null => {824): StorageError | null => {
700 if (!query) return null;825 if (!query) return null;
701 const indexedFields = pluginStorageCollectionIndexedFields(definition);826 const indexedFields = pluginStorageCollectionIndexedFields(definition);
@@ -711,13 +836,19 @@ const pluginStorageQueryValidationError = ( · 9 unmodified lines
711 });836 });
712 }837 }
713 }838 }
714 if (query.limit !== undefined && (!Number.isInteger(query.limit) || query.limit < 0)) {839 if (
840 query.limit !== undefined &&
841 (!Number.isInteger(query.limit) || query.limit < 0)
842 ) {
715 return new StorageError({843 return new StorageError({
716 message: `Plugin storage collection "${definition.name}" received an invalid query limit`,844 message: `Plugin storage collection "${definition.name}" received an invalid query limit`,
717 cause: undefined,845 cause: undefined,
718 });846 });
719 }847 }
720 if (query.offset !== undefined && (!Number.isInteger(query.offset) || query.offset < 0)) {848 if (
849 query.offset !== undefined &&
850 (!Number.isInteger(query.offset) || query.offset < 0)
851 ) {
721 return new StorageError({852 return new StorageError({
722 message: `Plugin storage collection "${definition.name}" received an invalid query offset`,853 message: `Plugin storage collection "${definition.name}" received an invalid query offset`,
723 cause: undefined,854 cause: undefined,
@@ -726,17 +857,35 @@ const pluginStorageQueryValidationError = ( · 2 unmodified lines
726 return null;857 return null;
727};858};
728 859
729const isPluginStorageRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>860const isPluginStorageRecord = (
861 value: unknown,
862): value is Readonly<Record<string, unknown>> =>
730 value !== null && typeof value === "object" && !Array.isArray(value);863 value !== null && typeof value === "object" && !Array.isArray(value);
731 864
732const pluginStorageWhereOperators = ["eq", "in", "gt", "gte", "lt", "lte"] as const;865const pluginStorageWhereOperators = [
866 "eq",
867 "in",
868 "gt",
869 "gte",
870 "lt",
871 "lte",
872] as const;
733 873
734const isPluginStorageWhereFilter = (value: unknown): value is Readonly<Record<string, unknown>> =>874const isPluginStorageWhereFilter = (
735 isPluginStorageRecord(value) && pluginStorageWhereOperators.some((operator) => operator in value);875 value: unknown,
876): value is Readonly<Record<string, unknown>> =>
877 isPluginStorageRecord(value) &&
878 pluginStorageWhereOperators.some((operator) => operator in value);
736 879
737const pluginStorageComparableValue = (value: unknown): string | number | boolean | null => {880const pluginStorageComparableValue = (
881 value: unknown,
882): string | number | boolean | null => {
738 if (value instanceof Date) return value.getTime();883 if (value instanceof Date) return value.getTime();
739 if (typeof value === "number" || typeof value === "string" || typeof value === "boolean") {884 if (
885 typeof value === "number" ||
886 typeof value === "string" ||
887 typeof value === "boolean"
888 ) {
740 return value;889 return value;
741 }890 }
742 if (value == null) return null;891 if (value == null) return null;
@@ -755,8 +904,13 @@ const comparePluginStorageValues = (left: unknown, right: unknown): number => { · 12 unmodified lines
755const pluginStorageDataField = (data: unknown, field: string): unknown =>904const pluginStorageDataField = (data: unknown, field: string): unknown =>
756 isPluginStorageRecord(data) ? data[field] : undefined;905 isPluginStorageRecord(data) ? data[field] : undefined;
757 906
758const matchesWhereOperator = (operator: string, value: unknown, operand: unknown): boolean => {907const matchesWhereOperator = (
759 if (operator === "eq") return comparePluginStorageValues(value, operand) === 0;908 operator: string,
909 value: unknown,
910 operand: unknown,
911): boolean => {
912 if (operator === "eq")
913 return comparePluginStorageValues(value, operand) === 0;
760 if (operator === "in") {914 if (operator === "in") {
761 return (915 return (
762 Array.isArray(operand) &&916 Array.isArray(operand) &&
@@ -764,9 +918,11 @@ const matchesWhereOperator = (operator: string, value: unknown, operand: unknown · 1 unmodified line
764 );918 );
765 }919 }
766 if (operator === "gt") return comparePluginStorageValues(value, operand) > 0;920 if (operator === "gt") return comparePluginStorageValues(value, operand) > 0;
767 if (operator === "gte") return comparePluginStorageValues(value, operand) >= 0;921 if (operator === "gte")
922 return comparePluginStorageValues(value, operand) >= 0;
768 if (operator === "lt") return comparePluginStorageValues(value, operand) < 0;923 if (operator === "lt") return comparePluginStorageValues(value, operand) < 0;
769 if (operator === "lte") return comparePluginStorageValues(value, operand) <= 0;924 if (operator === "lte")
925 return comparePluginStorageValues(value, operand) <= 0;
770 return false;926 return false;
771};927};
772 928
@@ -802,9 +958,12 @@ const makePluginStorageFacade = (input: { · 29 unmodified lines
802 readonly owner: OwnerBinding;958 readonly owner: OwnerBinding;
803}): PluginStorageFacade => {959}): PluginStorageFacade => {
804 // Owner partitions: org always, plus this subject's user partition.960 // Owner partitions: org always, plus this subject's user partition.
805 const readOwners: readonly Owner[] = input.owner.subject == null ? ["org"] : ["user", "org"];961 const readOwners: readonly Owner[] =
962 input.owner.subject == null ? ["org"] : ["user", "org"];
806 963
807 const ownerSubject = (owner: Owner): { owner: Owner; subject: string } | null => {964 const ownerSubject = (
965 owner: Owner,
966 ): { owner: Owner; subject: string } | null => {
808 if (owner === "org") return { owner: "org", subject: ORG_SUBJECT };967 if (owner === "org") return { owner: "org", subject: ORG_SUBJECT };
809 if (input.owner.subject == null) return null;968 if (input.owner.subject == null) return null;
810 return { owner: "user", subject: String(input.owner.subject) };969 return { owner: "user", subject: String(input.owner.subject) };
@@ -821,7 +980,11 @@ const makePluginStorageFacade = (input: { · 10 unmodified lines
821 key === undefined ? true : b("key", "=", key),980 key === undefined ? true : b("key", "=", key),
822 );981 );
823 982
824 const whereOwner = (owner: Owner, collection: string, key: string): CoreWhere => {983 const whereOwner = (
984 owner: Owner,
985 collection: string,
986 key: string,
987 ): CoreWhere => {
825 const os = ownerSubject(owner);988 const os = ownerSubject(owner);
826 return (b: AnyCb) =>989 return (b: AnyCb) =>
827 b.and(990 b.and(
@@ -843,19 +1006,28 @@ const makePluginStorageFacade = (input: { · 15 unmodified lines
843 });1006 });
844 1007
845 const getVisible = <T>(collection: string, key: string) =>1008 const getVisible = <T>(collection: string, key: string) =>
846 input.core.findMany("plugin_storage", { where: whereFor(collection, key) }).pipe(1009 input.core
847 Effect.map((rows) => sortByOwnerPrecedence(rows)[0] ?? null),1010 .findMany("plugin_storage", { where: whereFor(collection, key) })
848 Effect.map((row) => (row ? pluginStorageEntryFromRow<T>(row) : null)),1011 .pipe(
849 );1012 Effect.map((rows) => sortByOwnerPrecedence(rows)[0] ?? null),
1013 Effect.map((row) => (row ? pluginStorageEntryFromRow<T>(row) : null)),
1014 );
850 1015
851 const getForOwnerImpl = <T>(owner: Owner, collection: string, key: string) =>1016 const getForOwnerImpl = <T>(owner: Owner, collection: string, key: string) =>
852 input.core1017 input.core
853 .findFirst("plugin_storage", {1018 .findFirst("plugin_storage", {
854 where: whereOwner(owner, collection, key),1019 where: whereOwner(owner, collection, key),
855 })1020 })
856 .pipe(Effect.map((row) => (row ? pluginStorageEntryFromRow<T>(row) : null)));1021 .pipe(
1022 Effect.map((row) => (row ? pluginStorageEntryFromRow<T>(row) : null)),
1023 );
857 1024
858 const putImpl = <T>(owner: Owner, collection: string, key: string, data: unknown) =>1025 const putImpl = <T>(
1026 owner: Owner,
1027 collection: string,
1028 key: string,
1029 data: unknown,
1030 ) =>
859 Effect.gen(function* () {1031 Effect.gen(function* () {
860 const os = ownerSubject(owner);1032 const os = ownerSubject(owner);
861 if (!os) {1033 if (!os) {
@@ -935,7 +1107,10 @@ const makePluginStorageFacade = (input: { · 73 unmodified lines
935 offset < uniqueKeys.length;1107 offset < uniqueKeys.length;
936 offset += PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE1108 offset += PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE
937 ) {1109 ) {
938 const batchKeys = uniqueKeys.slice(offset, offset + PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE);1110 const batchKeys = uniqueKeys.slice(
1111 offset,
1112 offset + PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE,
1113 );
939 yield* input.core.deleteMany("plugin_storage", {1114 yield* input.core.deleteMany("plugin_storage", {
940 where: (b) =>1115 where: (b) =>
941 b.and(1116 b.and(
@@ -1013,7 +1188,9 @@ const makePluginStorageFacade = (input: { · 71 unmodified lines
1013 yield* deleteManyImpl(owner, os.subject, entries);1188 yield* deleteManyImpl(owner, os.subject, entries);
1014 });1189 });
1015 1190
1016 const queryCollection = <TDefinition extends PluginStorageCollectionDefinition>(1191 const queryCollection = <
1192 TDefinition extends PluginStorageCollectionDefinition,
1193 >(
1017 definition: TDefinition,1194 definition: TDefinition,
1018 queryInput?: PluginStorageCollectionQueryInput<TDefinition>,1195 queryInput?: PluginStorageCollectionQueryInput<TDefinition>,
1019 ) =>1196 ) =>
@@ -1031,7 +1208,9 @@ const makePluginStorageFacade = (input: { · 11 unmodified lines
1031 });1208 });
1032 const filtered = sortByOwnerPrecedence(rows)1209 const filtered = sortByOwnerPrecedence(rows)
1033 .filter((row) =>1210 .filter((row) =>
1034 queryInput?.keyPrefix === undefined ? true : row.key.startsWith(queryInput.keyPrefix),1211 queryInput?.keyPrefix === undefined
1212 ? true
1213 : row.key.startsWith(queryInput.keyPrefix),
1035 )1214 )
1036 .filter((row) =>1215 .filter((row) =>
1037 rowMatchesPluginStorageWhere(1216 rowMatchesPluginStorageWhere(
@@ -1053,7 +1232,8 @@ const makePluginStorageFacade = (input: { · 15 unmodified lines
1053 if (compared !== 0) return compared;1232 if (compared !== 0) return compared;
1054 }1233 }
1055 return (1234 return (
1056 ownerRank(left.owner as Owner) - ownerRank(right.owner as Owner) ||1235 ownerRank(left.owner as Owner) -
1236 ownerRank(right.owner as Owner) ||
1057 left.key.localeCompare(right.key)1237 left.key.localeCompare(right.key)
1058 );1238 );
1059 })1239 })
@@ -1065,7 +1245,9 @@ const makePluginStorageFacade = (input: { · 5 unmodified lines
1065 ? sorted.slice(offset)1245 ? sorted.slice(offset)
1066 : sorted.slice(offset, offset + queryInput.limit);1246 : sorted.slice(offset, offset + queryInput.limit);
1067 return limited.map((row) =>1247 return limited.map((row) =>
1068 pluginStorageEntryFromRow<PluginStorageCollectionData<TDefinition>>(row),1248 pluginStorageEntryFromRow<PluginStorageCollectionData<TDefinition>>(
1249 row,
1250 ),
1069 );1251 );
1070 });1252 });
1071 1253
@@ -1073,15 +1255,24 @@ const makePluginStorageFacade = (input: { · 1 unmodified line
1073 collection: (definition) => ({1255 collection: (definition) => ({
1074 get: (storageInput) =>1256 get: (storageInput) =>
1075 getVisible(definition.name, storageInput.key) as Effect.Effect<1257 getVisible(definition.name, storageInput.key) as Effect.Effect<
1076 PluginStorageEntry<PluginStorageCollectionData<typeof definition>> | null,1258 PluginStorageEntry<
1259 PluginStorageCollectionData<typeof definition>
1260 > | null,
1077 StorageFailure1261 StorageFailure
1078 >,1262 >,
1079 getForOwner: (storageInput) =>1263 getForOwner: (storageInput) =>
1080 getForOwnerImpl(storageInput.owner, definition.name, storageInput.key) as Effect.Effect<1264 getForOwnerImpl(
1081 PluginStorageEntry<PluginStorageCollectionData<typeof definition>> | null,1265 storageInput.owner,
1266 definition.name,
1267 storageInput.key,
1268 ) as Effect.Effect<
1269 PluginStorageEntry<
1270 PluginStorageCollectionData<typeof definition>
1271 > | null,
1082 StorageFailure1272 StorageFailure
1083 >,1273 >,
1084 list: (storageInput) => queryCollection(definition, { keyPrefix: storageInput?.keyPrefix }),1274 list: (storageInput) =>
1275 queryCollection(definition, { keyPrefix: storageInput?.keyPrefix }),
1085 put: (storageInput) =>1276 put: (storageInput) =>
1086 putImpl(1277 putImpl(
1087 storageInput.owner,1278 storageInput.owner,
@@ -1094,12 +1285,20 @@ const makePluginStorageFacade = (input: { · 6 unmodified lines
1094 >,1285 >,
1095 query: (storageInput) => queryCollection(definition, storageInput),1286 query: (storageInput) => queryCollection(definition, storageInput),
1096 count: (storageInput) =>1287 count: (storageInput) =>
1097 queryCollection(definition, storageInput).pipe(Effect.map((rows) => rows.length)),1288 queryCollection(definition, storageInput).pipe(
1098 remove: (storageInput) => removeImpl(storageInput.owner, definition.name, storageInput.key),1289 Effect.map((rows) => rows.length),
1290 ),
1291 remove: (storageInput) =>
1292 removeImpl(storageInput.owner, definition.name, storageInput.key),
1099 }),1293 }),
1100 get: (storageInput) => getVisible(storageInput.collection, storageInput.key),1294 get: (storageInput) =>
1295 getVisible(storageInput.collection, storageInput.key),
1101 getForOwner: (storageInput) =>1296 getForOwner: (storageInput) =>
1102 getForOwnerImpl(storageInput.owner, storageInput.collection, storageInput.key),1297 getForOwnerImpl(
1298 storageInput.owner,
1299 storageInput.collection,
1300 storageInput.key,
1301 ),
1103 list: (storageInput) =>1302 list: (storageInput) =>
1104 Effect.gen(function* () {1303 Effect.gen(function* () {
1105 const rows = yield* input.core.findMany("plugin_storage", {1304 const rows = yield* input.core.findMany("plugin_storage", {
@@ -1114,11 +1313,18 @@ const makePluginStorageFacade = (input: { · 8 unmodified lines
1114 .map((row) => pluginStorageEntryFromRow(row));1313 .map((row) => pluginStorageEntryFromRow(row));
1115 }),1314 }),
1116 put: (storageInput) =>1315 put: (storageInput) =>
1117 putImpl(storageInput.owner, storageInput.collection, storageInput.key, storageInput.data),1316 putImpl(
1118 putMany: (storageInput) => putManyImpl(storageInput.owner, storageInput.entries),1317 storageInput.owner,
1318 storageInput.collection,
1319 storageInput.key,
1320 storageInput.data,
1321 ),
1322 putMany: (storageInput) =>
1323 putManyImpl(storageInput.owner, storageInput.entries),
1119 remove: (storageInput) =>1324 remove: (storageInput) =>
1120 removeImpl(storageInput.owner, storageInput.collection, storageInput.key),1325 removeImpl(storageInput.owner, storageInput.collection, storageInput.key),
1121 removeMany: (storageInput) => removeManyImpl(storageInput.owner, storageInput.entries),1326 removeMany: (storageInput) =>
1327 removeManyImpl(storageInput.owner, storageInput.entries),
1122 };1328 };
1123};1329};
1124 1330
@@ -1161,7 +1367,9 @@ const EXECUTOR_SOURCE: StaticSourceDecl = { · 36 unmodified lines
1161 tools: [],1367 tools: [],
1162};1368};
1163 1369
1164const isReadonlyRecord = (value: unknown): value is Readonly<Record<PropertyKey, unknown>> =>1370const isReadonlyRecord = (
1371 value: unknown,
1372): value is Readonly<Record<PropertyKey, unknown>> =>
1165 typeof value === "object" && value !== null;1373 typeof value === "object" && value !== null;
1166 1374
1167type StandardJsonSchemaSide = "input" | "output";1375type StandardJsonSchemaSide = "input" | "output";
@@ -1180,10 +1388,14 @@ const staticToolSchemaRoot = ( · 12 unmodified lines
1180 const jsonSchema = standard["jsonSchema"];1388 const jsonSchema = standard["jsonSchema"];
1181 if (!isReadonlyRecord(jsonSchema)) return schema;1389 if (!isReadonlyRecord(jsonSchema)) return schema;
1182 const materialize = (jsonSchema as StandardJsonSchemaFns)[side];1390 const materialize = (jsonSchema as StandardJsonSchemaFns)[side];
1183 return typeof materialize === "function" ? materialize({ target: "draft-07" }) : jsonSchema;1391 return typeof materialize === "function"
1392 ? materialize({ target: "draft-07" })
1393 : jsonSchema;
1184};1394};
1185 1395
1186export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = readonly []>(1396export const createExecutor = <
1397 const TPlugins extends readonly AnyPlugin[] = readonly [],
1398>(
1187 config: ExecutorConfig<TPlugins>,1399 config: ExecutorConfig<TPlugins>,
1188): Effect.Effect<Executor<TPlugins>, StorageFailure> =>1400): Effect.Effect<Executor<TPlugins>, StorageFailure> =>
1189 Effect.gen(function* () {1401 Effect.gen(function* () {
@@ -1213,7 +1425,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 23 unmodified lines
1213 return { tenant, owner, subject };1425 return { tenant, owner, subject };
1214 };1426 };
1215 1427
1216 const requireUserSubject = (owner: Owner): Effect.Effect<void, StorageFailure> =>1428 const requireUserSubject = (
1429 owner: Owner,
1430 ): Effect.Effect<void, StorageFailure> =>
1217 owner === "user" && subject == null1431 owner === "user" && subject == null
1218 ? Effect.fail(1432 ? Effect.fail(
1219 new StorageError({1433 new StorageError({
@@ -1236,7 +1450,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 16 unmodified lines
1236 1450
1237 const tables = yield* Effect.try({1451 const tables = yield* Effect.try({
1238 try: () => collectTables(),1452 try: () => collectTables(),
1239 catch: (cause) => storageFailureFromUnknown("Failed to collect executor tables", cause),1453 catch: (cause) =>
1454 storageFailureFromUnknown("Failed to collect executor tables", cause),
1240 });1455 });
1241 const dbInput = yield* Effect.suspend(() => {1456 const dbInput = yield* Effect.suspend(() => {
1242 if (!config.db) return Effect.succeed(createDefaultMemoryDb(tables));1457 if (!config.db) return Effect.succeed(createDefaultMemoryDb(tables));
@@ -1251,7 +1466,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 8 unmodified lines
1251 validateExecutorDbTables(tables, rootDbUntyped.internal.tables);1466 validateExecutorDbTables(tables, rootDbUntyped.internal.tables);
1252 validateExecutorOwnerPolicyTables(rootDbUntyped.internal.tables);1467 validateExecutorOwnerPolicyTables(rootDbUntyped.internal.tables);
1253 },1468 },
1254 catch: (cause) => storageFailureFromUnknown("Failed to validate executor tables", cause),1469 catch: (cause) =>
1470 storageFailureFromUnknown("Failed to validate executor tables", cause),
1255 });1471 });
1256 1472
1257 const ownerContext: ExecutorOwnerPolicyContext = { tenant, subject };1473 const ownerContext: ExecutorOwnerPolicyContext = { tenant, subject };
@@ -1259,7 +1475,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line
1259 const fuma = makeFumaClient(rootDb);1475 const fuma = makeFumaClient(rootDb);
1260 const core = makeCoreDb(fuma);1476 const core = makeCoreDb(fuma);
1261 const blobs = config.blobs ?? makeFumaBlobStore(fuma);1477 const blobs = config.blobs ?? makeFumaBlobStore(fuma);
1262 const transaction = <A, E>(effect: Effect.Effect<A, E>) => fuma.transaction(effect);1478 const transaction = <A, E>(effect: Effect.Effect<A, E>) =>
1479 fuma.transaction(effect);
1263 1480
1264 // Populated once, never mutated after startup.1481 // Populated once, never mutated after startup.
1265 const staticTools = new Map<string, StaticTools>();1482 const staticTools = new Map<string, StaticTools>();
@@ -1270,7 +1487,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 4 unmodified lines
1270 1487
1271 const staticToolOwner = (): Owner => (subject == null ? "org" : "user");1488 const staticToolOwner = (): Owner => (subject == null ? "org" : "user");
1272 const staticToolConnection = (source: StaticSourceDecl): ConnectionName =>1489 const staticToolConnection = (source: StaticSourceDecl): ConnectionName =>
1273 ConnectionName.make(source.id === EXECUTOR_SOURCE_ID ? "coreTools" : "static");1490 ConnectionName.make(
1491 source.id === EXECUTOR_SOURCE_ID ? "coreTools" : "static",
1492 );
1274 1493
1275 const staticSources = (): readonly StaticSourceDecl[] => {1494 const staticSources = (): readonly StaticSourceDecl[] => {
1276 const byId = new Map<string, StaticSourceDecl>();1495 const byId = new Map<string, StaticSourceDecl>();
@@ -1280,7 +1499,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3 unmodified lines
1280 return [...byId.values()];1499 return [...byId.values()];
1281 };1500 };
1282 1501
1283 const staticSourceToIntegration = (source: StaticSourceDecl): Integration => ({1502 const staticSourceToIntegration = (
1503 source: StaticSourceDecl,
1504 ): Integration => ({
1284 slug: IntegrationSlug.make(source.id),1505 slug: IntegrationSlug.make(source.id),
1285 name: source.name,1506 name: source.name,
1286 description: source.name,1507 description: source.name,
@@ -1336,6 +1557,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 49 unmodified lines
1336 };1557 };
1337 1558
1338 const extensions: Record<string, object> = {};1559 const extensions: Record<string, object> = {};
1560 // Generic executor-wrapper hook: a plugin may expose `wrapExecutor` on its
1561 // extension to narrow the executor per request (the toolkits plugin does).
1562 // Collected here, run by the execution stack — core stays domain-agnostic.
1563 const executorWrappers: ExecutorWrapper[] = [];
1339 1564
1340 // ------------------------------------------------------------------1565 // ------------------------------------------------------------------
1341 // Owner condition builders. The owner policy already restricts reads to1566 // Owner condition builders. The owner policy already restricts reads to
@@ -1389,7 +1614,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 47 unmodified lines
1389 const performTokenRefresh = (1614 const performTokenRefresh = (
1390 row: ConnectionRow,1615 row: ConnectionRow,
1391 provider: CredentialProvider,1616 provider: CredentialProvider,
1392 ): Effect.Effect<string | null, StorageFailure | CredentialResolutionError> =>1617 ): Effect.Effect<
1618 string | null,
1619 StorageFailure | CredentialResolutionError
1620 > =>
1393 Effect.gen(function* () {1621 Effect.gen(function* () {
1394 const owner = row.owner as Owner;1622 const owner = row.owner as Owner;
1395 const reauth = (message: string): CredentialResolutionError =>1623 const reauth = (message: string): CredentialResolutionError =>
@@ -1404,15 +1632,21 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 8 unmodified lines
1404 // Load the backing app by the owner STORED on the connection (a Personal1632 // Load the backing app by the owner STORED on the connection (a Personal
1405 // connection may be backed by a shared Workspace app) — no derivation.1633 // connection may be backed by a shared Workspace app) — no derivation.
1406 const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner;1634 const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner;
1407 const clientRow = yield* loadOAuthClientRow(clientOwner, String(row.oauth_client));1635 const clientRow = yield* loadOAuthClientRow(
1636 clientOwner,
1637 String(row.oauth_client),
1638 );
1408 if (!clientRow) {1639 if (!clientRow) {
1409 return yield* reauth(`OAuth client "${row.oauth_client}" is no longer registered.`);1640 return yield* reauth(
1641 `OAuth client "${row.oauth_client}" is no longer registered.`,
1642 );
1410 }1643 }
1411 1644
1412 // The secret is stored in the provider (a vault item id), not inline.1645 // The secret is stored in the provider (a vault item id), not inline.
1413 const clientSecret = clientRow.client_secret_item_id1646 const clientSecret = clientRow.client_secret_item_id
1414 ? ((yield* provider.get(ProviderItemId.make(String(clientRow.client_secret_item_id)))) ??1647 ? ((yield* provider.get(
1415 "")1648 ProviderItemId.make(String(clientRow.client_secret_item_id)),
1649 )) ?? "")
1416 : "";1650 : "";
1417 // Re-request the scopes this connection was GRANTED (RFC 6749 §6: a1651 // Re-request the scopes this connection was GRANTED (RFC 6749 §6: a
1418 // refresh must not exceed the originally-granted scope). Empty → omit1652 // refresh must not exceed the originally-granted scope). Empty → omit
@@ -1433,7 +1667,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 14 unmodified lines
1433 clientId: String(clientRow.client_id),1667 clientId: String(clientRow.client_id),
1434 clientSecret,1668 clientSecret,
1435 scopes: grantedScopes,1669 scopes: grantedScopes,
1436 resource: clientRow.resource ? String(clientRow.resource) : undefined,1670 resource: clientRow.resource
1671 ? String(clientRow.resource)
1672 : undefined,
1437 endpointUrlPolicy: config.oauthEndpointUrlPolicy,1673 endpointUrlPolicy: config.oauthEndpointUrlPolicy,
1438 }).pipe(1674 }).pipe(
1439 // A client_credentials failure is never a rotated-refresh-token1675 // A client_credentials failure is never a rotated-refresh-token
@@ -1451,11 +1687,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 11 unmodified lines
1451 )1687 )
1452 : yield* Effect.gen(function* () {1688 : yield* Effect.gen(function* () {
1453 if (!row.refresh_item_id) {1689 if (!row.refresh_item_id) {
1454 return yield* reauth("No refresh token is stored for this connection.");1690 return yield* reauth(
1691 "No refresh token is stored for this connection.",
1692 );
1455 }1693 }
1456 const refreshToken = yield* provider.get(ProviderItemId.make(row.refresh_item_id));1694 const refreshToken = yield* provider.get(
1695 ProviderItemId.make(row.refresh_item_id),
1696 );
1457 if (!refreshToken) {1697 if (!refreshToken) {
1458 return yield* reauth("Stored refresh token could not be resolved.");1698 return yield* reauth(
1699 "Stored refresh token could not be resolved.",
1700 );
1459 }1701 }
1460 return yield* refreshAccessToken({1702 return yield* refreshAccessToken({
1461 tokenUrl: String(clientRow.token_url),1703 tokenUrl: String(clientRow.token_url),
@@ -1465,7 +1707,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3 unmodified lines
1465 scopes: grantedScopes,1707 scopes: grantedScopes,
1466 // RFC 8707: keep the re-minted token bound to the same resource1708 // RFC 8707: keep the re-minted token bound to the same resource
1467 // (MCP servers require this on refresh).1709 // (MCP servers require this on refresh).
1468 resource: clientRow.resource ? String(clientRow.resource) : undefined,1710 resource: clientRow.resource
1711 ? String(clientRow.resource)
1712 : undefined,
1469 endpointUrlPolicy: config.oauthEndpointUrlPolicy,1713 endpointUrlPolicy: config.oauthEndpointUrlPolicy,
1470 }).pipe(1714 }).pipe(
1471 Effect.mapError((cause) =>1715 Effect.mapError((cause) =>
@@ -1489,14 +1733,22 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 17 unmodified lines
1489 const tokenItemId =1733 const tokenItemId =
1490 connectionItemIds(row)[PRIMARY_INPUT_VARIABLE] ??1734 connectionItemIds(row)[PRIMARY_INPUT_VARIABLE] ??
1491 `connection:${row.owner}:${row.integration}:${row.name}:${PRIMARY_INPUT_VARIABLE}`;1735 `connection:${row.owner}:${row.integration}:${row.name}:${PRIMARY_INPUT_VARIABLE}`;
1492 yield* provider.set(ProviderItemId.make(tokenItemId), token.access_token);1736 yield* provider.set(
1737 ProviderItemId.make(tokenItemId),
1738 token.access_token,
1739 );
1493 if (token.refresh_token && row.refresh_item_id) {1740 if (token.refresh_token && row.refresh_item_id) {
1494 yield* provider.set(ProviderItemId.make(row.refresh_item_id), token.refresh_token);1741 yield* provider.set(
1742 ProviderItemId.make(row.refresh_item_id),
1743 token.refresh_token,
1744 );
1495 }1745 }
1496 }1746 }
1497 1747
1498 const nextExpiresAt =1748 const nextExpiresAt =
1499 typeof token.expires_in === "number" ? Date.now() + token.expires_in * 1000 : null;1749 typeof token.expires_in === "number"
1750 ? Date.now() + token.expires_in * 1000
1751 : null;
1500 const set: Record<string, unknown> = {1752 const set: Record<string, unknown> = {
1501 expires_at: nextExpiresAt,1753 expires_at: nextExpiresAt,
1502 updated_at: new Date(),1754 updated_at: new Date(),
@@ -1518,7 +1770,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 15 unmodified lines
1518 const refreshConnectionToken = (1770 const refreshConnectionToken = (
1519 row: ConnectionRow,1771 row: ConnectionRow,
1520 provider: CredentialProvider,1772 provider: CredentialProvider,
1521 ): Effect.Effect<string | null, StorageFailure | CredentialResolutionError> =>1773 ): Effect.Effect<
1774 string | null,
1775 StorageFailure | CredentialResolutionError
1776 > =>
1522 // Share a single refresh per connection so concurrent resolves of the same1777 // Share a single refresh per connection so concurrent resolves of the same
1523 // connection all await one refresh-token grant (the AS rotates the refresh1778 // connection all await one refresh-token grant (the AS rotates the refresh
1524 // token; parallel grants would race on a consumed token — v1's refresh1779 // token; parallel grants would race on a consumed token — v1's refresh
@@ -1530,7 +1785,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
1530 if (existing) return yield* existing;1785 if (existing) return yield* existing;
1531 // `Effect.cached` memoizes the grant onto a deferred: it runs once and1786 // `Effect.cached` memoizes the grant onto a deferred: it runs once and
1532 // replays to every awaiter sharing this entry.1787 // replays to every awaiter sharing this entry.
1533 const memoized = yield* Effect.cached(performTokenRefresh(row, provider));1788 const memoized = yield* Effect.cached(
1789 performTokenRefresh(row, provider),
1790 );
1534 const gated = memoized.pipe(1791 const gated = memoized.pipe(
1535 Effect.ensuring(Effect.sync(() => refreshInFlight.delete(key))),1792 Effect.ensuring(Effect.sync(() => refreshInFlight.delete(key))),
1536 );1793 );
@@ -1547,7 +1804,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 10 unmodified lines
1547 // first (always single-input → `{ token: <access> }`).1804 // first (always single-input → `{ token: <access> }`).
1548 const resolveConnectionValues = (1805 const resolveConnectionValues = (
1549 row: ConnectionRow,1806 row: ConnectionRow,
1550 ): Effect.Effect<Record<string, string | null>, StorageFailure | CredentialResolutionError> =>1807 ): Effect.Effect<
1808 Record<string, string | null>,
1809 StorageFailure | CredentialResolutionError
1810 > =>
1551 Effect.gen(function* () {1811 Effect.gen(function* () {
1552 const provider = credentialProviders.get(row.provider);1812 const provider = credentialProviders.get(row.provider);
1553 if (!provider) {1813 if (!provider) {
@@ -1557,13 +1817,16 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3 unmodified lines
1557 }1817 }
1558 // OAuth connections refresh their access token before resolving when1818 // OAuth connections refresh their access token before resolving when
1559 // it has expired (or is within the skew window).1819 // it has expired (or is within the skew window).
1560 const expiresAt = row.expires_at == null ? null : Number(row.expires_at);1820 const expiresAt =
1821 row.expires_at == null ? null : Number(row.expires_at);
1561 if (row.oauth_client != null && shouldRefreshToken({ expiresAt })) {1822 if (row.oauth_client != null && shouldRefreshToken({ expiresAt })) {
1562 const access = yield* refreshConnectionToken(row, provider);1823 const access = yield* refreshConnectionToken(row, provider);
1563 return { [PRIMARY_INPUT_VARIABLE]: access };1824 return { [PRIMARY_INPUT_VARIABLE]: access };
1564 }1825 }
1565 const out: Record<string, string | null> = {};1826 const out: Record<string, string | null> = {};
1566 for (const [variable, itemId] of Object.entries(connectionItemIds(row))) {1827 for (const [variable, itemId] of Object.entries(
1828 connectionItemIds(row),
1829 )) {
1567 out[variable] = yield* provider.get(ProviderItemId.make(itemId));1830 out[variable] = yield* provider.get(ProviderItemId.make(itemId));
1568 }1831 }
1569 return out;1832 return out;
@@ -1584,7 +1847,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 14 unmodified lines
1584 * callers that only ever need one value. */1847 * callers that only ever need one value. */
1585 const resolveConnectionValue = (1848 const resolveConnectionValue = (
1586 row: ConnectionRow,1849 row: ConnectionRow,
1587 ): Effect.Effect<string | null, StorageFailure | CredentialResolutionError> =>1850 ): Effect.Effect<
1851 string | null,
1852 StorageFailure | CredentialResolutionError
1853 > =>
1588 resolveConnectionValues(row).pipe(1854 resolveConnectionValues(row).pipe(
1589 Effect.map((values) => values[PRIMARY_INPUT_VARIABLE] ?? null),1855 Effect.map((values) => values[PRIMARY_INPUT_VARIABLE] ?? null),
1590 );1856 );
@@ -1644,7 +1910,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 53 unmodified lines
1644 // plugin's `describeAuthMethods` hook. The hook is plugin-authored, so a1910 // plugin's `describeAuthMethods` hook. The hook is plugin-authored, so a
1645 // throw (malformed config it didn't guard) degrades to `[]` rather than1911 // throw (malformed config it didn't guard) degrades to `[]` rather than
1646 // failing the catalog read.1912 // failing the catalog read.
1647 const describeAuthMethodsForRow = (row: IntegrationRow): readonly AuthMethodDescriptor[] => {1913 const describeAuthMethodsForRow = (
1914 row: IntegrationRow,
1915 ): readonly AuthMethodDescriptor[] => {
1648 const runtime = runtimes.get(row.plugin_id);1916 const runtime = runtimes.get(row.plugin_id);
1649 const describe = runtime?.plugin.describeAuthMethods;1917 const describe = runtime?.plugin.describeAuthMethods;
1650 if (!describe) return [];1918 if (!describe) return [];
@@ -1657,7 +1925,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 6 unmodified lines
1657 }1925 }
1658 };1926 };
1659 1927
1660 const describeDisplayUrlForRow = (row: IntegrationRow): string | undefined => {1928 const describeDisplayUrlForRow = (
1929 row: IntegrationRow,
1930 ): string | undefined => {
1661 const runtime = runtimes.get(row.plugin_id);1931 const runtime = runtimes.get(row.plugin_id);
1662 const describe = runtime?.plugin.describeIntegrationDisplay;1932 const describe = runtime?.plugin.describeIntegrationDisplay;
1663 if (!describe) return undefined;1933 if (!describe) return undefined;
@@ -1671,14 +1941,21 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines
1671 }1941 }
1672 };1942 };
1673 1943
1674 const integrationsList = (): Effect.Effect<readonly Integration[], StorageFailure> =>1944 const integrationsList = (): Effect.Effect<
1945 readonly Integration[],
1946 StorageFailure
1947 > =>
1675 core1948 core
1676 .findMany("integration", {})1949 .findMany("integration", {})
1677 .pipe(1950 .pipe(
1678 Effect.map((rows) => [1951 Effect.map((rows) => [
1679 ...staticSources().map(staticSourceToIntegration),1952 ...staticSources().map(staticSourceToIntegration),
1680 ...rows.map((row) =>1953 ...rows.map((row) =>
1681 rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)),1954 rowToIntegration(
1955 row,
1956 describeAuthMethodsForRow(row),
1957 describeDisplayUrlForRow(row),
1958 ),
1682 ),1959 ),
1683 ]),1960 ]),
1684 );1961 );
@@ -1687,11 +1964,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines
1687 slug: IntegrationSlug,1964 slug: IntegrationSlug,
1688 ): Effect.Effect<Integration | null, StorageFailure> =>1965 ): Effect.Effect<Integration | null, StorageFailure> =>
1689 Effect.gen(function* () {1966 Effect.gen(function* () {
1690 const staticSource = staticSources().find((source) => source.id === String(slug));1967 const staticSource = staticSources().find(
1968 (source) => source.id === String(slug),
1969 );
1691 if (staticSource) return staticSourceToIntegration(staticSource);1970 if (staticSource) return staticSourceToIntegration(staticSource);
1692 const row = yield* findIntegrationRow(slug);1971 const row = yield* findIntegrationRow(slug);
1693 return row1972 return row
1694 ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row))1973 ? rowToIntegration(
1974 row,
1975 describeAuthMethodsForRow(row),
1976 describeDisplayUrlForRow(row),
1977 )
1695 : null;1978 : null;
1696 });1979 });
1697 1980
@@ -1700,7 +1983,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines
1700 ): Effect.Effect<IntegrationRecord | null, StorageFailure> =>1983 ): Effect.Effect<IntegrationRecord | null, StorageFailure> =>
1701 findIntegrationRow(slug).pipe(1984 findIntegrationRow(slug).pipe(
1702 Effect.map((row) =>1985 Effect.map((row) =>
1703 row ? rowToIntegrationRecord(row, describeAuthMethodsForRow(row)) : null,1986 row
1987 ? rowToIntegrationRecord(row, describeAuthMethodsForRow(row))
1988 : null,
1704 ),1989 ),
1705 );1990 );
1706 1991
@@ -1755,7 +2040,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 48 unmodified lines
1755 const now = new Date();2040 const now = new Date();
1756 const set: Record<string, unknown> = { updated_at: now };2041 const set: Record<string, unknown> = { updated_at: now };
1757 if (patch.name !== undefined) set.name = patch.name;2042 if (patch.name !== undefined) set.name = patch.name;
1758 if (patch.description !== undefined) set.description = patch.description;2043 if (patch.description !== undefined)
2044 set.description = patch.description;
1759 if (patch.config !== undefined) {2045 if (patch.config !== undefined) {
1760 set.config = patch.config;2046 set.config = patch.config;
1761 // A config change can change the derived tools. The writer can only2047 // A config change can change the derived tools. The writer can only
@@ -1782,7 +2068,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines
1782 2068
1783 const integrationsRemove = (2069 const integrationsRemove = (
1784 slug: IntegrationSlug,2070 slug: IntegrationSlug,
1785 ): Effect.Effect<void, IntegrationRemovalNotAllowedError | StorageFailure> =>2071 ): Effect.Effect<
2072 void,
2073 IntegrationRemovalNotAllowedError | StorageFailure
2074 > =>
1786 transaction(2075 transaction(
1787 Effect.gen(function* () {2076 Effect.gen(function* () {
1788 const existing = yield* findIntegrationRow(slug);2077 const existing = yield* findIntegrationRow(slug);
@@ -1811,7 +2100,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 22 unmodified lines
1811 const result = yield* runtime.plugin2100 const result = yield* runtime.plugin
1812 .detect({ ctx: runtime.ctx, url })2101 .detect({ ctx: runtime.ctx, url })
1813 .pipe(2102 .pipe(
1814 Effect.mapError((cause) => pluginStorageFailure(runtime.plugin.id, "detect", cause)),2103 Effect.mapError((cause) =>
2104 pluginStorageFailure(runtime.plugin.id, "detect", cause),
2105 ),
1815 );2106 );
1816 if (result) results.push(result);2107 if (result) results.push(result);
1817 }2108 }
@@ -1825,7 +2116,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines
1825 const produceConnectionTools = (2116 const produceConnectionTools = (
1826 integrationRow: IntegrationRow,2117 integrationRow: IntegrationRow,
1827 ref: ConnectionRef,2118 ref: ConnectionRef,
1828 ): Effect.Effect<readonly Tool[], IntegrationNotFoundError | StorageFailure> =>2119 ): Effect.Effect<
2120 readonly Tool[],
2121 IntegrationNotFoundError | StorageFailure
2122 > =>
1829 Effect.gen(function* () {2123 Effect.gen(function* () {
1830 const runtime = runtimes.get(integrationRow.plugin_id);2124 const runtime = runtimes.get(integrationRow.plugin_id);
1831 const keys = yield* Effect.try({2125 const keys = yield* Effect.try({
@@ -1894,14 +2188,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 62 unmodified lines
1894 integration: rowToIntegration(integrationRow),2188 integration: rowToIntegration(integrationRow),
1895 config: decodeJsonColumn(integrationRow.config),2189 config: decodeJsonColumn(integrationRow.config),
1896 connection: ref,2190 connection: ref,
1897 template: existingRow ? AuthTemplateSlug.make(existingRow.template) : null,2191 template: existingRow
2192 ? AuthTemplateSlug.make(existingRow.template)
2193 : null,
1898 storage: runtime.storage,2194 storage: runtime.storage,
1899 getValue: () => resolveConnectionValueByRef(ref),2195 getValue: () => resolveConnectionValueByRef(ref),
1900 getValues: () => resolveConnectionValuesByRef(ref),2196 getValues: () => resolveConnectionValuesByRef(ref),
1901 })2197 })
1902 .pipe(2198 .pipe(
1903 Effect.mapError((cause) =>2199 Effect.mapError((cause) =>
1904 pluginStorageFailure(integrationRow.plugin_id, "resolveTools", cause),2200 pluginStorageFailure(
2201 integrationRow.plugin_id,
2202 "resolveTools",
2203 cause,
2204 ),
1905 ),2205 ),
1906 );2206 );
1907 2207
@@ -1922,17 +2222,19 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 14 unmodified lines
1922 updated_at: now,2222 updated_at: now,
1923 }));2223 }));
1924 2224
1925 const definitionRows = Object.entries(result.definitions ?? {}).map(([name, schema]) => ({2225 const definitionRows = Object.entries(result.definitions ?? {}).map(
1926 tenant: keys.tenant,2226 ([name, schema]) => ({
1927 owner: keys.owner,2227 tenant: keys.tenant,
1928 subject: keys.subject,2228 owner: keys.owner,
1929 integration: String(ref.integration),2229 subject: keys.subject,
1930 connection: String(ref.name),2230 integration: String(ref.integration),
1931 plugin_id: integrationRow.plugin_id,2231 connection: String(ref.name),
1932 name,2232 plugin_id: integrationRow.plugin_id,
1933 schema,2233 name,
1934 created_at: now,2234 schema,
1935 }));2235 created_at: now,
2236 }),
2237 );
1936 2238
1937 yield* transaction(2239 yield* transaction(
1938 Effect.gen(function* () {2240 Effect.gen(function* () {
@@ -2024,16 +2326,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 85 unmodified lines
2024 const itemIds: Record<string, string> = {};2326 const itemIds: Record<string, string> = {};
2025 if (external.length > 0 && pasted.length > 0) {2327 if (external.length > 0 && pasted.length > 0) {
2026 return yield* new InvalidConnectionInputError({2328 return yield* new InvalidConnectionInputError({
2027 message: "A connection cannot mix pasted and external-provider inputs.",2329 message:
2330 "A connection cannot mix pasted and external-provider inputs.",
2028 });2331 });
2029 }2332 }
2030 if (external.length > 0) {2333 if (external.length > 0) {
2031 const providers = new Set(2334 const providers = new Set(
2032 external.map((i) => ("from" in i.origin ? String(i.origin.from.provider) : "")),2335 external.map((i) =>
2336 "from" in i.origin ? String(i.origin.from.provider) : "",
2337 ),
2033 );2338 );
2034 if (providers.size > 1) {2339 if (providers.size > 1) {
2035 return yield* new InvalidConnectionInputError({2340 return yield* new InvalidConnectionInputError({
2036 message: "A connection's inputs must all use the same external provider.",2341 message:
2342 "A connection's inputs must all use the same external provider.",
2037 });2343 });
2038 }2344 }
2039 const [only] = [...providers];2345 const [only] = [...providers];
@@ -2045,7 +2351,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
2045 }2351 }
2046 providerKey = only ?? "";2352 providerKey = only ?? "";
2047 for (const i of external) {2353 for (const i of external) {
2048 if ("from" in i.origin) itemIds[i.variable] = String(i.origin.from.id);2354 if ("from" in i.origin)
2355 itemIds[i.variable] = String(i.origin.from.id);
2049 }2356 }
2050 } else {2357 } else {
2051 const provider = defaultWritableProvider();2358 const provider = defaultWritableProvider();
@@ -2083,7 +2390,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 31 unmodified lines
2083 identity_label: input.identityLabel ?? null,2390 identity_label: input.identityLabel ?? null,
2084 // Re-saving a credential keeps an existing curated description2391 // Re-saving a credential keeps an existing curated description
2085 // unless the caller explicitly provides one.2392 // unless the caller explicitly provides one.
2086 ...(input.description !== undefined ? { description: input.description } : {}),2393 ...(input.description !== undefined
2394 ? { description: input.description }
2395 : {}),
2087 updated_at: now,2396 updated_at: now,
2088 };2397 };
2089 if (existing) {2398 if (existing) {
@@ -2127,7 +2436,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 37 unmodified lines
2127 };2436 };
2128 // Produce + persist tools for the new connection.2437 // Produce + persist tools for the new connection.
2129 yield* produceConnectionTools(integrationRow, ref).pipe(2438 yield* produceConnectionTools(integrationRow, ref).pipe(
2130 Effect.catchTag("IntegrationNotFoundError", () => Effect.succeed([] as readonly Tool[])),2439 Effect.catchTag("IntegrationNotFoundError", () =>
2440 Effect.succeed([] as readonly Tool[]),
2441 ),
2131 );2442 );
2132 2443
2133 const row = yield* findConnectionRow(ref);2444 const row = yield* findConnectionRow(ref);
@@ -2236,7 +2547,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 102 unmodified lines
2236 // Produce + persist tools for the minted connection (same path2547 // Produce + persist tools for the minted connection (same path
2237 // connections.create uses).2548 // connections.create uses).
2238 yield* produceConnectionTools(integrationRow, ref).pipe(2549 yield* produceConnectionTools(integrationRow, ref).pipe(
2239 Effect.catchTag("IntegrationNotFoundError", () => Effect.succeed([] as readonly Tool[])),2550 Effect.catchTag("IntegrationNotFoundError", () =>
2551 Effect.succeed([] as readonly Tool[]),
2552 ),
2240 );2553 );
2241 2554
2242 const row = yield* findConnectionRow(ref);2555 const row = yield* findConnectionRow(ref);
@@ -2275,13 +2588,19 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 32 unmodified lines
2275 filter?.integration === undefined2588 filter?.integration === undefined
2276 ? true2589 ? true
2277 : b("integration", "=", String(filter.integration)),2590 : b("integration", "=", String(filter.integration)),
2278 filter?.owner === undefined ? true : b("owner", "=", filter.owner),2591 filter?.owner === undefined
2592 ? true
2593 : b("owner", "=", filter.owner),
2279 ),2594 ),
2280 })2595 })
2281 .pipe(Effect.map((rows) => rows.map(rowToConnection)));2596 .pipe(Effect.map((rows) => rows.map(rowToConnection)));
2282 2597
2283 const connectionsGet = (ref: ConnectionRef): Effect.Effect<Connection | null, StorageFailure> =>2598 const connectionsGet = (
2284 findConnectionRow(ref).pipe(Effect.map((row) => (row ? rowToConnection(row) : null)));2599 ref: ConnectionRef,
2600 ): Effect.Effect<Connection | null, StorageFailure> =>
2601 findConnectionRow(ref).pipe(
2602 Effect.map((row) => (row ? rowToConnection(row) : null)),
2603 );
2285 2604
2286 const connectionsUpdate = (2605 const connectionsUpdate = (
2287 ref: ConnectionRef,2606 ref: ConnectionRef,
@@ -2297,8 +2616,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 9 unmodified lines
2297 });2616 });
2298 }2617 }
2299 const set: Record<string, unknown> = { updated_at: new Date() };2618 const set: Record<string, unknown> = { updated_at: new Date() };
2300 if (input.description !== undefined) set.description = input.description;2619 if (input.description !== undefined)
2301 if (input.identityLabel !== undefined) set.identity_label = input.identityLabel;2620 set.description = input.description;
2621 if (input.identityLabel !== undefined)
2622 set.identity_label = input.identityLabel;
2302 yield* core.updateMany("connection", {2623 yield* core.updateMany("connection", {
2303 where: (b: AnyCb) =>2624 where: (b: AnyCb) =>
2304 b.and(2625 b.and(
@@ -2326,7 +2647,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 21 unmodified lines
2326 });2647 });
2327 }2648 }
2328 const integrationRow = yield* findIntegrationRow(ref.integration);2649 const integrationRow = yield* findIntegrationRow(ref.integration);
2329 const runtime = integrationRow ? runtimes.get(integrationRow.plugin_id) : undefined;2650 const runtime = integrationRow
2651 ? runtimes.get(integrationRow.plugin_id)
2652 : undefined;
2330 if (integrationRow && runtime?.plugin.removeConnection) {2653 if (integrationRow && runtime?.plugin.removeConnection) {
2331 yield* runtime.plugin2654 yield* runtime.plugin
2332 .removeConnection({2655 .removeConnection({
@@ -2336,7 +2659,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3 unmodified lines
2336 })2659 })
2337 .pipe(2660 .pipe(
2338 Effect.mapError((cause) =>2661 Effect.mapError((cause) =>
2339 pluginStorageFailure(integrationRow.plugin_id, "removeConnection", cause),2662 pluginStorageFailure(
2663 integrationRow.plugin_id,
2664 "removeConnection",
2665 cause,
2666 ),
2340 ),2667 ),
2341 );2668 );
2342 }2669 }
@@ -2385,11 +2712,23 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 42 unmodified lines
2385 // Tools (read surface)2712 // Tools (read surface)
2386 // ------------------------------------------------------------------2713 // ------------------------------------------------------------------
2387 2714
2388 const matchesToolFilter = (tool: Tool, filter: ToolListFilter | undefined): boolean => {2715 const matchesToolFilter = (
2716 tool: Tool,
2717 filter: ToolListFilter | undefined,
2718 ): boolean => {
2389 if (!filter) return true;2719 if (!filter) return true;
2390 if (filter.integration !== undefined && tool.integration !== filter.integration) return false;2720 if (
2391 if (filter.owner !== undefined && tool.owner !== filter.owner) return false;2721 filter.integration !== undefined &&
2392 if (filter.connection !== undefined && tool.connection !== filter.connection) return false;2722 tool.integration !== filter.integration
2723 )
2724 return false;
2725 if (filter.owner !== undefined && tool.owner !== filter.owner)
2726 return false;
2727 if (
2728 filter.connection !== undefined &&
2729 tool.connection !== filter.connection
2730 )
2731 return false;
2393 if (filter.query !== undefined) {2732 if (filter.query !== undefined) {
2394 const q = filter.query.toLowerCase();2733 const q = filter.query.toLowerCase();
2395 const hay = `${tool.name} ${tool.description}`.toLowerCase();2734 const hay = `${tool.name} ${tool.description}`.toLowerCase();
@@ -2410,18 +2749,25 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 14 unmodified lines
2410 });2749 });
2411 if (revised.length === 0) return;2750 if (revised.length === 0) return;
2412 const revisedAt = new Map(2751 const revisedAt = new Map(
2413 revised.map((row) => [row.slug, Number(row.config_revised_at)] as const),2752 revised.map(
2753 (row) => [row.slug, Number(row.config_revised_at)] as const,
2754 ),
2414 );2755 );
2415 const connections = yield* core.findMany("connection", {2756 const connections = yield* core.findMany("connection", {
2416 where: (b: AnyCb) => b.or(...revised.map((row) => b("integration", "=", row.slug))),2757 where: (b: AnyCb) =>
2758 b.or(...revised.map((row) => b("integration", "=", row.slug))),
2417 });2759 });
2418 for (const connection of connections) {2760 for (const connection of connections) {
2419 const revisedTime = revisedAt.get(connection.integration);2761 const revisedTime = revisedAt.get(connection.integration);
2420 if (revisedTime === undefined) continue;2762 if (revisedTime === undefined) continue;
2421 const syncedAt =2763 const syncedAt =
2422 connection.tools_synced_at == null ? 0 : Number(connection.tools_synced_at);2764 connection.tools_synced_at == null
2765 ? 0
2766 : Number(connection.tools_synced_at);
2423 if (syncedAt >= revisedTime) continue;2767 if (syncedAt >= revisedTime) continue;
2424 const integrationRow = revised.find((row) => row.slug === connection.integration);2768 const integrationRow = revised.find(
2769 (row) => row.slug === connection.integration,
2770 );
2425 if (!integrationRow) continue;2771 if (!integrationRow) continue;
2426 yield* produceConnectionTools(integrationRow, {2772 yield* produceConnectionTools(integrationRow, {
2427 owner: connection.owner as Owner,2773 owner: connection.owner as Owner,
@@ -2439,7 +2785,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 11 unmodified lines
2439 }2785 }
2440 });2786 });
2441 2787
2442 const toolsList = (filter?: ToolListFilter): Effect.Effect<readonly Tool[], StorageFailure> =>2788 const toolsList = (
2789 filter?: ToolListFilter,
2790 ): Effect.Effect<readonly Tool[], StorageFailure> =>
2443 Effect.gen(function* () {2791 Effect.gen(function* () {
2444 yield* syncStaleConnectionTools;2792 yield* syncStaleConnectionTools;
2445 // Projected: the list surface is metadata (address, description,2793 // Projected: the list surface is metadata (address, description,
@@ -2451,7 +2799,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 5 unmodified lines
2451 filter?.integration === undefined2799 filter?.integration === undefined
2452 ? true2800 ? true
2453 : b("integration", "=", String(filter.integration)),2801 : b("integration", "=", String(filter.integration)),
2454 filter?.owner === undefined ? true : b("owner", "=", filter.owner),2802 filter?.owner === undefined
2803 ? true
2804 : b("owner", "=", filter.owner),
2455 filter?.connection === undefined2805 filter?.connection === undefined
2456 ? true2806 ? true
2457 : b("connection", "=", String(filter.connection)),2807 : b("connection", "=", String(filter.connection)),
@@ -2507,7 +2857,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 49 unmodified lines
2507 defs: new Map(),2857 defs: new Map(),
2508 }),2858 }),
2509 catch: (cause) =>2859 catch: (cause) =>
2510 storageFailureFromUnknown("Failed to build static tool TypeScript preview", cause),2860 storageFailureFromUnknown(
2861 "Failed to build static tool TypeScript preview",
2862 cause,
2863 ),
2511 }).pipe(Effect.option);2864 }).pipe(Effect.option);
2512 return ToolSchemaView.make({2865 return ToolSchemaView.make({
2513 address,2866 address,
@@ -2517,7 +2870,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3 unmodified lines
2517 outputSchema: tool.outputSchema,2870 outputSchema: tool.outputSchema,
2518 inputTypeScript: Option.getOrUndefined(preview)?.inputTypeScript,2871 inputTypeScript: Option.getOrUndefined(preview)?.inputTypeScript,
2519 outputTypeScript: Option.getOrUndefined(preview)?.outputTypeScript,2872 outputTypeScript: Option.getOrUndefined(preview)?.outputTypeScript,
2520 typeScriptDefinitions: Option.getOrUndefined(preview)?.typeScriptDefinitions,2873 typeScriptDefinitions:
2874 Option.getOrUndefined(preview)?.typeScriptDefinitions,
2521 });2875 });
2522 }2876 }
2523 2877
@@ -2544,7 +2898,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines
2544 ),2898 ),
2545 });2899 });
2546 const defs = new Map<string, unknown>();2900 const defs = new Map<string, unknown>();
2547 for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema));2901 for (const def of definitionRows)
2902 defs.set(def.name, decodeJsonColumn(def.schema));
2548 2903
2549 const referenced = collectReferencedDefinitions(2904 const referenced = collectReferencedDefinitions(
2550 [tool.inputSchema, tool.outputSchema],2905 [tool.inputSchema, tool.outputSchema],
@@ -2558,7 +2913,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines
2558 defs,2913 defs,
2559 }),2914 }),
2560 catch: (cause) =>2915 catch: (cause) =>
2561 storageFailureFromUnknown("Failed to build tool TypeScript preview", cause),2916 storageFailureFromUnknown(
2917 "Failed to build tool TypeScript preview",
2918 cause,
2919 ),
2562 }).pipe(Effect.option);2920 }).pipe(Effect.option);
2563 2921
2564 const view = preview;2922 const view = preview;
@@ -2574,7 +2932,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 9 unmodified lines
2574 : undefined,2932 : undefined,
2575 inputTypeScript: Option.getOrUndefined(view)?.inputTypeScript,2933 inputTypeScript: Option.getOrUndefined(view)?.inputTypeScript,
2576 outputTypeScript: Option.getOrUndefined(view)?.outputTypeScript,2934 outputTypeScript: Option.getOrUndefined(view)?.outputTypeScript,
2577 typeScriptDefinitions: Option.getOrUndefined(view)?.typeScriptDefinitions,2935 typeScriptDefinitions:
2936 Option.getOrUndefined(view)?.typeScriptDefinitions,
2578 });2937 });
2579 });2938 });
2580 2939
@@ -2583,7 +2942,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2 unmodified lines
2583 // ------------------------------------------------------------------2942 // ------------------------------------------------------------------
2584 2943
2585 const providersList = (): Effect.Effect<readonly ProviderKey[]> =>2944 const providersList = (): Effect.Effect<readonly ProviderKey[]> =>
2586 Effect.sync(() => credentialProviderOrder.map((key) => ProviderKey.make(key)));2945 Effect.sync(() =>
2946 credentialProviderOrder.map((key) => ProviderKey.make(key)),
2947 );
2587 2948
2588 const providersItems = (2949 const providersItems = (
2589 key: ProviderKey,2950 key: ProviderKey,
@@ -2609,13 +2970,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 19 unmodified lines
2609 ? String(tool.address)2970 ? String(tool.address)
2610 : `${tool.integration}.${tool.owner}.${tool.connection}.${tool.name}`;2971 : `${tool.integration}.${tool.owner}.${tool.connection}.${tool.name}`;
2611 2972
2612 const policiesList = (): Effect.Effect<readonly ToolPolicy[], StorageFailure> =>2973 const policiesList = (): Effect.Effect<
2974 readonly ToolPolicy[],
2975 StorageFailure
2976 > =>
2613 core2977 core
2614 .findMany("tool_policy", {})2978 .findMany("tool_policy", {})
2615 .pipe(2979 .pipe(
2616 Effect.map((rows) =>2980 Effect.map((rows) =>
2617 [...rows]2981 [...rows]
2618 .sort((a, b) => ownerRankForRow(a) - ownerRankForRow(b) || comparePolicyRow(a, b))2982 .sort(
2983 (a, b) =>
2984 ownerRankForRow(a) - ownerRankForRow(b) ||
2985 comparePolicyRow(a, b),
2986 )
2619 .map(rowToToolPolicy),2987 .map(rowToToolPolicy),
2620 ),2988 ),
2621 );2989 );
@@ -2648,7 +3016,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 26 unmodified lines
2648 .map((row) => row.position)3016 .map((row) => row.position)
2649 .sort()3017 .sort()
2650 .at(0);3018 .at(0);
2651 const position = input.position ?? generateKeyBetween(null, minPosition ?? null);3019 const position =
3020 input.position ?? generateKeyBetween(null, minPosition ?? null);
2652 const id = PolicyId.make(3021 const id = PolicyId.make(
2653 `pol_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`,3022 `pol_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`,
2654 );3023 );
@@ -2677,7 +3046,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 22 unmodified lines
2677 cause: undefined,3046 cause: undefined,
2678 });3047 });
2679 }3048 }
2680 const where = (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id));3049 const where = (b: AnyCb) =>
3050 b.and(byOwner(input.owner)(b), b("id", "=", input.id));
2681 const existing = yield* core.findFirst("tool_policy", { where });3051 const existing = yield* core.findFirst("tool_policy", { where });
2682 if (!existing) {3052 if (!existing) {
2683 return yield* new StorageError({3053 return yield* new StorageError({
@@ -2691,12 +3061,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 7 unmodified lines
2691 if (input.position !== undefined) set.position = input.position;3061 if (input.position !== undefined) set.position = input.position;
2692 yield* core.updateMany("tool_policy", { where, set });3062 yield* core.updateMany("tool_policy", { where, set });
2693 const updated = yield* core.findFirst("tool_policy", { where });3063 const updated = yield* core.findFirst("tool_policy", { where });
2694 return rowToToolPolicy(updated ?? ({ ...existing, ...set } as ToolPolicyRow));3064 return rowToToolPolicy(
3065 updated ?? ({ ...existing, ...set } as ToolPolicyRow),
3066 );
2695 });3067 });
2696 3068
2697 const policiesRemove = (input: RemoveToolPolicyInput): Effect.Effect<void, StorageFailure> =>3069 const policiesRemove = (
3070 input: RemoveToolPolicyInput,
3071 ): Effect.Effect<void, StorageFailure> =>
2698 core.deleteMany("tool_policy", {3072 core.deleteMany("tool_policy", {
2699 where: (b: AnyCb) => b.and(byOwner(input.owner)(b), b("id", "=", input.id)),3073 where: (b: AnyCb) =>
3074 b.and(byOwner(input.owner)(b), b("id", "=", input.id)),
2700 });3075 });
2701 3076
2702 const policiesResolve = (3077 const policiesResolve = (
@@ -2721,20 +3096,31 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 18 unmodified lines
2721 ),3096 ),
2722 });3097 });
2723 if (row) {3098 if (row) {
2724 const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined;3099 const annotations = decodeJsonColumn(row.annotations) as
3100 | ToolAnnotations
3101 | undefined;
2725 requiresApproval = annotations?.requiresApproval;3102 requiresApproval = annotations?.requiresApproval;
2726 }3103 }
2727 }3104 }
2728 return resolveEffectivePolicy(toolId, policyRows, ownerRankForRow, requiresApproval);3105 return resolveEffectivePolicy(
3106 toolId,
3107 policyRows,
3108 ownerRankForRow,
3109 requiresApproval,
3110 );
2729 });3111 });
2730 3112
2731 // ------------------------------------------------------------------3113 // ------------------------------------------------------------------
2732 // Elicitation3114 // Elicitation
2733 // ------------------------------------------------------------------3115 // ------------------------------------------------------------------
2734 3116
2735 const defaultElicitationHandler = resolveElicitationHandler(config.onElicitation);3117 const defaultElicitationHandler = resolveElicitationHandler(
3118 config.onElicitation,
3119 );
2736 3120
2737 const pickHandler = (options: InvokeOptions | undefined): ElicitationHandler =>3121 const pickHandler = (
3122 options: InvokeOptions | undefined,
3123 ): ElicitationHandler =>
2738 options?.onElicitation3124 options?.onElicitation
2739 ? resolveElicitationHandler(options.onElicitation)3125 ? resolveElicitationHandler(options.onElicitation)
2740 : defaultElicitationHandler;3126 : defaultElicitationHandler;
@@ -2796,15 +3182,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 55 unmodified lines
2796 3182
2797 const TOOL_SUGGESTION_LIMIT = 5;3183 const TOOL_SUGGESTION_LIMIT = 5;
2798 3184
2799 const toolSuggestions = (rows: readonly ToolInvocationRow[]): readonly ToolAddress[] =>3185 const toolSuggestions = (
2800 rows.map((row) => rowToTool(row).address);3186 rows: readonly ToolInvocationRow[],
3187 ): readonly ToolAddress[] => rows.map((row) => rowToTool(row).address);
2801 3188
2802 const toolRowsForConnectionWhere = (parsed: ParsedToolAddress) => (b: AnyCb) =>3189 const toolRowsForConnectionWhere =
2803 b.and(3190 (parsed: ParsedToolAddress) => (b: AnyCb) =>
2804 byOwner(parsed.owner)(b),3191 b.and(
2805 b("integration", "=", String(parsed.integration)),3192 byOwner(parsed.owner)(b),
2806 b("connection", "=", String(parsed.connection)),3193 b("integration", "=", String(parsed.integration)),
2807 );3194 b("connection", "=", String(parsed.connection)),
3195 );
2808 3196
2809 const searchToolRowsForConnection = (3197 const searchToolRowsForConnection = (
2810 parsed: ParsedToolAddress,3198 parsed: ParsedToolAddress,
@@ -2842,7 +3230,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 31 unmodified lines
2842 return Effect.gen(function* () {3230 return Effect.gen(function* () {
2843 // oxlint-disable executor/no-instanceof-error, executor/no-unknown-error-message, executor/no-manual-tag-check -- boundary: normalize arbitrary unknown plugin failures into a human-readable message for ToolInvocationError/telemetry3231 // oxlint-disable executor/no-instanceof-error, executor/no-unknown-error-message, executor/no-manual-tag-check -- boundary: normalize arbitrary unknown plugin failures into a human-readable message for ToolInvocationError/telemetry
2844 const formatInvocationCauseMessage = (cause: unknown): string => {3232 const formatInvocationCauseMessage = (cause: unknown): string => {
2845 if (cause instanceof Error && cause.message.length > 0) return cause.message;3233 if (cause instanceof Error && cause.message.length > 0)
3234 return cause.message;
2846 // Non-Error / empty-message causes: `String(plainObject)` renders3235 // Non-Error / empty-message causes: `String(plainObject)` renders
2847 // "[object Object]", which is what telemetry then shows as the only3236 // "[object Object]", which is what telemetry then shows as the only
2848 // label for the failure. Prefer the tag, else stringify structurally.3237 // label for the failure. Prefer the tag, else stringify structurally.
@@ -2886,7 +3275,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 37 unmodified lines
2886 pattern: policy.pattern ?? "*",3275 pattern: policy.pattern ?? "*",
2887 });3276 });
2888 }3277 }
2889 yield* enforceApproval(staticEntry.tool.annotations, address, args, policy, handler);3278 yield* enforceApproval(
3279 staticEntry.tool.annotations,
3280 address,
3281 args,
3282 policy,
3283 handler,
3284 );
2890 return yield* wrapInvocationError(3285 return yield* wrapInvocationError(
2891 staticEntry.tool.handler({3286 staticEntry.tool.handler({
2892 ctx: staticEntry.ctx,3287 ctx: staticEntry.ctx,
@@ -2917,7 +3312,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 24 unmodified lines
2917 if (!row) {3312 if (!row) {
2918 const searchMatches = yield* searchToolRowsForConnection(parsed);3313 const searchMatches = yield* searchToolRowsForConnection(parsed);
2919 const connectionTools =3314 const connectionTools =
2920 searchMatches.length > 0 ? searchMatches : yield* findToolRowsForConnection(parsed);3315 searchMatches.length > 0
3316 ? searchMatches
3317 : yield* findToolRowsForConnection(parsed);
2921 return yield* new ToolNotFoundError({3318 return yield* new ToolNotFoundError({
2922 address,3319 address,
2923 suggestions: toolSuggestions(connectionTools),3320 suggestions: toolSuggestions(connectionTools),
@@ -2927,7 +3324,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 3 unmodified lines
2927 // Resolve policy (owner-ranked).3324 // Resolve policy (owner-ranked).
2928 const toolForPolicy = rowToTool(row);3325 const toolForPolicy = rowToTool(row);
2929 const policyRows = yield* core.findMany("tool_policy", {});3326 const policyRows = yield* core.findMany("tool_policy", {});
2930 const annotations = decodeJsonColumn(row.annotations) as ToolAnnotations | undefined;3327 const annotations = decodeJsonColumn(row.annotations) as
3328 | ToolAnnotations
3329 | undefined;
2931 const policy = resolveEffectivePolicy(3330 const policy = resolveEffectivePolicy(
2932 normalizedPolicyId(toolForPolicy),3331 normalizedPolicyId(toolForPolicy),
2933 policyRows,3332 policyRows,
@@ -2982,7 +3381,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 48 unmodified lines
2982 .pipe(wrapInvocationError);3381 .pipe(wrapInvocationError);
2983 resolvedAnnotations = map[String(parsed.tool)] ?? annotations;3382 resolvedAnnotations = map[String(parsed.tool)] ?? annotations;
2984 }3383 }
2985 yield* enforceApproval(resolvedAnnotations, address, args, policy, handler);3384 yield* enforceApproval(
3385 resolvedAnnotations,
3386 address,
3387 args,
3388 policy,
3389 handler,
3390 );
2986 3391
2987 // Resolve every named credential input (`variable → value`); `value` is3392 // Resolve every named credential input (`variable → value`); `value` is
2988 // the primary `token` for single-input + OAuth callers.3393 // the primary `token` for single-input + OAuth callers.
@@ -2995,7 +3400,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 6 unmodified lines
2995 template: AuthTemplateSlug.make(connectionRow.template),3400 template: AuthTemplateSlug.make(connectionRow.template),
2996 value: values[PRIMARY_INPUT_VARIABLE] ?? null,3401 value: values[PRIMARY_INPUT_VARIABLE] ?? null,
2997 values,3402 values,
2998 config: integrationRow ? decodeJsonColumn(integrationRow.config) : undefined,3403 config: integrationRow
3404 ? decodeJsonColumn(integrationRow.config)
3405 : undefined,
2999 };3406 };
3000 3407
3001 return yield* wrapInvocationError(3408 return yield* wrapInvocationError(
@@ -3035,7 +3442,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 33 unmodified lines
3035 subject,3442 subject,
3036 ownedKeys: (owner: Owner) => ownedKeys(owner),3443 ownedKeys: (owner: Owner) => ownedKeys(owner),
3037 defaultWritableProvider,3444 defaultWritableProvider,
3038 mintOAuthConnection: (input: MintOAuthConnectionInput) => mintOAuthConnection(input),3445 mintOAuthConnection: (input: MintOAuthConnectionInput) =>
3446 mintOAuthConnection(input),
3039 // Resolve the integration's DECLARED oauth scopes for a (integration,3447 // Resolve the integration's DECLARED oauth scopes for a (integration,
3040 // template): load the row, run the owning plugin's auth-method projector,3448 // template): load the row, run the owning plugin's auth-method projector,
3041 // and return the matching oauth method's declared `oauth.scopes`. Drives3449 // and return the matching oauth method's declared `oauth.scopes`. Drives
@@ -3043,15 +3451,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line
3043 // client on a broad integration still requests the integration's full3451 // client on a broad integration still requests the integration's full
3044 // scope set. Empty (no row / no oauth method / no declared scopes) ⇒ the3452 // scope set. Empty (no row / no oauth method / no declared scopes) ⇒ the
3045 // union collapses to the client's scopes (current behavior).3453 // union collapses to the client's scopes (current behavior).
3046 resolveDeclaredOAuthScopes: (integration: IntegrationSlug, template: AuthTemplateSlug) =>3454 resolveDeclaredOAuthScopes: (
3455 integration: IntegrationSlug,
3456 template: AuthTemplateSlug,
3457 ) =>
3047 findIntegrationRow(integration).pipe(3458 findIntegrationRow(integration).pipe(
3048 Effect.map((row): readonly string[] => {3459 Effect.map((row): readonly string[] => {
3049 if (!row) return [];3460 if (!row) return [];
3050 const methods = describeAuthMethodsForRow(row);3461 const methods = describeAuthMethodsForRow(row);
3051 const match =3462 const match =
3052 methods.find(3463 methods.find(
3053 (m: AuthMethodDescriptor) => m.kind === "oauth" && m.template === String(template),3464 (m: AuthMethodDescriptor) =>
3054 ) ?? methods.find((m: AuthMethodDescriptor) => m.kind === "oauth");3465 m.kind === "oauth" && m.template === String(template),
3466 ) ??
3467 methods.find((m: AuthMethodDescriptor) => m.kind === "oauth");
3055 return match?.oauth?.scopes ?? [];3468 return match?.oauth?.scopes ?? [];
3056 }),3469 }),
3057 ),3470 ),
@@ -3101,7 +3514,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 43 unmodified lines
3101 httpClientLayer: config.httpClientLayer ?? FetchHttpClient.layer,3514 httpClientLayer: config.httpClientLayer ?? FetchHttpClient.layer,
3102 core: {3515 core: {
3103 integrations: {3516 integrations: {
3104 register: (input: RegisterIntegrationInput) => integrationsRegister(plugin.id, input),3517 register: (input: RegisterIntegrationInput) =>
3518 integrationsRegister(plugin.id, input),
3105 update: (slug, patch) => integrationsUpdate(slug, patch),3519 update: (slug, patch) => integrationsUpdate(slug, patch),
3106 list: () => integrationsList(),3520 list: () => integrationsList(),
3107 get: (slug) => integrationsGetRecord(slug),3521 get: (slug) => integrationsGetRecord(slug),
@@ -3156,6 +3570,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 48 unmodified lines
3156 if (plugin.extension) {3570 if (plugin.extension) {
3157 extensions[plugin.id] = extension;3571 extensions[plugin.id] = extension;
3158 }3572 }
3573 // A plugin that narrows the executor exposes `wrapExecutor` on its
3574 // extension; collect it into the generic hook the execution stack runs.
3575 const wrapExecutor = (extension as { wrapExecutor?: ExecutorWrapper })
3576 .wrapExecutor;
3577 if (typeof wrapExecutor === "function")
3578 executorWrappers.push(wrapExecutor);
3159 3579
3160 const decls = plugin.staticSources ? plugin.staticSources(extension) : [];3580 const decls = plugin.staticSources ? plugin.staticSources(extension) : [];
3161 for (const source of decls) {3581 for (const source of decls) {
@@ -3212,7 +3632,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 50 unmodified lines
3212 yield* runtime.plugin3632 yield* runtime.plugin
3213 .close()3633 .close()
3214 .pipe(3634 .pipe(
3215 Effect.mapError((cause) => pluginStorageFailure(runtime.plugin.id, "close", cause)),3635 Effect.mapError((cause) =>
3636 pluginStorageFailure(runtime.plugin.id, "close", cause),
3637 ),
3216 );3638 );
3217 }3639 }
3218 }3640 }
@@ -3269,8 +3691,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 50 unmodified lines
3269 close,3691 close,
3270 };3692 };
3271 3693
3272 const toExecutor = (value: unknown): Executor<TPlugins> => value as Executor<TPlugins>;3694 const toExecutor = (value: unknown): Executor<TPlugins> =>
3273 return toExecutor(Object.assign(base, extensions));3695 value as Executor<TPlugins>;
3696 return toExecutor(Object.assign(base, extensions, { executorWrappers }));
3274 });3697 });
3275 3698
3276// Helper alias so the inline literal used for the optimistic projection in3699// Helper alias so the inline literal used for the optimistic projection in
packages/core/sdk/src/index.ts+21−14
@@ -18,8 +18,17 @@ export { · 17 unmodified lines
18// FumaDB integration.18// FumaDB integration.
19export { fumadb } from "@executor-js/fumadb";19export { fumadb } from "@executor-js/fumadb";
20export type { FumaDB } from "@executor-js/fumadb";20export type { FumaDB } from "@executor-js/fumadb";
21export type { AbstractQuery, Condition, ConditionBuilder } from "@executor-js/fumadb/query";21export type {
22export { column, idColumn, schema as fumaSchema, table } from "@executor-js/fumadb/schema";22 AbstractQuery,
23 Condition,
24 ConditionBuilder,
25} from "@executor-js/fumadb/query";
26export {
27 column,
28 idColumn,
29 schema as fumaSchema,
30 table,
31} from "@executor-js/fumadb/schema";
23export type {32export type {
24 AnyColumn,33 AnyColumn,
25 AnySchema,34 AnySchema,
@@ -36,7 +45,11 @@ export type { · 10 unmodified lines
36 IFumaClient,45 IFumaClient,
37 StorageFailure,46 StorageFailure,
38} from "./fuma-runtime";47} from "./fuma-runtime";
39export { StorageError, UniqueViolationError, isStorageFailure } from "./fuma-runtime";48export {
49 StorageError,
50 UniqueViolationError,
51 isStorageFailure,
52} from "./fuma-runtime";
40 53
41// IDs (branded) — the v2 set.54// IDs (branded) — the v2 set.
42export {55export {
@@ -56,7 +69,10 @@ export { · 13 unmodified lines
56 Subject,69 Subject,
57 Owner,70 Owner,
58} from "./ids";71} from "./ids";
59export { connectionIdentifier, isConnectionIdentifier } from "./connection-name-identifier";72export {
73 connectionIdentifier,
74 isConnectionIdentifier,
75} from "./connection-name-identifier";
60 76
61// Errors (tagged) — the ExecuteError set + integration lifecycle.77// Errors (tagged) — the ExecuteError set + integration lifecycle.
62export {78export {
@@ -299,6 +315,7 @@ export { · 236 unmodified lines
299// uses it; the host surface (`@executor-js/api/server`) re-exports it.315// uses it; the host surface (`@executor-js/api/server`) re-exports it.
300export {316export {
301 type Executor,317 type Executor,
318 type ExecutorWrapper,
302 type ExecutorConfig,319 type ExecutorConfig,
303 type ExecutorDb,320 type ExecutorDb,
304 type ExecutorDbFactory,321 type ExecutorDbFactory,
@@ -353,13 +370,3 @@ export { · 48 unmodified lines
353 type AuthToolFailureCode,370 type AuthToolFailureCode,
354 type AuthToolFailureInput,371 type AuthToolFailureInput,
355} from "./auth-tool-failure";372} from "./auth-tool-failure";
356export {
357 applyToolkitScope,
358 EMPTY_TOOLKIT_SCOPE,
359 type ResolvedToolkitScope,
360 type ToolkitAccess,
361 type ToolkitPolicyAction,
362 type ToolkitPolicyRule,
363 type ToolkitResolver,
364 type ToolkitScopeEntry,
365} from "./toolkit-scope";
packages/core/sdk/src/tool.ts+10−4
@@ -1,4 +1,10 @@
1import type { ConnectionName, IntegrationSlug, Owner, ToolAddress, ToolName } from "./ids";1import type {
2 ConnectionName,
3 IntegrationSlug,
4 Owner,
5 ToolAddress,
6 ToolName,
7} from "./ids";
2 8
3/* Tools belong to a connection and are PERSISTED, like v1 — not resolved live on9/* Tools belong to a connection and are PERSISTED, like v1 — not resolved live on
4 * every list. A plugin produces them at create/refresh (openapi from the10 * every list. A plugin produces them at create/refresh (openapi from the
@@ -13,9 +19,9 @@ export interface ToolAnnotations { · 8 unmodified lines
13 readonly approvalDescription?: string;19 readonly approvalDescription?: string;
14 readonly mayElicit?: boolean;20 readonly mayElicit?: boolean;
15 /** True when the tool only reads (no side effects). Set by the owning plugin21 /** True when the tool only reads (no side effects). Set by the owning plugin
16 * (e.g. OpenAPI classifies GET/HEAD/OPTIONS as read-only). Toolkits use this22 * (e.g. OpenAPI classifies GET/HEAD/OPTIONS as read-only). Consumers that
17 * to enforce read-only access: in a read-only slice a tool is hidden unless23 * offer a read-only mode use this fail-closed: a tool is hidden unless
18 * `readOnly === true` (fail-closed — unclassified counts as write). */24 * `readOnly === true` (unclassified counts as write). */
19 readonly readOnly?: boolean;25 readonly readOnly?: boolean;
20}26}
21 27
packages/hosts/mcp/src/in-memory-session-store.ts+84−30
@@ -2,7 +2,10 @@ import { Cause, Data, Effect, Layer } from "effect"; · 1 unmodified line
2import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";2import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";3import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
4 4
5import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution";5import {
6 formatPausedExecution,
7 type ExecutionEngine,
8} from "@executor-js/execution";
6 9
7import {10import {
8 buildResumeApprovalUrl,11 buildResumeApprovalUrl,
@@ -50,7 +53,9 @@ import type { BrowserApprovalStore } from "./tool-server"; · 41 unmodified lines
50// ---------------------------------------------------------------------------53// ---------------------------------------------------------------------------
51 54
52/** Engine construction failed for a principal. The store surfaces it as a 500. */55/** Engine construction failed for a principal. The store surfaces it as a 500. */
53export class McpEngineBuildError extends Data.TaggedError("McpEngineBuildError")<{56export class McpEngineBuildError extends Data.TaggedError(
57 "McpEngineBuildError",
58)<{
54 readonly cause: unknown;59 readonly cause: unknown;
55}> {}60}> {}
56 61
@@ -63,13 +68,17 @@ export interface BuiltMcpServer { · 6 unmodified lines
63/** The browser-mode wiring the store hands a build call when a session opts in. */68/** The browser-mode wiring the store hands a build call when a session opts in. */
64export interface McpBuildServerOptions {69export interface McpBuildServerOptions {
65 readonly elicitationMode?:70 readonly elicitationMode?:
66 | { readonly mode: "browser"; readonly approvalUrl: (executionId: string) => string }71 | {
72 readonly mode: "browser";
73 readonly approvalUrl: (executionId: string) => string;
74 }
67 | { readonly mode: "model" }75 | { readonly mode: "model" }
68 | { readonly mode: "native" };76 | { readonly mode: "native" };
69 readonly browserApprovalStore?: BrowserApprovalStore;77 readonly browserApprovalStore?: BrowserApprovalStore;
70 /** Toolkit selector (slug or id) pinned for this session — narrows the engine78 /** Per-request narrowing selector (the MCP `?toolkit=` value) pinned for this
71 * to that toolkit's slice. Read once at session create from the request. */79 * session. Core forwards it to plugin executor wrappers; an unset selector
72 readonly toolkitId?: string;80 * leaves the executor unnarrowed. Read once at session create. */
81 readonly selector?: string;
73}82}
74 83
75/** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */84/** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */
@@ -103,9 +112,15 @@ export interface InMemoryMcpSessionStore { · 27 unmodified lines
103 readonly close: () => Promise<void>;112 readonly close: () => Promise<void>;
104}113}
105 114
106const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> =>115const ignoreClose = (
116 close: (() => Promise<void>) | undefined,
117): Promise<void> =>
107 close118 close
108 ? Effect.runPromise(Effect.ignore(Effect.tryPromise({ try: close, catch: () => undefined })))119 ? Effect.runPromise(
120 Effect.ignore(
121 Effect.tryPromise({ try: close, catch: () => undefined }),
122 ),
123 )
109 : Promise.resolve();124 : Promise.resolve();
110 125
111const formatBoundaryError = (error: unknown): unknown =>126const formatBoundaryError = (error: unknown): unknown =>
@@ -115,14 +130,21 @@ const formatBoundaryError = (error: unknown): unknown => · 3 unmodified lines
115// The store's error bodies are INNER responses (no CORS): the serving envelope130// The store's error bodies are INNER responses (no CORS): the serving envelope
116// re-wraps the store `Response` with CORS before it leaves the origin, so the131// re-wraps the store `Response` with CORS before it leaves the origin, so the
117// canonical renderer is called with `cors: false` (content-type only).132// canonical renderer is called with `cors: false` (content-type only).
118const jsonRpcError = (status: number, code: number, message: string): Response =>133const jsonRpcError = (
119 jsonRpcErrorBody(status, code, message, { cors: false });134 status: number,
135 code: number,
136 message: string,
137): Response => jsonRpcErrorBody(status, code, message, { cors: false });
120 138
121const json = (value: unknown, status = 200): Response =>139const json = (value: unknown, status = 200): Response =>
122 new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });140 new Response(JSON.stringify(value), {
141 status,
142 headers: { "content-type": "application/json" },
143 });
123 144
124const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;145const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;
125const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;146const RESUME_PATH =
147 /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;
126 148
127/**149/**
128 * Build the in-process session store plus an explicit `close()` that disposes150 * Build the in-process session store plus an explicit `close()` that disposes
@@ -139,21 +161,30 @@ export const makeInMemoryMcpSessionStore = ( · 10 unmodified lines
139 // loopback hosts (local/desktop), where the request URL is already correct.161 // loopback hosts (local/desktop), where the request URL is already correct.
140 options: { readonly webBaseUrl?: string } = {},162 options: { readonly webBaseUrl?: string } = {},
141): InMemoryMcpSessionStore => {163): InMemoryMcpSessionStore => {
142 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();164 const transports = new Map<
165 string,
166 WebStandardStreamableHTTPServerTransport
167 >();
143 const servers = new Map<string, McpServer>();168 const servers = new Map<string, McpServer>();
144 const owners = new Map<string, Principal>();169 const owners = new Map<string, Principal>();
145 const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>();170 const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>();
146 const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore();171 const approvals: InProcessBrowserApprovalStore =
172 makeInProcessBrowserApprovalStore();
147 173
148 const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => {174 const dispose = async (
175 id: string,
176 opts: { transport?: boolean; server?: boolean } = {},
177 ) => {
149 const transport = transports.get(id);178 const transport = transports.get(id);
150 const server = servers.get(id);179 const server = servers.get(id);
151 transports.delete(id);180 transports.delete(id);
152 servers.delete(id);181 servers.delete(id);
153 owners.delete(id);182 owners.delete(id);
154 engines.delete(id);183 engines.delete(id);
155 if (opts.transport) await ignoreClose(transport ? () => transport.close() : undefined);184 if (opts.transport)
156 if (opts.server) await ignoreClose(server ? () => server.close() : undefined);185 await ignoreClose(transport ? () => transport.close() : undefined);
186 if (opts.server)
187 await ignoreClose(server ? () => server.close() : undefined);
157 };188 };
158 189
159 /**190 /**
@@ -173,7 +204,10 @@ export const makeInMemoryMcpSessionStore = ( · 13 unmodified lines
173 Effect.tap(() => Effect.sync(finish)),204 Effect.tap(() => Effect.sync(finish)),
174 Effect.catchCause((cause) =>205 Effect.catchCause((cause) =>
175 Effect.sync(() => {206 Effect.sync(() => {
176 console.error("[mcp] handleRequest error:", formatBoundaryError(cause));207 console.error(
208 "[mcp] handleRequest error:",
209 formatBoundaryError(cause),
210 );
177 finish();211 finish();
178 return jsonRpcError(500, -32603, "Internal server error");212 return jsonRpcError(500, -32603, "Internal server error");
179 }),213 }),
@@ -204,7 +238,8 @@ export const makeInMemoryMcpSessionStore = ( · 24 unmodified lines
204 request: Request,238 request: Request,
205 sessionId: () => string | null,239 sessionId: () => string | null,
206 ): McpBuildServerOptions => {240 ): McpBuildServerOptions => {
207 if (readElicitationMode(request) !== "browser") return { elicitationMode: { mode: "model" } };241 if (readElicitationMode(request) !== "browser")
242 return { elicitationMode: { mode: "model" } };
208 return {243 return {
209 elicitationMode: {244 elicitationMode: {
210 mode: "browser",245 mode: "browser",
@@ -222,11 +257,17 @@ export const makeInMemoryMcpSessionStore = ( · 11 unmodified lines
222 };257 };
223 258
224 /** Open a new session: build the server, connect a transport, drive the request. */259 /** Open a new session: build the server, connect a transport, drive the request. */
225 const create = (principal: Principal, request: Request): Effect.Effect<McpDispatchResult> => {260 const create = (
261 principal: Principal,
262 request: Request,
263 ): Effect.Effect<McpDispatchResult> => {
226 let createdSessionId: string | null = null;264 let createdSessionId: string | null = null;
227 const baseOptions = buildOptionsFor(request, () => createdSessionId);265 const baseOptions = buildOptionsFor(request, () => createdSessionId);
228 const toolkitId = readToolkitSelector(request);266 const selector = readToolkitSelector(request);
229 return buildServer(principal, toolkitId ? { ...baseOptions, toolkitId } : baseOptions).pipe(267 return buildServer(
268 principal,
269 selector ? { ...baseOptions, selector } : baseOptions,
270 ).pipe(
230 Effect.flatMap(({ mcpServer, engine }) =>271 Effect.flatMap(({ mcpServer, engine }) =>
231 Effect.gen(function* () {272 Effect.gen(function* () {
232 const transport = new WebStandardStreamableHTTPServerTransport({273 const transport = new WebStandardStreamableHTTPServerTransport({
@@ -263,9 +304,13 @@ export const makeInMemoryMcpSessionStore = ( · 30 unmodified lines
263 304
264 const store: McpSessionStore["Service"] = {305 const store: McpSessionStore["Service"] = {
265 dispatch: ({ request, principal, sessionId }: McpDispatchInput) =>306 dispatch: ({ request, principal, sessionId }: McpDispatchInput) =>
266 sessionId ? forward(sessionId, principal, request) : create(principal, request),307 sessionId
308 ? forward(sessionId, principal, request)
309 : create(principal, request),
267 dispose: (sessionId) =>310 dispose: (sessionId) =>
268 Effect.promise(() => dispose(sessionId, { transport: true, server: true })),311 Effect.promise(() =>
312 dispose(sessionId, { transport: true, server: true }),
313 ),
269 };314 };
270 315
271 const ownerAccess = (316 const ownerAccess = (
@@ -299,12 +344,17 @@ export const makeInMemoryMcpSessionStore = ( · 27 unmodified lines
299 ): Promise<Response | null> => {344 ): Promise<Response | null> => {
300 const match = PAUSED_PATH.exec(new URL(request.url).pathname);345 const match = PAUSED_PATH.exec(new URL(request.url).pathname);
301 if (!match) return null;346 if (!match) return null;
302 if (request.method !== "GET") return json({ error: "Method not allowed" }, 405);347 if (request.method !== "GET")
348 return json({ error: "Method not allowed" }, 405);
303 const sessionId = decodeURIComponent(match[1]!);349 const sessionId = decodeURIComponent(match[1]!);
304 const access = ownerAccess(sessionId, principal);350 const access = ownerAccess(sessionId, principal);
305 if (access === "forbidden") return json({ error: "Forbidden" }, 403);351 if (access === "forbidden") return json({ error: "Forbidden" }, 403);
306 if (access === "not-found") return json({ error: "Paused execution not found" }, 404);352 if (access === "not-found")
307 const paused = await pausedFromSession(sessionId, decodeURIComponent(match[2]!));353 return json({ error: "Paused execution not found" }, 404);
354 const paused = await pausedFromSession(
355 sessionId,
356 decodeURIComponent(match[2]!),
357 );
308 if (!paused) return json({ error: "Paused execution not found" }, 404);358 if (!paused) return json({ error: "Paused execution not found" }, 404);
309 return json({ text: paused.text, structured: paused.structured });359 return json({ text: paused.text, structured: paused.structured });
310 };360 };
@@ -315,13 +365,15 @@ export const makeInMemoryMcpSessionStore = ( · 4 unmodified lines
315 ): Promise<Response | null> => {365 ): Promise<Response | null> => {
316 const match = RESUME_PATH.exec(new URL(request.url).pathname);366 const match = RESUME_PATH.exec(new URL(request.url).pathname);
317 if (!match) return null;367 if (!match) return null;
318 if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);368 if (request.method !== "POST")
369 return json({ error: "Method not allowed" }, 405);
319 370
320 const sessionId = decodeURIComponent(match[1]!);371 const sessionId = decodeURIComponent(match[1]!);
321 const executionId = decodeURIComponent(match[2]!);372 const executionId = decodeURIComponent(match[2]!);
322 const access = ownerAccess(sessionId, principal);373 const access = ownerAccess(sessionId, principal);
323 if (access === "forbidden") return json({ error: "Forbidden" }, 403);374 if (access === "forbidden") return json({ error: "Forbidden" }, 403);
324 if (access === "not-found") return json({ error: "Paused execution not found" }, 404);375 if (access === "not-found")
376 return json({ error: "Paused execution not found" }, 404);
325 // The session must still hold the paused execution — guards stale ids and377 // The session must still hold the paused execution — guards stale ids and
326 // confirms the execution belongs to this session before recording.378 // confirms the execution belongs to this session before recording.
327 const paused = await pausedFromSession(sessionId, executionId);379 const paused = await pausedFromSession(sessionId, executionId);
@@ -349,7 +401,9 @@ export const makeInMemoryMcpSessionStore = ( · 21 unmodified lines
349 handleApprovalRequest,401 handleApprovalRequest,
350 close: async () => {402 close: async () => {
351 const ids = new Set([...transports.keys(), ...servers.keys()]);403 const ids = new Set([...transports.keys(), ...servers.keys()]);
352 await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true })));404 await Promise.all(
405 [...ids].map((id) => dispose(id, { transport: true, server: true })),
406 );
353 },407 },
354 };408 };
355};409};
packages/plugins/toolkits/src/server.ts+110−58
@@ -13,12 +13,17 @@ · 12 unmodified lines
13// ---------------------------------------------------------------------------13// ---------------------------------------------------------------------------
14 14
15import { Schema } from "effect";15import { Schema } from "effect";
16import { Context, definePlugin, Effect, HttpApiBuilder } from "@executor-js/sdk/core";16import {
17 Context,
18 definePlugin,
19 Effect,
20 HttpApiBuilder,
21} from "@executor-js/sdk/core";
17import {22import {
18 definePluginStorageCollection,23 definePluginStorageCollection,
19 Owner,24 Owner,
25 type ExecutorWrapper,
20 type PluginCtx,26 type PluginCtx,
21 type ResolvedToolkitScope,
22 type StorageFailure,27 type StorageFailure,
23} from "@executor-js/sdk";28} from "@executor-js/sdk";
24import { addGroup, capture } from "@executor-js/api";29import { addGroup, capture } from "@executor-js/api";
@@ -34,6 +39,11 @@ import { · 9 unmodified lines
34 type ToolkitView,39 type ToolkitView,
35 type UpdateToolkitPayload,40 type UpdateToolkitPayload,
36} from "./shared";41} from "./shared";
42import {
43 applyToolkitScope,
44 EMPTY_TOOLKIT_SCOPE,
45 type ResolvedToolkitScope,
46} from "./toolkit-scope";
37 47
38const ORG = Owner.make("org");48const ORG = Owner.make("org");
39const USER = Owner.make("user");49const USER = Owner.make("user");
@@ -64,14 +74,23 @@ const PolicyRow = Schema.Struct({ · 24 unmodified lines
64 action: Schema.String,74 action: Schema.String,
65});75});
66 76
67const TOOLKITS = definePluginStorageCollection("toolkits", ToolkitRow, { indexes: ["slug"] });77const TOOLKITS = definePluginStorageCollection("toolkits", ToolkitRow, {
68const CONNECTIONS = definePluginStorageCollection("connections", ConnectionRow, {78 indexes: ["slug"],
79});
80const CONNECTIONS = definePluginStorageCollection(
81 "connections",
82 ConnectionRow,
83 {
84 indexes: ["toolkitId"],
85 },
86);
87const POLICIES = definePluginStorageCollection("policies", PolicyRow, {
69 indexes: ["toolkitId"],88 indexes: ["toolkitId"],
70});89});
71const POLICIES = definePluginStorageCollection("policies", PolicyRow, { indexes: ["toolkitId"] });
72 90
73const newId = Effect.sync(() => crypto.randomUUID());91const newId = Effect.sync(() => crypto.randomUUID());
74const scopeOfOwner = (owner: Owner): ToolkitScope => (owner === ORG ? "workspace" : "personal");92const scopeOfOwner = (owner: Owner): ToolkitScope =>
93 owner === ORG ? "workspace" : "personal";
75 94
76// ---------------------------------------------------------------------------95// ---------------------------------------------------------------------------
77// Extension — the canonical implementation. Handlers (and later the MCP96// Extension — the canonical implementation. Handlers (and later the MCP
@@ -116,9 +135,14 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { · 38 unmodified lines
116 // candidate connections honoring scope: a workspace toolkit may use only135 // candidate connections honoring scope: a workspace toolkit may use only
117 // org connections; a personal toolkit may use org + the caller's own.136 // org connections; a personal toolkit may use org + the caller's own.
118 const candidateConnections = (scope: ToolkitScope) =>137 const candidateConnections = (scope: ToolkitScope) =>
119 scope === "workspace" ? ctx.connections.list({ owner: ORG }) : ctx.connections.list();138 scope === "workspace"
139 ? ctx.connections.list({ owner: ORG })
140 : ctx.connections.list();
120 141
121 const validate = (scope: ToolkitScope, entries: ReadonlyArray<ToolkitConnectionEntryInput>) =>142 const validate = (
143 scope: ToolkitScope,
144 entries: ReadonlyArray<ToolkitConnectionEntryInput>,
145 ) =>
122 Effect.gen(function* () {146 Effect.gen(function* () {
123 const allowed = yield* candidateConnections(scope);147 const allowed = yield* candidateConnections(scope);
124 const pairs = new Set(allowed.map((c) => `${c.integration}/${c.name}`));148 const pairs = new Set(allowed.map((c) => `${c.integration}/${c.name}`));
@@ -207,7 +231,9 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { · 82 unmodified lines
207 });231 });
208 232
209 const list = () =>233 const list = () =>
210 toolkits.list().pipe(Effect.flatMap((rows) => Effect.forEach(rows, viewFromRow)));234 toolkits
235 .list()
236 .pipe(Effect.flatMap((rows) => Effect.forEach(rows, viewFromRow)));
211 237
212 const update = (id: string, patch: UpdateToolkitPayload) =>238 const update = (id: string, patch: UpdateToolkitPayload) =>
213 Effect.gen(function* () {239 Effect.gen(function* () {
@@ -216,8 +242,10 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { · 2 unmodified lines
216 const data = {242 const data = {
217 slug: row.data.slug,243 slug: row.data.slug,
218 name: patch.name ?? row.data.name,244 name: patch.name ?? row.data.name,
219 inheritOrgPolicies: patch.inheritOrgPolicies ?? row.data.inheritOrgPolicies,245 inheritOrgPolicies:
220 briefing: patch.briefing === undefined ? row.data.briefing : patch.briefing,246 patch.inheritOrgPolicies ?? row.data.inheritOrgPolicies,
247 briefing:
248 patch.briefing === undefined ? row.data.briefing : patch.briefing,
221 };249 };
222 yield* toolkits.put({ key: id, owner: row.owner, data });250 yield* toolkits.put({ key: id, owner: row.owner, data });
223 if (patch.connections !== undefined) {251 if (patch.connections !== undefined) {
@@ -230,7 +258,9 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { · 6 unmodified lines
230 }258 }
231 if (patch.policies !== undefined) {259 if (patch.policies !== undefined) {
232 const existing = yield* policies.query({ where: { toolkitId: id } });260 const existing = yield* policies.query({ where: { toolkitId: id } });
233 yield* Effect.forEach(existing, (p) => policies.remove({ key: p.key, owner: row.owner }));261 yield* Effect.forEach(existing, (p) =>
262 policies.remove({ key: p.key, owner: row.owner }),
263 );
234 yield* putPolicies(id, row.owner, patch.policies);264 yield* putPolicies(id, row.owner, patch.policies);
235 }265 }
236 return yield* viewFromRow({ key: id, owner: row.owner, data });266 return yield* viewFromRow({ key: id, owner: row.owner, data });
@@ -241,9 +271,13 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { · 4 unmodified lines
241 const row = yield* toolkits.get({ key: id });271 const row = yield* toolkits.get({ key: id });
242 if (row == null) return yield* new ToolkitNotFound({ id });272 if (row == null) return yield* new ToolkitNotFound({ id });
243 const conns = yield* connections.query({ where: { toolkitId: id } });273 const conns = yield* connections.query({ where: { toolkitId: id } });
244 yield* Effect.forEach(conns, (c) => connections.remove({ key: c.key, owner: row.owner }));274 yield* Effect.forEach(conns, (c) =>
275 connections.remove({ key: c.key, owner: row.owner }),
276 );
245 const pols = yield* policies.query({ where: { toolkitId: id } });277 const pols = yield* policies.query({ where: { toolkitId: id } });
246 yield* Effect.forEach(pols, (p) => policies.remove({ key: p.key, owner: row.owner }));278 yield* Effect.forEach(pols, (p) =>
279 policies.remove({ key: p.key, owner: row.owner }),
280 );
247 yield* toolkits.remove({ key: id, owner: row.owner });281 yield* toolkits.remove({ key: id, owner: row.owner });
248 return { removed: true };282 return { removed: true };
249 });283 });
@@ -256,7 +290,10 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { · 6 unmodified lines
256 selector: string,290 selector: string,
257 ): Effect.Effect<ResolvedToolkitScope | null, StorageFailure> =>291 ): Effect.Effect<ResolvedToolkitScope | null, StorageFailure> =>
258 Effect.gen(function* () {292 Effect.gen(function* () {
259 const bySlug = yield* toolkits.query({ where: { slug: selector }, limit: 1 });293 const bySlug = yield* toolkits.query({
294 where: { slug: selector },
295 limit: 1,
296 });
260 const row = bySlug[0] ?? (yield* toolkits.get({ key: selector }));297 const row = bySlug[0] ?? (yield* toolkits.get({ key: selector }));
261 if (row == null) return null;298 if (row == null) return null;
262 const conns = yield* connections.query({ where: { toolkitId: row.key } });299 const conns = yield* connections.query({ where: { toolkitId: row.key } });
@@ -275,12 +312,24 @@ const makeToolkitsExtension = (ctx: PluginCtx) => { · 12 unmodified lines
275 };312 };
276 });313 });
277 314
278 return { create, get, list, update, remove, resolveScope };315 // The plugin's contribution to core's generic executor-wrapper seam: given a
316 // selector, resolve it to a scope (fail-closed to the empty slice when it
317 // doesn't resolve for this caller) and narrow the executor. Core collects and
318 // runs this without knowing it's about toolkits.
319 const wrapExecutor: ExecutorWrapper = (base, selector) =>
320 resolveScope(selector).pipe(
321 Effect.flatMap((scope) =>
322 applyToolkitScope(base, scope ?? EMPTY_TOOLKIT_SCOPE),
323 ),
324 );
325
326 return { create, get, list, update, remove, resolveScope, wrapExecutor };
279};327};
280 328
281// Local aliases so the storage/extension layer stays decoupled from the wire329// Local aliases so the storage/extension layer stays decoupled from the wire
282// schema's branded types without re-importing them everywhere.330// schema's branded types without re-importing them everywhere.
283type ToolkitConnectionIntegration = ToolkitView["connections"][number]["integration"];331type ToolkitConnectionIntegration =
332 ToolkitView["connections"][number]["integration"];
284type ToolkitConnectionEntryInput = {333type ToolkitConnectionEntryInput = {
285 readonly integration: ToolkitConnectionIntegration;334 readonly integration: ToolkitConnectionIntegration;
286 readonly connection: string;335 readonly connection: string;
@@ -300,48 +349,51 @@ export class ToolkitsExtensionService extends Context.Service< · 13 unmodified lines
300// the host's composed API requires) — not a standalone single-group bundle.349// the host's composed API requires) — not a standalone single-group bundle.
301const ExecutorApiWithToolkits = addGroup(ToolkitsApi);350const ExecutorApiWithToolkits = addGroup(ToolkitsApi);
302 351
303const ToolkitsHandlers = HttpApiBuilder.group(ExecutorApiWithToolkits, "toolkits", (h) =>352const ToolkitsHandlers = HttpApiBuilder.group(
304 h353 ExecutorApiWithToolkits,
305 .handle("list", () =>354 "toolkits",
306 capture(355 (h) =>
307 Effect.gen(function* () {356 h
308 const ext = yield* ToolkitsExtensionService;357 .handle("list", () =>
309 return yield* ext.list();358 capture(
310 }),359 Effect.gen(function* () {
311 ),360 const ext = yield* ToolkitsExtensionService;
312 )361 return yield* ext.list();
313 .handle("create", ({ payload }) =>362 }),
314 capture(363 ),
315 Effect.gen(function* () {364 )
316 const ext = yield* ToolkitsExtensionService;365 .handle("create", ({ payload }) =>
317 return yield* ext.create(payload);366 capture(
318 }),367 Effect.gen(function* () {
319 ),368 const ext = yield* ToolkitsExtensionService;
320 )369 return yield* ext.create(payload);
321 .handle("get", ({ params }) =>370 }),
322 capture(371 ),
323 Effect.gen(function* () {372 )
324 const ext = yield* ToolkitsExtensionService;373 .handle("get", ({ params }) =>
325 return yield* ext.get(params.id);374 capture(
326 }),375 Effect.gen(function* () {
327 ),376 const ext = yield* ToolkitsExtensionService;
328 )377 return yield* ext.get(params.id);
329 .handle("update", ({ params, payload }) =>378 }),
330 capture(379 ),
331 Effect.gen(function* () {380 )
332 const ext = yield* ToolkitsExtensionService;381 .handle("update", ({ params, payload }) =>
333 return yield* ext.update(params.id, payload);382 capture(
334 }),383 Effect.gen(function* () {
335 ),384 const ext = yield* ToolkitsExtensionService;
336 )385 return yield* ext.update(params.id, payload);
337 .handle("remove", ({ params }) =>386 }),
338 capture(387 ),
339 Effect.gen(function* () {388 )
340 const ext = yield* ToolkitsExtensionService;389 .handle("remove", ({ params }) =>
341 return yield* ext.remove(params.id);390 capture(
342 }),391 Effect.gen(function* () {
392 const ext = yield* ToolkitsExtensionService;
393 return yield* ext.remove(params.id);
394 }),
395 ),
343 ),396 ),
344 ),
345);397);
346 398
347export const toolkitsPlugin = definePlugin(() => ({399export const toolkitsPlugin = definePlugin(() => ({
packages/plugins/toolkits/src/toolkit-scope.tsrenamed from packages/core/sdk/src/toolkit-scope.ts+40−28
@@ -1,8 +1,9 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Toolkit scope — wraps an Executor so the MCP surface only sees, and can only2// Toolkit scope — wraps an Executor so the MCP surface only sees, and can only
3// run, a toolkit's slice of connections. Toolkit-agnostic: it operates on a3// run, a toolkit's slice of connections. This is the toolkits plugin's
4// plain ResolvedToolkitScope (produced by the toolkits plugin's `resolveScope`)4// implementation of core's generic `ExecutorWrapper` seam: the plugin
5// and the base Executor. Because the wrapped Executor is handed to the engine5// contributes `wrapExecutor` (server.ts) and core runs it without ever knowing
6// what "toolkit" means. Because the wrapped Executor is handed to the engine
6// before construction, EVERY surface (tools.list, search, describe,7// before construction, EVERY surface (tools.list, search, describe,
7// sources/connections/integrations.list, core-tools, and execute) flows through8// sources/connections/integrations.list, core-tools, and execute) flows through
8// it — enforcement is at execute, not just listing, so guessed out-of-slice9// it — enforcement is at execute, not just listing, so guessed out-of-slice
@@ -11,15 +12,15 @@ · 2 unmodified lines
11 12
12import { Effect } from "effect";13import { Effect } from "effect";
13 14
14import { ToolBlockedError } from "./errors";15import {
15import type { Executor } from "./executor";16 ToolBlockedError,
16import type { ToolAddress } from "./ids";17 type AnyPlugin,
17import type { AnyPlugin } from "./plugin";18 type Executor,
18import type { StorageFailure } from "./fuma-runtime";19 type StorageFailure,
20 type ToolAddress,
21} from "@executor-js/sdk";
19 22
20export type ToolkitAccess = "off" | "read" | "full";23import type { ToolkitAccess, ToolkitPolicyAction } from "./shared";
21
22export type ToolkitPolicyAction = "approve" | "require_approval" | "block";
23 24
24export interface ToolkitPolicyRule {25export interface ToolkitPolicyRule {
25 /** Glob over `<integration>.<connection>.<tool>` — `*` matches one segment,26 /** Glob over `<integration>.<connection>.<tool>` — `*` matches one segment,
@@ -62,28 +63,23 @@ const matchPattern = (pattern: string, target: string): boolean => { · 36 unmodified lines
62 if (p[i] === "*" && i === p.length - 1) return true;63 if (p[i] === "*" && i === p.length - 1) return true;
63 if (a[i] === undefined) return false;64 if (a[i] === undefined) return false;
64 const re = new RegExp(65 const re = new RegExp(
65 "^" + p[i].replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",66 "^" +
67 p[i].replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") +
68 "$",
66 );69 );
67 if (!re.test(a[i])) return false;70 if (!re.test(a[i])) return false;
68 }71 }
69 return p.length === a.length;72 return p.length === a.length;
70};73};
71 74
72/** A plugin extension capable of resolving a selector (slug or id) to a scope,
73 * scoped to the caller. `resolveScope` returns null when nothing matches. */
74export interface ToolkitResolver {
75 readonly resolveScope: (
76 selector: string,
77 ) => Effect.Effect<ResolvedToolkitScope | null, StorageFailure>;
78}
79
80const accessFor = (75const accessFor = (
81 scope: ResolvedToolkitScope,76 scope: ResolvedToolkitScope,
82 integration: string,77 integration: string,
83 connection: string,78 connection: string,
84): ToolkitAccess => {79): ToolkitAccess => {
85 for (const e of scope.entries) {80 for (const e of scope.entries) {
86 if (e.integration === integration && e.connection === connection) return e.access;81 if (e.integration === integration && e.connection === connection)
82 return e.access;
87 }83 }
88 for (const e of scope.entries) {84 for (const e of scope.entries) {
89 if (e.integration === integration && e.connection === "*") return e.access;85 if (e.integration === integration && e.connection === "*") return e.access;
@@ -101,7 +97,9 @@ export const applyToolkitScope = <TPlugins extends readonly AnyPlugin[]>( · 11 unmodified lines
101 Effect.gen(function* () {97 Effect.gen(function* () {
102 const blockRules = scope.policies.filter((p) => p.action === "block");98 const blockRules = scope.policies.filter((p) => p.action === "block");
103 const isBlocked = (integration: string, connection: string, name: string) =>99 const isBlocked = (integration: string, connection: string, name: string) =>
104 blockRules.some((p) => matchPattern(p.pattern, `${integration}.${connection}.${name}`));100 blockRules.some((p) =>
101 matchPattern(p.pattern, `${integration}.${connection}.${name}`),
102 );
105 103
106 const all = yield* base.tools.list();104 const all = yield* base.tools.list();
107 const allowed = new Set<string>();105 const allowed = new Set<string>();
@@ -112,7 +110,10 @@ export const applyToolkitScope = <TPlugins extends readonly AnyPlugin[]>( · 4 unmodified lines
112 allowed.add(String(t.address));110 allowed.add(String(t.address));
113 continue;111 continue;
114 }112 }
115 if (isBlocked(String(t.integration), String(t.connection), String(t.name))) continue;113 if (
114 isBlocked(String(t.integration), String(t.connection), String(t.name))
115 )
116 continue;
116 const a = accessFor(scope, String(t.integration), String(t.connection));117 const a = accessFor(scope, String(t.integration), String(t.connection));
117 if (a === "full" || (a === "read" && t.annotations?.readOnly === true)) {118 if (a === "full" || (a === "read" && t.annotations?.readOnly === true)) {
118 allowed.add(String(t.address));119 allowed.add(String(t.address));
@@ -132,7 +133,11 @@ export const applyToolkitScope = <TPlugins extends readonly AnyPlugin[]>( · 13 unmodified lines
132 list: () =>133 list: () =>
133 base.integrations134 base.integrations
134 .list()135 .list()
135 .pipe(Effect.map((xs) => xs.filter((i) => allowedIntegrations.has(String(i.slug))))),136 .pipe(
137 Effect.map((xs) =>
138 xs.filter((i) => allowedIntegrations.has(String(i.slug))),
139 ),
140 ),
136 get: (slug) =>141 get: (slug) =>
137 allowedIntegrations.has(String(slug))142 allowedIntegrations.has(String(slug))
138 ? base.integrations.get(slug)143 ? base.integrations.get(slug)
@@ -144,20 +149,27 @@ export const applyToolkitScope = <TPlugins extends readonly AnyPlugin[]>( · 5 unmodified lines
144 base.connections149 base.connections
145 .list(filter)150 .list(filter)
146 .pipe(151 .pipe(
147 Effect.map((xs) => xs.filter((c) => connOk(String(c.integration), String(c.name)))),152 Effect.map((xs) =>
153 xs.filter((c) => connOk(String(c.integration), String(c.name))),
154 ),
148 ),155 ),
149 get: (ref) =>156 get: (ref) =>
150 base.connections157 base.connections
151 .get(ref)158 .get(ref)
152 .pipe(159 .pipe(
153 Effect.map((c) => (c && connOk(String(c.integration), String(c.name)) ? c : null)),160 Effect.map((c) =>
161 c && connOk(String(c.integration), String(c.name)) ? c : null,
162 ),
154 ),163 ),
155 },164 },
156 tools: {165 tools: {
157 ...base.tools,166 ...base.tools,
158 list: (filter) =>167 list: (filter) =>
159 base.tools.list(filter).pipe(Effect.map((xs) => xs.filter((t) => addrOk(t.address)))),168 base.tools
160 schema: (address) => (addrOk(address) ? base.tools.schema(address) : Effect.succeed(null)),169 .list(filter)
170 .pipe(Effect.map((xs) => xs.filter((t) => addrOk(t.address)))),
171 schema: (address) =>
172 addrOk(address) ? base.tools.schema(address) : Effect.succeed(null),
161 },173 },
162 execute: (address, args, options) =>174 execute: (address, args, options) =>
163 addrOk(address)175 addrOk(address)