import { Effect, type Layer } from "effect"; import type { HttpClient } from "effect/unstable/http"; import { AuthTemplateSlug, IntegrationAlreadyExistsError, IntegrationSlug, ToolName, ToolResult, authToolFailure, definePlugin, type IntegrationConfig, type IntegrationDetectionResult, type InvokeToolInput, type PluginCtx, type ResolveToolsInput, type ResolveToolsResult, type StorageFailure, type ToolDef, } from "@executor-js/sdk"; import { HttpRequestError } from "./errors"; import { applyAuthTemplate, findAuthTemplate, requiredVariables } from "./template"; import { issueRequest } from "./request"; import { type Authentication, type HttpMethod, type HttpRequestArgs, type HttpSourceConfig, } from "./types"; // --------------------------------------------------------------------------- // Plugin identity // --------------------------------------------------------------------------- export const HTTP_SOURCE_PLUGIN_ID = "http-source"; export const REQUEST_TOOL_NAME = ToolName.make("request"); // --------------------------------------------------------------------------- // Config narrowing — core stores `IntegrationConfig` as an opaque blob; read it // back into the http-source shape. // --------------------------------------------------------------------------- const asHttpConfig = (config: IntegrationConfig): HttpSourceConfig | null => { if (typeof config !== "object" || config === null) return null; const candidate = config as Partial; if (typeof candidate.baseUrl !== "string") return null; if (!Array.isArray(candidate.authenticationTemplate)) return null; return { baseUrl: candidate.baseUrl, authenticationTemplate: candidate.authenticationTemplate as readonly Authentication[], defaultHeaders: candidate.defaultHeaders, }; }; // --------------------------------------------------------------------------- // Auth-template merge — `configure` appends custom auth methods to the // integration's existing `authenticationTemplate`. Mirrors the openapi plugin: // a method whose slug matches an existing entry replaces it in place; a method // with no usable slug is assigned a generated `custom_` slug, collision- // checked against the merged set. // --------------------------------------------------------------------------- const shortId = (): string => Math.random().toString(36).slice(2, 8); const freshCustomSlug = (taken: ReadonlySet): AuthTemplateSlug => { let candidate = `custom_${shortId()}`; while (taken.has(candidate)) candidate = `custom_${shortId()}`; return AuthTemplateSlug.make(candidate); }; const mergeAuthenticationTemplate = ( existing: readonly Authentication[], incoming: readonly Authentication[], ): readonly Authentication[] => { const result: Authentication[] = existing.map((entry: Authentication) => entry); const taken = new Set(result.map((entry: Authentication) => String(entry.slug))); for (const entry of incoming) { // `slug` is branded as required, but JSON callers may omit it; read defensively. const rawSlug = (entry as { readonly slug?: unknown }).slug; const requested = typeof rawSlug === "string" ? rawSlug.trim() : ""; const existingIndex = result.findIndex( (current: Authentication) => String(current.slug) === requested, ); if (requested.length > 0 && existingIndex >= 0) { result[existingIndex] = entry; continue; } const slug = requested.length > 0 && !taken.has(requested) ? AuthTemplateSlug.make(requested) : freshCustomSlug(taken); taken.add(String(slug)); result.push({ ...entry, slug } as Authentication); } return result; }; // --------------------------------------------------------------------------- // Argument coercion — the executor passes through `args: unknown`. Decode into // the request envelope, tolerating loose shapes. // --------------------------------------------------------------------------- const decodeRequestArgs = (args: unknown): HttpRequestArgs | null => { if (typeof args !== "object" || args === null) return null; const record = args as Record; if (typeof record.path !== "string") return null; const method = typeof record.method === "string" ? (record.method.toUpperCase() as HttpMethod) : undefined; return { method, path: record.path, query: typeof record.query === "object" && record.query !== null ? (record.query as Record) : undefined, headers: typeof record.headers === "object" && record.headers !== null ? (record.headers as Record) : undefined, body: record.body, }; }; // --------------------------------------------------------------------------- // The generic `request` tool — identical per connection (raw HTTP has no spec), // so `resolveTools` never needs `getValue`. The JSON schema is a literal so the // plugin doesn't depend on a Schema→JSON-Schema conversion at runtime. // --------------------------------------------------------------------------- const REQUEST_INPUT_SCHEMA = { type: "object", properties: { method: { type: "string", enum: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"], description: "HTTP method. Defaults to GET.", }, path: { type: "string", description: "Path appended to the integration's base URL, or an absolute http(s) URL.", }, query: { type: "object", additionalProperties: true, description: "Query string parameters.", }, headers: { type: "object", additionalProperties: { type: "string" }, description: "Additional request headers.", }, body: { description: "Request body. Objects are JSON-encoded; strings pass through.", }, }, required: ["path"], additionalProperties: false, } as const; const REQUEST_OUTPUT_SCHEMA = { type: "object", properties: { status: { type: "number" }, headers: { type: "object", additionalProperties: { type: "string" } }, body: {}, }, required: ["status", "headers", "body"], } as const; const requestToolDef = (baseUrl: string): ToolDef => ({ name: REQUEST_TOOL_NAME, description: `Issue a raw HTTP request against ${baseUrl}.`, inputSchema: REQUEST_INPUT_SCHEMA, outputSchema: REQUEST_OUTPUT_SCHEMA, annotations: { // Raw HTTP can mutate; the executor's tool-policy layer can require approval // per address. We flag it conservatively. requiresApproval: true, approvalDescription: `Raw HTTP request to ${baseUrl}`, }, }); // --------------------------------------------------------------------------- // Extension — the plugin's public API. `register` writes an http-source // integration into the catalog with its opaque config. // --------------------------------------------------------------------------- export interface RegisterHttpIntegrationInput { readonly slug: IntegrationSlug; readonly description: string; readonly config: HttpSourceConfig; readonly canRemove?: boolean; } /** Add / merge custom auth methods onto an existing http-source integration's * `authenticationTemplate`. Mirrors the openapi plugin's `configure`. */ export interface HttpSourceConfigureInput { readonly authenticationTemplate: readonly Authentication[]; } // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- export const httpSourcePlugin = definePlugin(() => ({ id: HTTP_SOURCE_PLUGIN_ID, packageName: "@executor-js/plugin-http-source", storage: () => ({}), // The extension's shape is inferred from this factory's return value; // `HttpSourceExtension` below is derived from it (no duplicated shape). extension: (ctx: PluginCtx) => ({ register: ( input: RegisterHttpIntegrationInput, ): Effect.Effect => Effect.gen(function* () { // Block re-adding an existing slug. The core `integrations.register` // primitive upserts (so boot re-registration is idempotent), but this // user-facing add must NOT silently clobber an existing integration's // tools, connections, and policies. To add more auth, update the // existing integration instead. const existing = yield* ctx.core.integrations.get(input.slug); if (existing) { return yield* new IntegrationAlreadyExistsError({ slug: input.slug }); } yield* ctx.core.integrations.register({ slug: input.slug, description: input.description, config: input.config, canRemove: input.canRemove ?? true, // Raw-HTTP tools are static per connection, but refresh is harmless // (re-stamps the same `request` tool), so allow it. canRefresh: true, }); }), /** Read the integration's full opaque config (including its * `authenticationTemplate`). Returns null when the integration is absent * or its config isn't a valid http-source config. */ getConfig: (slug: string): Effect.Effect => ctx.core.integrations .get(IntegrationSlug.make(slug)) .pipe(Effect.map((record) => (record ? asHttpConfig(record.config) : null))), /** Add / merge custom auth methods onto the integration's * `authenticationTemplate`. Returns the resulting template array. */ configure: ( slug: string, input: HttpSourceConfigureInput, ): Effect.Effect => ctx.transaction( Effect.gen(function* () { const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug)); if (!record) return [] as readonly Authentication[]; const current = asHttpConfig(record.config); if (!current) return [] as readonly Authentication[]; const merged = mergeAuthenticationTemplate( current.authenticationTemplate, input.authenticationTemplate, ); const next: HttpSourceConfig = { ...current, authenticationTemplate: merged, }; yield* ctx.core.integrations.update(IntegrationSlug.make(slug), { config: next satisfies HttpSourceConfig as IntegrationConfig, }); return merged; }), ), }), // Per-connection tool production. Raw HTTP yields one generic `request` tool; // identical for every connection, so `getValue` is never called. resolveTools: (input: ResolveToolsInput): Effect.Effect => Effect.sync(() => { const config = asHttpConfig(input.config); const baseUrl = config?.baseUrl ?? "the configured endpoint"; return { tools: [requestToolDef(baseUrl)] }; }), // Apply the resolved credential to the outbound request via the integration's // auth template (D11) and issue it. invokeTool: (input: InvokeToolInput): Effect.Effect => Effect.gen(function* () { const config = asHttpConfig(input.credential.config); if (config === null) { return ToolResult.fail({ code: "http_config_invalid", message: "The http-source integration config is missing baseUrl / authenticationTemplate.", retryable: false, }); } const args = decodeRequestArgs(input.args); if (args === null) { return ToolResult.fail({ code: "invalid_arguments", message: "Expected a request with at least a `path` string.", retryable: false, }); } // A selected auth template with any unresolved input means the credential // couldn't be resolved — surface a reauth-style failure keyed to the // connection (owner/integration/name), not a v1 slot. Checks every input the // template requires (e.g. both of Datadog's two keys), not just the primary. const selectedTemplate = findAuthTemplate( config.authenticationTemplate, input.credential.template, ); if (selectedTemplate) { const missing = requiredVariables(selectedTemplate).filter((name) => { const value = input.credential.values[name]; return value == null || value === ""; }); if (missing.length > 0) { return authToolFailure({ code: "connection_value_missing", message: `No credential value resolved for connection "${input.credential.connection}" on integration "${input.credential.integration}".`, credential: { kind: "secret", label: String(input.credential.connection), }, }); } } const auth = applyAuthTemplate( config.authenticationTemplate, input.credential.template, input.credential.values, ); const httpClientLayer: Layer.Layer = input.ctx.httpClientLayer; const response = yield* issueRequest( { baseUrl: config.baseUrl, method: args.method ?? "GET", path: args.path, query: args.query, headers: args.headers, body: args.body, auth, defaultHeaders: config.defaultHeaders, }, httpClientLayer, ).pipe( // `HttpRequestError` is a typed tagged error; reshape it into a // value-based `ToolResult` so transport failures surface as data. Effect.catchTag("HttpRequestError", (failure: HttpRequestError) => Effect.succeed( ToolResult.fail({ code: "http_request_failed", message: failure.message, retryable: true, }), ), ), ); // `issueRequest` resolves to `HttpResponse`; on transport failure the // catch above replaced it with a `ToolResult`. Discriminate. return "ok" in response ? response : ToolResult.ok(response); }), // Raw HTTP claims any http(s) URL as a low-confidence fallback so onboarding // can offer it when no spec-derived plugin matches. detect: (input: { readonly ctx: PluginCtx; readonly url: string; }): Effect.Effect => Effect.sync(() => { if (!/^https?:\/\//i.test(input.url)) return null; let host: string; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL parsing of arbitrary onboarding input; fall back to the raw string try { host = new URL(input.url).host; } catch { host = input.url; } return { kind: HTTP_SOURCE_PLUGIN_ID, confidence: "low", endpoint: input.url, name: host, slug: host, }; }), })); /** The plugin's extension API (`executor["http-source"]`) — derived from the * inferred `extension` factory return so the shape isn't duplicated. */ export type HttpSourceExtension = ReturnType< NonNullable["extension"]> >;