diff --git a/packages/core/sdk/src/executor-credential-bindings.ts b/packages/core/sdk/src/executor-credential-bindings.ts new file mode 100644 index 000000000..2ca7f3401 --- /dev/null +++ b/packages/core/sdk/src/executor-credential-bindings.ts @@ -0,0 +1,542 @@ +import { Effect } from "effect"; + +import { + credentialBindingId, + credentialBindingRowToRef, + ResolvedCredentialSlot, + type CredentialBindingRef, + type CredentialBindingsFacade, + type CredentialBindingSlotInput, + type CredentialBindingSourceInput, + type RemoveCredentialBindingInput, + type ReplaceCredentialBindingsInput, + type SetCredentialBindingInput, +} from "./credential-bindings"; +import type { ConnectionRow, CredentialBindingRow, SecretRow, SourceRow } from "./core-schema"; +import { makeCoreDb, scopedWhere } from "./executor-helpers"; +import { StorageError, type StorageFailure } from "./fuma-runtime"; +import { ScopeId } from "./ids"; +import type { SecretProvider } from "./secrets"; +import { Usage } from "./usages"; + +export const makeCredentialBindings = (deps: { + readonly core: ReturnType; + readonly scopeIds: readonly string[]; + readonly scopePrecedence: ReadonlyMap; + readonly scopeRank: (row: { readonly scope_id: unknown }) => number; + readonly findInnermost: ( + rows: readonly T[], + ) => T | null; + readonly assertScopeInStack: ( + label: string, + scopeId: string, + ) => Effect.Effect; + readonly findSourceRowAtScope: (input: { + readonly pluginId: string; + readonly sourceId: string; + readonly sourceScope: string; + }) => Effect.Effect; + readonly findSecretRowAtScope: (input: { + readonly secretId: string; + readonly scopeId: string; + }) => Effect.Effect; + readonly findConnectionRowAtScope: (input: { + readonly connectionId: string; + readonly scopeId: string; + }) => Effect.Effect; + readonly secretProviders: ReadonlyMap; + readonly secretRouteHasBackingValue: (row: SecretRow) => Effect.Effect; +}): CredentialBindingsFacade => { + const { + core, + scopeIds, + scopePrecedence, + scopeRank, + findInnermost, + assertScopeInStack, + findSourceRowAtScope, + findSecretRowAtScope, + findConnectionRowAtScope, + secretProviders, + secretRouteHasBackingValue, + } = deps; + + const credentialBindingRowsForSource = ( + input: CredentialBindingSourceInput, + ): Effect.Effect => + scopeIds.includes(input.sourceScope) + ? (core + .findMany("credential_binding", { + where: scopedWhere(scopeIds, (b) => + b.and( + b("plugin_id", "=", input.pluginId), + b("source_id", "=", input.sourceId), + b("source_scope_id", "=", input.sourceScope), + ), + ), + }) + .pipe( + Effect.map((rows) => { + const sourceSourceRank = scopePrecedence.get(input.sourceScope) ?? Infinity; + return (rows as readonly CredentialBindingRow[]).filter( + (row) => scopeRank(row) <= sourceSourceRank, + ); + }), + ) as Effect.Effect) + : Effect.succeed([]); + + const credentialBindingRowsForSlot = ( + input: CredentialBindingSlotInput, + ): Effect.Effect => + scopeIds.includes(input.sourceScope) + ? (core + .findMany("credential_binding", { + where: scopedWhere(scopeIds, (b) => + b.and( + b("plugin_id", "=", input.pluginId), + b("source_id", "=", input.sourceId), + b("source_scope_id", "=", input.sourceScope), + b("slot_key", "=", input.slotKey), + ), + ), + }) + .pipe( + Effect.map((rows) => { + const sourceSourceRank = scopePrecedence.get(input.sourceScope) ?? Infinity; + return (rows as readonly CredentialBindingRow[]).filter( + (row) => scopeRank(row) <= sourceSourceRank, + ); + }), + ) as Effect.Effect) + : Effect.succeed([]); + + const assertCredentialBindingTargetNotOuter = (input: { + readonly label: string; + readonly targetScope: string; + readonly sourceScope: string; + readonly sourceId: string; + }): Effect.Effect => + Effect.gen(function* () { + const sourceSourceRank = scopePrecedence.get(input.sourceScope) ?? Infinity; + const targetRank = scopePrecedence.get(input.targetScope) ?? Infinity; + if (targetRank > sourceSourceRank) { + return yield* new StorageError({ + message: + `${input.label} for source "${input.sourceId}" cannot target outer scope ` + + `"${input.targetScope}" because the source lives at scope "${input.sourceScope}".`, + cause: undefined, + }); + } + }); + + const credentialBindingListForSource = (input: CredentialBindingSourceInput) => + Effect.gen(function* () { + const rows = yield* credentialBindingRowsForSource(input); + return rows + .slice() + .sort((a, b) => { + const slot = a.slot_key.localeCompare(b.slot_key); + return slot === 0 ? scopeRank(a) - scopeRank(b) : slot; + }) + .map(credentialBindingRowToRef); + }); + + const credentialBindingSet = (input: SetCredentialBindingInput) => + Effect.gen(function* () { + yield* assertScopeInStack("credential binding targetScope", input.targetScope); + yield* assertScopeInStack("credential binding sourceScope", input.sourceScope); + yield* assertCredentialBindingTargetNotOuter({ + label: "credential binding", + targetScope: input.targetScope, + sourceScope: input.sourceScope, + sourceId: input.sourceId, + }); + + const source = yield* findSourceRowAtScope({ + pluginId: input.pluginId, + sourceId: input.sourceId, + sourceScope: input.sourceScope, + }); + if (!source) { + return yield* new StorageError({ + message: + `Cannot set credential binding for source "${input.sourceId}" ` + + `at scope "${input.sourceScope}": source is not visible.`, + cause: undefined, + }); + } + + if (input.value.kind === "secret") { + const secretId = input.value.secretId; + const secretScope = input.value.secretScopeId ?? input.targetScope; + yield* assertScopeInStack("credential binding secretScope", secretScope); + if (scopePrecedence.get(secretScope)! < scopePrecedence.get(input.targetScope)!) { + return yield* new StorageError({ + message: + `Cannot bind secret "${secretId}" from scope "${secretScope}" ` + + `to target scope "${input.targetScope}": shared bindings cannot reference inner-scope secrets.`, + cause: undefined, + }); + } + const secret = yield* findSecretRowAtScope({ + secretId, + scopeId: secretScope, + }); + if (!secret) { + // No core routing row at this scope yet. Read-only providers + // (1password, env, …) own items that never get a row via + // `secrets.set()`, so a config-sync referencing one of those + // ids by value otherwise fails here. Walk providers that can + // enumerate, and if any owns the id, materialize a routing row + // pointing at that provider so resolution finds it. + let materialized = false; + for (const [key, provider] of secretProviders) { + let name: string | undefined; + if (provider.list) { + const entries = yield* provider + .list() + .pipe(Effect.catch(() => Effect.succeed([] as const))); + const found = entries.find((e) => e.id === secretId); + if (found) name = found.name; + } + if (name === undefined) { + // Provider didn't enumerate the id (slow list(), failed list, + // or no list() at all). Probe with get() — cheap for most + // backends — and use the id as the display name. + const value = yield* provider + .get(secretId, secretScope) + .pipe(Effect.catch(() => Effect.succeed(null as string | null))); + if (value !== null) name = secretId; + } + if (name === undefined) continue; + const now = new Date(); + yield* core.create("secret", { + id: secretId, + scope_id: secretScope, + name, + provider: key, + owned_by_connection_id: null, + created_at: now, + }); + materialized = true; + break; + } + if (!materialized) { + const providerKeys = [...secretProviders.keys()]; + return yield* new StorageError({ + message: + `Cannot bind secret "${secretId}" at scope "${secretScope}": ` + + `no registered secret provider has an item with this id ` + + `(checked: ${providerKeys.join(", ") || "none"}). ` + + `If this id points to a 1Password item, the item may have been deleted, ` + + `renamed, or live in a different vault than the one configured for this scope.`, + cause: undefined, + }); + } + } + } + + if (input.value.kind === "connection") { + const connection = yield* findConnectionRowAtScope({ + connectionId: input.value.connectionId, + scopeId: input.targetScope, + }); + if (!connection) { + return yield* new StorageError({ + message: + `Cannot bind connection "${input.value.connectionId}" at scope "${input.targetScope}": ` + + `the connection must be owned by the same scope as the binding.`, + cause: undefined, + }); + } + } + + const id = credentialBindingId(input); + const now = new Date(); + yield* core.deleteMany("credential_binding", { + where: (b) => + b.and( + b("scope_id", "=", input.targetScope), + b("plugin_id", "=", input.pluginId), + b("source_id", "=", input.sourceId), + b("source_scope_id", "=", input.sourceScope), + b("slot_key", "=", input.slotKey), + ), + }); + yield* core.create("credential_binding", { + id, + scope_id: input.targetScope, + plugin_id: input.pluginId, + source_id: input.sourceId, + source_scope_id: input.sourceScope, + slot_key: input.slotKey, + kind: input.value.kind, + text_value: input.value.kind === "text" ? input.value.text : null, + secret_id: input.value.kind === "secret" ? input.value.secretId : null, + secret_scope_id: + input.value.kind === "secret" ? (input.value.secretScopeId ?? input.targetScope) : null, + connection_id: input.value.kind === "connection" ? input.value.connectionId : null, + created_at: now, + updated_at: now, + }); + return credentialBindingRowToRef({ + id, + scope_id: input.targetScope, + plugin_id: input.pluginId, + source_id: input.sourceId, + source_scope_id: input.sourceScope, + slot_key: input.slotKey, + kind: input.value.kind, + text_value: input.value.kind === "text" ? input.value.text : undefined, + secret_id: input.value.kind === "secret" ? input.value.secretId : undefined, + secret_scope_id: + input.value.kind === "secret" + ? (input.value.secretScopeId ?? input.targetScope) + : undefined, + connection_id: input.value.kind === "connection" ? input.value.connectionId : undefined, + created_at: now, + updated_at: now, + } as CredentialBindingRow); + }); + + const credentialBindingRemove = (input: RemoveCredentialBindingInput) => + Effect.gen(function* () { + yield* assertScopeInStack("credential binding targetScope", input.targetScope); + yield* assertScopeInStack("credential binding sourceScope", input.sourceScope); + yield* assertCredentialBindingTargetNotOuter({ + label: "credential binding removal", + targetScope: input.targetScope, + sourceScope: input.sourceScope, + sourceId: input.sourceId, + }); + + const source = yield* findSourceRowAtScope({ + pluginId: input.pluginId, + sourceId: input.sourceId, + sourceScope: input.sourceScope, + }); + if (!source) { + return yield* new StorageError({ + message: + `Cannot remove credential binding for source "${input.sourceId}" ` + + `at scope "${input.sourceScope}": source is not visible.`, + cause: undefined, + }); + } + + yield* core.deleteMany("credential_binding", { + where: (b) => + b.and( + b("scope_id", "=", input.targetScope), + b("plugin_id", "=", input.pluginId), + b("source_id", "=", input.sourceId), + b("source_scope_id", "=", input.sourceScope), + b("slot_key", "=", input.slotKey), + ), + }); + }); + + const credentialBindingReplaceForSource = (input: ReplaceCredentialBindingsInput) => + Effect.gen(function* () { + yield* assertScopeInStack("credential binding targetScope", input.targetScope); + yield* assertScopeInStack("credential binding sourceScope", input.sourceScope); + yield* assertCredentialBindingTargetNotOuter({ + label: "credential binding replacement", + targetScope: input.targetScope, + sourceScope: input.sourceScope, + sourceId: input.sourceId, + }); + + const source = yield* findSourceRowAtScope({ + pluginId: input.pluginId, + sourceId: input.sourceId, + sourceScope: input.sourceScope, + }); + if (!source) { + return yield* new StorageError({ + message: + `Cannot replace credential bindings for source "${input.sourceId}" ` + + `at scope "${input.sourceScope}": source is not visible.`, + cause: undefined, + }); + } + + const nextSlots = new Set(input.bindings.map((binding) => binding.slotKey)); + const existing = yield* core.findMany("credential_binding", { + where: (b) => + b.and( + b("scope_id", "=", input.targetScope), + b("plugin_id", "=", input.pluginId), + b("source_id", "=", input.sourceId), + b("source_scope_id", "=", input.sourceScope), + ), + }); + for (const row of existing as readonly CredentialBindingRow[]) { + const shouldOwnSlot = input.slotPrefixes.some((prefix) => row.slot_key.startsWith(prefix)); + if (shouldOwnSlot && !nextSlots.has(row.slot_key)) { + yield* credentialBindingRemove({ + targetScope: input.targetScope, + pluginId: input.pluginId, + sourceId: input.sourceId, + sourceScope: input.sourceScope, + slotKey: row.slot_key, + }); + } + } + + const refs: CredentialBindingRef[] = []; + for (const binding of input.bindings) { + refs.push( + yield* credentialBindingSet({ + targetScope: input.targetScope, + pluginId: input.pluginId, + sourceId: input.sourceId, + sourceScope: input.sourceScope, + slotKey: binding.slotKey, + value: binding.value, + }), + ); + } + return refs; + }); + + const credentialBindingRemoveForSource = (input: CredentialBindingSourceInput) => + Effect.gen(function* () { + yield* assertScopeInStack("credential binding sourceScope", input.sourceScope); + const source = yield* findSourceRowAtScope(input); + if (!source) return; + + // Source-owner cleanup is intentionally broader than a normal scoped + // binding delete. Removing a shared source must detach all credential + // rows for that source identity, including user-owned bindings that + // are not in the source owner's current stack. + yield* core.deleteMany("credential_binding", { + where: (b) => + b.and( + b("plugin_id", "=", input.pluginId), + b("source_id", "=", input.sourceId), + b("source_scope_id", "=", input.sourceScope), + ), + }); + }); + + const credentialBindingResolutionStatus = ( + row: CredentialBindingRow, + ): Effect.Effect<"resolved" | "missing", StorageFailure> => + Effect.gen(function* () { + if (row.kind === "text") return typeof row.text_value === "string" ? "resolved" : "missing"; + if (row.kind === "secret") { + if (!row.secret_id) return "missing"; + const secret = yield* findSecretRowAtScope({ + secretId: row.secret_id, + scopeId: row.secret_scope_id ?? row.scope_id, + }); + if (!secret) return "missing"; + return (yield* secretRouteHasBackingValue(secret)) ? "resolved" : "missing"; + } + if (row.kind === "connection") { + if (!row.connection_id) return "missing"; + const connection = yield* findConnectionRowAtScope({ + connectionId: row.connection_id, + scopeId: row.scope_id, + }); + return connection ? "resolved" : "missing"; + } + return "missing"; + }); + + const credentialBindingResolve = (input: CredentialBindingSlotInput) => + Effect.gen(function* () { + const rows = yield* credentialBindingRowsForSlot(input); + const row = findInnermost(rows); + if (!row) { + return ResolvedCredentialSlot.make({ + pluginId: input.pluginId, + sourceId: input.sourceId, + sourceScopeId: input.sourceScope, + slotKey: input.slotKey, + bindingScopeId: null, + kind: null, + status: "missing" as const, + }); + } + return ResolvedCredentialSlot.make({ + pluginId: input.pluginId, + sourceId: input.sourceId, + sourceScopeId: input.sourceScope, + slotKey: input.slotKey, + bindingScopeId: ScopeId.make(row.scope_id), + kind: + row.kind === "text" || row.kind === "secret" || row.kind === "connection" + ? row.kind + : null, + status: yield* credentialBindingResolutionStatus(row), + }); + }); + + const sourceNamesForCredentialBindings = ( + rows: readonly CredentialBindingRow[], + ): Effect.Effect, StorageFailure> => + Effect.gen(function* () { + const sourceIds = [...new Set(rows.map((row) => row.source_id))]; + if (sourceIds.length === 0) return new Map(); + const sourceRows = yield* core.findMany("source", { + where: scopedWhere(scopeIds, (b) => b("id", "in", sourceIds)), + }); + return new Map( + sourceRows.map((row) => [`${row.scope_id}\u0000${row.id}`, row.name] as const), + ); + }); + + const credentialBindingRowsToUsages = ( + rows: readonly CredentialBindingRow[], + ): Effect.Effect => + Effect.gen(function* () { + const names = yield* sourceNamesForCredentialBindings(rows); + return rows.map((row) => + Usage.make({ + pluginId: row.plugin_id, + scopeId: ScopeId.make( + row.kind === "secret" ? (row.secret_scope_id ?? row.scope_id) : row.scope_id, + ), + ownerKind: "credential-binding", + ownerId: row.source_id, + ownerName: names.get(`${row.source_scope_id}\u0000${row.source_id}`) ?? null, + slot: row.slot_key, + }), + ); + }); + + const credentialBindingUsagesForSecret = ( + id: string, + ): Effect.Effect => + Effect.gen(function* () { + const rows = yield* core.findMany("credential_binding", { + where: scopedWhere(scopeIds, (b) => b("secret_id", "=", id)), + }); + return yield* credentialBindingRowsToUsages(rows as readonly CredentialBindingRow[]); + }); + + const credentialBindingUsagesForConnection = ( + id: string, + ): Effect.Effect => + Effect.gen(function* () { + const rows = yield* core.findMany("credential_binding", { + where: scopedWhere(scopeIds, (b) => b("connection_id", "=", id)), + }); + return yield* credentialBindingRowsToUsages(rows as readonly CredentialBindingRow[]); + }); + + const credentialBindings: CredentialBindingsFacade = { + listForSource: credentialBindingListForSource, + resolve: credentialBindingResolve, + set: credentialBindingSet, + remove: credentialBindingRemove, + replaceForSource: credentialBindingReplaceForSource, + removeForSource: credentialBindingRemoveForSource, + usagesForSecret: credentialBindingUsagesForSecret, + usagesForConnection: credentialBindingUsagesForConnection, + }; + + return credentialBindings; +}; diff --git a/packages/core/sdk/src/executor-helpers.ts b/packages/core/sdk/src/executor-helpers.ts new file mode 100644 index 000000000..2da8121f7 --- /dev/null +++ b/packages/core/sdk/src/executor-helpers.ts @@ -0,0 +1,442 @@ +import { Effect, Option, Schema } from "effect"; +import { fumadb } from "fumadb"; +import { memoryAdapter } from "fumadb/adapters/memory"; +import { type Condition, type ConditionBuilder } from "fumadb/query"; +import { schema as fumaSchema, type RelationsMap } from "fumadb/schema"; +import type { AnyColumn } from "fumadb/schema"; + +import { ConnectionProviderState } from "./connections"; +import { + coreSchema, + type CoreSchema, + type DefinitionsInput, + type SourceInput, + type SourceRow, + type ToolAnnotations, + type ToolRow, +} from "./core-schema"; +import { + StorageError, + isStorageFailure, + makeFumaClient, + type FumaDb, + type FumaRow, + type FumaTables, + type StorageFailure, +} from "./fuma-runtime"; +import type { AnyPlugin, StaticSourceDecl, StaticToolDecl, StaticToolSchema } from "./plugin"; +import { assertExecutorScopePolicyTable } from "./scope-policy"; +import type { Source, Tool, ToolListFilter } from "./types"; + +const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000; + +// --------------------------------------------------------------------------- +// collectTables — merge core tables with every plugin's declared Fuma table. +// Hosts pass the result to FumaDB when constructing the database client. +// --------------------------------------------------------------------------- + +export const collectTables = (plugins: readonly AnyPlugin[]): FumaTables => { + const merged: FumaTables = { ...coreSchema }; + for (const plugin of plugins) { + if (!plugin.schema) continue; + for (const [tableKey, tableDef] of Object.entries(plugin.schema)) { + if (merged[tableKey]) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: collectTables is a synchronous configuration API + throw new StorageError({ + message: + `Duplicate storage table "${tableKey}" contributed by plugin "${plugin.id}"` + + ` (reserved by core or another plugin)`, + cause: undefined, + }); + } + merged[tableKey] = tableDef as FumaTables[string]; + } + } + + validateExecutorScopePolicyTables(merged); + + return merged; +}; + +export const validateExecutorScopePolicyTables = (tables: FumaTables): void => { + for (const [tableKey, tableDef] of Object.entries(tables)) { + assertExecutorScopePolicyTable(tableDef, tableKey); + } +}; + +export const validateExecutorDbTables = (required: FumaTables, actual: FumaTables): void => { + const missing = Object.keys(required) + .filter((tableName) => !actual[tableName]) + .sort(); + if (missing.length === 0) return; + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: synchronous startup validation before Executor services are built + throw new StorageError({ + message: `Executor database is missing required table definitions: ${missing.join(", ")}`, + cause: { + missing, + available: Object.keys(actual).sort(), + }, + }); +}; + +export const storageFailureFromUnknown = (message: string, cause: unknown): StorageFailure => + isStorageFailure(cause) ? cause : new StorageError({ message, cause }); + +export const pluginStorageFailure = ( + pluginId: string, + hook: string, + cause: unknown, +): StorageFailure => storageFailureFromUnknown(`${hook} failed for plugin ${pluginId}`, cause); + +export const createDefaultMemoryDb = (tables: FumaTables): { readonly db: FumaDb } => { + const version = "1.0.0"; + const latestSchema = fumaSchema>({ + version, + tables, + }); + const factory = fumadb({ + namespace: "executor_memory", + schemas: [latestSchema], + }); + + // oxlint-disable-next-line executor/no-double-cast -- boundary: dynamic plugin table map is known only after collectTables() + const db = factory.client(memoryAdapter()).orm(version) as unknown as FumaDb; + return { + db, + }; +}; + +// --------------------------------------------------------------------------- +// Row → public projection conversions +// --------------------------------------------------------------------------- + +export const rowToSource = (row: SourceRow): Source => ({ + id: row.id, + scopeId: row.scope_id, + kind: row.kind, + name: row.name, + url: row.url ?? undefined, + pluginId: row.plugin_id, + canRemove: Boolean(row.can_remove), + canRefresh: Boolean(row.can_refresh), + canEdit: Boolean(row.can_edit), + runtime: false, +}); + +export const staticDeclToSource = (decl: StaticSourceDecl, pluginId: string): Source => ({ + id: decl.id, + scopeId: undefined, + kind: decl.kind, + name: decl.name, + url: decl.url, + pluginId, + canRemove: decl.canRemove ?? false, + canRefresh: decl.canRefresh ?? false, + canEdit: decl.canEdit ?? false, + runtime: true, +}); + +const decodeJsonFromString = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); + +export const decodeJsonColumn = (value: unknown): unknown => { + if (value === null || value === undefined) return undefined; + if (typeof value !== "string") return value; + return decodeJsonFromString(value).pipe(Option.getOrElse(() => value)); +}; + +export const decodeProviderState = Schema.decodeUnknownOption(ConnectionProviderState); + +export const rowToTool = (row: ToolRow, annotations?: ToolAnnotations): Tool => ({ + id: row.id, + sourceId: row.source_id, + pluginId: row.plugin_id, + name: row.name, + description: row.description, + inputSchema: decodeJsonColumn(row.input_schema), + outputSchema: decodeJsonColumn(row.output_schema), + annotations, +}); + +export const staticDeclToTool = ( + source: StaticSourceDecl, + tool: StaticToolDecl, + pluginId: string, +): Tool => ({ + id: `${source.id}.${tool.name}`, + sourceId: source.id, + pluginId, + name: tool.name, + description: tool.description, + inputSchema: toToolJsonSchema(tool.inputSchema), + outputSchema: toToolJsonSchema(tool.outputSchema, "output"), + annotations: tool.annotations, +}); + +export const toToolJsonSchema = ( + schema: StaticToolSchema | undefined, + direction: "input" | "output" = "input", +): unknown => { + if (schema == null) return undefined; + return schema["~standard"].jsonSchema[direction]({ + target: "draft-2020-12", + }); +}; + +export const EXECUTOR_SOURCE_ID = "executor"; +export const EXECUTOR_SOURCE: StaticSourceDecl = { + id: EXECUTOR_SOURCE_ID, + kind: "built-in", + name: "Executor", + canRemove: false, + canRefresh: false, + canEdit: false, + tools: [], +}; + +const scopeFilter = + (scopes: readonly string[]) => + (b: ConditionBuilder>): Condition => + scopes.length === 1 ? b("scope_id", "=", scopes[0]!) : b("scope_id", "in", [...scopes]); + +export const scopedWhere = + ( + scopes: readonly string[], + where?: (b: ConditionBuilder>) => Condition | boolean, + ) => + (b: ConditionBuilder>): Condition | boolean => + b.and(scopeFilter(scopes)(b), where ? where(b) : true); + +export const byId = + (id: string) => + (b: ConditionBuilder>): Condition => + b("id", "=", id); + +export const byScopedId = + (scope: string, id: string) => + (b: ConditionBuilder>): Condition => + b.and(b("scope_id", "=", scope), b("id", "=", id)) as Condition; + +type CoreTableName = keyof CoreSchema & string; +type CoreRow = FumaRow; +type CoreWhere<_TName extends CoreTableName> = ( + b: ConditionBuilder>, +) => Condition | boolean; +type CoreFindManyOptions = { + readonly where?: CoreWhere; + readonly limit?: number; + readonly offset?: number; + readonly orderBy?: + | readonly [string, "asc" | "desc"] + | readonly (readonly [string, "asc" | "desc"])[]; +}; +type CoreFindFirstOptions = Omit< + CoreFindManyOptions, + "limit" | "offset" +>; + +type LooseStorageDb = { + readonly count: (tableName: string, options?: unknown) => Promise; + readonly create: ( + tableName: string, + row: Record, + ) => Promise>; + readonly createMany: ( + tableName: string, + rows: readonly Record[], + ) => Promise; + readonly deleteMany: (tableName: string, options?: unknown) => Promise; + readonly findFirst: ( + tableName: string, + options?: unknown, + ) => Promise | null>; + readonly findMany: ( + tableName: string, + options?: unknown, + ) => Promise[]>; + readonly updateMany: (tableName: string, options: unknown) => Promise; +}; + +const asLooseStorageDb = (db: unknown): LooseStorageDb => db as LooseStorageDb; + +export const makeCoreDb = (fuma: ReturnType) => ({ + count: ( + tableName: TName, + options?: { readonly where?: CoreWhere }, + ): Effect.Effect => + fuma.use(`${tableName}.count`, (db) => asLooseStorageDb(db).count(tableName, options)), + create: ( + tableName: TName, + row: Record, + ): Effect.Effect, StorageFailure> => + fuma.use(`${tableName}.create`, (db) => + asLooseStorageDb(db).create(tableName, row), + ) as Effect.Effect, StorageFailure>, + createMany: ( + tableName: TName, + rows: readonly Record[], + ): Effect.Effect => + rows.length === 0 + ? Effect.void + : fuma + .use(`${tableName}.createMany`, (db) => asLooseStorageDb(db).createMany(tableName, rows)) + .pipe(Effect.asVoid), + deleteMany: ( + tableName: TName, + options: { readonly where?: CoreWhere } = {}, + ): Effect.Effect => + fuma.use(`${tableName}.deleteMany`, (db) => + asLooseStorageDb(db).deleteMany(tableName, options), + ), + findFirst: ( + tableName: TName, + options: CoreFindFirstOptions, + ): Effect.Effect | null, StorageFailure> => + fuma.use(`${tableName}.findFirst`, (db) => + asLooseStorageDb(db).findFirst(tableName, options), + ) as Effect.Effect | null, StorageFailure>, + findMany: ( + tableName: TName, + options: CoreFindManyOptions = {}, + ): Effect.Effect[], StorageFailure> => + fuma.use(`${tableName}.findMany`, (db) => + asLooseStorageDb(db).findMany(tableName, options), + ) as Effect.Effect[], StorageFailure>, + updateMany: ( + tableName: TName, + options: { + readonly where?: CoreWhere; + readonly set: Record; + }, + ): Effect.Effect => + fuma.use(`${tableName}.updateMany`, (db) => + asLooseStorageDb(db).updateMany(tableName, options), + ), +}); + +// --------------------------------------------------------------------------- +// Dynamic-row writers — used by ctx.core.sources.register. Static sources +// never touch these functions. +// --------------------------------------------------------------------------- + +// Upsert shape: delete any existing source + tools + definitions for +// `input.id` before creating fresh rows. Keeps replayable — boot-time +// sync from executor.jsonc can call register() on rows that already +// exist without tripping a UNIQUE constraint. +export const writeSourceInput = ( + core: ReturnType, + pluginId: string, + input: SourceInput, +): Effect.Effect => + Effect.gen(function* () { + yield* deleteSourceById(core, input.id, input.scope); + + const now = new Date(); + yield* core.create("source", { + id: input.id, + scope_id: input.scope, + plugin_id: pluginId, + kind: input.kind, + name: input.name, + url: input.url ?? null, + can_remove: input.canRemove ?? true, + can_refresh: input.canRefresh ?? false, + can_edit: input.canEdit ?? false, + created_at: now, + updated_at: now, + }); + + const toolsById = new Map(); + for (const tool of input.tools) { + toolsById.set(`${input.id}.${tool.name}`, tool); + } + const tools = [...toolsById.entries()]; + + if (tools.length > 0) { + yield* core.createMany( + "tool", + tools.map(([id, tool]) => ({ + id, + scope_id: input.scope, + source_id: input.id, + plugin_id: pluginId, + name: tool.name, + description: tool.description, + input_schema: tool.inputSchema ?? null, + output_schema: tool.outputSchema ?? null, + created_at: now, + updated_at: now, + })), + ); + } + }); + +// Delete a source and its tools + definitions at ONE specific scope. +// The helper pins `scope_id = scopeId` so it never widens into a stack-wide +// wipe; a bystander scope's rows with a colliding `source_id` must survive. +export const deleteSourceById = ( + core: ReturnType, + sourceId: string, + scopeId: string, +): Effect.Effect => + Effect.gen(function* () { + yield* core.deleteMany("tool", { + where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scopeId)), + }); + yield* core.deleteMany("definition", { + where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scopeId)), + }); + yield* core.deleteMany("source", { + where: byScopedId(scopeId, sourceId), + }); + }); + +export const writeDefinitions = ( + core: ReturnType, + pluginId: string, + input: DefinitionsInput, +): Effect.Effect => + Effect.gen(function* () { + // Pin the delete to `input.scope` so an inner-scope writer cannot remove + // outer-scope definitions for the same source id. + yield* core.deleteMany("definition", { + where: (b) => b.and(b("source_id", "=", input.sourceId), b("scope_id", "=", input.scope)), + }); + const entries = Object.entries(input.definitions); + if (entries.length === 0) return; + const now = new Date(); + yield* core.createMany( + "definition", + entries.map(([name, schema]) => ({ + id: `${input.sourceId}.${name}`, + scope_id: input.scope, + source_id: input.sourceId, + plugin_id: pluginId, + name, + schema: schema as Record, + created_at: now, + })), + ); + }); + +// --------------------------------------------------------------------------- +// Filtering — shared between dynamic (DB) and static (in-memory) pools +// so `tools.list({ query, sourceId })` matches across both. +// --------------------------------------------------------------------------- + +export const toolMatchesFilter = (tool: Tool, filter: ToolListFilter): boolean => { + if (filter.sourceId && tool.sourceId !== filter.sourceId) return false; + if (filter.query) { + const q = filter.query.toLowerCase(); + const hay = `${tool.name} ${tool.description}`.toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; +}; + +export const approvalArgumentPreview = (args: unknown): string => { + const text = JSON.stringify(args ?? {}, null, 2) ?? "null"; + return text.length > MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS + ? `${text.slice(0, MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS)}...` + : text; +}; diff --git a/packages/core/sdk/src/executor-policy-facade.ts b/packages/core/sdk/src/executor-policy-facade.ts new file mode 100644 index 000000000..ea9fbc048 --- /dev/null +++ b/packages/core/sdk/src/executor-policy-facade.ts @@ -0,0 +1,169 @@ +import { Effect } from "effect"; +import { generateKeyBetween } from "fractional-indexing"; + +import { isToolPolicyAction, type ToolPolicyRow } from "./core-schema"; +import { byScopedId, makeCoreDb, scopedWhere } from "./executor-helpers"; +import { StorageError, type StorageFailure } from "./fuma-runtime"; +import { + comparePolicyRow, + isValidPattern, + resolveToolPolicy, + rowToToolPolicy, + type CreateToolPolicyInput, + type RemoveToolPolicyInput, + type UpdateToolPolicyInput, +} from "./policies"; + +export const makePolicyFacade = (deps: { + readonly core: ReturnType; + readonly scopeIds: readonly string[]; + readonly scopeRank: (row: { readonly scope_id: unknown }) => number; + readonly assertScopeInStack: ( + label: string, + scopeId: string, + ) => Effect.Effect; +}) => { + const loadAll = () => deps.core.findMany("tool_policy", { where: scopedWhere(deps.scopeIds) }); + + const resolveForId = (toolId: string) => + Effect.gen(function* () { + const policies = yield* loadAll(); + return resolveToolPolicy(toolId, policies, deps.scopeRank); + }); + + const list = () => + Effect.gen(function* () { + const rows = yield* loadAll(); + const sorted = [...rows].sort((a, b) => { + const sa = deps.scopeRank(a); + const sb = deps.scopeRank(b); + if (sa !== sb) return sa - sb; + return comparePolicyRow(a, b); + }); + return sorted.map((row) => rowToToolPolicy(row)); + }).pipe(Effect.withSpan("executor.policies.list")); + + const create = (input: CreateToolPolicyInput) => + Effect.gen(function* () { + yield* deps.assertScopeInStack("tool policy targetScope", input.targetScope); + if (!isValidPattern(input.pattern)) { + return yield* new StorageError({ + message: + `Invalid tool policy pattern "${input.pattern}". ` + + `Patterns must be "*" (every tool), an exact tool id ("a.b.c"), ` + + `or a trailing wildcard ("a.b.*"). Leading "*" prefixes ` + + `("*foo", "*.foo") and "**" are not supported.`, + cause: undefined, + }); + } + if (!isToolPolicyAction(input.action)) { + return yield* new StorageError({ + message: + `Invalid tool policy action "${String(input.action)}". ` + + `Expected "approve" | "require_approval" | "block".`, + cause: undefined, + }); + } + + let position = input.position; + if (position === undefined) { + const existing = yield* deps.core.findMany("tool_policy", { + where: (b) => b("scope_id", "=", input.targetScope), + }); + let min: string | null = null; + for (const row of existing) { + const p = row.position; + if (min === null || p < min) min = p; + } + position = generateKeyBetween(null, min); + } + + const id = `pol_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`; + const now = new Date(); + yield* deps.core.create("tool_policy", { + id, + scope_id: input.targetScope, + pattern: input.pattern, + action: input.action, + position, + created_at: now, + updated_at: now, + }); + return rowToToolPolicy({ + id, + scope_id: input.targetScope, + pattern: input.pattern, + action: input.action, + position, + created_at: now, + updated_at: now, + } as ToolPolicyRow); + }).pipe(Effect.withSpan("executor.policies.create")); + + const update = (input: UpdateToolPolicyInput) => + Effect.gen(function* () { + yield* deps.assertScopeInStack("tool policy targetScope", input.targetScope); + if (input.pattern !== undefined && !isValidPattern(input.pattern)) { + return yield* new StorageError({ + message: `Invalid tool policy pattern "${input.pattern}".`, + cause: undefined, + }); + } + if (input.action !== undefined && !isToolPolicyAction(input.action)) { + return yield* new StorageError({ + message: `Invalid tool policy action "${String(input.action)}".`, + cause: undefined, + }); + } + + const rows = yield* deps.core.findMany("tool_policy", { + where: byScopedId(input.targetScope, input.id), + }); + const row = rows[0] ?? null; + if (!row) { + return yield* new StorageError({ + message: `Tool policy "${input.id}" not found in scope "${input.targetScope}".`, + cause: undefined, + }); + } + + const updated: ToolPolicyRow = { + ...row, + pattern: input.pattern ?? row.pattern, + action: input.action ?? row.action, + position: input.position ?? row.position, + updated_at: new Date(), + }; + yield* deps.core.updateMany("tool_policy", { + where: byScopedId(input.targetScope, input.id), + set: { + pattern: updated.pattern, + action: updated.action, + position: updated.position, + updated_at: updated.updated_at, + }, + }); + return rowToToolPolicy(updated); + }).pipe(Effect.withSpan("executor.policies.update")); + + const remove = (input: RemoveToolPolicyInput): Effect.Effect => + Effect.gen(function* () { + yield* deps.assertScopeInStack("tool policy targetScope", input.targetScope); + yield* deps.core.deleteMany("tool_policy", { + where: byScopedId(input.targetScope, input.id), + }); + }).pipe(Effect.withSpan("executor.policies.remove")); + + const resolve = (toolId: string) => + resolveForId(toolId).pipe(Effect.withSpan("executor.policies.resolve")); + + return { + create, + list, + loadAll, + remove, + resolve, + resolveForId, + update, + }; +}; diff --git a/packages/core/sdk/src/executor-surface.ts b/packages/core/sdk/src/executor-surface.ts new file mode 100644 index 000000000..1aa678535 --- /dev/null +++ b/packages/core/sdk/src/executor-surface.ts @@ -0,0 +1,809 @@ +import { Duration, Effect, Match, Option } from "effect"; + +import { + ElicitationDeclinedError, + ElicitationResponse, + FormElicitation, + type ElicitationHandler, + type ElicitationRequest, +} from "./elicitation"; +import { + NoHandlerError, + PluginNotLoadedError, + SourceRemovalNotAllowedError, + ToolBlockedError, + ToolInvocationError, + ToolNotFoundError, +} from "./errors"; +import { makePolicyFacade } from "./executor-policy-facade"; +import { + approvalArgumentPreview, + byId, + byScopedId, + decodeJsonColumn, + deleteSourceById, + makeCoreDb, + pluginStorageFailure, + rowToSource, + rowToTool, + scopedWhere, + staticDeclToSource, + staticDeclToTool, + toToolJsonSchema, + toolMatchesFilter, +} from "./executor-helpers"; +import type { StorageFailure } from "./fuma-runtime"; +import { validateHostedOutboundUrl } from "./hosted-http-client"; +import { ToolId } from "./ids"; +import type { Elicit, PluginCtx, StaticSourceDecl, StaticToolDecl } from "./plugin"; +import { resolveToolPolicy, type PolicyMatch } from "./policies"; +import { buildToolTypeScriptPreview } from "./schema-types"; +import { + ToolSchema, + type RefreshSourceInput, + type RemoveSourceInput, + type Source, + type SourceDetectionResult, + type Tool, + type ToolListFilter, +} from "./types"; +import type { ToolAnnotations, ToolRow } from "./core-schema"; +import { StorageError } from "./fuma-runtime"; + +const MAX_ANNOTATION_GROUPS = 64; + +type OnElicitation = ElicitationHandler | "accept-all"; +type InvokeOptions = { readonly onElicitation?: OnElicitation }; + +type StaticTools = { + readonly source: StaticSourceDecl; + readonly tool: StaticToolDecl; + readonly pluginId: string; + readonly ctx: PluginCtx; +}; + +type StaticSources = { + readonly source: StaticSourceDecl; + readonly pluginId: string; +}; + +type PluginRuntime = { + readonly plugin: { + readonly id: string; + readonly resolveAnnotations?: (input: { + readonly ctx: PluginCtx; + readonly sourceId: string; + readonly toolRows: readonly ToolRow[]; + }) => Effect.Effect, unknown>; + readonly invokeTool?: (input: { + readonly ctx: PluginCtx; + readonly toolRow: ToolRow; + readonly args: unknown; + readonly elicit: Elicit; + }) => Effect.Effect; + readonly removeSource?: (input: { + readonly ctx: PluginCtx; + readonly sourceId: string; + readonly scope: string; + }) => Effect.Effect; + readonly refreshSource?: (input: { + readonly ctx: PluginCtx; + readonly sourceId: string; + readonly scope: string; + }) => Effect.Effect; + readonly detect?: (input: { + readonly ctx: PluginCtx; + readonly url: string; + }) => Effect.Effect; + }; + readonly ctx: PluginCtx; +}; + +export const makeExecutorSurface = (deps: { + readonly core: ReturnType; + readonly scopeIds: readonly string[]; + readonly scopeRank: (row: { readonly scope_id: unknown }) => number; + readonly findInnermost: ( + rows: readonly T[], + ) => T | null; + readonly staticTools: ReadonlyMap; + readonly staticSources: ReadonlyMap; + readonly runtimes: ReadonlyMap; + readonly transaction: (effect: Effect.Effect) => Effect.Effect; + readonly assertScopeInStack: ( + label: string, + scopeId: string, + ) => Effect.Effect; + readonly onElicitation: OnElicitation; + readonly resolveElicitationHandler: (onElicitation: OnElicitation) => ElicitationHandler; + readonly sourceDetection?: { + readonly maxUrlLength?: number; + readonly maxDetectors?: number; + readonly maxResults?: number; + readonly timeout?: Duration.Input; + readonly hostedOutboundPolicy?: boolean; + }; + readonly hostedOutboundPolicyDefault: boolean; +}) => { + const { + core, + scopeIds, + scopeRank, + findInnermost, + staticTools, + staticSources, + runtimes, + transaction, + assertScopeInStack, + onElicitation, + resolveElicitationHandler, + sourceDetection, + hostedOutboundPolicyDefault, + } = deps; + + const listSources = () => + Effect.gen(function* () { + const dynamic = yield* core.findMany("source", { where: scopedWhere(scopeIds) }); + // Dedup by id with innermost scope winning. Without this, a user + // who shadowed an org-wide source at their inner scope would see + // two rows — their override and the outer default — which is + // inconsistent with how `secrets.list` and every other list + // surface dedup shadowed entries. + const byId = new Map(); + const byIdRank = new Map(); + for (const row of dynamic) { + const rank = scopeRank(row); + const existing = byIdRank.get(row.id); + if (existing === undefined || rank < existing) { + byId.set(row.id, row); + byIdRank.set(row.id, rank); + } + } + const dynamicDeduped = [...byId.values()]; + const staticList: Source[] = []; + for (const { source, pluginId } of staticSources.values()) { + staticList.push(staticDeclToSource(source, pluginId)); + } + const merged = [...staticList, ...dynamicDeduped.map(rowToSource)]; + yield* Effect.annotateCurrentSpan({ + "executor.sources.static_count": staticList.length, + "executor.sources.dynamic_count": dynamicDeduped.length, + }); + return merged; + }).pipe(Effect.withSpan("executor.sources.list")); + + // Bulk-resolve annotations across a set of dynamic tool rows by + // grouping them under their owning plugin's resolveAnnotations + // callback. One plugin call per (plugin_id, source_id) pair, not + // per row. Plugins without a resolver simply contribute no + // annotations for their rows. + const resolveAnnotationsFor = (rows: readonly ToolRow[]) => + Effect.gen(function* () { + const result = new Map(); + if (rows.length === 0) return result; + + // Group by (plugin_id, source_id) + const groups = new Map(); + for (const row of rows) { + const key = `${row.plugin_id}\u0000${row.source_id}`; + const bucket = groups.get(key); + if (bucket) bucket.push(row); + else groups.set(key, [row]); + } + + // Each (plugin_id, source_id) group is an independent DB read, + // so fan them out concurrently. Yielding them serially stacks + // ~200-300ms storage round-trips end-to-end and dominates the + // `executor.tools.list.annotations` span. + const maps = yield* Effect.forEach( + [...groups].slice(0, MAX_ANNOTATION_GROUPS), + ([key, groupRows]) => + Effect.gen(function* () { + const [pluginId, sourceId] = key.split("\u0000") as [string, string]; + const runtime = runtimes.get(pluginId); + if (!runtime?.plugin.resolveAnnotations) return undefined; + return yield* runtime.plugin + .resolveAnnotations({ + ctx: runtime.ctx, + sourceId, + toolRows: groupRows, + }) + .pipe( + Effect.mapError((cause) => + pluginStorageFailure(pluginId, "resolveAnnotations", cause), + ), + ); + }), + { concurrency: "unbounded" }, + ); + for (const map of maps) { + if (!map) continue; + for (const [toolId, annotations] of Object.entries(map)) { + result.set(toolId, annotations); + } + } + return result; + }); + + const listTools = (filter?: ToolListFilter) => + Effect.gen(function* () { + const dynamic = yield* core.findMany("tool", { + where: scopedWhere( + scopeIds, + filter?.sourceId ? (b) => b("source_id", "=", filter.sourceId!) : undefined, + ), + }); + // Dedup by tool id, innermost scope winning — same reason as + // `listSources` above: a shadowed id must surface as one entry + // (the inner one), not two. + const byId = new Map(); + const byIdRank = new Map(); + for (const row of dynamic) { + const rank = scopeRank(row); + const existing = byIdRank.get(row.id); + if (existing === undefined || rank < existing) { + byId.set(row.id, row); + byIdRank.set(row.id, rank); + } + } + const dynamicDeduped = [...byId.values()]; + const annotations = + filter?.includeAnnotations === false + ? new Map() + : yield* resolveAnnotationsFor(dynamicDeduped).pipe( + Effect.withSpan("executor.tools.list.annotations"), + ); + + const out: Tool[] = []; + // Static tools — annotations from the declaration, not a resolver. + for (const entry of staticTools.values()) { + out.push(staticDeclToTool(entry.source, entry.tool, entry.pluginId)); + } + for (const row of dynamicDeduped) { + out.push(rowToTool(row, annotations.get(row.id))); + } + const filtered = filter ? out.filter((t) => toolMatchesFilter(t, filter)) : out; + + // Drop tools blocked by user policy unless the caller explicitly + // asked to see them (the settings UI does, agent surfaces don't). + // One findMany covers the entire scope stack; resolution per + // tool is in-memory. + let result = filtered; + let blockedCount = 0; + if (filter?.includeBlocked !== true) { + const policies = yield* loadAllPolicies(); + if (policies.length > 0) { + const kept: Tool[] = []; + for (const tool of filtered) { + const match = resolveToolPolicy(tool.id, policies, scopeRank); + if (match?.action === "block") { + blockedCount++; + continue; + } + kept.push(tool); + } + result = kept; + } + } + + yield* Effect.annotateCurrentSpan({ + "executor.tools.static_count": staticTools.size, + "executor.tools.dynamic_count": dynamicDeduped.length, + "executor.tools.result_count": result.length, + "executor.tools.blocked_count": blockedCount, + }); + return result; + }).pipe(Effect.withSpan("executor.tools.list")); + + // Load all definitions for a single source as a plain map. Defs + // for the same name can exist at multiple scopes (an admin registers + // a default, a user overrides one entry with a tighter schema) — + // dedup by name keeping the innermost-scope row. + const loadDefinitionsForSource = (sourceId: string) => + Effect.gen(function* () { + const defRows = yield* core.findMany("definition", { + where: scopedWhere(scopeIds, (b) => b("source_id", "=", sourceId)), + }); + const winners = new Map(); + for (const row of defRows) { + const rank = scopeRank(row); + const existing = winners.get(row.name); + if (!existing || rank < existing.rank) { + winners.set(row.name, { row, rank }); + } + } + const out: Record = {}; + for (const [name, { row }] of winners) out[name] = row.schema; + return out; + }); + + // Render the ToolSchema view for a tool — wraps the raw JSON schemas + // with attached `$defs` and runs them through the TypeScript preview + // helpers so the UI gets ready-to-display code samples. + const buildToolSchemaView = (opts: { + toolId: string; + name?: string; + description?: string; + sourceId: string | undefined; + rawInput: unknown; + rawOutput: unknown; + }) => + Effect.gen(function* () { + const defs: Record = opts.sourceId + ? yield* loadDefinitionsForSource(opts.sourceId).pipe( + Effect.withSpan("executor.tool.schema.load_defs"), + ) + : {}; + + const attachDefs = (schema: unknown): unknown => { + if (schema == null || typeof schema !== "object") return schema; + if (Object.keys(defs).length === 0) return schema; + return { ...(schema as Record), $defs: defs }; + }; + + const inputSchema = attachDefs(opts.rawInput); + const outputSchema = attachDefs(opts.rawOutput); + + const defsMap = new Map(Object.entries(defs)); + const preview = yield* Effect.sync(() => + buildToolTypeScriptPreview({ + inputSchema, + outputSchema, + defs: defsMap, + }), + ).pipe( + Effect.withSpan("schema.compile.preview", { + attributes: { + "schema.kind": "tool.preview", + "schema.has_input": inputSchema !== undefined, + "schema.has_output": outputSchema !== undefined, + "schema.def_count": defsMap.size, + }, + }), + ); + + return ToolSchema.make({ + id: ToolId.make(opts.toolId), + name: opts.name, + description: opts.description, + inputSchema, + outputSchema, + inputTypeScript: preview.inputTypeScript ?? undefined, + outputTypeScript: preview.outputTypeScript ?? undefined, + typeScriptDefinitions: preview.typeScriptDefinitions ?? undefined, + }); + }); + + const toolSchema = (toolId: string) => + Effect.gen(function* () { + // Static pool first — static tools have no source in the DB so + // no `$defs` attach; just wrap the declared schemas. + const staticEntry = staticTools.get(toolId); + if (staticEntry) { + yield* Effect.annotateCurrentSpan({ + "executor.tool.dispatch_path": "static", + "executor.source_id": staticEntry.source.id, + "executor.source_kind": staticEntry.source.kind, + }); + return yield* buildToolSchemaView({ + toolId, + name: staticEntry.tool.name, + description: staticEntry.tool.description, + sourceId: undefined, + rawInput: toToolJsonSchema(staticEntry.tool.inputSchema), + rawOutput: toToolJsonSchema(staticEntry.tool.outputSchema, "output"), + }); + } + // Innermost-wins lookup across every visible scope. + const rows = yield* core + .findMany("tool", { + where: scopedWhere(scopeIds, byId(toolId)), + }) + .pipe(Effect.withSpan("executor.tool.resolve")); + const row = findInnermost(rows); + if (!row) return null; + yield* Effect.annotateCurrentSpan({ + "executor.tool.dispatch_path": "dynamic", + "executor.source_id": row.source_id, + "executor.plugin_id": row.plugin_id, + }); + return yield* buildToolSchemaView({ + toolId, + name: row.name, + description: row.description, + sourceId: row.source_id, + rawInput: decodeJsonColumn(row.input_schema), + rawOutput: decodeJsonColumn(row.output_schema), + }); + }).pipe( + Effect.withSpan("executor.tool.schema", { + attributes: { "mcp.tool.name": toolId }, + }), + ); + + // Bulk definitions accessor — every source's $defs, grouped by + // source id. One query against the definition table, plus an + // in-memory group-by with innermost-scope dedup: if the same + // (source_id, name) pair exists at multiple scopes, the inner + // scope's schema wins. + const toolsDefinitions = () => + Effect.gen(function* () { + const rows = yield* core.findMany("definition", { where: scopedWhere(scopeIds) }); + const winners = new Map(); + for (const row of rows) { + const key = `${row.source_id}\u0000${row.name}`; + const rank = scopeRank(row); + const existing = winners.get(key); + if (!existing || rank < existing.rank) { + winners.set(key, { row, rank }); + } + } + const out: Record> = {}; + for (const { row } of winners.values()) { + let bucket = out[row.source_id]; + if (!bucket) { + bucket = {}; + out[row.source_id] = bucket; + } + bucket[row.name] = row.schema; + } + return out; + }); + + const defaultElicitationHandler = resolveElicitationHandler(onElicitation); + const pickHandler = (options: InvokeOptions | undefined): ElicitationHandler => + options?.onElicitation + ? resolveElicitationHandler(options.onElicitation) + : defaultElicitationHandler; + + const buildElicit = (toolId: string, args: unknown, handler: ElicitationHandler): Elicit => { + return (request: ElicitationRequest) => + Effect.gen(function* () { + const tid = ToolId.make(toolId); + const response: ElicitationResponse = yield* handler({ + toolId: tid, + args, + request, + }); + if (response.action !== "accept") { + return yield* new ElicitationDeclinedError({ + toolId: tid, + action: response.action, + }); + } + return response; + }); + }; + + // ------------------------------------------------------------------ + // Tool policies — user-authored overrides of the plugin-derived + // approval annotations. Resolution walks the scope-stacked policy + // table with first-match-wins ordering (innermost scope first, then + // `position` ascending). The result either short-circuits invoke + // (`block`), forces approval (`require_approval`), skips approval + // (`approve`), or returns `undefined` so the plugin annotation is + // used as today. + // ------------------------------------------------------------------ + + const policyFacade = makePolicyFacade({ + core, + scopeIds, + scopeRank, + assertScopeInStack, + }); + const loadAllPolicies = policyFacade.loadAll; + const resolveToolPolicyForId = policyFacade.resolveForId; + + const enforceApproval = ( + annotations: ToolAnnotations | undefined, + toolId: string, + args: unknown, + policy: PolicyMatch | undefined, + handler: ElicitationHandler, + ) => + Effect.gen(function* () { + // approve → never prompt regardless of plugin annotation. + if (policy?.action === "approve") return; + + // require_approval → always prompt. If the plugin already had a + // description, prefer it; otherwise show the matched pattern so + // the user can see *why* the prompt fired. + const policyForcesApproval = policy?.action === "require_approval"; + if (!policyForcesApproval && !annotations?.requiresApproval) return; + + const tid = ToolId.make(toolId); + const message = annotations?.approvalDescription + ? annotations.approvalDescription + : policyForcesApproval && policy + ? `Approve ${toolId}? (matched policy: ${policy.pattern})` + : `Approve ${toolId}?`; + const request = FormElicitation.make({ + message: `${message}\n\nArguments:\n${approvalArgumentPreview(args)}`, + requestedSchema: { + type: "object", + properties: {}, + }, + }); + const response = yield* handler({ toolId: tid, args, request }); + if (response.action !== "accept") { + return yield* new ElicitationDeclinedError({ + toolId: tid, + action: response.action, + }); + } + }); + + const invokeTool = (toolId: string, args: unknown, options?: InvokeOptions) => { + const handler = pickHandler(options); + return Effect.gen(function* () { + const formatInvocationCauseMessage = (cause: unknown): string => { + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: preserve public invoke error message wrapping for unknown plugin failures + return cause instanceof Error ? cause.message : String(cause); + }; + const wrapInvocationError = ( + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.mapError( + (cause) => + new ToolInvocationError({ + toolId: ToolId.make(toolId), + message: formatInvocationCauseMessage(cause), + cause, + }), + ), + ); + + // Resolve the user-authored policy first. A `block` rule + // short-circuits both the static and dynamic paths before any + // plugin code runs. + const policy = yield* resolveToolPolicyForId(toolId).pipe( + Effect.withSpan("executor.tool.resolve_policy"), + ); + if (policy?.action === "block") { + return yield* new ToolBlockedError({ + toolId: ToolId.make(toolId), + pattern: policy.pattern, + }); + } + + // Static path — O(1) map lookup, no DB hit. + const staticEntry = staticTools.get(toolId); + if (staticEntry) { + yield* Effect.annotateCurrentSpan({ + "executor.tool.dispatch_path": "static", + "executor.source_id": staticEntry.source.id, + "executor.source_kind": staticEntry.source.kind, + "executor.plugin_id": staticEntry.pluginId, + }); + yield* enforceApproval(staticEntry.tool.annotations, toolId, args, policy, handler).pipe( + Effect.withSpan("executor.tool.enforce_approval"), + ); + return yield* wrapInvocationError( + staticEntry.tool.handler({ + ctx: staticEntry.ctx, + args, + elicit: buildElicit(toolId, args, handler), + }), + ).pipe(Effect.withSpan("executor.tool.handler")); + } + + // Dynamic path — DB lookup + delegate to owning plugin. Walk the + // whole scope stack and pick the innermost-scope row so a user's + // shadow of an outer tool actually wins on invoke. + const toolRows = yield* core + .findMany("tool", { + where: scopedWhere(scopeIds, byId(toolId)), + }) + .pipe(Effect.withSpan("executor.tool.resolve")); + const row = findInnermost(toolRows); + if (!row) { + return yield* new ToolNotFoundError({ + toolId: ToolId.make(toolId), + }); + } + yield* Effect.annotateCurrentSpan({ + "executor.tool.dispatch_path": "dynamic", + "executor.source_id": row.source_id, + "executor.plugin_id": row.plugin_id, + }); + const runtime = runtimes.get(row.plugin_id); + if (!runtime) { + return yield* new PluginNotLoadedError({ + pluginId: row.plugin_id, + toolId: ToolId.make(toolId), + }); + } + if (!runtime.plugin.invokeTool) { + return yield* new NoHandlerError({ + toolId: ToolId.make(toolId), + pluginId: row.plugin_id, + }); + } + + // Ask the plugin to derive annotations for this one row, if it + // has a resolver. Cheap because the plugin typically already + // needs to load its enrichment data to invoke the tool — + // implementations should structure their resolver + invokeTool + // around a single storage read. Skipped entirely when the user + // policy is `approve` — the prompt is going to be skipped no + // matter what the plugin says, so don't pay for the lookup. + let annotations: ToolAnnotations | undefined; + if (policy?.action !== "approve" && runtime.plugin.resolveAnnotations) { + const map = yield* runtime.plugin + .resolveAnnotations({ + ctx: runtime.ctx, + sourceId: row.source_id, + toolRows: [row], + }) + .pipe(wrapInvocationError) + .pipe(Effect.withSpan("executor.tool.resolve_annotations")); + annotations = map[toolId]; + } + yield* enforceApproval(annotations, toolId, args, policy, handler).pipe( + Effect.withSpan("executor.tool.enforce_approval"), + ); + + return yield* wrapInvocationError( + runtime.plugin.invokeTool({ + ctx: runtime.ctx, + toolRow: row, + args, + elicit: buildElicit(toolId, args, handler), + }), + ).pipe(Effect.withSpan("executor.tool.handler")); + }).pipe( + Effect.withSpan("executor.tool.invoke", { + attributes: { + "mcp.tool.name": toolId, + }, + }), + ); + }; + + const removeSource = (input: RemoveSourceInput) => + Effect.gen(function* () { + yield* assertScopeInStack("source remove targetScope", input.targetScope); + const sourceId = input.id; + // Block removal of static sources structurally. + if (staticSources.has(sourceId)) { + return yield* new SourceRemovalNotAllowedError({ sourceId }); + } + const sourceRow = yield* core.findFirst("source", { + where: byScopedId(input.targetScope, sourceId), + }); + if (!sourceRow) return; + if (!sourceRow.can_remove) { + return yield* new SourceRemovalNotAllowedError({ sourceId }); + } + const runtime = runtimes.get(sourceRow.plugin_id); + // Group the plugin's own cleanup + the core row delete into one + // Fuma transaction so removeSource never leaves orphan rows on failure. + yield* transaction( + Effect.gen(function* () { + if (runtime?.plugin.removeSource) { + yield* runtime.plugin + .removeSource({ + ctx: runtime.ctx, + sourceId, + scope: input.targetScope, + }) + .pipe( + Effect.mapError((cause) => + pluginStorageFailure(runtime.plugin.id, "removeSource", cause), + ), + ); + } + yield* deleteSourceById(core, sourceId, input.targetScope); + }), + ); + }); + + const refreshSource = (input: RefreshSourceInput) => + Effect.gen(function* () { + yield* assertScopeInStack("source refresh targetScope", input.targetScope); + const sourceId = input.id; + if (staticSources.has(sourceId)) return; + const sourceRow = yield* core.findFirst("source", { + where: byScopedId(input.targetScope, sourceId), + }); + if (!sourceRow) return; + const runtime = runtimes.get(sourceRow.plugin_id); + if (runtime?.plugin.refreshSource) { + yield* runtime.plugin + .refreshSource({ + ctx: runtime.ctx, + sourceId, + scope: input.targetScope, + }) + .pipe( + Effect.mapError((cause) => + pluginStorageFailure(runtime.plugin.id, "refreshSource", cause), + ), + ); + } + }); + + const sourceDetectionMaxUrlLength = sourceDetection?.maxUrlLength ?? 2_048; + const sourceDetectionMaxDetectors = sourceDetection?.maxDetectors ?? 6; + const sourceDetectionMaxResults = sourceDetection?.maxResults ?? 4; + const sourceDetectionTimeout = sourceDetection?.timeout ?? "60 seconds"; + const sourceDetectionHostedOutboundPolicy = + sourceDetection?.hostedOutboundPolicy ?? hostedOutboundPolicyDefault; + + // URL autodetection — fan out across a bounded set of plugins that + // declared a `detect` hook. Collect non-null results up to the + // configured cap. Plugin-level detect implementations should + // swallow fetch errors and return null, so one flaky plugin doesn't + // block the whole dispatch. + const detectionConfidenceScore = (confidence: SourceDetectionResult["confidence"]) => + Match.value(confidence).pipe( + Match.when("high", () => 3), + Match.when("medium", () => 2), + Match.when("low", () => 1), + Match.exhaustive, + ); + + const detectSource = (url: string) => + Effect.gen(function* () { + const trimmed = url.trim(); + if (trimmed.length === 0 || trimmed.length > sourceDetectionMaxUrlLength) return []; + const parsed = yield* Effect.try({ + try: () => new URL(trimmed), + catch: (error) => error, + }).pipe(Effect.option); + if (Option.isNone(parsed)) return []; + if (parsed.value.protocol !== "http:" && parsed.value.protocol !== "https:") return []; + if (sourceDetectionHostedOutboundPolicy) { + const allowed = yield* validateHostedOutboundUrl(trimmed).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ); + if (!allowed) return []; + } + + const results: SourceDetectionResult[] = []; + let detectorCount = 0; + for (const runtime of runtimes.values()) { + if (!runtime.plugin.detect) continue; + if (detectorCount >= sourceDetectionMaxDetectors) break; + detectorCount++; + const result = yield* runtime.plugin + .detect({ ctx: runtime.ctx, url: trimmed }) + .pipe(Effect.timeout(sourceDetectionTimeout)) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (result) results.push(result); + } + return results + .sort( + (a, b) => detectionConfidenceScore(b.confidence) - detectionConfidenceScore(a.confidence), + ) + .slice(0, sourceDetectionMaxResults); + }); + + // Per-source definitions accessor — one query, one mapping pass. + const sourceDefinitions = (sourceId: string) => loadDefinitionsForSource(sourceId); + + return { + policies: { + create: policyFacade.create, + list: policyFacade.list, + remove: policyFacade.remove, + resolve: policyFacade.resolve, + update: policyFacade.update, + }, + sources: { + definitions: sourceDefinitions, + detect: detectSource, + list: listSources, + refresh: refreshSource, + remove: removeSource, + }, + tools: { + definitions: toolsDefinitions, + invoke: invokeTool, + list: listTools, + schema: toolSchema, + }, + }; +}; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index af5cab153..c6a88c99c 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1,35 +1,17 @@ -import { - Deferred, - Duration, - Effect, - Layer, - Match, - Option, - Result, - Schema, - Semaphore, -} from "effect"; +import { Deferred, Duration, Effect, Layer, Option, Result, Semaphore } from "effect"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; -import { fumadb } from "fumadb"; -import { memoryAdapter } from "fumadb/adapters/memory"; -import { withQueryContext, type Condition, type ConditionBuilder } from "fumadb/query"; -import { schema as fumaSchema, type RelationsMap } from "fumadb/schema"; -import type { AnyColumn } from "fumadb/schema"; +import { withQueryContext } from "fumadb/query"; import type { OAuthEndpointUrlPolicy } from "./oauth-helpers"; -import { generateKeyBetween } from "fractional-indexing"; import { StorageError, - isStorageFailure, makeFumaClient, type FumaDb, - type FumaRow, type FumaTables, type StorageFailure, } from "./fuma-runtime"; import { makeFumaBlobStore, pluginBlobStore } from "./blob"; import { - ConnectionProviderState, ConnectionRef, ConnectionRefreshError, type ConnectionProvider, @@ -38,38 +20,18 @@ import { type RemoveConnectionInput, type UpdateConnectionTokensInput, } from "./connections"; +import { type CredentialBindingsFacade } from "./credential-bindings"; import { - credentialBindingId, - credentialBindingRowToRef, - type CredentialBindingRef, - type CredentialBindingsFacade, - type CredentialBindingSlotInput, - type CredentialBindingSourceInput, - type RemoveCredentialBindingInput, - type ReplaceCredentialBindingsInput, - ResolvedCredentialSlot, - type SetCredentialBindingInput, -} from "./credential-bindings"; -import { - coreSchema, - isToolPolicyAction, type ConnectionRow, - type CredentialBindingRow, - type CoreSchema, type DefinitionsInput, type SecretRow, type SourceInput, type SourceRow, - type ToolAnnotations, - type ToolPolicyRow, - type ToolRow, } from "./core-schema"; import { ElicitationDeclinedError, ElicitationResponse, - FormElicitation, type ElicitationHandler, - type ElicitationRequest, } from "./elicitation"; import { ConnectionInUseError, @@ -86,14 +48,10 @@ import { ToolInvocationError, ToolNotFoundError, } from "./errors"; -import { ConnectionId, ScopeId, SecretId, ToolId } from "./ids"; +import { ConnectionId, ScopeId, SecretId } from "./ids"; import { makeOAuth2Service } from "./oauth-service"; import type { OAuthService } from "./oauth"; import { - comparePolicyRow, - isValidPattern, - resolveToolPolicy, - rowToToolPolicy, type CreateToolPolicyInput, type PolicyMatch, type RemoveToolPolicyInput, @@ -102,12 +60,10 @@ import { } from "./policies"; import type { AnyPlugin, - Elicit, PluginCtx, PluginExtensions, StaticSourceDecl, StaticToolDecl, - StaticToolSchema, StorageDeps, } from "./plugin"; import type { Scope } from "./scope"; @@ -122,12 +78,28 @@ import { type Tool, type ToolListFilter, } from "./types"; -import { buildToolTypeScriptPreview } from "./schema-types"; -import { assertExecutorScopePolicyTable, type ExecutorScopePolicyContext } from "./scope-policy"; -import { validateHostedOutboundUrl } from "./hosted-http-client"; - -const MAX_ANNOTATION_GROUPS = 64; -const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000; +import type { ExecutorScopePolicyContext } from "./scope-policy"; +import { makeCredentialBindings } from "./executor-credential-bindings"; +import { makeExecutorSurface } from "./executor-surface"; +import { + EXECUTOR_SOURCE, + EXECUTOR_SOURCE_ID, + byId, + byScopedId, + collectTables, + createDefaultMemoryDb, + decodeJsonColumn, + decodeProviderState, + deleteSourceById, + makeCoreDb, + pluginStorageFailure, + scopedWhere, + storageFailureFromUnknown, + validateExecutorDbTables, + validateExecutorScopePolicyTables, + writeDefinitions, + writeSourceInput, +} from "./executor-helpers"; // --------------------------------------------------------------------------- // Elicitation handler — set once at `createExecutor({ onElicitation })` @@ -378,413 +350,7 @@ export interface ExecutorConfig { - const merged: FumaTables = { ...coreSchema }; - for (const plugin of plugins) { - if (!plugin.schema) continue; - for (const [tableKey, tableDef] of Object.entries(plugin.schema)) { - if (merged[tableKey]) { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: collectTables is a synchronous configuration API - throw new StorageError({ - message: - `Duplicate storage table "${tableKey}" contributed by plugin "${plugin.id}"` + - ` (reserved by core or another plugin)`, - cause: undefined, - }); - } - merged[tableKey] = tableDef as FumaTables[string]; - } - } - - validateExecutorScopePolicyTables(merged); - - return merged; -}; - -const validateExecutorScopePolicyTables = (tables: FumaTables): void => { - for (const [tableKey, tableDef] of Object.entries(tables)) { - assertExecutorScopePolicyTable(tableDef, tableKey); - } -}; - -const validateExecutorDbTables = (required: FumaTables, actual: FumaTables): void => { - const missing = Object.keys(required) - .filter((tableName) => !actual[tableName]) - .sort(); - if (missing.length === 0) return; - - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: synchronous startup validation before Executor services are built - throw new StorageError({ - message: `Executor database is missing required table definitions: ${missing.join(", ")}`, - cause: { - missing, - available: Object.keys(actual).sort(), - }, - }); -}; - -const storageFailureFromUnknown = (message: string, cause: unknown): StorageFailure => - isStorageFailure(cause) ? cause : new StorageError({ message, cause }); - -const pluginStorageFailure = (pluginId: string, hook: string, cause: unknown): StorageFailure => - storageFailureFromUnknown(`${hook} failed for plugin ${pluginId}`, cause); - -const createDefaultMemoryDb = (tables: FumaTables): ExecutorDb => { - const version = "1.0.0"; - const latestSchema = fumaSchema>({ - version, - tables, - }); - const factory = fumadb({ - namespace: "executor_memory", - schemas: [latestSchema], - }); - - // oxlint-disable-next-line executor/no-double-cast -- boundary: dynamic plugin table map is known only after collectTables() - const db = factory.client(memoryAdapter()).orm(version) as unknown as FumaDb; - return { - db, - }; -}; - -// --------------------------------------------------------------------------- -// Row → public projection conversions -// --------------------------------------------------------------------------- - -const rowToSource = (row: SourceRow): Source => ({ - id: row.id, - scopeId: row.scope_id, - kind: row.kind, - name: row.name, - url: row.url ?? undefined, - pluginId: row.plugin_id, - canRemove: Boolean(row.can_remove), - canRefresh: Boolean(row.can_refresh), - canEdit: Boolean(row.can_edit), - runtime: false, -}); - -const staticDeclToSource = (decl: StaticSourceDecl, pluginId: string): Source => ({ - id: decl.id, - scopeId: undefined, - kind: decl.kind, - name: decl.name, - url: decl.url, - pluginId, - canRemove: decl.canRemove ?? false, - canRefresh: decl.canRefresh ?? false, - canEdit: decl.canEdit ?? false, - runtime: true, -}); - -const decodeJsonFromString = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); - -const decodeJsonColumn = (value: unknown): unknown => { - if (value === null || value === undefined) return undefined; - if (typeof value !== "string") return value; - return decodeJsonFromString(value).pipe(Option.getOrElse(() => value)); -}; - -const decodeProviderState = Schema.decodeUnknownOption(ConnectionProviderState); - -const rowToTool = (row: ToolRow, annotations?: ToolAnnotations): Tool => ({ - id: row.id, - sourceId: row.source_id, - pluginId: row.plugin_id, - name: row.name, - description: row.description, - inputSchema: decodeJsonColumn(row.input_schema), - outputSchema: decodeJsonColumn(row.output_schema), - annotations, -}); - -const staticDeclToTool = ( - source: StaticSourceDecl, - tool: StaticToolDecl, - pluginId: string, -): Tool => ({ - id: `${source.id}.${tool.name}`, - sourceId: source.id, - pluginId, - name: tool.name, - description: tool.description, - inputSchema: toToolJsonSchema(tool.inputSchema), - outputSchema: toToolJsonSchema(tool.outputSchema, "output"), - annotations: tool.annotations, -}); - -const toToolJsonSchema = ( - schema: StaticToolSchema | undefined, - direction: "input" | "output" = "input", -): unknown => { - if (schema == null) return undefined; - return schema["~standard"].jsonSchema[direction]({ - target: "draft-2020-12", - }); -}; - -const EXECUTOR_SOURCE_ID = "executor"; -const EXECUTOR_SOURCE: StaticSourceDecl = { - id: EXECUTOR_SOURCE_ID, - kind: "built-in", - name: "Executor", - canRemove: false, - canRefresh: false, - canEdit: false, - tools: [], -}; - -const scopeFilter = - (scopes: readonly string[]) => - (b: ConditionBuilder>): Condition => - scopes.length === 1 ? b("scope_id", "=", scopes[0]!) : b("scope_id", "in", [...scopes]); - -const scopedWhere = - ( - scopes: readonly string[], - where?: (b: ConditionBuilder>) => Condition | boolean, - ) => - (b: ConditionBuilder>): Condition | boolean => - b.and(scopeFilter(scopes)(b), where ? where(b) : true); - -const byId = - (id: string) => - (b: ConditionBuilder>): Condition => - b("id", "=", id); - -const byScopedId = - (scope: string, id: string) => - (b: ConditionBuilder>): Condition => - b.and(b("scope_id", "=", scope), b("id", "=", id)) as Condition; - -type CoreTableName = keyof CoreSchema & string; -type CoreRow = FumaRow; -type CoreWhere<_TName extends CoreTableName> = ( - b: ConditionBuilder>, -) => Condition | boolean; -type CoreFindManyOptions = { - readonly where?: CoreWhere; - readonly limit?: number; - readonly offset?: number; - readonly orderBy?: - | readonly [string, "asc" | "desc"] - | readonly (readonly [string, "asc" | "desc"])[]; -}; -type CoreFindFirstOptions = Omit< - CoreFindManyOptions, - "limit" | "offset" ->; - -type LooseStorageDb = { - readonly count: (tableName: string, options?: unknown) => Promise; - readonly create: ( - tableName: string, - row: Record, - ) => Promise>; - readonly createMany: ( - tableName: string, - rows: readonly Record[], - ) => Promise; - readonly deleteMany: (tableName: string, options?: unknown) => Promise; - readonly findFirst: ( - tableName: string, - options?: unknown, - ) => Promise | null>; - readonly findMany: ( - tableName: string, - options?: unknown, - ) => Promise[]>; - readonly updateMany: (tableName: string, options: unknown) => Promise; -}; - -const asLooseStorageDb = (db: unknown): LooseStorageDb => db as LooseStorageDb; - -const makeCoreDb = (fuma: ReturnType) => ({ - count: ( - tableName: TName, - options?: { readonly where?: CoreWhere }, - ): Effect.Effect => - fuma.use(`${tableName}.count`, (db) => asLooseStorageDb(db).count(tableName, options)), - create: ( - tableName: TName, - row: Record, - ): Effect.Effect, StorageFailure> => - fuma.use(`${tableName}.create`, (db) => - asLooseStorageDb(db).create(tableName, row), - ) as Effect.Effect, StorageFailure>, - createMany: ( - tableName: TName, - rows: readonly Record[], - ): Effect.Effect => - rows.length === 0 - ? Effect.void - : fuma - .use(`${tableName}.createMany`, (db) => asLooseStorageDb(db).createMany(tableName, rows)) - .pipe(Effect.asVoid), - deleteMany: ( - tableName: TName, - options: { readonly where?: CoreWhere } = {}, - ): Effect.Effect => - fuma.use(`${tableName}.deleteMany`, (db) => - asLooseStorageDb(db).deleteMany(tableName, options), - ), - findFirst: ( - tableName: TName, - options: CoreFindFirstOptions, - ): Effect.Effect | null, StorageFailure> => - fuma.use(`${tableName}.findFirst`, (db) => - asLooseStorageDb(db).findFirst(tableName, options), - ) as Effect.Effect | null, StorageFailure>, - findMany: ( - tableName: TName, - options: CoreFindManyOptions = {}, - ): Effect.Effect[], StorageFailure> => - fuma.use(`${tableName}.findMany`, (db) => - asLooseStorageDb(db).findMany(tableName, options), - ) as Effect.Effect[], StorageFailure>, - updateMany: ( - tableName: TName, - options: { - readonly where?: CoreWhere; - readonly set: Record; - }, - ): Effect.Effect => - fuma.use(`${tableName}.updateMany`, (db) => - asLooseStorageDb(db).updateMany(tableName, options), - ), -}); - -// --------------------------------------------------------------------------- -// Dynamic-row writers — used by ctx.core.sources.register. Static sources -// never touch these functions. -// --------------------------------------------------------------------------- - -// Upsert shape: delete any existing source + tools + definitions for -// `input.id` before creating fresh rows. Keeps replayable — boot-time -// sync from executor.jsonc can call register() on rows that already -// exist without tripping a UNIQUE constraint. -const writeSourceInput = ( - core: ReturnType, - pluginId: string, - input: SourceInput, -): Effect.Effect => - Effect.gen(function* () { - yield* deleteSourceById(core, input.id, input.scope); - - const now = new Date(); - yield* core.create("source", { - id: input.id, - scope_id: input.scope, - plugin_id: pluginId, - kind: input.kind, - name: input.name, - url: input.url ?? null, - can_remove: input.canRemove ?? true, - can_refresh: input.canRefresh ?? false, - can_edit: input.canEdit ?? false, - created_at: now, - updated_at: now, - }); - - const toolsById = new Map(); - for (const tool of input.tools) { - toolsById.set(`${input.id}.${tool.name}`, tool); - } - const tools = [...toolsById.entries()]; - - if (tools.length > 0) { - yield* core.createMany( - "tool", - tools.map(([id, tool]) => ({ - id, - scope_id: input.scope, - source_id: input.id, - plugin_id: pluginId, - name: tool.name, - description: tool.description, - input_schema: tool.inputSchema ?? null, - output_schema: tool.outputSchema ?? null, - created_at: now, - updated_at: now, - })), - ); - } - }); - -// Delete a source and its tools + definitions at ONE specific scope. -// The helper pins `scope_id = scopeId` so it never widens into a stack-wide -// wipe; a bystander scope's rows with a colliding `source_id` must survive. -const deleteSourceById = ( - core: ReturnType, - sourceId: string, - scopeId: string, -): Effect.Effect => - Effect.gen(function* () { - yield* core.deleteMany("tool", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scopeId)), - }); - yield* core.deleteMany("definition", { - where: (b) => b.and(b("source_id", "=", sourceId), b("scope_id", "=", scopeId)), - }); - yield* core.deleteMany("source", { - where: byScopedId(scopeId, sourceId), - }); - }); - -const writeDefinitions = ( - core: ReturnType, - pluginId: string, - input: DefinitionsInput, -): Effect.Effect => - Effect.gen(function* () { - // Pin the delete to `input.scope` so an inner-scope writer cannot remove - // outer-scope definitions for the same source id. - yield* core.deleteMany("definition", { - where: (b) => b.and(b("source_id", "=", input.sourceId), b("scope_id", "=", input.scope)), - }); - const entries = Object.entries(input.definitions); - if (entries.length === 0) return; - const now = new Date(); - yield* core.createMany( - "definition", - entries.map(([name, schema]) => ({ - id: `${input.sourceId}.${name}`, - scope_id: input.scope, - source_id: input.sourceId, - plugin_id: pluginId, - name, - schema: schema as Record, - created_at: now, - })), - ); - }); - -// --------------------------------------------------------------------------- -// Filtering — shared between dynamic (DB) and static (in-memory) pools -// so `tools.list({ query, sourceId })` matches across both. -// --------------------------------------------------------------------------- - -const toolMatchesFilter = (tool: Tool, filter: ToolListFilter): boolean => { - if (filter.sourceId && tool.sourceId !== filter.sourceId) return false; - if (filter.query) { - const q = filter.query.toLowerCase(); - const hay = `${tool.name} ${tool.description}`.toLowerCase(); - if (!hay.includes(q)) return false; - } - return true; -}; - -const approvalArgumentPreview = (args: unknown): string => { - const text = JSON.stringify(args ?? {}, null, 2) ?? "null"; - return text.length > MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS - ? `${text.slice(0, MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS)}...` - : text; -}; +export { collectTables }; // --------------------------------------------------------------------------- // createExecutor @@ -821,7 +387,7 @@ export const createExecutor = collectTables(plugins), catch: (cause) => storageFailureFromUnknown("Failed to collect executor tables", cause), }); - const dbInput = yield* Effect.suspend(() => { + const dbInput = yield* Effect.suspend((): Effect.Effect => { if (!config.db) return Effect.succeed(createDefaultMemoryDb(tables)); if (typeof config.db !== "function") return Effect.succeed(config.db); const out = config.db({ tables }); @@ -2075,484 +1641,21 @@ export const createExecutor = => - scopeIds.includes(input.sourceScope) - ? (core - .findMany("credential_binding", { - where: scopedWhere(scopeIds, (b) => - b.and( - b("plugin_id", "=", input.pluginId), - b("source_id", "=", input.sourceId), - b("source_scope_id", "=", input.sourceScope), - ), - ), - }) - .pipe( - Effect.map((rows) => { - const sourceSourceRank = scopePrecedence.get(input.sourceScope) ?? Infinity; - return (rows as readonly CredentialBindingRow[]).filter( - (row) => scopeRank(row) <= sourceSourceRank, - ); - }), - ) as Effect.Effect) - : Effect.succeed([]); - - const credentialBindingRowsForSlot = ( - input: CredentialBindingSlotInput, - ): Effect.Effect => - scopeIds.includes(input.sourceScope) - ? (core - .findMany("credential_binding", { - where: scopedWhere(scopeIds, (b) => - b.and( - b("plugin_id", "=", input.pluginId), - b("source_id", "=", input.sourceId), - b("source_scope_id", "=", input.sourceScope), - b("slot_key", "=", input.slotKey), - ), - ), - }) - .pipe( - Effect.map((rows) => { - const sourceSourceRank = scopePrecedence.get(input.sourceScope) ?? Infinity; - return (rows as readonly CredentialBindingRow[]).filter( - (row) => scopeRank(row) <= sourceSourceRank, - ); - }), - ) as Effect.Effect) - : Effect.succeed([]); - - const assertCredentialBindingTargetNotOuter = (input: { - readonly label: string; - readonly targetScope: string; - readonly sourceScope: string; - readonly sourceId: string; - }): Effect.Effect => - Effect.gen(function* () { - const sourceSourceRank = scopePrecedence.get(input.sourceScope) ?? Infinity; - const targetRank = scopePrecedence.get(input.targetScope) ?? Infinity; - if (targetRank > sourceSourceRank) { - return yield* new StorageError({ - message: - `${input.label} for source "${input.sourceId}" cannot target outer scope ` + - `"${input.targetScope}" because the source lives at scope "${input.sourceScope}".`, - cause: undefined, - }); - } - }); - - const credentialBindingListForSource = (input: CredentialBindingSourceInput) => - Effect.gen(function* () { - const rows = yield* credentialBindingRowsForSource(input); - return rows - .slice() - .sort((a, b) => { - const slot = a.slot_key.localeCompare(b.slot_key); - return slot === 0 ? scopeRank(a) - scopeRank(b) : slot; - }) - .map(credentialBindingRowToRef); - }); - - const credentialBindingSet = (input: SetCredentialBindingInput) => - Effect.gen(function* () { - yield* assertScopeInStack("credential binding targetScope", input.targetScope); - yield* assertScopeInStack("credential binding sourceScope", input.sourceScope); - yield* assertCredentialBindingTargetNotOuter({ - label: "credential binding", - targetScope: input.targetScope, - sourceScope: input.sourceScope, - sourceId: input.sourceId, - }); - - const source = yield* findSourceRowAtScope({ - pluginId: input.pluginId, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - }); - if (!source) { - return yield* new StorageError({ - message: - `Cannot set credential binding for source "${input.sourceId}" ` + - `at scope "${input.sourceScope}": source is not visible.`, - cause: undefined, - }); - } - - if (input.value.kind === "secret") { - const secretId = input.value.secretId; - const secretScope = input.value.secretScopeId ?? input.targetScope; - yield* assertScopeInStack("credential binding secretScope", secretScope); - if (scopePrecedence.get(secretScope)! < scopePrecedence.get(input.targetScope)!) { - return yield* new StorageError({ - message: - `Cannot bind secret "${secretId}" from scope "${secretScope}" ` + - `to target scope "${input.targetScope}": shared bindings cannot reference inner-scope secrets.`, - cause: undefined, - }); - } - const secret = yield* findSecretRowAtScope({ - secretId, - scopeId: secretScope, - }); - if (!secret) { - // No core routing row at this scope yet. Read-only providers - // (1password, env, …) own items that never get a row via - // `secrets.set()`, so a config-sync referencing one of those - // ids by value otherwise fails here. Walk providers that can - // enumerate, and if any owns the id, materialize a routing row - // pointing at that provider so resolution finds it. - let materialized = false; - for (const [key, provider] of secretProviders) { - let name: string | undefined; - if (provider.list) { - const entries = yield* provider - .list() - .pipe(Effect.catch(() => Effect.succeed([] as const))); - const found = entries.find((e) => e.id === secretId); - if (found) name = found.name; - } - if (name === undefined) { - // Provider didn't enumerate the id (slow list(), failed list, - // or no list() at all). Probe with get() — cheap for most - // backends — and use the id as the display name. - const value = yield* provider - .get(secretId, secretScope) - .pipe(Effect.catch(() => Effect.succeed(null as string | null))); - if (value !== null) name = secretId; - } - if (name === undefined) continue; - const now = new Date(); - yield* core.create("secret", { - id: secretId, - scope_id: secretScope, - name, - provider: key, - owned_by_connection_id: null, - created_at: now, - }); - materialized = true; - break; - } - if (!materialized) { - const providerKeys = [...secretProviders.keys()]; - return yield* new StorageError({ - message: - `Cannot bind secret "${secretId}" at scope "${secretScope}": ` + - `no registered secret provider has an item with this id ` + - `(checked: ${providerKeys.join(", ") || "none"}). ` + - `If this id points to a 1Password item, the item may have been deleted, ` + - `renamed, or live in a different vault than the one configured for this scope.`, - cause: undefined, - }); - } - } - } - - if (input.value.kind === "connection") { - const connection = yield* findConnectionRowAtScope({ - connectionId: input.value.connectionId, - scopeId: input.targetScope, - }); - if (!connection) { - return yield* new StorageError({ - message: - `Cannot bind connection "${input.value.connectionId}" at scope "${input.targetScope}": ` + - `the connection must be owned by the same scope as the binding.`, - cause: undefined, - }); - } - } - - const id = credentialBindingId(input); - const now = new Date(); - yield* core.deleteMany("credential_binding", { - where: (b) => - b.and( - b("scope_id", "=", input.targetScope), - b("plugin_id", "=", input.pluginId), - b("source_id", "=", input.sourceId), - b("source_scope_id", "=", input.sourceScope), - b("slot_key", "=", input.slotKey), - ), - }); - yield* core.create("credential_binding", { - id, - scope_id: input.targetScope, - plugin_id: input.pluginId, - source_id: input.sourceId, - source_scope_id: input.sourceScope, - slot_key: input.slotKey, - kind: input.value.kind, - text_value: input.value.kind === "text" ? input.value.text : null, - secret_id: input.value.kind === "secret" ? input.value.secretId : null, - secret_scope_id: - input.value.kind === "secret" ? (input.value.secretScopeId ?? input.targetScope) : null, - connection_id: input.value.kind === "connection" ? input.value.connectionId : null, - created_at: now, - updated_at: now, - }); - return credentialBindingRowToRef({ - id, - scope_id: input.targetScope, - plugin_id: input.pluginId, - source_id: input.sourceId, - source_scope_id: input.sourceScope, - slot_key: input.slotKey, - kind: input.value.kind, - text_value: input.value.kind === "text" ? input.value.text : undefined, - secret_id: input.value.kind === "secret" ? input.value.secretId : undefined, - secret_scope_id: - input.value.kind === "secret" - ? (input.value.secretScopeId ?? input.targetScope) - : undefined, - connection_id: input.value.kind === "connection" ? input.value.connectionId : undefined, - created_at: now, - updated_at: now, - } as CredentialBindingRow); - }); - - const credentialBindingRemove = (input: RemoveCredentialBindingInput) => - Effect.gen(function* () { - yield* assertScopeInStack("credential binding targetScope", input.targetScope); - yield* assertScopeInStack("credential binding sourceScope", input.sourceScope); - yield* assertCredentialBindingTargetNotOuter({ - label: "credential binding removal", - targetScope: input.targetScope, - sourceScope: input.sourceScope, - sourceId: input.sourceId, - }); - - const source = yield* findSourceRowAtScope({ - pluginId: input.pluginId, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - }); - if (!source) { - return yield* new StorageError({ - message: - `Cannot remove credential binding for source "${input.sourceId}" ` + - `at scope "${input.sourceScope}": source is not visible.`, - cause: undefined, - }); - } - - yield* core.deleteMany("credential_binding", { - where: (b) => - b.and( - b("scope_id", "=", input.targetScope), - b("plugin_id", "=", input.pluginId), - b("source_id", "=", input.sourceId), - b("source_scope_id", "=", input.sourceScope), - b("slot_key", "=", input.slotKey), - ), - }); - }); - - const credentialBindingReplaceForSource = (input: ReplaceCredentialBindingsInput) => - Effect.gen(function* () { - yield* assertScopeInStack("credential binding targetScope", input.targetScope); - yield* assertScopeInStack("credential binding sourceScope", input.sourceScope); - yield* assertCredentialBindingTargetNotOuter({ - label: "credential binding replacement", - targetScope: input.targetScope, - sourceScope: input.sourceScope, - sourceId: input.sourceId, - }); - - const source = yield* findSourceRowAtScope({ - pluginId: input.pluginId, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - }); - if (!source) { - return yield* new StorageError({ - message: - `Cannot replace credential bindings for source "${input.sourceId}" ` + - `at scope "${input.sourceScope}": source is not visible.`, - cause: undefined, - }); - } - - const nextSlots = new Set(input.bindings.map((binding) => binding.slotKey)); - const existing = yield* core.findMany("credential_binding", { - where: (b) => - b.and( - b("scope_id", "=", input.targetScope), - b("plugin_id", "=", input.pluginId), - b("source_id", "=", input.sourceId), - b("source_scope_id", "=", input.sourceScope), - ), - }); - for (const row of existing as readonly CredentialBindingRow[]) { - const shouldOwnSlot = input.slotPrefixes.some((prefix) => - row.slot_key.startsWith(prefix), - ); - if (shouldOwnSlot && !nextSlots.has(row.slot_key)) { - yield* credentialBindingRemove({ - targetScope: input.targetScope, - pluginId: input.pluginId, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - slotKey: row.slot_key, - }); - } - } - - const refs: CredentialBindingRef[] = []; - for (const binding of input.bindings) { - refs.push( - yield* credentialBindingSet({ - targetScope: input.targetScope, - pluginId: input.pluginId, - sourceId: input.sourceId, - sourceScope: input.sourceScope, - slotKey: binding.slotKey, - value: binding.value, - }), - ); - } - return refs; - }); - - const credentialBindingRemoveForSource = (input: CredentialBindingSourceInput) => - Effect.gen(function* () { - yield* assertScopeInStack("credential binding sourceScope", input.sourceScope); - const source = yield* findSourceRowAtScope(input); - if (!source) return; - - // Source-owner cleanup is intentionally broader than a normal scoped - // binding delete. Removing a shared source must detach all credential - // rows for that source identity, including user-owned bindings that - // are not in the source owner's current stack. - yield* core.deleteMany("credential_binding", { - where: (b) => - b.and( - b("plugin_id", "=", input.pluginId), - b("source_id", "=", input.sourceId), - b("source_scope_id", "=", input.sourceScope), - ), - }); - }); - - const credentialBindingResolutionStatus = ( - row: CredentialBindingRow, - ): Effect.Effect<"resolved" | "missing", StorageFailure> => - Effect.gen(function* () { - if (row.kind === "text") return typeof row.text_value === "string" ? "resolved" : "missing"; - if (row.kind === "secret") { - if (!row.secret_id) return "missing"; - const secret = yield* findSecretRowAtScope({ - secretId: row.secret_id, - scopeId: row.secret_scope_id ?? row.scope_id, - }); - if (!secret) return "missing"; - return (yield* secretRouteHasBackingValue(secret)) ? "resolved" : "missing"; - } - if (row.kind === "connection") { - if (!row.connection_id) return "missing"; - const connection = yield* findConnectionRowAtScope({ - connectionId: row.connection_id, - scopeId: row.scope_id, - }); - return connection ? "resolved" : "missing"; - } - return "missing"; - }); - - const credentialBindingResolve = (input: CredentialBindingSlotInput) => - Effect.gen(function* () { - const rows = yield* credentialBindingRowsForSlot(input); - const row = findInnermost(rows); - if (!row) { - return ResolvedCredentialSlot.make({ - pluginId: input.pluginId, - sourceId: input.sourceId, - sourceScopeId: input.sourceScope, - slotKey: input.slotKey, - bindingScopeId: null, - kind: null, - status: "missing" as const, - }); - } - return ResolvedCredentialSlot.make({ - pluginId: input.pluginId, - sourceId: input.sourceId, - sourceScopeId: input.sourceScope, - slotKey: input.slotKey, - bindingScopeId: ScopeId.make(row.scope_id), - kind: - row.kind === "text" || row.kind === "secret" || row.kind === "connection" - ? row.kind - : null, - status: yield* credentialBindingResolutionStatus(row), - }); - }); - - const sourceNamesForCredentialBindings = ( - rows: readonly CredentialBindingRow[], - ): Effect.Effect, StorageFailure> => - Effect.gen(function* () { - const sourceIds = [...new Set(rows.map((row) => row.source_id))]; - if (sourceIds.length === 0) return new Map(); - const sourceRows = yield* core.findMany("source", { - where: scopedWhere(scopeIds, (b) => b("id", "in", sourceIds)), - }); - return new Map( - sourceRows.map((row) => [`${row.scope_id}\u0000${row.id}`, row.name] as const), - ); - }); - - const credentialBindingRowsToUsages = ( - rows: readonly CredentialBindingRow[], - ): Effect.Effect => - Effect.gen(function* () { - const names = yield* sourceNamesForCredentialBindings(rows); - return rows.map((row) => - Usage.make({ - pluginId: row.plugin_id, - scopeId: ScopeId.make( - row.kind === "secret" ? (row.secret_scope_id ?? row.scope_id) : row.scope_id, - ), - ownerKind: "credential-binding", - ownerId: row.source_id, - ownerName: names.get(`${row.source_scope_id}\u0000${row.source_id}`) ?? null, - slot: row.slot_key, - }), - ); - }); - - const credentialBindingUsagesForSecret = ( - id: string, - ): Effect.Effect => - Effect.gen(function* () { - const rows = yield* core.findMany("credential_binding", { - where: scopedWhere(scopeIds, (b) => b("secret_id", "=", id)), - }); - return yield* credentialBindingRowsToUsages(rows as readonly CredentialBindingRow[]); - }); - - const credentialBindingUsagesForConnection = ( - id: string, - ): Effect.Effect => - Effect.gen(function* () { - const rows = yield* core.findMany("credential_binding", { - where: scopedWhere(scopeIds, (b) => b("connection_id", "=", id)), - }); - return yield* credentialBindingRowsToUsages(rows as readonly CredentialBindingRow[]); - }); - - const credentialBindings: CredentialBindingsFacade = { - listForSource: credentialBindingListForSource, - resolve: credentialBindingResolve, - set: credentialBindingSet, - remove: credentialBindingRemove, - replaceForSource: credentialBindingReplaceForSource, - removeForSource: credentialBindingRemoveForSource, - usagesForSecret: credentialBindingUsagesForSecret, - usagesForConnection: credentialBindingUsagesForConnection, - }; + const credentialBindings = makeCredentialBindings({ + core, + scopeIds, + scopePrecedence, + scopeRank, + findInnermost, + assertScopeInStack, + findSourceRowAtScope, + findSecretRowAtScope, + findConnectionRowAtScope, + secretProviders, + secretRouteHasBackingValue, + }); + const credentialBindingUsagesForSecret = credentialBindings.usagesForSecret; + const credentialBindingUsagesForConnection = credentialBindings.usagesForConnection; const oauthBundle = makeOAuth2Service({ fuma, @@ -2794,648 +1897,35 @@ export const createExecutor = - Effect.gen(function* () { - const dynamic = yield* core.findMany("source", { where: scopedWhere(scopeIds) }); - // Dedup by id with innermost scope winning. Without this, a user - // who shadowed an org-wide source at their inner scope would see - // two rows — their override and the outer default — which is - // inconsistent with how `secrets.list` and every other list - // surface dedup shadowed entries. - const byId = new Map(); - const byIdRank = new Map(); - for (const row of dynamic) { - const rank = scopeRank(row); - const existing = byIdRank.get(row.id); - if (existing === undefined || rank < existing) { - byId.set(row.id, row); - byIdRank.set(row.id, rank); - } - } - const dynamicDeduped = [...byId.values()]; - const staticList: Source[] = []; - for (const { source, pluginId } of staticSources.values()) { - staticList.push(staticDeclToSource(source, pluginId)); - } - const merged = [...staticList, ...dynamicDeduped.map(rowToSource)]; - yield* Effect.annotateCurrentSpan({ - "executor.sources.static_count": staticList.length, - "executor.sources.dynamic_count": dynamicDeduped.length, - }); - return merged; - }).pipe(Effect.withSpan("executor.sources.list")); - - // Bulk-resolve annotations across a set of dynamic tool rows by - // grouping them under their owning plugin's resolveAnnotations - // callback. One plugin call per (plugin_id, source_id) pair, not - // per row. Plugins without a resolver simply contribute no - // annotations for their rows. - const resolveAnnotationsFor = (rows: readonly ToolRow[]) => - Effect.gen(function* () { - const result = new Map(); - if (rows.length === 0) return result; - - // Group by (plugin_id, source_id) - const groups = new Map(); - for (const row of rows) { - const key = `${row.plugin_id}\u0000${row.source_id}`; - const bucket = groups.get(key); - if (bucket) bucket.push(row); - else groups.set(key, [row]); - } - - // Each (plugin_id, source_id) group is an independent DB read, - // so fan them out concurrently. Yielding them serially stacks - // ~200-300ms storage round-trips end-to-end and dominates the - // `executor.tools.list.annotations` span. - const maps = yield* Effect.forEach( - [...groups].slice(0, MAX_ANNOTATION_GROUPS), - ([key, groupRows]) => - Effect.gen(function* () { - const [pluginId, sourceId] = key.split("\u0000") as [string, string]; - const runtime = runtimes.get(pluginId); - if (!runtime?.plugin.resolveAnnotations) return undefined; - return yield* runtime.plugin - .resolveAnnotations({ - ctx: runtime.ctx, - sourceId, - toolRows: groupRows, - }) - .pipe( - Effect.mapError((cause) => - pluginStorageFailure(pluginId, "resolveAnnotations", cause), - ), - ); - }), - { concurrency: "unbounded" }, - ); - for (const map of maps) { - if (!map) continue; - for (const [toolId, annotations] of Object.entries(map)) { - result.set(toolId, annotations); - } - } - return result; - }); - - const listTools = (filter?: ToolListFilter) => - Effect.gen(function* () { - const dynamic = yield* core.findMany("tool", { - where: scopedWhere( - scopeIds, - filter?.sourceId ? (b) => b("source_id", "=", filter.sourceId!) : undefined, - ), - }); - // Dedup by tool id, innermost scope winning — same reason as - // `listSources` above: a shadowed id must surface as one entry - // (the inner one), not two. - const byId = new Map(); - const byIdRank = new Map(); - for (const row of dynamic) { - const rank = scopeRank(row); - const existing = byIdRank.get(row.id); - if (existing === undefined || rank < existing) { - byId.set(row.id, row); - byIdRank.set(row.id, rank); - } - } - const dynamicDeduped = [...byId.values()]; - const annotations = - filter?.includeAnnotations === false - ? new Map() - : yield* resolveAnnotationsFor(dynamicDeduped).pipe( - Effect.withSpan("executor.tools.list.annotations"), - ); - - const out: Tool[] = []; - // Static tools — annotations from the declaration, not a resolver. - for (const entry of staticTools.values()) { - out.push(staticDeclToTool(entry.source, entry.tool, entry.pluginId)); - } - for (const row of dynamicDeduped) { - out.push(rowToTool(row, annotations.get(row.id))); - } - const filtered = filter ? out.filter((t) => toolMatchesFilter(t, filter)) : out; - - // Drop tools blocked by user policy unless the caller explicitly - // asked to see them (the settings UI does, agent surfaces don't). - // One findMany covers the entire scope stack; resolution per - // tool is in-memory. - let result = filtered; - let blockedCount = 0; - if (filter?.includeBlocked !== true) { - const policies = yield* loadAllPolicies(); - if (policies.length > 0) { - const kept: Tool[] = []; - for (const tool of filtered) { - const match = resolveToolPolicy(tool.id, policies, scopeRank); - if (match?.action === "block") { - blockedCount++; - continue; - } - kept.push(tool); - } - result = kept; - } - } - - yield* Effect.annotateCurrentSpan({ - "executor.tools.static_count": staticTools.size, - "executor.tools.dynamic_count": dynamicDeduped.length, - "executor.tools.result_count": result.length, - "executor.tools.blocked_count": blockedCount, - }); - return result; - }).pipe(Effect.withSpan("executor.tools.list")); - - // Load all definitions for a single source as a plain map. Defs - // for the same name can exist at multiple scopes (an admin registers - // a default, a user overrides one entry with a tighter schema) — - // dedup by name keeping the innermost-scope row. - const loadDefinitionsForSource = (sourceId: string) => - Effect.gen(function* () { - const defRows = yield* core.findMany("definition", { - where: scopedWhere(scopeIds, (b) => b("source_id", "=", sourceId)), - }); - const winners = new Map(); - for (const row of defRows) { - const rank = scopeRank(row); - const existing = winners.get(row.name); - if (!existing || rank < existing.rank) { - winners.set(row.name, { row, rank }); - } - } - const out: Record = {}; - for (const [name, { row }] of winners) out[name] = row.schema; - return out; - }); - - // Render the ToolSchema view for a tool — wraps the raw JSON schemas - // with attached `$defs` and runs them through the TypeScript preview - // helpers so the UI gets ready-to-display code samples. - const buildToolSchemaView = (opts: { - toolId: string; - name?: string; - description?: string; - sourceId: string | undefined; - rawInput: unknown; - rawOutput: unknown; - }) => - Effect.gen(function* () { - const defs: Record = opts.sourceId - ? yield* loadDefinitionsForSource(opts.sourceId).pipe( - Effect.withSpan("executor.tool.schema.load_defs"), - ) - : {}; - - const attachDefs = (schema: unknown): unknown => { - if (schema == null || typeof schema !== "object") return schema; - if (Object.keys(defs).length === 0) return schema; - return { ...(schema as Record), $defs: defs }; - }; - - const inputSchema = attachDefs(opts.rawInput); - const outputSchema = attachDefs(opts.rawOutput); - - const defsMap = new Map(Object.entries(defs)); - const preview = yield* Effect.sync(() => - buildToolTypeScriptPreview({ - inputSchema, - outputSchema, - defs: defsMap, - }), - ).pipe( - Effect.withSpan("schema.compile.preview", { - attributes: { - "schema.kind": "tool.preview", - "schema.has_input": inputSchema !== undefined, - "schema.has_output": outputSchema !== undefined, - "schema.def_count": defsMap.size, - }, - }), - ); - - return ToolSchema.make({ - id: ToolId.make(opts.toolId), - name: opts.name, - description: opts.description, - inputSchema, - outputSchema, - inputTypeScript: preview.inputTypeScript ?? undefined, - outputTypeScript: preview.outputTypeScript ?? undefined, - typeScriptDefinitions: preview.typeScriptDefinitions ?? undefined, - }); - }); - - const toolSchema = (toolId: string) => - Effect.gen(function* () { - // Static pool first — static tools have no source in the DB so - // no `$defs` attach; just wrap the declared schemas. - const staticEntry = staticTools.get(toolId); - if (staticEntry) { - yield* Effect.annotateCurrentSpan({ - "executor.tool.dispatch_path": "static", - "executor.source_id": staticEntry.source.id, - "executor.source_kind": staticEntry.source.kind, - }); - return yield* buildToolSchemaView({ - toolId, - name: staticEntry.tool.name, - description: staticEntry.tool.description, - sourceId: undefined, - rawInput: toToolJsonSchema(staticEntry.tool.inputSchema), - rawOutput: toToolJsonSchema(staticEntry.tool.outputSchema, "output"), - }); - } - // Innermost-wins lookup across every visible scope. - const rows = yield* core - .findMany("tool", { - where: scopedWhere(scopeIds, byId(toolId)), - }) - .pipe(Effect.withSpan("executor.tool.resolve")); - const row = findInnermost(rows); - if (!row) return null; - yield* Effect.annotateCurrentSpan({ - "executor.tool.dispatch_path": "dynamic", - "executor.source_id": row.source_id, - "executor.plugin_id": row.plugin_id, - }); - return yield* buildToolSchemaView({ - toolId, - name: row.name, - description: row.description, - sourceId: row.source_id, - rawInput: decodeJsonColumn(row.input_schema), - rawOutput: decodeJsonColumn(row.output_schema), - }); - }).pipe( - Effect.withSpan("executor.tool.schema", { - attributes: { "mcp.tool.name": toolId }, - }), - ); - - // Bulk definitions accessor — every source's $defs, grouped by - // source id. One query against the definition table, plus an - // in-memory group-by with innermost-scope dedup: if the same - // (source_id, name) pair exists at multiple scopes, the inner - // scope's schema wins. - const toolsDefinitions = () => - Effect.gen(function* () { - const rows = yield* core.findMany("definition", { where: scopedWhere(scopeIds) }); - const winners = new Map(); - for (const row of rows) { - const key = `${row.source_id}\u0000${row.name}`; - const rank = scopeRank(row); - const existing = winners.get(key); - if (!existing || rank < existing.rank) { - winners.set(key, { row, rank }); - } - } - const out: Record> = {}; - for (const { row } of winners.values()) { - let bucket = out[row.source_id]; - if (!bucket) { - bucket = {}; - out[row.source_id] = bucket; - } - bucket[row.name] = row.schema; - } - return out; - }); - - const defaultElicitationHandler = resolveElicitationHandler(config.onElicitation); - const pickHandler = (options: InvokeOptions | undefined): ElicitationHandler => - options?.onElicitation - ? resolveElicitationHandler(options.onElicitation) - : defaultElicitationHandler; - - const buildElicit = (toolId: string, args: unknown, handler: ElicitationHandler): Elicit => { - return (request: ElicitationRequest) => - Effect.gen(function* () { - const tid = ToolId.make(toolId); - const response: ElicitationResponse = yield* handler({ - toolId: tid, - args, - request, - }); - if (response.action !== "accept") { - return yield* new ElicitationDeclinedError({ - toolId: tid, - action: response.action, - }); - } - return response; - }); - }; - - // ------------------------------------------------------------------ - // Tool policies — user-authored overrides of the plugin-derived - // approval annotations. Resolution walks the scope-stacked policy - // table with first-match-wins ordering (innermost scope first, then - // `position` ascending). The result either short-circuits invoke - // (`block`), forces approval (`require_approval`), skips approval - // (`approve`), or returns `undefined` so the plugin annotation is - // used as today. - // ------------------------------------------------------------------ - - const loadAllPolicies = () => core.findMany("tool_policy", { where: scopedWhere(scopeIds) }); - - const resolveToolPolicyForId = (toolId: string) => - Effect.gen(function* () { - const policies = yield* loadAllPolicies(); - return resolveToolPolicy(toolId, policies, scopeRank); - }); - - const enforceApproval = ( - annotations: ToolAnnotations | undefined, - toolId: string, - args: unknown, - policy: PolicyMatch | undefined, - handler: ElicitationHandler, - ) => - Effect.gen(function* () { - // approve → never prompt regardless of plugin annotation. - if (policy?.action === "approve") return; - - // require_approval → always prompt. If the plugin already had a - // description, prefer it; otherwise show the matched pattern so - // the user can see *why* the prompt fired. - const policyForcesApproval = policy?.action === "require_approval"; - if (!policyForcesApproval && !annotations?.requiresApproval) return; - - const tid = ToolId.make(toolId); - const message = annotations?.approvalDescription - ? annotations.approvalDescription - : policyForcesApproval && policy - ? `Approve ${toolId}? (matched policy: ${policy.pattern})` - : `Approve ${toolId}?`; - const request = FormElicitation.make({ - message: `${message}\n\nArguments:\n${approvalArgumentPreview(args)}`, - requestedSchema: { - type: "object", - properties: {}, - }, - }); - const response = yield* handler({ toolId: tid, args, request }); - if (response.action !== "accept") { - return yield* new ElicitationDeclinedError({ - toolId: tid, - action: response.action, - }); - } - }); - - const invokeTool = (toolId: string, args: unknown, options?: InvokeOptions) => { - const handler = pickHandler(options); - return Effect.gen(function* () { - const formatInvocationCauseMessage = (cause: unknown): string => { - // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: preserve public invoke error message wrapping for unknown plugin failures - return cause instanceof Error ? cause.message : String(cause); - }; - const wrapInvocationError = ( - effect: Effect.Effect, - ): Effect.Effect => - effect.pipe( - Effect.mapError( - (cause) => - new ToolInvocationError({ - toolId: ToolId.make(toolId), - message: formatInvocationCauseMessage(cause), - cause, - }), - ), - ); - - // Resolve the user-authored policy first. A `block` rule - // short-circuits both the static and dynamic paths before any - // plugin code runs. - const policy = yield* resolveToolPolicyForId(toolId).pipe( - Effect.withSpan("executor.tool.resolve_policy"), - ); - if (policy?.action === "block") { - return yield* new ToolBlockedError({ - toolId: ToolId.make(toolId), - pattern: policy.pattern, - }); - } - - // Static path — O(1) map lookup, no DB hit. - const staticEntry = staticTools.get(toolId); - if (staticEntry) { - yield* Effect.annotateCurrentSpan({ - "executor.tool.dispatch_path": "static", - "executor.source_id": staticEntry.source.id, - "executor.source_kind": staticEntry.source.kind, - "executor.plugin_id": staticEntry.pluginId, - }); - yield* enforceApproval(staticEntry.tool.annotations, toolId, args, policy, handler).pipe( - Effect.withSpan("executor.tool.enforce_approval"), - ); - return yield* wrapInvocationError( - staticEntry.tool.handler({ - ctx: staticEntry.ctx, - args, - elicit: buildElicit(toolId, args, handler), - }), - ).pipe(Effect.withSpan("executor.tool.handler")); - } - - // Dynamic path — DB lookup + delegate to owning plugin. Walk the - // whole scope stack and pick the innermost-scope row so a user's - // shadow of an outer tool actually wins on invoke. - const toolRows = yield* core - .findMany("tool", { - where: scopedWhere(scopeIds, byId(toolId)), - }) - .pipe(Effect.withSpan("executor.tool.resolve")); - const row = findInnermost(toolRows); - if (!row) { - return yield* new ToolNotFoundError({ - toolId: ToolId.make(toolId), - }); - } - yield* Effect.annotateCurrentSpan({ - "executor.tool.dispatch_path": "dynamic", - "executor.source_id": row.source_id, - "executor.plugin_id": row.plugin_id, - }); - const runtime = runtimes.get(row.plugin_id); - if (!runtime) { - return yield* new PluginNotLoadedError({ - pluginId: row.plugin_id, - toolId: ToolId.make(toolId), - }); - } - if (!runtime.plugin.invokeTool) { - return yield* new NoHandlerError({ - toolId: ToolId.make(toolId), - pluginId: row.plugin_id, - }); - } - - // Ask the plugin to derive annotations for this one row, if it - // has a resolver. Cheap because the plugin typically already - // needs to load its enrichment data to invoke the tool — - // implementations should structure their resolver + invokeTool - // around a single storage read. Skipped entirely when the user - // policy is `approve` — the prompt is going to be skipped no - // matter what the plugin says, so don't pay for the lookup. - let annotations: ToolAnnotations | undefined; - if (policy?.action !== "approve" && runtime.plugin.resolveAnnotations) { - const map = yield* runtime.plugin - .resolveAnnotations({ - ctx: runtime.ctx, - sourceId: row.source_id, - toolRows: [row], - }) - .pipe(wrapInvocationError) - .pipe(Effect.withSpan("executor.tool.resolve_annotations")); - annotations = map[toolId]; - } - yield* enforceApproval(annotations, toolId, args, policy, handler).pipe( - Effect.withSpan("executor.tool.enforce_approval"), - ); - - return yield* wrapInvocationError( - runtime.plugin.invokeTool({ - ctx: runtime.ctx, - toolRow: row, - args, - elicit: buildElicit(toolId, args, handler), - }), - ).pipe(Effect.withSpan("executor.tool.handler")); - }).pipe( - Effect.withSpan("executor.tool.invoke", { - attributes: { - "mcp.tool.name": toolId, - }, - }), - ); - }; - - const removeSource = (input: RemoveSourceInput) => - Effect.gen(function* () { - yield* assertScopeInStack("source remove targetScope", input.targetScope); - const sourceId = input.id; - // Block removal of static sources structurally. - if (staticSources.has(sourceId)) { - return yield* new SourceRemovalNotAllowedError({ sourceId }); - } - const sourceRow = yield* core.findFirst("source", { - where: byScopedId(input.targetScope, sourceId), - }); - if (!sourceRow) return; - if (!sourceRow.can_remove) { - return yield* new SourceRemovalNotAllowedError({ sourceId }); - } - const runtime = runtimes.get(sourceRow.plugin_id); - // Group the plugin's own cleanup + the core row delete into one - // Fuma transaction so removeSource never leaves orphan rows on failure. - yield* transaction( - Effect.gen(function* () { - if (runtime?.plugin.removeSource) { - yield* runtime.plugin - .removeSource({ - ctx: runtime.ctx, - sourceId, - scope: input.targetScope, - }) - .pipe( - Effect.mapError((cause) => - pluginStorageFailure(runtime.plugin.id, "removeSource", cause), - ), - ); - } - yield* deleteSourceById(core, sourceId, input.targetScope); - }), - ); - }); - - const refreshSource = (input: RefreshSourceInput) => - Effect.gen(function* () { - yield* assertScopeInStack("source refresh targetScope", input.targetScope); - const sourceId = input.id; - if (staticSources.has(sourceId)) return; - const sourceRow = yield* core.findFirst("source", { - where: byScopedId(input.targetScope, sourceId), - }); - if (!sourceRow) return; - const runtime = runtimes.get(sourceRow.plugin_id); - if (runtime?.plugin.refreshSource) { - yield* runtime.plugin - .refreshSource({ - ctx: runtime.ctx, - sourceId, - scope: input.targetScope, - }) - .pipe( - Effect.mapError((cause) => - pluginStorageFailure(runtime.plugin.id, "refreshSource", cause), - ), - ); - } - }); - - const sourceDetectionMaxUrlLength = config.sourceDetection?.maxUrlLength ?? 2_048; - const sourceDetectionMaxDetectors = config.sourceDetection?.maxDetectors ?? 6; - const sourceDetectionMaxResults = config.sourceDetection?.maxResults ?? 4; - const sourceDetectionTimeout = config.sourceDetection?.timeout ?? "60 seconds"; - const sourceDetectionHostedOutboundPolicy = - config.sourceDetection?.hostedOutboundPolicy ?? config.httpClientLayer !== undefined; - - // URL autodetection — fan out across a bounded set of plugins that - // declared a `detect` hook. Collect non-null results up to the - // configured cap. Plugin-level detect implementations should - // swallow fetch errors and return null, so one flaky plugin doesn't - // block the whole dispatch. - const detectionConfidenceScore = (confidence: SourceDetectionResult["confidence"]) => - Match.value(confidence).pipe( - Match.when("high", () => 3), - Match.when("medium", () => 2), - Match.when("low", () => 1), - Match.exhaustive, - ); - - const detectSource = (url: string) => - Effect.gen(function* () { - const trimmed = url.trim(); - if (trimmed.length === 0 || trimmed.length > sourceDetectionMaxUrlLength) return []; - const parsed = yield* Effect.try({ - try: () => new URL(trimmed), - catch: (error) => error, - }).pipe(Effect.option); - if (Option.isNone(parsed)) return []; - if (parsed.value.protocol !== "http:" && parsed.value.protocol !== "https:") return []; - if (sourceDetectionHostedOutboundPolicy) { - const allowed = yield* validateHostedOutboundUrl(trimmed).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ); - if (!allowed) return []; - } - - const results: SourceDetectionResult[] = []; - let detectorCount = 0; - for (const runtime of runtimes.values()) { - if (!runtime.plugin.detect) continue; - if (detectorCount >= sourceDetectionMaxDetectors) break; - detectorCount++; - const result = yield* runtime.plugin - .detect({ ctx: runtime.ctx, url: trimmed }) - .pipe(Effect.timeout(sourceDetectionTimeout)) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (result) results.push(result); - } - return results - .sort( - (a, b) => - detectionConfidenceScore(b.confidence) - detectionConfidenceScore(a.confidence), - ) - .slice(0, sourceDetectionMaxResults); - }); - - // Per-source definitions accessor — one query, one mapping pass. - const sourceDefinitions = (sourceId: string) => loadDefinitionsForSource(sourceId); + const executorSurface = makeExecutorSurface({ + core, + scopeIds, + scopeRank, + findInnermost, + staticTools, + staticSources, + runtimes, + transaction, + assertScopeInStack, + onElicitation: config.onElicitation, + resolveElicitationHandler, + sourceDetection: config.sourceDetection, + hostedOutboundPolicyDefault: config.httpClientLayer !== undefined, + }); + const listSources = executorSurface.sources.list; + const removeSource = executorSurface.sources.remove; + const refreshSource = executorSurface.sources.refresh; + const detectSource = executorSurface.sources.detect; + const sourceDefinitions = executorSurface.sources.definitions; + const listTools = executorSurface.tools.list; + const toolSchema = executorSurface.tools.schema; + const toolsDefinitions = executorSurface.tools.definitions; + const invokeTool = executorSurface.tools.invoke; + const policiesList = executorSurface.policies.list; + const policiesCreate = executorSurface.policies.create; + const policiesUpdate = executorSurface.policies.update; + const policiesRemove = executorSurface.policies.remove; + const policiesResolve = executorSurface.policies.resolve; // Existence check for user-facing secret pickers. Core `secret` // rows are routing metadata; when a provider can answer `has()`, @@ -3453,146 +1943,6 @@ export const createExecutor = - Effect.gen(function* () { - const rows = yield* loadAllPolicies(); - const sorted = [...rows].sort((a, b) => { - const sa = scopeRank(a); - const sb = scopeRank(b); - if (sa !== sb) return sa - sb; - return comparePolicyRow(a, b); - }); - return sorted.map((row) => rowToToolPolicy(row)); - }).pipe(Effect.withSpan("executor.policies.list")); - - const policiesCreate = (input: CreateToolPolicyInput) => - Effect.gen(function* () { - yield* assertScopeInStack("tool policy targetScope", input.targetScope); - if (!isValidPattern(input.pattern)) { - return yield* new StorageError({ - message: - `Invalid tool policy pattern "${input.pattern}". ` + - `Patterns must be "*" (every tool), an exact tool id ("a.b.c"), ` + - `or a trailing wildcard ("a.b.*"). Leading "*" prefixes ` + - `("*foo", "*.foo") and "**" are not supported.`, - cause: undefined, - }); - } - if (!isToolPolicyAction(input.action)) { - return yield* new StorageError({ - message: - `Invalid tool policy action "${String(input.action)}". ` + - `Expected "approve" | "require_approval" | "block".`, - cause: undefined, - }); - } - - // Default position: a fractional-indexing key above the - // current minimum. Lets newly-created rules win against - // existing ones, which matches the v1 design — users typically - // add a rule to override behavior they're seeing right now, - // not as a background fallback. - let position = input.position; - if (position === undefined) { - const existing = yield* core.findMany("tool_policy", { - where: (b) => b("scope_id", "=", input.targetScope), - }); - let min: string | null = null; - for (const row of existing) { - const p = row.position; - if (min === null || p < min) min = p; - } - position = generateKeyBetween(null, min); - } - - const id = `pol_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`; - const now = new Date(); - yield* core.create("tool_policy", { - id, - scope_id: input.targetScope, - pattern: input.pattern, - action: input.action, - position, - created_at: now, - updated_at: now, - }); - return rowToToolPolicy({ - id, - scope_id: input.targetScope, - pattern: input.pattern, - action: input.action, - position, - created_at: now, - updated_at: now, - } as ToolPolicyRow); - }).pipe(Effect.withSpan("executor.policies.create")); - - const policiesUpdate = (input: UpdateToolPolicyInput) => - Effect.gen(function* () { - yield* assertScopeInStack("tool policy targetScope", input.targetScope); - if (input.pattern !== undefined && !isValidPattern(input.pattern)) { - return yield* new StorageError({ - message: `Invalid tool policy pattern "${input.pattern}".`, - cause: undefined, - }); - } - if (input.action !== undefined && !isToolPolicyAction(input.action)) { - return yield* new StorageError({ - message: `Invalid tool policy action "${String(input.action)}".`, - cause: undefined, - }); - } - - const rows = yield* core.findMany("tool_policy", { - where: byScopedId(input.targetScope, input.id), - }); - const row = rows[0] ?? null; - if (!row) { - return yield* new StorageError({ - message: `Tool policy "${input.id}" not found in scope "${input.targetScope}".`, - cause: undefined, - }); - } - - const updated: ToolPolicyRow = { - ...row, - pattern: input.pattern ?? row.pattern, - action: input.action ?? row.action, - position: input.position ?? row.position, - updated_at: new Date(), - }; - yield* core.updateMany("tool_policy", { - where: byScopedId(input.targetScope, input.id), - set: { - pattern: updated.pattern, - action: updated.action, - position: updated.position, - updated_at: updated.updated_at, - }, - }); - return rowToToolPolicy(updated); - }).pipe(Effect.withSpan("executor.policies.update")); - - const policiesRemove = (input: RemoveToolPolicyInput) => - Effect.gen(function* () { - yield* assertScopeInStack("tool policy targetScope", input.targetScope); - yield* core.deleteMany("tool_policy", { - where: byScopedId(input.targetScope, input.id), - }); - }).pipe(Effect.withSpan("executor.policies.remove")); - - const policiesResolve = (toolId: string) => - resolveToolPolicyForId(toolId).pipe(Effect.withSpan("executor.policies.resolve")); - const close = () => Effect.gen(function* () { for (const runtime of runtimes.values()) {