1520 lines · 61.8 KB · TypeScript
RawDownload
import { Effect, Match, Option, Schema } from "effect";import type { Layer } from "effect";import { HttpClient } from "effect/unstable/http"; import {  authToolFailure,  detectInsufficientScope,  AuthTemplateSlug,  definePlugin,  IntegrationAlreadyExistsError,  IntegrationDetectionResult,  IntegrationSlug,  mergeAuthTemplates,  sha256Hex,  ToolName,  ToolResult,  type AuthMethodDescriptor,  type HealthCheckCandidate,  type HealthCheckResult,  type IntegrationConfig,  type IntegrationRecord,  type PluginCtx,  type StorageFailure,  type ToolAnnotations,  type ToolDef,} from "@executor-js/sdk/core"; import {  TOKEN_VARIABLE,  describeApiKeyAuthMethod,  describeNoneAuthMethod,  oauthBearerPlacement,  renderAuthPlacements,  requiredPlacementVariables,  type RenderedAuthPlacements,} from "@executor-js/sdk/http-auth"; import {  introspect,  parseIntrospectionJson,  type IntrospectionResult,  type IntrospectionType,  type IntrospectionField,  type IntrospectionInputValue,  type IntrospectionTypeRef,} from "./introspect";import { extract } from "./extract";import {  GraphqlAuthRequiredError,  GraphqlIntrospectionError,  GraphqlInvocationError,} from "./errors";import { effectiveOperationString, invokeWithLayer, type GraphqlInvokeOptions } from "./invoke";import { validateOperationString } from "./validate-selection";import { graphqlPresets } from "./presets";import { makeDefaultGraphqlStore, type GraphqlStore, type StoredOperation } from "./store";import {  GraphqlAuthMethodInput,  decodeGraphqlIntegrationConfig,  decodeGraphqlIntegrationConfigOption,  ExtractedField,  GraphqlIntegrationConfig,  expandGraphqlAuthMethodInputs,  normalizeGraphqlAuthMethods,  OperationBinding,  type GraphqlAuthMethod,  type GraphqlOperationKind,} from "./types"; // ---------------------------------------------------------------------------// GraphQL error-body decoding (for invocation responses)// --------------------------------------------------------------------------- const GraphqlErrorBody = Schema.Struct({ message: Schema.String });const GraphqlErrorsBody = Schema.Array(Schema.Unknown);const decodeGraphqlErrorBody = Schema.decodeUnknownOption(GraphqlErrorBody);const decodeGraphqlErrorsBody = Schema.decodeUnknownOption(GraphqlErrorsBody); const decodeGraphqlErrors = (errors: unknown): readonly unknown[] | undefined =>  Option.getOrUndefined(decodeGraphqlErrorsBody(errors)); const extractGraphqlErrorMessage = (errors: readonly unknown[]): string | undefined =>  errors    .map((error) => Option.getOrUndefined(decodeGraphqlErrorBody(error))?.message)    .find((message) => message !== undefined && message.length > 0); const GRAPHQL_PLUGIN_ID = "graphql";const GRAPHQL_HEALTH_OPERATION = "__schema";const GRAPHQL_HEALTH_CHECK = { operation: GRAPHQL_HEALTH_OPERATION };const GRAPHQL_HEALTH_CANDIDATE: HealthCheckCandidate = {  operation: GRAPHQL_HEALTH_OPERATION,  method: "post",  requiredArgCount: 0,  destructive: false,  summary: "Introspect the GraphQL schema",};const GRAPHQL_INVALID_SCHEMA_DETAIL =  "The endpoint responded without a GraphQL introspection schema. Check that the URL points to a GraphQL endpoint and that introspection is enabled.";const GRAPHQL_AUTH_DETAIL = "Check the credential and selected authentication method.";const GRAPHQL_NETWORK_DETAIL =  "The GraphQL endpoint could not be reached. Check the URL and upstream availability, then try again."; const truncateHealthDetail = (text: string, max = 240): string => {  const normalized = text.replaceAll(/\s+/g, " ").trim();  if (normalized.length <= max) return normalized;  return `${normalized.slice(0, max - 3)}...`;}; const appendUpstreamMessage = (detail: string, message?: string): string =>  message && message.trim().length > 0    ? `${detail} Upstream said: ${truncateHealthDetail(message)}`    : detail; const isAuthMessage = (message: string | undefined): boolean =>  message !== undefined &&  /authoriz|authenticat|forbidden|permission|credential|api.?key|access denied|access token|invalid token|token expired|logged in|sign in/i.test(    message,  ); /** Upstream error text can echo the request back (URLs with query params, *  auth headers), so scrub every credential value out of anything that leaves *  as `detail` so a probe can never leak the secret it authenticated with. */const scrubCredentialValues = (text: string, values: Record<string, string | null>): string =>  Object.values(values)    .filter((value): value is string => typeof value === "string" && value.length > 0)    .reduce((out, secret) => out.split(secret).join("[redacted]"), text); const missingCredentialVariables = (  method: GraphqlAuthMethod | undefined,  values: Record<string, string | null>,): readonly string[] => {  if (!method || method.kind === "none") return [];  const required =    method.kind === "oauth2" ? [TOKEN_VARIABLE] : requiredPlacementVariables(method.placements);  return required.filter((variable) => {    const value = values[variable];    return value == null || value.length === 0;  });}; const healthFromIntrospectionError = (  error: GraphqlIntrospectionError,  checkedAt: number,): HealthCheckResult => {  const upstream = error.upstreamMessage;  const httpStatus = error.status;   if (httpStatus === 401 || httpStatus === 403 || isAuthMessage(upstream)) {    const statusDetail =      httpStatus === 401 || httpStatus === 403        ? `The endpoint rejected the credential with HTTP ${httpStatus}.`        : "The endpoint rejected the credential.";    return {      status: "expired",      ...(httpStatus !== undefined ? { httpStatus } : {}),      checkedAt,      detail: appendUpstreamMessage(`${statusDetail} ${GRAPHQL_AUTH_DETAIL}`, upstream),    };  }   if (    error.reason === "invalid-json" ||    error.reason === "invalid-shape" ||    error.reason === "missing-schema"  ) {    return {      status: "degraded",      ...(httpStatus !== undefined ? { httpStatus } : {}),      checkedAt,      detail: GRAPHQL_INVALID_SCHEMA_DETAIL,    };  }   if (error.reason === "network") {    return {      status: "degraded",      checkedAt,      detail: GRAPHQL_NETWORK_DETAIL,    };  }   if (error.reason === "graphql-errors") {    return {      status: "degraded",      ...(httpStatus !== undefined ? { httpStatus } : {}),      checkedAt,      detail: appendUpstreamMessage(        "Schema introspection returned GraphQL errors. Check that introspection is enabled and the credential can read the schema.",        upstream,      ),    };  }   return {    status: "degraded",    ...(httpStatus !== undefined ? { httpStatus } : {}),    checkedAt,    detail: appendUpstreamMessage(      httpStatus !== undefined        ? `Schema introspection failed with HTTP ${httpStatus}. Check the endpoint and upstream status, then try again.`        : "Schema introspection failed. Check the endpoint and credential, then try again.",      upstream,    ),  };}; // ---------------------------------------------------------------------------// Extension input shapes// --------------------------------------------------------------------------- /** Register a GraphQL integration in the catalog. `endpoint` is the GraphQL URL; *  `slug` (defaulted from the endpoint) is the catalog id; `introspectionJson` *  supplies the schema when the endpoint disables live introspection; `headers` *  / `queryParams` are static and also applied to add-time introspection; *  `authenticationTemplate` declares the auth methods a connection can apply *  through. */const GraphqlAddIntegrationInputSchema = Schema.Struct({  endpoint: Schema.String,  slug: Schema.optional(Schema.String),  name: Schema.optional(Schema.String),  /** Agent-visible catalog description. Falls back to the introspected   *  schema's own description, then the display name. */  description: Schema.optional(Schema.String),  introspectionJson: Schema.optional(Schema.String),  headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),  queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),  authenticationTemplate: Schema.optional(Schema.Array(GraphqlAuthMethodInput)),});export type GraphqlAddIntegrationInput = typeof GraphqlAddIntegrationInputSchema.Type; const GraphqlConfigureInputSchema = Schema.Struct({  name: Schema.optional(Schema.String),  endpoint: Schema.optional(Schema.String),  headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),  queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),  authenticationTemplate: Schema.optional(Schema.Array(GraphqlAuthMethodInput)),});export type GraphqlConfigureInput = typeof GraphqlConfigureInputSchema.Type; /** Input for the custom-method-create flow (HTTP `POST /graphql/integrations/ *  :slug/config`). Unlike `configure` (which REPLACES the whole config for the *  generic repair path), `configureAuth` MERGE-APPENDS these methods onto the *  integration's existing `authenticationTemplate`, mirroring OpenAPI's *  `configure`. */const GraphqlConfigureAuthInputSchema = Schema.Struct({  authenticationTemplate: Schema.Array(GraphqlAuthMethodInput),  mode: Schema.optional(Schema.Literals(["merge", "replace"])),});export type GraphqlConfigureAuthInput = typeof GraphqlConfigureAuthInputSchema.Type; // ---------------------------------------------------------------------------// Static control-tool schemas// --------------------------------------------------------------------------- const StaticAddIntegrationOutputSchema = Schema.Struct({  slug: Schema.String,  name: Schema.String,});const StaticGetIntegrationInputSchema = Schema.Struct({  slug: Schema.String,});const StaticGetIntegrationOutputSchema = Schema.Struct({  integration: Schema.NullOr(Schema.Unknown),}); const StaticAddIntegrationInputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(GraphqlAddIntegrationInputSchema),);const StaticAddIntegrationOutputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(StaticAddIntegrationOutputSchema),);const StaticGetIntegrationInputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(StaticGetIntegrationInputSchema),);const StaticGetIntegrationOutputStandardSchema = Schema.toStandardSchemaV1(  Schema.toStandardJSONSchemaV1(StaticGetIntegrationOutputSchema),); const graphqlToolFailure = (code: string, message: string, details?: unknown) =>  ToolResult.fail({    code,    message,    ...(details === undefined ? {} : { details }),  }); const graphqlAuthToolFailure = (failure: GraphqlAuthRequiredError) =>  authToolFailure({    code: failure.code,    message: failure.message,    integration: { id: failure.integration, scope: failure.owner },    credential: {      kind: failure.credentialKind,      ...(failure.credentialLabel ? { label: failure.credentialLabel } : {}),      connectionId: failure.connection,    },    ...(failure.status !== undefined ? { status: failure.status } : {}),    ...(failure.details !== undefined      ? {          upstream: {            ...(failure.status !== undefined ? { status: failure.status } : {}),            details: failure.details,          },        }      : {}),  }); // ---------------------------------------------------------------------------// Helpers// --------------------------------------------------------------------------- /** Match `token` as a separator-bounded run inside a URL hostname or path, *  used as a low-confidence detection hint when introspection fails. */const urlMatchesToken = (url: URL, token: string): boolean => {  const re = new RegExp(`(?:^|[^a-z0-9])${token}(?:$|[^a-z0-9])`, "i");  return re.test(url.hostname) || re.test(url.pathname);}; /** Derive an integration slug from an endpoint URL. */const slugFromEndpoint = (endpoint: string): string => {  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL construction throws; this helper intentionally falls back to the stable default slug  try {    const url = new URL(endpoint);    return url.hostname.replace(/[^a-z0-9]+/gi, "_").toLowerCase();  } catch {    return "graphql";  }}; const formatTypeRef = (ref: IntrospectionTypeRef): string =>  Match.value(ref.kind).pipe(    Match.when("NON_NULL", () => (ref.ofType ? `${formatTypeRef(ref.ofType)}!` : "Unknown!")),    Match.when("LIST", () => (ref.ofType ? `[${formatTypeRef(ref.ofType)}]` : "[Unknown]")),    Match.option,    Option.getOrElse(() => ref.name ?? "Unknown"),  ); const unwrapTypeName = (ref: IntrospectionTypeRef): string => {  if (ref.name) return ref.name;  if (ref.ofType) return unwrapTypeName(ref.ofType);  return "Unknown";}; // Composite (output) types require a sub-selection; leaves (scalars/enums) must// not have one. Anything we cannot resolve in the type map is treated as a leaf// (custom scalars live in the map as SCALAR; truly-unknown types are rare).const isCompositeType = (  ref: IntrospectionTypeRef,  types: ReadonlyMap<string, IntrospectionType>,): boolean => {  const kind = types.get(unwrapTypeName(ref))?.kind;  return kind === "OBJECT" || kind === "INTERFACE" || kind === "UNION";}; // A field whose argument is non-null without a default cannot be selected by the// generator: it has no value to pass and emitting the field without the argument// is invalid (e.g. GitLab's `metadata.featureFlags(names:)`). Root-field required// arguments are different: those are threaded as operation variables (and// surfaced on the tool's input schema) in `buildOperationStringForField`.const hasRequiredArgWithoutDefault = (field: IntrospectionField): boolean =>  field.args.some(    (arg: IntrospectionInputValue) => arg.type.kind === "NON_NULL" && arg.defaultValue == null,  ); // Build the DEFAULT selection set for a field's return type: every scalar/enum// leaf the generator can select without arguments. It deliberately does NOT// recurse into composite fields or guess at nested selections, for two reasons://   - A real schema (GitLab has 4000+ types) makes any recursive auto-expansion//     either arbitrary (which N fields? how deep?) or so large the server//     rejects it for exceeding its query-complexity budget.//   - A bounded-but-arbitrary selection silently freezes a partial view at sync//     time. Instead, callers that want nested or list data pass an explicit//     `select` (see buildOperationStringForField / invoke), so the choice of//     deeper fields is the caller's, not a guess baked into the tool.// The result is always valid: selecting only leaves never needs a sub-selection,// and a composite type with no selectable leaves falls back to `__typename`.const buildDefaultSelectionSet = (  ref: IntrospectionTypeRef,  types: ReadonlyMap<string, IntrospectionType>,): string => {  const objectType = types.get(unwrapTypeName(ref));  if (!objectType?.fields) return ""; // scalar / enum / unknown: no selection  if (objectType.kind === "SCALAR" || objectType.kind === "ENUM") return "";   const leaves = objectType.fields    .filter(      (f: IntrospectionField) =>        !f.name.startsWith("__") &&        !hasRequiredArgWithoutDefault(f) &&        !isCompositeType(f.type, types),    )    .map((f: IntrospectionField) => f.name);   // A composite type MUST have a non-empty selection; `__typename` is a leaf  // that exists on every composite, so it is a safe minimal fallback.  return leaves.length > 0 ? `{ ${leaves.join(" ")} }` : "{ __typename }";}; // Name every generated operation: some servers reject anonymous operations, and// APM tooling keys traces off the operation name. Field names are already valid// GraphQL name tokens, so the upper-cased field name is a safe operation name.const operationNameForField = (fieldName: string): string =>  fieldName.charAt(0).toUpperCase() + fieldName.slice(1); interface BuiltOperation {  /** The operation with the default (scalar-leaf) selection. */  readonly operationString: string;  /** Everything up to (not including) the field's selection set:   *  `query Op($a: T) { field(a: $a)`. A caller-supplied `select` is wrapped as   *  `{ <select> }` and spliced between this prefix and the suffix at invoke   *  time, so the selection can be chosen per call without re-introspecting. */  readonly operationPrefix: string;  /** Closes the operation: ` }`. */  readonly operationSuffix: string;} const buildOperationStringForField = (  kind: GraphqlOperationKind,  field: IntrospectionField,  types: ReadonlyMap<string, IntrospectionType>,): BuiltOperation => {  const opType = kind === "query" ? "query" : "mutation";  const opName = operationNameForField(field.name);   const varDefs = field.args.map((arg) => {    const typeName = formatTypeRef(arg.type);    return `$${arg.name}: ${typeName}`;  });   const argPasses = field.args.map((arg) => `${arg.name}: $${arg.name}`);  const defaultSelection = buildDefaultSelectionSet(field.type, types);   const varDefsStr = varDefs.length > 0 ? `(${varDefs.join(", ")})` : "";  const argPassStr = argPasses.length > 0 ? `(${argPasses.join(", ")})` : "";   const operationPrefix = `${opType} ${opName}${varDefsStr} { ${field.name}${argPassStr}`;  const operationSuffix = ` }`;  const operationString = `${operationPrefix}${defaultSelection ? ` ${defaultSelection}` : ""}${operationSuffix}`;   return { operationString, operationPrefix, operationSuffix };}; interface PreparedOperation {  readonly toolName: string;  readonly description: string;  readonly inputSchema: unknown;  readonly binding: OperationBinding;} // Surface an optional `select` input on every tool so a caller can choose the// return fields per call. The default operation selects only scalar leaves; to// fetch nested or list data the caller passes a GraphQL selection set here, which// is spliced into the operation at invoke time. A real field argument named// `select` (rare) is left untouched.const withSelectInput = (inputSchema: unknown, returnTypeName: string): unknown => {  const base =    inputSchema && typeof inputSchema === "object"      ? (inputSchema as Record<string, unknown>)      : { type: "object", properties: {} };  const properties = {    ...((base.properties as Record<string, unknown> | undefined) ?? {}),  };  if (!("select" in properties)) {    properties.select = {      type: "string",      description: `Optional GraphQL selection set for the \`${returnTypeName}\` return type. Overrides the default, which selects only scalar fields. Provide the fields to return, with sub-selections for nested objects and arguments where required, e.g. "id name items { id title }". Omit for the default.`,    };  }  return { ...base, type: "object", properties };}; const prepareOperations = (  fields: readonly ExtractedField[],  introspection: IntrospectionResult,): readonly PreparedOperation[] => {  const typeMap = new Map<string, IntrospectionType>();  for (const t of introspection.__schema.types) {    typeMap.set(t.name, t);  }   const fieldMap = new Map<string, { kind: GraphqlOperationKind; field: IntrospectionField }>();  const schema = introspection.__schema;  for (const rootKind of ["query", "mutation"] as const) {    const typeName = rootKind === "query" ? schema.queryType?.name : schema.mutationType?.name;    if (!typeName) continue;    const rootType = typeMap.get(typeName);    if (!rootType?.fields) continue;    for (const f of rootType.fields) {      if (!f.name.startsWith("__")) {        fieldMap.set(`${rootKind}.${f.name}`, { kind: rootKind, field: f });      }    }  }   return fields.map((extracted) => {    const prefix = extracted.kind === "mutation" ? "mutation" : "query";    // A tool's name keeps its `<kind>.<field>` path (e.g. `query.hello`,    // `mutation.setGreeting`). The address grammar treats `<tool>` as the    // trailing remainder (see parseToolAddress), so the dot nests naturally.    const toolName = `${prefix}.${extracted.fieldName}`;    const description = Option.getOrElse(

Lines 1–500 of 1520.