diff --git a/e2e/scenarios/connect-handoff.test.ts b/e2e/scenarios/connect-handoff.test.ts new file mode 100644 index 000000000..75a5e1ae7 --- /dev/null +++ b/e2e/scenarios/connect-handoff.test.ts @@ -0,0 +1,218 @@ +// The agentic connect handoff: an agent adds an API over MCP, asks for a +// handoff URL (`coreTools.connections.createHandoff`), and the user opens that +// URL in a browser to paste the credential. This scenario walks the WHOLE +// path — the exact flow that failed in production with a "wrong / bad" URL — +// against a real emulated provider (resend.emulators.dev) so the failure +// point is captured with trace + screenshots instead of guessed at: +// +// 1. MCP `execute` → `openapi.addSpec` registers the emulated Resend API +// 2. MCP `execute` → `connections.createHandoff` returns the browser URL +// 3. The URL's origin must be THIS deployment (not a hardcoded host) +// 4. Playwright opens it: the Add connection modal must be open with a +// credential field, the emulator-minted API key is pasted and submitted +// 5. The saved connection is proven live: `execute` sends an email through +// the new tools and the emulator's request ledger shows the call +// arriving with the pasted bearer token +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; +import type { Identity, Target as TargetShape } from "../src/target"; +import type { BrowserSurface } from "../src/surfaces/browser"; +import type { McpSession } from "../src/surfaces/mcp"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +const EMULATOR_BASE = "https://resend.emulators.dev"; + +// The emulator serves its own OpenAPI document (bearer auth, same shape as +// real Resend — and as the Sentry spec that failed in prod). Adding it by URL +// with no authenticationTemplate exercises exactly the agentic path: the +// add-account modal must render a paste-a-token flow derived from the spec's +// bare `http`/`bearer` security scheme. +const EMULATOR_SPEC_URL = `${EMULATOR_BASE}/openapi.json`; + +const addSpecCode = (slug: string) => ` +const added = await tools.executor.openapi.addSpec({ + spec: { kind: "url", url: ${JSON.stringify(EMULATOR_SPEC_URL)} }, + slug: ${JSON.stringify(slug)}, +}); +return added.ok ? { ok: true, slug: added.data.slug, toolCount: added.data.toolCount } : { ok: false, error: added.error }; +`; + +const createHandoffCode = (slug: string) => ` +const handoff = await tools.executor.coreTools.connections.createHandoff({ + integration: ${JSON.stringify(slug)}, + owner: "org", + label: "Resend (emulated)", +}); +return handoff.ok ? { ok: true, url: handoff.data.url } : { ok: false, error: handoff.error }; +`; + +// Selfhost scenarios share one workspace identity — leaked connections fail +// other scenarios' zero-state assertions, so remove everything this one made. +const removeConnectionsCode = (slug: string) => ` +const list = await tools.executor.coreTools.connections.list({}); +const mine = (list.ok ? list.data.connections : []).filter((c) => c.integration === ${JSON.stringify(slug)}); +for (const c of mine) { + await tools.executor.coreTools.connections.remove({ owner: c.owner, integration: c.integration, name: c.name }); +} +return { removed: mine.length }; +`; + +const sendEmailCode = (slug: string, subject: string) => ` +const found = await tools.search({ namespace: ${JSON.stringify(slug)}, query: "send email", limit: 5 }); +const path = found.items[0]?.path; +if (!path) return { ok: false, error: "no send tool found", items: found.items }; +let t = tools; +for (const seg of path.split(".")) t = t[seg]; +const sent = await t({ + body: { + from: "onboarding@example.com", + to: "e2e@example.com", + subject: ${JSON.stringify(subject)}, + html: "

connect-handoff e2e

", + }, +}); +return { ok: sent.ok, path, result: sent.ok ? sent.data : sent.error }; +`; + +/** Run `execute`, auto-approving a paused execution (policy elicitation) once, + * and parse the sandbox's JSON return value. */ +const executeJson = (session: McpSession, code: string) => + Effect.gen(function* () { + let result = yield* session.call("execute", { code }); + if (result.text.includes("executionId:")) { + result = yield* session.approvePaused(result.text); + } + expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true); + return JSON.parse(result.text) as Record; + }); + +const mintEmulatorApiKey = Effect.promise(async () => { + const response = await fetch(`${EMULATOR_BASE}/_emulate/credentials`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "api-key" }), + }); + const body = (await response.json()) as { credential?: { token?: string } }; + const token = body.credential?.token; + if (!token) throw new Error(`emulator credential mint failed: ${JSON.stringify(body)}`); + return token; +}); + +const fetchLedgerText = Effect.promise(async () => { + const response = await fetch(`${EMULATOR_BASE}/_emulate/ledger`); + return response.text(); +}); + +scenario( + "Connect · the agentic handoff URL opens this deployment's add-account flow and the pasted key works", + { timeout: 240_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const browser = yield* Browser; + const { client: makeApiClient } = yield* Api; + + const integration = unique("resendhf"); + const emailSubject = unique("connect-handoff"); + const apiKey = yield* mintEmulatorApiKey; + + const identity = yield* target.newIdentity(); + const session = mcp.session(identity); + const client = yield* makeApiClient(api, identity); + + yield* runScenario({ + target, + browser, + session, + identity, + integration, + emailSubject, + apiKey, + }).pipe( + // Best-effort cleanup even on failure: drop the created connection(s) + // over MCP, then the integration over the API. + Effect.ensuring( + Effect.gen(function* () { + yield* session.call("execute", { code: removeConnectionsCode(integration) }); + yield* client.openapi.removeSpec({ params: { slug: integration } }); + }).pipe(Effect.ignore), + ), + ); + }), +); + +const runScenario = (input: { + readonly target: TargetShape; + readonly browser: BrowserSurface; + readonly session: McpSession; + readonly identity: Identity; + readonly integration: string; + readonly emailSubject: string; + readonly apiKey: string; +}) => + Effect.gen(function* () { + const { target, browser, session, identity, integration, emailSubject, apiKey } = input; + + // 1. Agent registers the emulated provider over MCP. + const added = yield* executeJson(session, addSpecCode(integration)); + expect(added.ok, `addSpec succeeded: ${JSON.stringify(added)}`).toBe(true); + + // 2. Agent asks for the browser handoff URL. + const handoff = yield* executeJson(session, createHandoffCode(integration)); + expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true); + const handoffUrl = String(handoff.url); + + // 3. The URL must target THIS deployment. (Production returned a URL the + // user called "wrong/bad" — pin the contract here.) + const parsed = new URL(handoffUrl); + expect(parsed.origin, `handoff URL (${handoffUrl}) targets this deployment`).toBe( + new URL(target.baseUrl).origin, + ); + expect(parsed.pathname).toBe(`/integrations/${integration}`); + expect(parsed.searchParams.get("addAccount")).toBe("1"); + + // 4. The user opens the handoff URL and pastes the credential. + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the handoff URL from the agent", async () => { + await page.goto(handoffUrl, { waitUntil: "networkidle" }); + }); + + await step("The Add connection modal is open", async () => { + await page.getByRole("heading", { name: /Add connection/ }).waitFor({ timeout: 15_000 }); + }); + + await step("Paste the emulator API key", async () => { + const credential = page.getByPlaceholder(/paste the value \/ token/i); + await credential.waitFor({ timeout: 15_000 }); + await credential.fill(apiKey); + }); + + await step("Submit Add connection", async () => { + await page.getByRole("button", { name: "Add connection", exact: true }).click(); + await page + .getByRole("heading", { name: /Add connection/ }) + .waitFor({ state: "hidden", timeout: 20_000 }); + }); + }); + + // 5. The connection is live: send an email through the new tools and see + // it arrive at the emulator with the pasted token. + const sent = yield* executeJson(session, sendEmailCode(integration, emailSubject)); + expect(sent.ok, `email sent through the pasted connection: ${JSON.stringify(sent)}`).toBe(true); + + const ledger = yield* fetchLedgerText; + expect( + ledger.includes(emailSubject), + "the emulator's request ledger recorded the call made through Executor", + ).toBe(true); + }); diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index 9755032e1..3632a16b9 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -4,11 +4,7 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; -import { - AuthTemplateSlug, - IntegrationSlug, - type OAuthAuthentication, -} from "@executor-js/sdk/shared"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { slugifyNamespace, @@ -48,9 +44,10 @@ import { googleOpenApiPresets, type GoogleOpenApiPreset, } from "../sdk/google-presets"; -import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; -import { type APIKeyAuthentication, type Authentication, type ServerInfo } from "../sdk/types"; +import type { SpecPreview } from "../sdk/preview"; +import { type Authentication, type ServerInfo } from "../sdk/types"; import { expandServerUrlOptions } from "../sdk/openapi-utils"; +import { detectedAuthenticationTemplates, firstBaseUrlForPreview } from "../sdk/derive-auth"; const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; @@ -73,46 +70,6 @@ const googleBundleUrls = ( return [...new Set([...fromPresets, ...customUrls])]; }; -// --------------------------------------------------------------------------- -// OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve -// them against the chosen base URL so the stored auth template is absolute. -// --------------------------------------------------------------------------- - -export function resolveOAuthUrl(url: string, baseUrl: string): string { - if (!url) return url; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs - try { - new URL(url); - return url; - } catch { - if (!baseUrl) return url; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs - try { - return new URL(url, baseUrl).toString(); - } catch { - return url; - } - } -} - -const standardOidcIdentityScopes = ["openid", "email", "profile"] as const; - -const identityScopesForPreset = ( - identityScopes: OAuth2Preset["identityScopes"], -): readonly string[] => { - if (identityScopes === false) return []; - return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes; -}; - -const resolvedOAuthScopes = ( - apiScopes: Iterable, - identityScopes: OAuth2Preset["identityScopes"], -): string[] => { - const merged = new Set(apiScopes); - for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope); - return [...merged]; -}; - const isGoogleDiscoveryUrl = (url: string): boolean => { const trimmed = url.trim(); if (!URL.canParse(trimmed)) return false; @@ -146,100 +103,9 @@ const specInputForAdd = (input: string) => { : { kind: "blob" as const, value }; }; -// --------------------------------------------------------------------------- -// Auth-template builders — turn a preview preset into the integration's stored -// `Authentication` template (v2). The header preset becomes an `apiKey` template -// whose secret header value renders the resolved credential via `variable(token)`; -// the oauth2 preset becomes an `oauth` template carrying the provider endpoints. -// -// Post-redesign the add flow no longer asks the user to pick ONE method: every -// spec-detected method is registered so the integration's detail hub can list -// them and Add-account can choose among them (P6: add without auth, connect -// later). -// --------------------------------------------------------------------------- - -const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefined => { - const label = preset.label.toLowerCase(); - if (headerName.toLowerCase() === "authorization") { - if (label.includes("bearer")) return "Bearer "; - if (label.includes("basic")) return "Basic "; - } - return undefined; -}; - -const apiKeyTemplateFromHeaderPreset = ( - preset: HeaderPreset, - slug: AuthTemplateSlug, -): APIKeyAuthentication => ({ - slug, - kind: "apikey", - // Every secret header shares the one credential input (the canonical - // `token`, stored as an absent placement variable). - placements: preset.secretHeaders.map((headerName) => { - const prefix = headerPrefix(preset, headerName); - return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) }; - }), -}); - -const oauthTemplateFromPreset = ( - preset: OAuth2Preset, - baseUrl: string, - slug: AuthTemplateSlug, - scopes: readonly string[], -): OAuthAuthentication => ({ - slug, - kind: "oauth2", - authorizationUrl: resolveOAuthUrl( - Option.getOrElse(preset.authorizationUrl, () => ""), - baseUrl, - ), - tokenUrl: resolveOAuthUrl(preset.tokenUrl, baseUrl), - scopes: [...scopes], -}); - const expandServerOptions = (server: ServerInfo) => expandServerUrlOptions(server).map((value) => ({ value, label: value })); -const firstBaseUrlForPreview = (preview: SpecPreview): string => { - const firstServer = preview.servers[0]; - return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : ""; -}; - -// --------------------------------------------------------------------------- -// All spec-detected auth methods → the union of stored `Authentication` -// templates. Header presets become apiKey templates; each oauth2 preset becomes -// an oauth template (with its declared API scopes plus, for auth-code flows, -// the standard identity scopes). Slugs stay deterministic per method so the -// stored template is stable across previews of the same spec. Adding an -// integration whose slug already exists is blocked (see the existing-slug -// guard below); to add more auth, update the existing integration instead. -// --------------------------------------------------------------------------- - -const detectedAuthenticationTemplates = ( - headerPresets: readonly HeaderPreset[], - oauth2Presets: readonly OAuth2Preset[], - baseUrl: string, -): readonly Authentication[] => { - const templates: Authentication[] = []; - headerPresets.forEach((preset, index) => { - templates.push( - apiKeyTemplateFromHeaderPreset(preset, AuthTemplateSlug.make(`apikey-${index}`)), - ); - }); - for (const preset of oauth2Presets) { - const scopes = resolvedOAuthScopes(Object.keys(preset.scopes), preset.identityScopes); - templates.push( - oauthTemplateFromPreset( - preset, - baseUrl, - AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`), - scopes, - ), - ); - } - return templates; -}; - // --------------------------------------------------------------------------- // Component — single progressive form. Post-redesign: preview → addSpec // (register the integration catalog entry with ALL detected auth methods) → @@ -452,7 +318,13 @@ export default function AddOpenApiSource(props: { slug: resolvedSourceId, description: resolvedDisplayName, baseUrl: resolvedBaseUrl, - ...(!isGoogleBundlePreset && editedAuthenticationTemplate.length > 0 + // Always send the edited method list (even empty) when the user has + // inspected a preview: an explicit [] means "no auth methods", while + // OMITTING the field tells the server to derive defaults from the + // spec — which would silently resurrect methods the user deleted. + // The Google bundle path stays omitted; its auth is converter-derived + // server-side. + ...(!isGoogleBundlePreset ? { // Serialize to the wire input dialect (apikey → request-shaped). authenticationTemplate: editedAuthenticationTemplate.map(openApiWireAuthInput), diff --git a/packages/plugins/openapi/src/sdk/derive-auth.ts b/packages/plugins/openapi/src/sdk/derive-auth.ts new file mode 100644 index 000000000..8bd6336ba --- /dev/null +++ b/packages/plugins/openapi/src/sdk/derive-auth.ts @@ -0,0 +1,150 @@ +// Spec-detected auth → stored `Authentication` templates, shared by every add +// path. The React add flow derives templates from the preview before calling +// `addSpec`; `addSpec` itself falls back to the same derivation when the +// caller omits `authenticationTemplate` (the agentic/API path has no client +// to do it). One implementation so the web UI and headless callers cannot +// drift: an integration added over MCP gets the same auth methods the add +// page would have produced. +import * as Option from "effect/Option"; + +import { AuthTemplateSlug, type OAuthAuthentication } from "@executor-js/sdk/shared"; + +import type { HeaderPreset, OAuth2Preset, SpecPreview } from "./preview"; +import type { APIKeyAuthentication, Authentication } from "./types"; +import { expandServerUrlOptions } from "./openapi-utils"; + +// --------------------------------------------------------------------------- +// OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve +// them against the chosen base URL so the stored auth template is absolute. +// --------------------------------------------------------------------------- + +export function resolveOAuthUrl(url: string, baseUrl: string): string { + if (!url) return url; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs + try { + new URL(url); + return url; + } catch { + if (!baseUrl) return url; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs + try { + return new URL(url, baseUrl).toString(); + } catch { + return url; + } + } +} + +const standardOidcIdentityScopes = ["openid", "email", "profile"] as const; + +const identityScopesForPreset = ( + identityScopes: OAuth2Preset["identityScopes"], +): readonly string[] => { + if (identityScopes === false) return []; + return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes; +}; + +export const resolvedOAuthScopes = ( + apiScopes: Iterable, + identityScopes: OAuth2Preset["identityScopes"], +): string[] => { + const merged = new Set(apiScopes); + for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope); + return [...merged]; +}; + +// --------------------------------------------------------------------------- +// Auth-template builders — turn a preview preset into the integration's stored +// `Authentication` template (v2). The header preset becomes an `apiKey` template +// whose secret header value renders the resolved credential via `variable(token)`; +// the oauth2 preset becomes an `oauth` template carrying the provider endpoints. +// --------------------------------------------------------------------------- + +const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefined => { + const label = preset.label.toLowerCase(); + if (headerName.toLowerCase() === "authorization") { + if (label.includes("bearer")) return "Bearer "; + if (label.includes("basic")) return "Basic "; + } + return undefined; +}; + +const apiKeyTemplateFromHeaderPreset = ( + preset: HeaderPreset, + slug: AuthTemplateSlug, +): APIKeyAuthentication => ({ + slug, + kind: "apikey", + // Every secret header shares the one credential input (the canonical + // `token`, stored as an absent placement variable). + placements: preset.secretHeaders.map((headerName) => { + const prefix = headerPrefix(preset, headerName); + return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) }; + }), +}); + +const oauthTemplateFromPreset = ( + preset: OAuth2Preset, + baseUrl: string, + slug: AuthTemplateSlug, + scopes: readonly string[], +): OAuthAuthentication => ({ + slug, + kind: "oauth2", + authorizationUrl: resolveOAuthUrl( + Option.getOrElse(preset.authorizationUrl, () => ""), + baseUrl, + ), + tokenUrl: resolveOAuthUrl(preset.tokenUrl, baseUrl), + scopes: [...scopes], +}); + +// --------------------------------------------------------------------------- +// All spec-detected auth methods → the union of stored `Authentication` +// templates. Header presets become apiKey templates; each oauth2 preset becomes +// an oauth template (with its declared API scopes plus, for auth-code flows, +// the standard identity scopes). Slugs stay deterministic per method so the +// stored template is stable across previews of the same spec. +// --------------------------------------------------------------------------- + +export const detectedAuthenticationTemplates = ( + headerPresets: readonly HeaderPreset[], + oauth2Presets: readonly OAuth2Preset[], + baseUrl: string, +): readonly Authentication[] => { + const templates: Authentication[] = []; + headerPresets.forEach((preset, index) => { + templates.push( + apiKeyTemplateFromHeaderPreset(preset, AuthTemplateSlug.make(`apikey-${index}`)), + ); + }); + for (const preset of oauth2Presets) { + const scopes = resolvedOAuthScopes(Object.keys(preset.scopes), preset.identityScopes); + templates.push( + oauthTemplateFromPreset( + preset, + baseUrl, + AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`), + scopes, + ), + ); + } + return templates; +}; + +export const firstBaseUrlForPreview = (preview: SpecPreview): string => { + const firstServer = preview.servers[0]; + return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : ""; +}; + +/** The fallback `addSpec` uses when no explicit template was passed: every + * spec-detected method, resolved against the integration's base URL. */ +export const deriveAuthenticationTemplateFromPreview = ( + preview: SpecPreview, + baseUrl: string | undefined, +): readonly Authentication[] => + detectedAuthenticationTemplates( + preview.headerPresets, + preview.oauth2Presets, + baseUrl ?? firstBaseUrlForPreview(preview), + ); diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index ca8d7450c..c3578da05 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -506,6 +506,118 @@ describe("OpenAPI Plugin", () => { ), ); + it.effect("addSpec derives auth methods from the spec's security schemes by default", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* servePluginTestApi(); + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + + // The spec declares bearer auth; the caller passes NO template — the + // agentic add path (MCP/API) does exactly this. Without server-side + // derivation the integration is auth-less and its Add-connection + // modal is a dead end (e2e/scenarios/connect-handoff.test.ts). + // oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture surgery on the test server's own spec JSON + const spec = JSON.parse(server.specJson) as Record; + const specWithBearer = JSON.stringify({ + ...spec, + components: { + ...(spec.components as Record | undefined), + securitySchemes: { auth_token: { type: "http", scheme: "bearer" } }, + }, + security: [{ auth_token: [] }], + }); + + yield* executor.openapi.addSpec({ + spec: { kind: "blob", value: specWithBearer }, + slug: "derived_auth_api", + baseUrl: server.baseUrl, + }); + + // The derived template is persisted on the integration… + const config = yield* executor.openapi.getConfig("derived_auth_api"); + const derived = config?.authenticationTemplate ?? []; + expect(derived.map((a) => ({ slug: String(a.slug), kind: a.kind }))).toEqual([ + { slug: "apikey-0", kind: "apikey" }, + ]); + + // …and it renders a pasted credential as a bearer Authorization header. + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("derived_auth_api"), + template: AuthTemplateSlug.make("apikey-0"), + value: "pasted-token-xyz", + }); + const result = unwrapInvocation( + yield* executor.execute( + ToolAddress.make("tools.derived_auth_api.org.main.items.echoHeaders"), + {}, + ), + ).data as { authorization?: string }; + expect(result.authorization).toBe("Bearer pasted-token-xyz"); + }), + ), + ); + + it.effect("addSpec defaults baseUrl to the spec's first server when omitted", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* servePluginTestApi(); + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + + // No baseUrl passed — the spec's own servers entry must fill it, or + // every invocation on the integration fails with no host to call. + // oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture surgery on the test server's own spec JSON + const spec = JSON.parse(server.specJson) as Record; + const specWithServer = JSON.stringify({ + ...spec, + servers: [{ url: server.baseUrl }], + }); + + yield* executor.openapi.addSpec({ + spec: { kind: "blob", value: specWithServer }, + slug: "derived_base_api", + }); + + const config = yield* executor.openapi.getConfig("derived_base_api"); + expect(config?.baseUrl).toBe(server.baseUrl); + }), + ), + ); + + it.effect("addSpec treats an explicit empty authenticationTemplate as no auth", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* servePluginTestApi(); + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + + // The add page sends [] when the user deletes every detected method. + // That intent must survive — deriving methods back from the spec here + // would silently override the user's choice. + // oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture surgery on the test server's own spec JSON + const spec = JSON.parse(server.specJson) as Record; + const specWithBearer = JSON.stringify({ + ...spec, + components: { + ...(spec.components as Record | undefined), + securitySchemes: { auth_token: { type: "http", scheme: "bearer" } }, + }, + security: [{ auth_token: [] }], + }); + + yield* executor.openapi.addSpec({ + spec: { kind: "blob", value: specWithBearer }, + slug: "no_auth_api", + baseUrl: server.baseUrl, + authenticationTemplate: [], + }); + + const config = yield* executor.openapi.getConfig("no_auth_api"); + expect(config?.authenticationTemplate ?? []).toEqual([]); + }), + ), + ); + it.effect("removeSpec cleans up the integration and its tools", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index ee1816c55..e80e81d9f 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -41,7 +41,8 @@ import { import { extract } from "./extract"; import { compileToolDefinitions, type ToolDefinition } from "./definitions"; import { annotationsForOperation, invokeWithLayer } from "./invoke"; -import { previewSpec, type SpecPreview } from "./preview"; +import { previewSpec, previewSpecText, type SpecPreview } from "./preview"; +import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth"; import { openApiPresets } from "./presets"; import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store"; import type { Authentication } from "./types"; @@ -713,6 +714,32 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer); const compiled = yield* compileSpec(resolved.specText); + // Defaults the add page derives from its preview, applied here so + // headless callers (MCP, API) get the same integration the UI's + // add flow would produce — see e2e/scenarios/connect-handoff.test.ts: + // - baseUrl: the spec's first server (else tools have no host to + // call and every invocation fails with "HTTP request failed") + // - authenticationTemplate: the spec's declared security schemes + // (else the Add-connection modal is a dead "No authentication" + // end with nowhere to paste a credential) + // An explicit input always wins; for auth, an explicit EMPTY array + // means "no auth methods" and suppresses the derivation. + const explicitBaseUrl = config.baseUrl ?? resolved.baseUrl; + const needsDerivedBaseUrl = explicitBaseUrl == null; + const needsDerivedAuth = + config.authenticationTemplate == null && resolved.authenticationTemplate == null; + const preview = + needsDerivedBaseUrl || needsDerivedAuth + ? yield* previewSpecText(resolved.specText) + : undefined; + const derivedBaseUrl = + needsDerivedBaseUrl && preview ? firstBaseUrlForPreview(preview) : undefined; + const effectiveBaseUrl = explicitBaseUrl ?? (derivedBaseUrl || undefined); + const derivedAuthenticationTemplate = + needsDerivedAuth && preview + ? deriveAuthenticationTemplateFromPreview(preview, effectiveBaseUrl) + : undefined; + const slug = IntegrationSlug.make(config.slug); // Block re-adding an existing slug. The core `integrations.register` @@ -735,21 +762,22 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { ...(specInputToGoogleBundle(config.spec) !== undefined ? { googleDiscoveryUrls: specInputToGoogleBundle(config.spec) } : {}), - ...((config.baseUrl ?? resolved.baseUrl) - ? { baseUrl: config.baseUrl ?? resolved.baseUrl } - : {}), + ...(effectiveBaseUrl ? { baseUrl: effectiveBaseUrl } : {}), ...(config.headers ? { headers: config.headers } : {}), ...(config.queryParams ? { queryParams: config.queryParams } : {}), // Prefer the caller's explicit template; otherwise adopt the one the // Google Discovery converter derived from the spec (the bundle add - // path relies on this — it has no preview to detect auth from). + // path relies on this — it has no preview to detect auth from); + // otherwise derive from the spec's declared security schemes. ...(config.authenticationTemplate ? { authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate), } : resolved.authenticationTemplate ? { authenticationTemplate: resolved.authenticationTemplate } - : {}), + : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0 + ? { authenticationTemplate: derivedAuthenticationTemplate } + : {}), }; // The spec blob is written OUTSIDE the transaction: it's @@ -894,7 +922,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { tool({ name: "addSpec", description: - "Add an OpenAPI integration to the catalog and persist its operations as tools. Recommended flow: call `previewSpec`, choose a `slug`, declare an `authenticationTemplate` for how a credential is applied (apiKey header/query, or oauth bearer), then create a connection for that integration with the user's API key or via `oauth.start`.", + "Add an OpenAPI integration to the catalog and persist its operations as tools. Recommended flow: call `previewSpec`, choose a `slug`, then create a connection for that integration with the user's API key or via `oauth.start`. When `baseUrl` is omitted it defaults to the spec's first server; when `authenticationTemplate` is omitted the auth methods are derived from the spec's declared security schemes (pass an explicit template to override how a credential is applied — apiKey header/query, or oauth bearer — or an empty array for no auth methods).", annotations: { requiresApproval: true, approvalDescription: "Add an OpenAPI integration", diff --git a/packages/plugins/openapi/src/sdk/preview.ts b/packages/plugins/openapi/src/sdk/preview.ts index e38d86f51..8066789fb 100644 --- a/packages/plugins/openapi/src/sdk/preview.ts +++ b/packages/plugins/openapi/src/sdk/preview.ts @@ -374,10 +374,9 @@ const collectTags = (result: ExtractionResult): string[] => { // Public API // --------------------------------------------------------------------------- -/** Preview an OpenAPI spec — extract metadata without registering anything. - * Accepts either a URL or raw JSON/YAML text. */ -export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: string) { - const specText = yield* resolveSpecText(input); +/** Preview already-resolved spec text — extract metadata without registering + * anything and without any HTTP dependency. */ +export const previewSpecText = Effect.fn("OpenApi.previewSpecText")(function* (specText: string) { const doc: ParsedDocument = yield* parse(specText); const result = yield* extract(doc); @@ -417,3 +416,10 @@ export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: st oauth2Presets: buildOAuth2Presets(securitySchemes), }); }); + +/** Preview an OpenAPI spec — extract metadata without registering anything. + * Accepts either a URL or raw JSON/YAML text. */ +export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: string) { + const specText = yield* resolveSpecText(input); + return yield* previewSpecText(specText); +});