import { Effect, Option, Predicate, Schema } from "effect"; import type { Layer } from "effect"; import { HttpClient } from "effect/unstable/http"; import { ConnectionId, CredentialBindingRef, ScopeId, SecretId, SourceDetectionResult, StorageError, ToolResult, defaultSourceInstallScopeId, definePlugin, tool, resolveSecretBackedMap, type CredentialBindingValue, type PluginCtx, type StorageFailure, type ToolAnnotations, type ToolRow, } from "@executor-js/sdk/core"; import { headersToConfigValues, type ConfigFileSink, type OpenApiSourceConfig, } from "@executor-js/config"; import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors"; import { parse, resolveSpecText } from "./parse"; import { extract } from "./extract"; import { compileToolDefinitions, type ToolDefinition } from "./definitions"; import { annotationsForOperation, invokeWithLayer } from "./invoke"; import { previewSpec, type SpecPreview } from "./preview"; import { openApiPresets } from "./presets"; import { makeDefaultOpenapiStore, openapiSchema, type OpenapiStore, type SourceConfig, type StoredOperation, type StoredSource, } from "./store"; import { HeaderValue as HeaderValueSchema, ConfiguredHeaderBinding, OAuth2SourceConfig, OperationBinding, type ConfiguredHeaderValue as ConfiguredHeaderValueValue, type HeaderValue as HeaderValueValue, } from "./types"; // --------------------------------------------------------------------------- // Plugin config // --------------------------------------------------------------------------- const STRINGIFIED_BODY_CAP = 1024; const UpstreamMessageBody = Schema.Struct({ message: Schema.String }); const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String }); const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody }); const UpstreamErrorsArrayBody = Schema.Struct({ errors: Schema.Array( Schema.Struct({ detail: Schema.optional(Schema.String), message: Schema.optional(Schema.String), title: Schema.optional(Schema.String), }), ), }); const UpstreamDescriptionBody = Schema.Struct({ detail: Schema.optional(Schema.String), title: Schema.optional(Schema.String), description: Schema.optional(Schema.String), }); const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody); const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody); const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody); const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody); const decodeUpstreamDescriptionBody = Schema.decodeUnknownOption(UpstreamDescriptionBody); const clampedStringify = (value: unknown): string => { let s: string; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.stringify may throw on cycles; fall back to String() so the upstream body can still be surfaced as ToolError.details fallback text try { s = JSON.stringify(value); } catch { s = String(value); } return s.length > STRINGIFIED_BODY_CAP ? `${s.slice(0, STRINGIFIED_BODY_CAP)}…` : s; }; const firstNonEmpty = (...values: readonly (string | undefined)[]): string | undefined => values.find((value) => value !== undefined && value.length > 0); // Walk known upstream error-body shapes so ToolError.message stays concise // while ToolError.details preserves the original body. const extractUpstreamMessage = (body: unknown, status: number): string => { if (typeof body === "string") { return body.length > 0 ? body : `Upstream returned HTTP ${status}`; } const nested = Option.getOrUndefined(decodeUpstreamNestedErrorBody(body)); const messageBody = Option.getOrUndefined(decodeUpstreamMessageBody(body)); const errorMessageBody = Option.getOrUndefined(decodeUpstreamErrorMessageBody(body)); const errorsBody = Option.getOrUndefined(decodeUpstreamErrorsArrayBody(body)); const descriptionBody = Option.getOrUndefined(decodeUpstreamDescriptionBody(body)); const arrayMessage = errorsBody?.errors .map(({ detail, message: upstreamMessage, title }) => firstNonEmpty(detail, upstreamMessage, title), ) .find((message) => message !== undefined); const message = firstNonEmpty( nested?.error.message, messageBody?.message, errorMessageBody?.errorMessage, arrayMessage, descriptionBody?.detail, descriptionBody?.title, descriptionBody?.description, ); if (message !== undefined) return message; if (body !== null && typeof body === "object") { return clampedStringify(body); } return `Upstream returned HTTP ${status}`; }; export type HeaderValue = HeaderValueValue; export type ConfiguredHeaderValue = ConfiguredHeaderValueValue; export type OpenApiOAuthInput = OAuth2SourceConfig; export type OpenApiSpecInput = typeof OpenApiSpecInputSchema.Type; export type OpenApiSecretShapeInput = typeof OpenApiSecretShapeInputSchema.Type; export type OpenApiConfiguredValueInput = | string | OpenApiSecretShapeInput | ConfiguredHeaderValueValue; export interface OpenApiSpecFetchCredentialsInput { readonly headers?: Record; readonly queryParams?: Record; } export interface OpenApiPreviewSpecFetchCredentialsInput { readonly headers?: Record; readonly queryParams?: Record; } export interface OpenApiPreviewInput { readonly spec: string; readonly specFetchCredentials?: OpenApiPreviewSpecFetchCredentialsInput; } export interface OpenApiSpecConfig { readonly spec: OpenApiSpecInput; readonly specFetchCredentials?: OpenApiSpecFetchCredentialsInput; /** * Executor scope id that owns this source row. Must be one of the * executor's configured scopes. Typical shape: an admin adds the * source at the outermost (organization) scope so it's visible to * every inner (per-user) scope via fall-through reads. */ readonly scope: string; readonly name: string; readonly baseUrl: string; readonly namespace: string; readonly headers?: Record; readonly queryParams?: Record; readonly oauth2?: OpenApiOAuthInput; } export interface OpenApiSourceRef { readonly id: string; readonly scope: string; } export type OpenApiConfigureCredentialInput = | string | { readonly kind: "text"; readonly text: string; readonly prefix?: string; } | { readonly kind: "secret"; readonly secretId: string; readonly secretScope?: string; readonly prefix?: string; } | { readonly kind: "connection"; readonly connectionId: string; }; /** * Errors any OpenAPI extension method may surface. The first three are * plugin-domain tagged errors that flow directly to clients (4xx, each * carrying its own `HttpApiSchema` status). `StorageFailure` covers * raw backend failures (`StorageError`) plus `UniqueViolationError`; * the HTTP edge (`@executor-js/api`'s `withCapture`) translates * `StorageError` to the opaque `InternalError({ traceId })` at Layer * composition. `UniqueViolationError` passes through — plugins can * `Effect.catchTag` it if they want a friendlier user-facing error. */ export type OpenApiExtensionFailure = | OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError | StorageFailure; export interface OpenApiPluginExtension { readonly previewSpec: ( input: string | OpenApiPreviewInput, ) => Effect.Effect< SpecPreview, OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError | StorageFailure >; readonly addSpec: ( config: OpenApiSpecConfig, ) => Effect.Effect< { readonly sourceId: string; readonly toolCount: number }, OpenApiParseError | OpenApiExtractionError | OpenApiOAuthError | StorageFailure >; readonly removeSpec: (namespace: string, scope: string) => Effect.Effect; readonly getSource: ( namespace: string, scope: string, ) => Effect.Effect; readonly configure: ( source: OpenApiSourceRef, input: OpenApiConfigureInput, ) => Effect.Effect; } // --------------------------------------------------------------------------- // Control-tool input/output schemas // --------------------------------------------------------------------------- const PreviewSpecInputSchema = Schema.Struct({ spec: Schema.String, specFetchCredentials: Schema.optional( Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, HeaderValueSchema)), queryParams: Schema.optional(Schema.Record(Schema.String, HeaderValueSchema)), }), ), }); const StaticPreviewServerVariableSchema = Schema.Struct({ default: Schema.String, enum: Schema.NullOr(Schema.Array(Schema.String)), description: Schema.NullOr(Schema.String), }); const StaticPreviewServerSchema = Schema.Struct({ url: Schema.String, description: Schema.NullOr(Schema.String), variables: Schema.NullOr(Schema.Record(Schema.String, StaticPreviewServerVariableSchema)), }); const StaticPreviewOAuthAuthorizationCodeFlowSchema = Schema.Struct({ authorizationUrl: Schema.String, tokenUrl: Schema.String, refreshUrl: Schema.NullOr(Schema.String), scopes: Schema.Record(Schema.String, Schema.String), }); const StaticPreviewOAuthClientCredentialsFlowSchema = Schema.Struct({ tokenUrl: Schema.String, refreshUrl: Schema.NullOr(Schema.String), scopes: Schema.Record(Schema.String, Schema.String), }); const StaticPreviewOAuthFlowsSchema = Schema.Struct({ authorizationCode: Schema.NullOr(StaticPreviewOAuthAuthorizationCodeFlowSchema), clientCredentials: Schema.NullOr(StaticPreviewOAuthClientCredentialsFlowSchema), }); const StaticPreviewSecuritySchemeSchema = Schema.Struct({ name: Schema.String, type: Schema.Literals(["http", "apiKey", "oauth2", "openIdConnect"]), scheme: Schema.NullOr(Schema.String), bearerFormat: Schema.NullOr(Schema.String), in: Schema.NullOr(Schema.Literals(["header", "query", "cookie"])), headerName: Schema.NullOr(Schema.String), description: Schema.NullOr(Schema.String), flows: Schema.NullOr(StaticPreviewOAuthFlowsSchema), openIdConnectUrl: Schema.NullOr(Schema.String), }); const StaticPreviewOAuth2PresetSchema = Schema.Struct({ label: Schema.String, securitySchemeName: Schema.String, flow: Schema.Literals(["authorizationCode", "clientCredentials"]), authorizationUrl: Schema.NullOr(Schema.String), tokenUrl: Schema.String, refreshUrl: Schema.NullOr(Schema.String), scopes: Schema.Record(Schema.String, Schema.String), }); const StaticPreviewSpecOutputSchema = Schema.Struct({ title: Schema.NullOr(Schema.String), version: Schema.NullOr(Schema.String), servers: Schema.Array(StaticPreviewServerSchema), operationCount: Schema.Number, tags: Schema.Array(Schema.String), securitySchemes: Schema.Array(StaticPreviewSecuritySchemeSchema), authStrategies: Schema.Array(Schema.Struct({ schemes: Schema.Array(Schema.String) })), headerPresets: Schema.Array( Schema.Struct({ label: Schema.String, headers: Schema.Record(Schema.String, Schema.NullOr(Schema.String)), secretHeaders: Schema.Array(Schema.String), }), ), oauth2Presets: Schema.Array(StaticPreviewOAuth2PresetSchema), }); type StaticPreviewSpecOutput = typeof StaticPreviewSpecOutputSchema.Type; const OpenApiSpecInputSchema = Schema.Union([ Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }), Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }), ]); const OpenApiSecretShapeInputSchema = Schema.Struct({ kind: Schema.Literal("secret"), prefix: Schema.optional(Schema.String), }); const OpenApiConfiguredValueInputSchema = Schema.Union([ Schema.String, OpenApiSecretShapeInputSchema, ]); const OpenApiConfigureCredentialInputSchema = Schema.Union([ Schema.String, Schema.Struct({ kind: Schema.Literal("text"), text: Schema.String, prefix: Schema.optional(Schema.String), }), Schema.Struct({ kind: Schema.Literal("secret"), secretId: Schema.String, secretScope: Schema.optional(Schema.String), prefix: Schema.optional(Schema.String), }), Schema.Struct({ kind: Schema.Literal("connection"), connectionId: Schema.String, }), ]); const OpenApiConfigureInputSchema = Schema.Struct({ scope: Schema.String, name: Schema.optional(Schema.String), baseUrl: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfigureCredentialInputSchema)), queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfigureCredentialInputSchema)), specFetchCredentials: Schema.optional( Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfigureCredentialInputSchema)), queryParams: Schema.optional( Schema.Record(Schema.String, OpenApiConfigureCredentialInputSchema), ), }), ), oauth2: Schema.optional( Schema.Struct({ clientId: Schema.optional(OpenApiConfigureCredentialInputSchema), clientSecret: Schema.optional(OpenApiConfigureCredentialInputSchema), connection: Schema.optional(OpenApiConfigureCredentialInputSchema), }), ), oauth2Source: Schema.optional(OAuth2SourceConfig), }); export type OpenApiConfigureInput = typeof OpenApiConfigureInputSchema.Type; const OpenApiConfigureSourceInputSchema = Schema.Struct({ source: Schema.Struct({ id: Schema.String, scope: Schema.String, }), ...OpenApiConfigureInputSchema.fields, }); const OpenApiOAuthInputSchema = OAuth2SourceConfig; const AddSourceInputSchema = Schema.Struct({ spec: OpenApiSpecInputSchema, name: Schema.String, baseUrl: Schema.String, namespace: Schema.String, headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)), queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)), oauth2: Schema.optional(OpenApiOAuthInputSchema), specFetchCredentials: Schema.optional( Schema.Struct({ headers: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)), queryParams: Schema.optional(Schema.Record(Schema.String, OpenApiConfiguredValueInputSchema)), }), ), }); const AddSourceOutputSchema = Schema.Struct({ sourceId: Schema.String, source: Schema.Struct({ id: Schema.String, scope: Schema.String, }), toolCount: Schema.Number, }); const GetSourceInputSchema = Schema.Struct({ namespace: Schema.String, scope: Schema.String, }); const GetSourceOutputSchema = Schema.Struct({ source: Schema.NullOr(Schema.Unknown), }); const PreviewSpecInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(PreviewSpecInputSchema), ); const PreviewSpecOutputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(StaticPreviewSpecOutputSchema), ); const AddSourceInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(AddSourceInputSchema), ); const AddSourceOutputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(AddSourceOutputSchema), ); const OpenApiConfigureSourceInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(OpenApiConfigureSourceInputSchema), ); const OpenApiConfigureSourceOutputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(Schema.Struct({ result: Schema.Array(CredentialBindingRef) })), ); const GetSourceInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(GetSourceInputSchema), ); const GetSourceOutputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(GetSourceOutputSchema), ); const openApiToolFailure = (code: string, message: string, details?: unknown) => ToolResult.fail({ code, message, ...(details === undefined ? {} : { details }), }); const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ({ title: Option.getOrNull(preview.title), version: Option.getOrNull(preview.version), servers: preview.servers.map((server) => ({ url: server.url, description: Option.getOrNull(server.description), variables: Option.getOrNull(server.variables) ? Object.fromEntries( Object.entries(Option.getOrNull(server.variables) ?? {}).map(([name, variable]) => [ name, { default: variable.default, enum: Option.getOrNull(variable.enum), description: Option.getOrNull(variable.description), }, ]), ) : null, })), operationCount: preview.operationCount, tags: preview.tags, securitySchemes: preview.securitySchemes.map((scheme) => ({ name: scheme.name, type: scheme.type, scheme: Option.getOrNull(scheme.scheme), bearerFormat: Option.getOrNull(scheme.bearerFormat), in: Option.getOrNull(scheme.in), headerName: Option.getOrNull(scheme.headerName), description: Option.getOrNull(scheme.description), flows: Option.isSome(scheme.flows) ? { authorizationCode: Option.isSome(scheme.flows.value.authorizationCode) ? { authorizationUrl: scheme.flows.value.authorizationCode.value.authorizationUrl, tokenUrl: scheme.flows.value.authorizationCode.value.tokenUrl, refreshUrl: Option.getOrNull(scheme.flows.value.authorizationCode.value.refreshUrl), scopes: scheme.flows.value.authorizationCode.value.scopes, } : null, clientCredentials: Option.isSome(scheme.flows.value.clientCredentials) ? { tokenUrl: scheme.flows.value.clientCredentials.value.tokenUrl, refreshUrl: Option.getOrNull(scheme.flows.value.clientCredentials.value.refreshUrl), scopes: scheme.flows.value.clientCredentials.value.scopes, } : null, } : null, openIdConnectUrl: Option.getOrNull(scheme.openIdConnectUrl), })), authStrategies: preview.authStrategies, headerPresets: preview.headerPresets, oauth2Presets: preview.oauth2Presets.map((preset) => ({ label: preset.label, securitySchemeName: preset.securitySchemeName, flow: preset.flow, authorizationUrl: Option.getOrNull(preset.authorizationUrl), tokenUrl: preset.tokenUrl, refreshUrl: Option.getOrNull(preset.refreshUrl), scopes: preset.scopes, })), }); const resolveStaticScopeInput = ( ctx: { readonly scopes: readonly { readonly id: ScopeId; readonly name: string }[] }, value: string, ): string => String( ctx.scopes.find((scope) => scope.name === value || String(scope.id) === value)?.id ?? value, ); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Rewrite OpenAPI `#/components/schemas/X` refs to standard `#/$defs/X`. */ const normalizeOpenApiRefs = (node: unknown): unknown => { if (node == null || typeof node !== "object") return node; if (Array.isArray(node)) { let changed = false; const out = node.map((item) => { const n = normalizeOpenApiRefs(item); if (n !== item) changed = true; return n; }); return changed ? out : node; } const obj = node as Record; if (typeof obj.$ref === "string") { const match = obj.$ref.match(/^#\/components\/schemas\/(.+)$/); if (match) return { ...obj, $ref: `#/$defs/${match[1]}` }; return obj; } let changed = false; const result: Record = {}; for (const [k, v] of Object.entries(obj)) { const n = normalizeOpenApiRefs(v); if (n !== v) changed = true; result[k] = n; } return changed ? result : obj; }; const toBinding = (def: ToolDefinition): OperationBinding => OperationBinding.make({ method: def.operation.method, pathTemplate: def.operation.pathTemplate, parameters: [...def.operation.parameters], requestBody: def.operation.requestBody, }); const descriptionFor = (def: ToolDefinition): string => { const op = def.operation; return Option.getOrElse(op.description, () => Option.getOrElse(op.summary, () => `${op.method.toUpperCase()} ${op.pathTemplate}`), ); }; const slotPart = (value: string): string => value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") || "default"; const headerSlotFromName = (name: string): string => `header:${slotPart(name)}`; const queryParamSlotFromName = (name: string): string => `query_param:${slotPart(name)}`; const specFetchHeaderSlotFromName = (name: string): string => `spec_fetch_header:${slotPart(name)}`; const specFetchQueryParamSlotFromName = (name: string): string => `spec_fetch_query_param:${slotPart(name)}`; const canonicalizeHeaders = ( headers: Record | undefined, ): { readonly headers: Record; } => { const nextHeaders: Record = {}; for (const [name, value] of Object.entries(headers ?? {})) { if (typeof value === "string") { nextHeaders[name] = value; continue; } if (value.kind === "binding") { nextHeaders[name] = value; continue; } const slot = headerSlotFromName(name); nextHeaders[name] = ConfiguredHeaderBinding.make({ kind: "binding", slot, prefix: value.prefix, }); } return { headers: nextHeaders }; }; const canonicalizeCredentialMap = ( values: Record | undefined, slotForName: (name: string) => string, ): { readonly values: Record; } => { const nextValues: Record = {}; for (const [name, value] of Object.entries(values ?? {})) { if (typeof value === "string") { nextValues[name] = value; continue; } if (value.kind === "binding") { nextValues[name] = value; continue; } const slot = slotForName(name); nextValues[name] = ConfiguredHeaderBinding.make({ kind: "binding", slot, prefix: value.prefix, }); } return { values: nextValues }; }; const canonicalizeSpecFetchCredentials = ( credentials: | { readonly headers?: Record; readonly queryParams?: Record; } | undefined, ): { readonly credentials?: SourceConfig["specFetchCredentials"]; } => { const headers = canonicalizeCredentialMap(credentials?.headers, specFetchHeaderSlotFromName); const queryParams = canonicalizeCredentialMap( credentials?.queryParams, specFetchQueryParamSlotFromName, ); const nextCredentials = Object.keys(headers.values).length === 0 && Object.keys(queryParams.values).length === 0 ? undefined : { ...(Object.keys(headers.values).length > 0 ? { headers: headers.values } : {}), ...(Object.keys(queryParams.values).length > 0 ? { queryParams: queryParams.values } : {}), }; return { credentials: nextCredentials, }; }; const canonicalizeOAuth2 = ( oauth2: OpenApiOAuthInput | undefined, ): { readonly oauth2?: OAuth2SourceConfig; } => { if (!oauth2) return {}; return { oauth2, }; }; const configuredValueFromConfigureInput = ( slot: string, input: OpenApiConfigureCredentialInput, ): { readonly configured: ConfiguredHeaderValue; readonly value: CredentialBindingValue; } => { if (typeof input === "string") { return { configured: ConfiguredHeaderBinding.make({ kind: "binding", slot }), value: { kind: "text", text: input }, }; } if (input.kind === "text") { return { configured: ConfiguredHeaderBinding.make({ kind: "binding", slot, prefix: input.prefix, }), value: { kind: "text", text: input.text }, }; } if (input.kind === "secret") { return { configured: ConfiguredHeaderBinding.make({ kind: "binding", slot, prefix: input.prefix, }), value: { kind: "secret", secretId: SecretId.make(input.secretId), ...(input.secretScope ? { secretScopeId: ScopeId.make(input.secretScope) } : {}), }, }; } return { configured: ConfiguredHeaderBinding.make({ kind: "binding", slot }), value: { kind: "connection", connectionId: ConnectionId.make(input.connectionId) }, }; }; const configureMap = ( values: Record | undefined, slotForName: (name: string) => string, ): { readonly configured: Record; readonly bindings: ReadonlyArray<{ readonly slotKey: string; readonly value: CredentialBindingValue; }>; } => { const configured: Record = {}; const bindings: Array<{ readonly slotKey: string; readonly value: CredentialBindingValue; }> = []; for (const [name, input] of Object.entries(values ?? {})) { const slotKey = slotForName(name); const next = configuredValueFromConfigureInput(slotKey, input); configured[name] = next.configured; bindings.push({ slotKey, value: next.value }); } return { configured, bindings }; }; const configureOpenApiSource = ( ctx: PluginCtx, source: OpenApiSourceRef, input: OpenApiConfigureInput, ): Effect.Effect => Effect.gen(function* () { const existing = yield* ctx.storage.getSource(source.id, source.scope); if (!existing) { return yield* new StorageError({ message: `Cannot configure OpenAPI source "${source.id}" at scope "${source.scope}": ` + "source is not visible.", cause: undefined, }); } const headers = configureMap(input.headers, headerSlotFromName); const queryParams = configureMap(input.queryParams, queryParamSlotFromName); const specFetchHeaders = configureMap( input.specFetchCredentials?.headers, specFetchHeaderSlotFromName, ); const specFetchQueryParams = configureMap( input.specFetchCredentials?.queryParams, specFetchQueryParamSlotFromName, ); const oauth2 = existing.config.oauth2; const oauth2Bindings: Array<{ readonly slotKey: string; readonly value: CredentialBindingValue; }> = []; if (oauth2 && input.oauth2?.clientId) { oauth2Bindings.push({ slotKey: oauth2.clientIdSlot, value: configuredValueFromConfigureInput(oauth2.clientIdSlot, input.oauth2.clientId).value, }); } if (oauth2?.clientSecretSlot && input.oauth2?.clientSecret) { oauth2Bindings.push({ slotKey: oauth2.clientSecretSlot, value: configuredValueFromConfigureInput(oauth2.clientSecretSlot, input.oauth2.clientSecret) .value, }); } if (oauth2 && input.oauth2?.connection) { oauth2Bindings.push({ slotKey: oauth2.connectionSlot, value: configuredValueFromConfigureInput(oauth2.connectionSlot, input.oauth2.connection) .value, }); } const specFetchCredentials = input.specFetchCredentials === undefined ? existing.config.specFetchCredentials : { headers: specFetchHeaders.configured, queryParams: specFetchQueryParams.configured, }; const affectedPrefixes = [ ...(input.headers !== undefined ? ["header:"] : []), ...(input.queryParams !== undefined ? ["query_param:"] : []), ...(input.specFetchCredentials?.headers !== undefined ? ["spec_fetch:header:"] : []), ...(input.specFetchCredentials?.queryParams !== undefined ? ["spec_fetch:query_param:"] : []), ...(input.oauth2 !== undefined ? ["oauth2:"] : []), ]; const bindings = [ ...headers.bindings, ...queryParams.bindings, ...specFetchHeaders.bindings, ...specFetchQueryParams.bindings, ...oauth2Bindings, ]; const nextConfiguredValues = ( current: Record | undefined, next: Record, provided: boolean, ): Record | undefined => { if (!provided) return current; if (Object.keys(next).length === 0) return {}; return { ...(current ?? {}), ...next }; }; return yield* ctx.transaction( Effect.gen(function* () { yield* ctx.storage.updateSourceMeta(source.id, source.scope, { headers: nextConfiguredValues( existing.config.headers, headers.configured, input.headers !== undefined, ), queryParams: nextConfiguredValues( existing.config.queryParams, queryParams.configured, input.queryParams !== undefined, ), specFetchCredentials, }); if (affectedPrefixes.length > 0 || bindings.length > 0) { return yield* ctx.credentialBindings.replaceForSource({ targetScope: ScopeId.make(input.scope), pluginId: OPENAPI_PLUGIN_ID, sourceId: source.id, sourceScope: ScopeId.make(source.scope), slotPrefixes: affectedPrefixes, bindings, }); } return []; }), ); }); interface EffectiveSourceConfig { readonly config: SourceConfig; readonly headersSource: StoredSource; readonly queryParamsSource: StoredSource; readonly specFetchCredentialsSource: StoredSource; readonly oauth2Source: StoredSource; } const OPENAPI_PLUGIN_ID = "openapi"; const scopeRanks = (ctx: PluginCtx): ReadonlyMap => new Map(ctx.scopes.map((scope, index) => [String(scope.id), index])); const scopeRank = (ranks: ReadonlyMap, scopeId: string): number => ranks.get(scopeId) ?? Infinity; const resolveOpenApiCredentialBinding = ( ctx: PluginCtx, sourceId: string, sourceScope: string, slot: string, ): Effect.Effect => ctx.credentialBindings.resolveBinding({ pluginId: OPENAPI_PLUGIN_ID, sourceId, sourceScope: ScopeId.make(sourceScope), slotKey: slot, }); const findOuterSource = ( ctx: PluginCtx, namespace: string, scope: string, ): Effect.Effect => Effect.gen(function* () { const ranks = scopeRanks(ctx); const baseRank = scopeRank(ranks, scope); for (let index = baseRank + 1; index < ctx.scopes.length; index++) { const candidateScope = ctx.scopes[index]; if (!candidateScope) continue; const source = yield* ctx.storage.getSource(namespace, candidateScope.id); if (source) return source; } return null; }); const resolveEffectiveSourceConfig = ( ctx: PluginCtx, base: StoredSource, ): Effect.Effect => Effect.gen(function* () { const fallback = yield* findOuterSource(ctx, base.namespace, base.scope); if (!fallback) { return { config: base.config, headersSource: base, queryParamsSource: base, specFetchCredentialsSource: base, oauth2Source: base, }; } const hasBaseHeaders = Object.keys(base.config.headers ?? {}).length > 0; const hasBaseQueryParams = Object.keys(base.config.queryParams ?? {}).length > 0; const hasBaseSpecFetchCredentials = base.config.specFetchCredentials !== undefined; return { config: { ...base.config, sourceUrl: base.config.sourceUrl ?? fallback.config.sourceUrl, baseUrl: fallback.config.baseUrl, namespace: base.config.namespace ?? fallback.config.namespace, headers: hasBaseHeaders ? base.config.headers : fallback.config.headers, queryParams: hasBaseQueryParams ? base.config.queryParams : fallback.config.queryParams, specFetchCredentials: base.config.specFetchCredentials ?? fallback.config.specFetchCredentials, oauth2: base.config.oauth2 ?? fallback.config.oauth2, }, headersSource: hasBaseHeaders ? base : fallback, queryParamsSource: hasBaseQueryParams ? base : fallback, specFetchCredentialsSource: hasBaseSpecFetchCredentials ? base : fallback, oauth2Source: base.config.oauth2 ? base : fallback, }; }); const resolveConfiguredValueMap = ( ctx: PluginCtx, params: { readonly sourceId: string; readonly sourceScope: string; readonly values: Record; readonly missingLabel: string; }, ): Effect.Effect, OpenApiOAuthError | StorageFailure> => Effect.gen(function* () { const resolved: Record = {}; for (const [name, value] of Object.entries(params.values)) { if (typeof value === "string") { resolved[name] = value; continue; } const binding = yield* resolveOpenApiCredentialBinding( ctx, params.sourceId, params.sourceScope, value.slot, ); if (binding?.value.kind === "secret") { const secret = yield* ctx.secrets .getAtScope(binding.value.secretId, binding.value.secretScopeId ?? binding.scopeId) .pipe( Effect.catchTag("SecretOwnedByConnectionError", () => Effect.fail( new OpenApiOAuthError({ message: `Secret not found for header "${name}"`, }), ), ), ); if (secret === null) { return yield* new OpenApiOAuthError({ message: `Missing secret "${binding.value.secretId}" for ${params.missingLabel} "${name}"`, }); } resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; continue; } if (binding?.value.kind === "text") { resolved[name] = value.prefix ? `${value.prefix}${binding.value.text}` : binding.value.text; continue; } return yield* new OpenApiOAuthError({ message: `Missing binding for ${params.missingLabel} "${name}"`, }); } return resolved; }); const resolveConfiguredHeaders = ( ctx: PluginCtx, params: { readonly sourceId: string; readonly sourceScope: string; readonly headers: Record; }, ): Effect.Effect, OpenApiOAuthError | StorageFailure> => resolveConfiguredValueMap(ctx, { sourceId: params.sourceId, sourceScope: params.sourceScope, values: params.headers, missingLabel: "header", }); const resolveSecretBackedValues = ( ctx: PluginCtx, values: Record | undefined, ): Effect.Effect, OpenApiOAuthError | StorageFailure> => resolveSecretBackedMap({ values, getSecret: ctx.secrets.get, onMissing: (name) => new OpenApiOAuthError({ message: `Secret not found for "${name}"`, }), onError: (err, name) => Predicate.isTagged("SecretOwnedByConnectionError")(err) ? new OpenApiOAuthError({ message: `Secret not found for "${name}"`, }) : err, }).pipe( Effect.mapError((err) => Predicate.isTagged("SecretOwnedByConnectionError")(err) ? new OpenApiOAuthError({ message: "Secret resolution failed" }) : err, ), Effect.map((resolved) => resolved ?? {}), ); const resolveOAuthConnectionId = ( ctx: PluginCtx, params: { readonly sourceId: string; readonly sourceScope: string; readonly oauth2: OAuth2SourceConfig; }, ): Effect.Effect< { readonly connectionId: string; readonly scopeId: string } | null, StorageFailure > => Effect.gen(function* () { const binding = yield* resolveOpenApiCredentialBinding( ctx, params.sourceId, params.sourceScope, params.oauth2.connectionSlot, ); if (binding?.value.kind === "connection") { const connectionId = binding.value.connectionId; const connection = yield* ctx.connections.getAtScope(connectionId, binding.scopeId); return connection ? { connectionId, scopeId: binding.scopeId } : null; } return null; }); const resolveSpecFetchInputCredentials = ( ctx: PluginCtx, credentials: OpenApiPreviewSpecFetchCredentialsInput | undefined, ) => Effect.gen(function* () { if (!credentials) return undefined; return { headers: yield* resolveSecretBackedValues(ctx, credentials.headers), queryParams: yield* resolveSecretBackedValues(ctx, credentials.queryParams), }; }); const resolveStoredSpecFetchCredentials = ( ctx: PluginCtx, params: { readonly sourceId: string; readonly sourceScope: string; readonly credentials: SourceConfig["specFetchCredentials"] | undefined; }, ) => Effect.gen(function* () { if (!params.credentials) return undefined; return { headers: yield* resolveConfiguredValueMap(ctx, { sourceId: params.sourceId, sourceScope: params.sourceScope, values: params.credentials.headers ?? {}, missingLabel: "spec fetch header", }), queryParams: yield* resolveConfiguredValueMap(ctx, { sourceId: params.sourceId, sourceScope: params.sourceScope, values: params.credentials.queryParams ?? {}, missingLabel: "spec fetch query parameter", }), }; }); // --------------------------------------------------------------------------- // OAuth2 token exchange / refresh is owned by `ctx.oauth`, which registers // the canonical core `"oauth2"` ConnectionProvider. OpenAPI owns only the // source-specific semantics: slots for client credentials and the connection // binding that invocation resolves before calling `ctx.connections.accessToken`. // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Plugin factory // --------------------------------------------------------------------------- export interface OpenApiPluginOptions { readonly httpClientLayer?: Layer.Layer; /** If provided, source add/remove is mirrored to executor.jsonc * (best-effort — file errors are logged, not raised). */ readonly configFile?: ConfigFileSink; } const toOpenApiSourceConfig = ( namespace: string, config: OpenApiSpecConfig, ): OpenApiSourceConfig => { const configHeaders: Record = {}; for (const [name, value] of Object.entries(config.headers ?? {})) { if (typeof value === "string") { configHeaders[name] = value; } } return { kind: "openapi", spec: specInputToConfigString(config.spec), baseUrl: config.baseUrl, namespace, headers: headersToConfigValues( Object.keys(configHeaders).length > 0 ? configHeaders : undefined, ), }; }; const specInputToConfigString = (spec: OpenApiSpecInput): string => spec.kind === "url" ? spec.url : spec.value; export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { type RebuildInput = { readonly specText: string; readonly scope: string; readonly sourceUrl?: string; readonly name: string; readonly baseUrl: string | undefined; readonly namespace: string; readonly headers?: Record; readonly queryParams?: Record; readonly specFetchCredentials?: { readonly headers?: Record; readonly queryParams?: Record; }; readonly oauth2?: OpenApiOAuthInput; }; // ctx comes from the plugin runtime — the same instance is passed to // `extension(ctx)` and to every lifecycle hook (`refreshSource`, etc.), // so helpers parameterised on ctx can be called from either surface. const rebuildSource = (ctx: PluginCtx, input: RebuildInput) => Effect.gen(function* () { const doc = yield* parse(input.specText); const result = yield* extract(doc); const namespace = input.namespace; const outerSource = yield* findOuterSource(ctx, namespace, input.scope); if (outerSource && input.baseUrl !== undefined && input.baseUrl.trim() !== "") { return yield* new OpenApiOAuthError({ message: "OpenAPI source shadows inherit the outer source base URL", }); } const hoistedDefs: Record = {}; if (doc.components?.schemas) { for (const [k, v] of Object.entries(doc.components.schemas)) { hoistedDefs[k] = normalizeOpenApiRefs(v); } } const baseUrl = outerSource ? undefined : input.baseUrl; const canonicalHeaders = canonicalizeHeaders(input.headers); const canonicalQueryParams = canonicalizeCredentialMap( input.queryParams, queryParamSlotFromName, ); const canonicalSpecFetchCredentials = canonicalizeSpecFetchCredentials( input.specFetchCredentials, ); const canonicalOAuth2 = canonicalizeOAuth2(input.oauth2); const definitions = compileToolDefinitions(result.operations); const sourceName = input.name; const sourceConfig: SourceConfig = { spec: input.specText, sourceUrl: input.sourceUrl, baseUrl, namespace: input.namespace, headers: canonicalHeaders.headers, queryParams: canonicalQueryParams.values, specFetchCredentials: canonicalSpecFetchCredentials.credentials, oauth2: canonicalOAuth2.oauth2, }; const storedSource: StoredSource = { namespace, scope: input.scope, name: sourceName, config: sourceConfig, }; const storedOps: StoredOperation[] = definitions.map((def) => ({ toolId: `${namespace}.${def.toolPath}`, sourceId: namespace, binding: toBinding(def), })); yield* ctx.transaction( Effect.gen(function* () { yield* ctx.storage.upsertSource(storedSource, storedOps); yield* ctx.core.sources.register({ id: namespace, scope: input.scope, kind: "openapi", name: sourceName, url: baseUrl || undefined, canRemove: true, // `canRefresh` reflects whether we still know the // origin URL — sources added from raw spec text have // nothing to re-fetch, so refresh stays disabled. canRefresh: input.sourceUrl != null, canEdit: true, tools: definitions.map((def) => ({ name: def.toolPath, description: descriptionFor(def), inputSchema: normalizeOpenApiRefs(Option.getOrUndefined(def.operation.inputSchema)), outputSchema: normalizeOpenApiRefs(Option.getOrUndefined(def.operation.outputSchema)), })), }); if (Object.keys(hoistedDefs).length > 0) { yield* ctx.core.definitions.register({ sourceId: namespace, scope: input.scope, definitions: hoistedDefs, }); } }), ); return { sourceId: namespace, toolCount: definitions.length }; }); // No-op for missing sources and for sources added from raw spec // text (no URL to re-fetch from). UIs gate the action via // `canRefresh` on the source row; reaching here without a URL // means the caller bypassed that gate, so we stay quiet rather // than surface a 500 through the unwhitelisted error channel. const refreshSourceInternal = (ctx: PluginCtx, sourceId: string, scope: string) => Effect.gen(function* () { const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; const existing = yield* ctx.storage.getSource(sourceId, scope); if (!existing) return; const effective = yield* resolveEffectiveSourceConfig(ctx, existing); const resolvedConfig = effective.config; const sourceUrl = resolvedConfig.sourceUrl; if (!sourceUrl) return; const credentials = yield* resolveStoredSpecFetchCredentials(ctx, { sourceId: existing.namespace, sourceScope: effective.specFetchCredentialsSource.scope, credentials: resolvedConfig.specFetchCredentials, }); const specText = yield* resolveSpecText(sourceUrl, credentials).pipe( Effect.provide(httpClientLayer), ); yield* rebuildSource(ctx, { specText, scope, sourceUrl, name: existing.name, baseUrl: existing.config.baseUrl, namespace: existing.namespace, headers: existing.config.headers, queryParams: existing.config.queryParams, specFetchCredentials: existing.config.specFetchCredentials, oauth2: existing.config.oauth2, }); }); return { id: "openapi" as const, packageName: "@executor-js/plugin-openapi", sourcePresets: openApiPresets, schema: openapiSchema, storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), extension: (ctx) => { const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; const addSpecInternal = (config: OpenApiSpecConfig) => Effect.gen(function* () { // Resolve URL → text and parse BEFORE opening a transaction. // Holding `BEGIN` on the pool=1 Postgres connection across a // network fetch is the Hyperdrive deadlock path in production. const specText = config.spec.kind === "url" ? yield* resolveSpecText(config.spec.url).pipe(Effect.provide(httpClientLayer)) : config.spec.value; return yield* rebuildSource(ctx, { specText, scope: config.scope, sourceUrl: config.spec.kind === "url" ? config.spec.url : undefined, name: config.name, baseUrl: config.baseUrl, namespace: config.namespace, headers: config.headers, queryParams: config.queryParams, specFetchCredentials: config.specFetchCredentials, oauth2: config.oauth2, }); }); const configFile = options?.configFile; return { previewSpec: (input: string | OpenApiPreviewInput) => Effect.gen(function* () { const previewInput = typeof input === "string" ? { spec: input } : input; const credentials = yield* resolveSpecFetchInputCredentials( ctx, previewInput.specFetchCredentials, ); const specText = yield* resolveSpecText(previewInput.spec, credentials).pipe( Effect.provide(httpClientLayer), ); return yield* previewSpec(specText).pipe(Effect.provide(httpClientLayer)); }), addSpec: (config: OpenApiSpecConfig) => Effect.gen(function* () { const result = yield* addSpecInternal(config); if (configFile) { yield* configFile.upsertSource(toOpenApiSourceConfig(result.sourceId, config)); } return result; }), removeSpec: (namespace: string, scope: string) => Effect.gen(function* () { yield* ctx.transaction( Effect.gen(function* () { yield* ctx.credentialBindings.removeForSource({ pluginId: OPENAPI_PLUGIN_ID, sourceId: namespace, sourceScope: ScopeId.make(scope), }); yield* ctx.storage.removeSource(namespace, scope); yield* ctx.core.sources.unregister({ id: namespace, targetScope: scope, }); }), ); if (configFile) { yield* configFile.removeSource(namespace); } }), getSource: (namespace: string, scope: string) => Effect.gen(function* () { const source = yield* ctx.storage.getSource(namespace, scope); if (!source) return null; const effective = yield* resolveEffectiveSourceConfig(ctx, source); return { ...source, config: effective.config, }; }), configure: (source: OpenApiSourceRef, input: OpenApiConfigureInput) => configureOpenApiSource(ctx, source, input), }; }, sourceConfigure: { type: "openapi", schema: OpenApiConfigureInputSchema, configure: ({ ctx, sourceId, sourceScope, config }) => Effect.gen(function* () { const input = config as OpenApiConfigureInput; if ( input.name !== undefined || input.baseUrl !== undefined || input.oauth2Source !== undefined ) { if (input.baseUrl !== undefined && input.baseUrl.trim() !== "") { const outerSource = yield* findOuterSource(ctx, sourceId, sourceScope); if (outerSource) { return yield* new OpenApiOAuthError({ message: "OpenAPI source shadows inherit the outer source base URL", }); } } yield* ctx.storage.updateSourceMeta(sourceId, sourceScope, { name: input.name?.trim() || undefined, baseUrl: input.baseUrl, oauth2: input.oauth2Source, }); } return yield* configureOpenApiSource(ctx, { id: sourceId, scope: sourceScope }, input); }), }, staticSources: (self) => [ { id: "openapi", kind: "executor", name: "OpenAPI", tools: [ tool({ name: "previewSpec", description: "Preview an OpenAPI document before adding it as a source. Call this first when the user provides a spec URL/blob so you can inspect servers, auth schemes, operation count, tags, and credential slots before `addSource`. This agent-facing preview intentionally omits the full operations list; use `operationCount` and `tags` for full-size specs. Do not collect API keys or OAuth client secrets in chat; use `executor.coreTools.secrets.create` for those values.", inputSchema: PreviewSpecInputStandardSchema, outputSchema: PreviewSpecOutputStandardSchema, execute: (input) => self.previewSpec(input).pipe( Effect.map((preview) => ToolResult.ok(staticPreviewOutput(preview))), Effect.catchTags({ OpenApiParseError: ({ message }) => Effect.succeed(openApiToolFailure("openapi_parse_failed", message)), OpenApiExtractionError: ({ message }) => Effect.succeed(openApiToolFailure("openapi_extraction_failed", message)), OpenApiOAuthError: ({ message }) => Effect.succeed(openApiToolFailure("openapi_oauth_failed", message)), }), ), }), tool({ name: "getSource", description: "Inspect an existing OpenAPI source, including effective base URL, configured headers/query params, OAuth settings, and stored credential slots. Use this before repairing an existing source with `openapi.configureSource`, `secrets.create`, or `oauth.start`.", inputSchema: GetSourceInputStandardSchema, outputSchema: GetSourceOutputStandardSchema, execute: (input, { ctx }) => Effect.map( self.getSource(input.namespace, resolveStaticScopeInput(ctx, input.scope)), (source) => ToolResult.ok({ source }), ), }), tool({ name: "addSource", description: "Add an OpenAPI source and register its operations as tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. Recommended flow: call `previewSpec`, choose or confirm namespace/name/baseUrl from the preview (baseUrl is only needed when the spec cannot infer one or the user wants an override), declare credential slots here for sensitive headers/query params, then call `secrets.create` and `openapi.configureSource` with the user's chosen credential scope for per-scope bindings. Use `oauth.start` with `credentialScope` set to the user's chosen personal or organization credential scope for browser OAuth sign-in.", annotations: { requiresApproval: true, approvalDescription: "Add an OpenAPI source", }, inputSchema: AddSourceInputStandardSchema, outputSchema: AddSourceOutputStandardSchema, execute: (input, { ctx }) => { const sourceScope = defaultSourceInstallScopeId(ctx.scopes); if (sourceScope === null) { return Effect.succeed( openApiToolFailure( "source_scope_unavailable", "Cannot add an OpenAPI source because this executor has no source install scope.", ), ); } return self.addSpec({ ...input, scope: sourceScope }).pipe( Effect.map((result) => ToolResult.ok({ ...result, source: { id: result.sourceId, scope: sourceScope }, }), ), Effect.catchTags({ OpenApiParseError: ({ message }) => Effect.succeed(openApiToolFailure("openapi_parse_failed", message)), OpenApiExtractionError: ({ message }) => Effect.succeed(openApiToolFailure("openapi_extraction_failed", message)), OpenApiOAuthError: ({ message }) => Effect.succeed(openApiToolFailure("openapi_oauth_failed", message)), }), ); }, }), tool({ name: "configureSource", description: 'Configure an existing OpenAPI source with concrete fields. Use `source` returned by `openapi.addSource` or `sources.list`. The top-level `scope` is the credential target scope for bindings; in cloud, choose the user or organization credential scope deliberately. Pass secret refs as `{kind:"secret", secretId}` and OAuth connections as `{kind:"connection", connectionId}`.', annotations: { requiresApproval: true, approvalDescription: "Configure an OpenAPI source", }, inputSchema: OpenApiConfigureSourceInputStandardSchema, outputSchema: OpenApiConfigureSourceOutputStandardSchema, execute: (input, { ctx }) => { const { source, ...config } = input as typeof OpenApiConfigureSourceInputSchema.Type; const sourceScope = resolveStaticScopeInput(ctx, source.scope); const targetScope = resolveStaticScopeInput(ctx, config.scope); return Effect.map( self.configure( { id: source.id, scope: sourceScope }, { ...config, scope: targetScope }, ), (result) => ToolResult.ok({ result }), ); }, }), ], }, ], invokeTool: ({ ctx, toolRow, args }) => Effect.gen(function* () { const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; // toolRow.scope_id is the resolved owning scope of the tool // (innermost-wins from the executor's stack). The matching // OpenAPI operation + source plugin-storage rows live at the same // scope, so pin every store lookup to it instead of relying on // stack-wide scope fall-through. const toolScope = toolRow.scope_id; const op = yield* ctx.storage.getOperationByToolId(toolRow.id, toolScope); if (!op) { return yield* new OpenApiExtractionError({ message: `No OpenAPI operation found for tool "${toolRow.id}"`, }); } const source = yield* ctx.storage.getSource(op.sourceId, toolScope); if (!source) { return yield* new OpenApiExtractionError({ message: `No OpenAPI source found for "${op.sourceId}"`, }); } const effective = yield* resolveEffectiveSourceConfig(ctx, source); const config = effective.config; const resolvedHeaders = yield* resolveConfiguredHeaders(ctx, { sourceId: op.sourceId, sourceScope: effective.headersSource.scope, headers: config.headers ?? {}, }); const resolvedQueryParams = yield* resolveConfiguredValueMap(ctx, { sourceId: op.sourceId, sourceScope: effective.queryParamsSource.scope, values: config.queryParams ?? {}, missingLabel: "query parameter", }); // If the source has OAuth2 auth, resolve a guaranteed-fresh // access token from the backing Connection and inject the // Authorization header (wins over a manually-set one). All the // refresh complexity lives in the SDK — the plugin just asks. if (config.oauth2) { const connection = yield* resolveOAuthConnectionId(ctx, { sourceId: op.sourceId, sourceScope: effective.oauth2Source.scope, oauth2: config.oauth2, }); if (!connection) { return yield* new OpenApiOAuthError({ message: `OAuth configuration for "${op.sourceId}" is missing a connection binding`, }); } const accessToken = yield* ctx.connections .accessTokenAtScope(connection.connectionId, connection.scopeId) .pipe( Effect.mapError( () => new OpenApiOAuthError({ message: "OAuth connection resolution failed", }), ), ); resolvedHeaders.authorization = `Bearer ${accessToken}`; } const result = yield* invokeWithLayer( op.binding, (args ?? {}) as Record, config.baseUrl ?? "", resolvedHeaders, resolvedQueryParams, httpClientLayer, ); const ok = result.status >= 200 && result.status < 300; if (!ok) { return ToolResult.fail({ code: "upstream_http_error", status: result.status, message: extractUpstreamMessage(result.error, result.status), details: result.error, }); } return ToolResult.ok({ status: result.status, headers: result.headers, data: result.data, }); }), resolveAnnotations: ({ ctx, sourceId, toolRows }) => Effect.gen(function* () { // toolRows for a single (plugin_id, source_id) group can still // straddle multiple scopes when the source is shadowed (e.g. an // org-level openapi source plus a per-user override that // re-registers the same tool ids). Run one listOperationsBySource // per distinct scope so each lookup pins {source_id, scope_id} // and we don't fall through to the wrong scope's bindings. const scopes = new Set(); for (const row of toolRows as readonly ToolRow[]) { scopes.add(row.scope_id); } // One listOperationsBySource per scope is independent storage // work; run them in parallel so a shadowed source doesn't // serialise two ~200ms reads back-to-back in the caller's // `executor.tools.list.annotations` span. const entries = yield* Effect.forEach( [...scopes], (scope) => Effect.gen(function* () { const ops = yield* ctx.storage.listOperationsBySource(sourceId, scope); const byId = new Map(); for (const op of ops) byId.set(op.toolId, op.binding); return [scope, byId] as const; }), { concurrency: "unbounded" }, ); const byScope = new Map>(entries); const out: Record = {}; for (const row of toolRows as readonly ToolRow[]) { const binding = byScope.get(row.scope_id)?.get(row.id); if (binding) { out[row.id] = annotationsForOperation(binding.method, binding.pathTemplate); } } return out; }), removeSource: ({ ctx, sourceId, scope }) => Effect.gen(function* () { yield* ctx.transaction( Effect.gen(function* () { yield* ctx.credentialBindings.removeForSource({ pluginId: OPENAPI_PLUGIN_ID, sourceId, sourceScope: ScopeId.make(scope), }); yield* ctx.storage.removeSource(sourceId, scope); }), ); if (options?.configFile) { yield* options.configFile.removeSource(sourceId); } }), // OpenAPI credential usages are reported by the core `credential_binding` // table. Source storage carries only source-owned slot structure. usagesForSecret: () => Effect.succeed([]), usagesForConnection: () => Effect.succeed([]), // Re-fetch the spec from its origin URL (captured at addSpec time) // and replay the same parse → extract → upsertSource → register // path used by addSpec. Sources without a stored URL surface a // typed `OpenApiParseError` — the executor only dispatches refresh // when `canRefresh: true`, so a raw-text source reaching here // means stale UI state, which is worth surfacing to the caller. refreshSource: ({ ctx, sourceId, scope }) => refreshSourceInternal(ctx, sourceId, scope), detect: ({ ctx, url }) => Effect.gen(function* () { const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; const trimmed = url.trim(); if (!trimmed) return null; const parsed = yield* Effect.try({ try: () => new URL(trimmed), catch: (error) => error, }).pipe(Effect.option); if (Option.isNone(parsed)) return null; const specText = yield* resolveSpecText(trimmed).pipe( Effect.provide(httpClientLayer), Effect.catch(() => Effect.succeed(null)), ); if (specText === null) return null; const doc = yield* parse(specText).pipe(Effect.catch(() => Effect.succeed(null))); if (!doc) return null; const result = yield* extract(doc).pipe(Effect.catch(() => Effect.succeed(null))); if (!result) return null; const namespace = Option.getOrElse(result.title, () => "api") .toLowerCase() .replace(/[^a-z0-9]+/g, "_"); const name = Option.getOrElse(result.title, () => namespace); return SourceDetectionResult.make({ kind: "openapi", confidence: "high", endpoint: trimmed, name, namespace, }); }), }; // HTTP transport (routes/handlers/extensionService) is layered on by // the api-aware factory in `@executor-js/plugin-openapi/api`. Hosts that // want the HTTP surface import the plugin from there; SDK-only // consumers stay on this entry and avoid the server-only deps. });