3568 lines · 137.8 KB · TypeScript
RawDownload
import { Clock, Effect, Inspectable, Layer, Option, Predicate, Schema } from "effect";import { FetchHttpClient, type HttpClient } from "effect/unstable/http";import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore";import { fumadb } from "@executor-js/fumadb";import { memoryAdapter } from "@executor-js/fumadb/adapters/memory";import { withQueryContext, type Condition, type ConditionBuilder } from "@executor-js/fumadb/query";import { schema as fumaSchema, type RelationsMap } from "@executor-js/fumadb/schema";import type { AnyColumn } from "@executor-js/fumadb/schema";import { generateKeyBetween } from "fractional-indexing"; import {  StorageError,  isStorageFailure,  makeFumaClient,  type FumaDb,  type FumaRow,  type FumaTables,  type StorageFailure,} from "./fuma-runtime";import { makeFumaBlobStore, pluginBlobStore, type BlobStore, type OwnerPartitions } from "./blob";import { coreToolsPlugin } from "./core-tools";import type {  Connection,  ConnectionInputOrigin,  ConnectionRef,  CreateConnectionInput,  ConnectionValueInput,  UpdateConnectionInput,} from "./connection";import {  coreSchema,  isToolPolicyAction,  TOOL_INVOCATION_COLUMNS,  type ConnectionRow,  type CoreSchema,  type IntegrationRow,  type OAuthClientRow,  type ToolInvocationRow,  type ToolRow,  type ToolPolicyRow,} from "./core-schema";import {  ElicitationDeclinedError,  ElicitationResponse,  FormElicitation,  type ElicitationHandler,  type ElicitationRequest,  type OnElicitation,  type InvokeOptions,} from "./elicitation"; export type { OnElicitation, InvokeOptions } from "./elicitation";import {  ConnectionNotFoundError,  CredentialProviderNotRegisteredError,  CredentialResolutionError,  IntegrationNotFoundError,  InvalidConnectionInputError,  IntegrationRemovalNotAllowedError,  NoHandlerError,  PluginNotLoadedError,  ToolBlockedError,  ToolInvocationError,  ToolNotFoundError,  type ExecuteError,} from "./errors";import {  AuthTemplateSlug,  ConnectionAddress,  ConnectionName,  IntegrationSlug,  NO_AUTH_TEMPLATE,  OAuthClientSlug,  Owner,  PolicyId,  ProviderItemId,  ProviderKey,  Subject,  Tenant,  ToolAddress,  ToolName,} from "./ids";import type {  AuthMethodDescriptor,  Integration,  IntegrationConfig,  RegisterIntegrationInput,} from "./integration";import {  makeOAuthService,  type MintOAuthConnectionInput,  type OAuthScopePolicy,} from "./oauth-service";import type { OAuthService } from "./oauth-client";import {  comparePolicyRow,  isValidPattern,  matchPattern,  resolveEffectivePolicy,  rowToToolPolicy,  type CreateToolPolicyInput,  type EffectivePolicy,  type RemoveToolPolicyInput,  type ToolPolicy,  type UpdateToolPolicyInput,} from "./policies";import type { CredentialProvider, ProviderEntry } from "./provider";import type {  AnyPlugin,  Elicit,  IntegrationConfigureSchema,  IntegrationPresetCatalogEntry,  IntegrationRecord,  OwnerBinding,  PluginCtx,  PluginExtensions,  ResolveToolsResult,  StaticSourceDecl,  StaticToolDecl,  StorageDeps,  ToolPolicyProvider,  ToolPolicyProviderRule,  ToolInvocationCredential,} from "./plugin";import {  pluginStorageId,  type PluginStorageCollectionData,  type PluginStorageCollectionDefinition,  type PluginStorageCollectionQueryInput,  type PluginStorageEntry,  type PluginStorageFacade,  type PluginStorageRuntimeCollectionDefinition,  type PluginStorageRuntimeIndexSpec,} from "./plugin-storage";import {  assertExecutorOwnerPolicyTable,  ORG_SUBJECT,  type ExecutorOwnerPolicyContext,} from "./owner-policy";import { ToolSchemaView, type IntegrationDetectionResult } from "./types";import { type Tool, type ToolAnnotations, type ToolDef, type ToolListFilter } from "./tool";import { buildToolTypeScriptPreview } from "./schema-types";import { collectReferencedDefinitions } from "./schema-refs";import {  refreshAccessToken,  exchangeClientCredentials,  shouldRefreshToken,  type OAuthEndpointUrlPolicy,} from "./oauth-helpers";import { connectionIdentifier } from "./connection-name-identifier";import { annotateToolResultOutcome } from "./tool-result"; const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90;const PLUGIN_STORAGE_CREATE_ROW_BATCH_SIZE = 90;const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000; // ---------------------------------------------------------------------------// Elicitation handler — resolved once at `createExecutor({ onElicitation })`// and overridable per `execute`. A tool that requests user input mid-execution// suspends the fiber and the handler decides how to respond. The "accept-all"// sentinel auto-accepts (tests / non-interactive hosts).// --------------------------------------------------------------------------- const acceptAllHandler: ElicitationHandler = () =>  Effect.succeed(ElicitationResponse.make({ action: "accept" })); const resolveElicitationHandler = (onElicitation: OnElicitation): ElicitationHandler =>  onElicitation === "accept-all" ? acceptAllHandler : onElicitation; // ---------------------------------------------------------------------------// Address scheme — `tools.<integration>.<owner>.<connection>.<tool>`.// --------------------------------------------------------------------------- const ADDRESS_PREFIX = "tools"; export interface ParsedToolAddress {  readonly integration: IntegrationSlug;  readonly owner: Owner;  readonly connection: ConnectionName;  readonly tool: ToolName;} const isOwner = (value: string): value is Owner => value === "org" || value === "user"; /** Parse a callable address; null when it's not a well-formed *  `tools.<integration>.<owner>.<connection>.<tool>`. * *  The four leading segments (prefix, integration, owner, connection) are *  slug-like and never contain a `.`; the `<tool>` segment is the *entire* *  remainder after the 4th dot, so it may itself contain dots. That lets a tool *  whose name is a structured `group.leaf` path (e.g. an OpenAPI *  `aliases.deleteAlias`) address naturally as *  `tools.<integration>.<owner>.<connection>.aliases.deleteAlias` — the same *  dotted nesting the sandbox `tools` proxy produces from property access. */export const parseToolAddress = (address: string): ParsedToolAddress | null => {  // Walk to the 4th dot; everything past it is the tool (dots and all).  let cut = -1;  for (let i = 0; i < 4; i++) {    cut = address.indexOf(".", cut + 1);    if (cut === -1) return null;  }  const [prefix, integration, owner, connection] = address.slice(0, cut).split(".") as [    string,    string,    string,    string,  ];  const tool = address.slice(cut + 1);  if (prefix !== ADDRESS_PREFIX) return null;  if (!isOwner(owner)) return null;  if (integration.length === 0 || connection.length === 0 || tool.length === 0) {    return null;  }  return {    integration: IntegrationSlug.make(integration),    owner,    connection: ConnectionName.make(connection),    tool: ToolName.make(tool),  };}; export const connectionAddress = (  owner: Owner,  integration: IntegrationSlug,  connection: ConnectionName,): ConnectionAddress =>  ConnectionAddress.make(`${ADDRESS_PREFIX}.${integration}.${owner}.${connection}`); export const toolAddress = (  owner: Owner,  integration: IntegrationSlug,  connection: ConnectionName,  tool: ToolName,): ToolAddress =>  ToolAddress.make(`${ADDRESS_PREFIX}.${integration}.${owner}.${connection}.${tool}`); // ---------------------------------------------------------------------------// Owner key helpers — every owned-row write stamps `tenant`, `owner`,// `subject` (org → subject="").// --------------------------------------------------------------------------- interface OwnedKeys {  readonly tenant: string;  readonly owner: Owner;  readonly subject: string;} // ---------------------------------------------------------------------------// Executor — public surface. Every list/execute/schema call is a direct// core-table query unioned with the in-memory static pool.// --------------------------------------------------------------------------- export type Executor<TPlugins extends readonly AnyPlugin[] = readonly []> = {  readonly integrations: {    readonly list: () => Effect.Effect<readonly Integration[], StorageFailure>;    readonly get: (slug: IntegrationSlug) => Effect.Effect<Integration | null, StorageFailure>;    readonly update: (      slug: IntegrationSlug,      patch: { readonly name?: string; readonly description?: string },    ) => Effect.Effect<void, IntegrationNotFoundError | StorageFailure>;    readonly remove: (      slug: IntegrationSlug,    ) => Effect.Effect<void, IntegrationRemovalNotAllowedError | StorageFailure>;    readonly detect: (      url: string,    ) => Effect.Effect<readonly IntegrationDetectionResult[], StorageFailure>;  };   readonly connections: {    readonly create: (      input: CreateConnectionInput,    ) => Effect.Effect<      Connection,      | IntegrationNotFoundError      | CredentialProviderNotRegisteredError      | InvalidConnectionInputError      | StorageFailure    >;    readonly list: (filter?: {      readonly integration?: IntegrationSlug;      readonly owner?: Owner;    }) => Effect.Effect<readonly Connection[], StorageFailure>;    readonly get: (ref: ConnectionRef) => Effect.Effect<Connection | null, StorageFailure>;    /** Edit user-curated metadata (description, identityLabel). Credentials and     *  OAuth lifecycle fields are not editable here. */    readonly update: (      ref: ConnectionRef,      input: UpdateConnectionInput,    ) => Effect.Effect<Connection, ConnectionNotFoundError | StorageFailure>;    readonly remove: (      ref: ConnectionRef,    ) => Effect.Effect<void, ConnectionNotFoundError | StorageFailure>;    readonly refresh: (      ref: ConnectionRef,    ) => Effect.Effect<      readonly Tool[],      ConnectionNotFoundError | IntegrationNotFoundError | StorageFailure    >;  };   /** Shared OAuth service. Hosts use this through the core HTTP OAuth group;   *  plugins see the same service as `ctx.oauth`. */  readonly oauth: OAuthService;   readonly tools: {    readonly list: (filter?: ToolListFilter) => Effect.Effect<readonly Tool[], StorageFailure>;    readonly schema: (address: ToolAddress) => Effect.Effect<ToolSchemaView | null, StorageFailure>;  };   readonly providers: {    readonly list: () => Effect.Effect<readonly ProviderKey[]>;    readonly items: (key: ProviderKey) => Effect.Effect<readonly ProviderEntry[], StorageFailure>;  };   readonly policies: {    readonly list: () => Effect.Effect<readonly ToolPolicy[], StorageFailure>;    readonly create: (input: CreateToolPolicyInput) => Effect.Effect<ToolPolicy, StorageFailure>;    readonly update: (input: UpdateToolPolicyInput) => Effect.Effect<ToolPolicy, StorageFailure>;    readonly remove: (input: RemoveToolPolicyInput) => Effect.Effect<void, StorageFailure>;    readonly resolve: (address: ToolAddress) => Effect.Effect<EffectivePolicy, StorageFailure>;  };   readonly execute: (    address: ToolAddress,    args: unknown,    options?: InvokeOptions,  ) => Effect.Effect<unknown, ExecuteError>;   readonly close: () => Effect.Effect<void, StorageFailure>;   /** Ephemeral key-value cache for derived data. Host-provided when durable   *  storage exists (e.g. Cloudflare KV via `ExecutorConfig.cache`); otherwise   *  a bounded in-memory TTL store scoped to this executor. Consumers must   *  treat entries as best-effort and re-derivable — a cold cache is always   *  correct — and must NOT key anything tenant-sensitive without prefixing,   *  since a host-provided namespace may outlive and span executors. */  readonly cache: KeyValueStore.KeyValueStore;} & PluginExtensions<TPlugins>; export interface ExecutorDb {  readonly db: FumaDb<any>;  readonly close?: () => Effect.Effect<void, StorageFailure> | Promise<void> | void;} export type ExecutorDbInput = FumaDb<any> | ExecutorDb; export type ExecutorDbFactory = (config: {  readonly tables: FumaTables;}) => ExecutorDbInput | Effect.Effect<ExecutorDbInput, StorageFailure>; export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly []> {  /** The org / workspace this executor is bound to. `owner: "org"` rows file   *  here. */  readonly tenant: Tenant;  /** The acting member, or omit for a pure-org executor (no `owner:"user"`). */  readonly subject?: Subject;  readonly db?: ExecutorDbInput | ExecutorDbFactory;  /**   * Backend for the plugin blob seam (`StorageDeps.blobs`). Defaults to the   * FumaDB `blob` table over `db`. Hosts with an object store hand one in   * (e.g. the R2 store in `@executor-js/cloudflare/blob-store`) so multi-MB   * values stay out of the relational tier.   */  readonly blobs?: BlobStore;  /**   * Backend for `executor.cache`. Hosts with a durable KV tier hand one in   * (e.g. `makeCloudflareKeyValueStore` from `@executor-js/cloudflare/key-value-store`);   * without one the executor falls back to a bounded in-memory TTL store,   * which only helps within a single long-lived process.   */  readonly cache?: KeyValueStore.KeyValueStore;  readonly plugins?: TPlugins;  /** Config-level credential providers, merged with every   *  `plugin.credentialProviders`. Config providers register first, so the   *  default (first writable) store is selected from them when present. */  readonly providers?: readonly CredentialProvider[];  /**   * How to respond when a tool requests user input mid-invocation. Pass   * `"accept-all"` for tests / non-interactive hosts, or a handler.   */  readonly onElicitation: OnElicitation;  readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;  /**   * Fetch API implementation for dependencies that cannot consume `httpClientLayer`.   * Prefer `httpClientLayer` for normal SDK and plugin HTTP.   */  readonly fetch?: typeof globalThis.fetch;  /**   * The OAuth callback URL (`${webBaseUrl}/oauth/callback`) the host serves and   * sends to providers. There is NO localhost default: omit it (or pass   * undefined) only for executors that never run interactive OAuth — the   * redirect-requiring flows then fail loudly instead of guessing a callback.   * Hosts serving OAuth derive this from the request origin / web base URL.   */  readonly redirectUri?: string;  /** Optional URL selected organization slug to carry inside OAuth `state`. */  readonly oauthCallbackStateOrgSlug?: string;  readonly oauthEndpointUrlPolicy?: OAuthEndpointUrlPolicy;  /**   * Enable the built-in `core-tools` plugin which contributes agent-facing   * static tools over the v2 surface (integrations / connections / policies).   */  readonly coreTools?: {    readonly webBaseUrl?: string;    readonly orgSlug?: string;    readonly includeProviders?: boolean;  };} // ---------------------------------------------------------------------------// collectTables — return the executor-owned Fuma table set. Plugins persist// through host-owned facades (`pluginStorage`, `blobs`) instead of contributing// table definitions, so the schema is fixed and plugin-independent.// --------------------------------------------------------------------------- export const collectTables = (): FumaTables => {  validateExecutorOwnerPolicyTables(coreSchema);  return { ...coreSchema };}; const validateExecutorOwnerPolicyTables = (tables: FumaTables): void => {  for (const [tableKey, tableDef] of Object.entries(tables)) {    assertExecutorOwnerPolicyTable(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 }); // ---------------------------------------------------------------------------// Default `executor.cache` backend — a bounded in-memory string store with TTL// expiry and LRU eviction (Map iteration order is insertion order; touched// keys are re-inserted). Deliberately modest: it exists so cache consumers can// be written against one interface everywhere, while hosts with real KV// (Cloudflare) swap in a durable backend via `ExecutorConfig.cache`.// --------------------------------------------------------------------------- const MEMORY_CACHE_CAPACITY = 2_048;const MEMORY_CACHE_TTL_MS = 10 * 60 * 1000; const makeMemoryCacheStore = (): KeyValueStore.KeyValueStore => {  const rows = new Map<string, { readonly value: string; readonly expiresAt: number }>();  const evictExpired = (now: number): void => {    for (const [key, entry] of rows) {      if (entry.expiresAt <= now) rows.delete(key);    }  };  const evictOverCapacity = (): void => {    while (rows.size > MEMORY_CACHE_CAPACITY) {      const oldest = rows.keys().next().value;      if (oldest === undefined) break;      rows.delete(oldest);    }  };  return KeyValueStore.makeStringOnly({    get: (key) =>      Effect.map(Clock.currentTimeMillis, (now) => {        const entry = rows.get(key);        if (entry === undefined) return undefined;        if (entry.expiresAt <= now) {          rows.delete(key);          return undefined;        }        // LRU touch: move to the back of the insertion order.        rows.delete(key);        rows.set(key, entry);        return entry.value;      }),    set: (key, value) =>      Effect.map(Clock.currentTimeMillis, (now) => {        evictExpired(now);        rows.delete(key);        rows.set(key, { value, expiresAt: now + MEMORY_CACHE_TTL_MS });        evictOverCapacity();      }),    remove: (key) =>      Effect.sync(() => {        rows.delete(key);      }),    clear: Effect.sync(() => {      rows.clear();    }),    size: Effect.map(Clock.currentTimeMillis, (now) => {      evictExpired(now);

Lines 1–500 of 3568.