From 0613102dbd291583380fe4c4ec96ab080ab21074 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Thu, 11 Jun 2026 16:27:33 -0700 Subject: [PATCH 1/2] Derive OpenAPI auth methods from the spec when addSpec gets no template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addSpec only persisted auth methods when the caller passed an explicit authenticationTemplate (or for Google Discovery conversions). The add page derives templates from the preview client-side before calling it, but headless callers (MCP execute, direct API) don't — so any spec added that way silently lost its declared security schemes. The resulting integration had zero auth methods and its Add-connection modal rendered a dead 'No authentication' state with nowhere to paste a credential, even though the connections.createHandoff URL pointing at it was correct. - Move the preset-to-template derivation from AddOpenApiSource into the sdk (derive-auth.ts); the React add flow now imports it, so the two paths cannot drift. - addSpec falls back to that derivation when no template is passed, via a new previewSpecText (preview minus the URL fetch, so addSpec keeps its error/context channels). - Unit test: a bearer-scheme spec added with no template persists a derived apikey method and renders a pasted token as a bearer header. - New e2e scenario walks the whole agentic handoff: addSpec over MCP, createHandoff URL contract, browser paste into the add-account modal, then a live call through the connection verified against an emulated provider's request ledger. --- e2e/scenarios/connect-handoff.test.ts | 252 ++++++++++++++++++ .../openapi/src/react/AddOpenApiSource.tsx | 142 +--------- .../plugins/openapi/src/sdk/derive-auth.ts | 150 +++++++++++ .../plugins/openapi/src/sdk/plugin.test.ts | 53 ++++ packages/plugins/openapi/src/sdk/plugin.ts | 23 +- packages/plugins/openapi/src/sdk/preview.ts | 14 +- 6 files changed, 489 insertions(+), 145 deletions(-) create mode 100644 e2e/scenarios/connect-handoff.test.ts create mode 100644 packages/plugins/openapi/src/sdk/derive-auth.ts diff --git a/e2e/scenarios/connect-handoff.test.ts b/e2e/scenarios/connect-handoff.test.ts new file mode 100644 index 000000000..881f77527 --- /dev/null +++ b/e2e/scenarios/connect-handoff.test.ts @@ -0,0 +1,252 @@ +// 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"; + +/** Minimal Resend-shaped OpenAPI subset pointed at the emulator. Bearer auth + * mirrors the real provider (and the Sentry spec that failed in prod): the + * add-account modal must render a paste-a-token flow from a bare + * `http`/`bearer` security scheme, not just from an explicit apiKey + * authenticationTemplate. */ +const resendSpec = { + openapi: "3.0.3", + info: { title: "Resend (emulated)", version: "1.0.0" }, + paths: { + "/emails": { + post: { + operationId: "sendEmail", + tags: ["emails"], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + from: { type: "string" }, + to: { type: "string" }, + subject: { type: "string" }, + html: { type: "string" }, + }, + required: ["from", "to", "subject"], + }, + }, + }, + }, + responses: { "200": { description: "sent" } }, + }, + }, + }, + components: { + securitySchemes: { auth_token: { type: "http", scheme: "bearer" } }, + }, + security: [{ auth_token: [] }], +} as const; + +const addSpecCode = (slug: string) => ` +const added = await tools.executor.openapi.addSpec({ + spec: { kind: "blob", value: ${JSON.stringify(JSON.stringify(resendSpec))} }, + slug: ${JSON.stringify(slug)}, + baseUrl: ${JSON.stringify(EMULATOR_BASE)}, +}); +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..ac6cc8cf8 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) → 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..029167ed7 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -506,6 +506,59 @@ 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("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..1292b66d1 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 } from "./derive-auth"; import { openApiPresets } from "./presets"; import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store"; import type { Authentication } from "./types"; @@ -713,6 +714,19 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer); const compiled = yield* compileSpec(resolved.specText); + // No explicit template and nothing converter-derived → fall back to + // the spec's own declared auth (same derivation the add page runs on + // its preview). Without this, headless callers (MCP, API) silently + // produce auth-less integrations whose Add-connection modal is a + // dead end — see e2e/scenarios/connect-handoff.test.ts. + const derivedAuthenticationTemplate = + config.authenticationTemplate || resolved.authenticationTemplate + ? undefined + : deriveAuthenticationTemplateFromPreview( + yield* previewSpecText(resolved.specText), + config.baseUrl ?? resolved.baseUrl, + ); + const slug = IntegrationSlug.make(config.slug); // Block re-adding an existing slug. The core `integrations.register` @@ -742,14 +756,17 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { ...(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 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); +}); From 1ab4043b557b403c14c24368f91d6ee62893ec5a Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Thu, 11 Jun 2026 16:54:22 -0700 Subject: [PATCH 2/2] Default addSpec baseUrl from the spec's servers; explicit [] means no auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refinements from review of the derivation fallback: - baseUrl now also derives from the spec's first server when the caller passes none (same default the add page applies). Without it, a headless add produced tools with no host to call — every invocation failed with a bare 'HTTP request failed'. - An explicit empty authenticationTemplate now means 'no auth methods' and suppresses derivation. The add page sends the user's edited method list even when emptied, so deleting every detected method in the inspect step survives instead of being silently re-derived. - The addSpec tool description documents both defaults and the override semantics. - The e2e scenario now adds the integration purely by spec URL — the Resend emulator serves its own /openapi.json now — with no baseUrl and no template, so both derivations are covered by the live paste-key flow and the emulator ledger check. --- e2e/scenarios/connect-handoff.test.ts | 48 +++------------ .../openapi/src/react/AddOpenApiSource.tsx | 8 ++- .../plugins/openapi/src/sdk/plugin.test.ts | 59 +++++++++++++++++++ packages/plugins/openapi/src/sdk/plugin.ts | 43 +++++++++----- 4 files changed, 100 insertions(+), 58 deletions(-) diff --git a/e2e/scenarios/connect-handoff.test.ts b/e2e/scenarios/connect-handoff.test.ts index 881f77527..75a5e1ae7 100644 --- a/e2e/scenarios/connect-handoff.test.ts +++ b/e2e/scenarios/connect-handoff.test.ts @@ -32,51 +32,17 @@ const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}` const EMULATOR_BASE = "https://resend.emulators.dev"; -/** Minimal Resend-shaped OpenAPI subset pointed at the emulator. Bearer auth - * mirrors the real provider (and the Sentry spec that failed in prod): the - * add-account modal must render a paste-a-token flow from a bare - * `http`/`bearer` security scheme, not just from an explicit apiKey - * authenticationTemplate. */ -const resendSpec = { - openapi: "3.0.3", - info: { title: "Resend (emulated)", version: "1.0.0" }, - paths: { - "/emails": { - post: { - operationId: "sendEmail", - tags: ["emails"], - requestBody: { - required: true, - content: { - "application/json": { - schema: { - type: "object", - properties: { - from: { type: "string" }, - to: { type: "string" }, - subject: { type: "string" }, - html: { type: "string" }, - }, - required: ["from", "to", "subject"], - }, - }, - }, - }, - responses: { "200": { description: "sent" } }, - }, - }, - }, - components: { - securitySchemes: { auth_token: { type: "http", scheme: "bearer" } }, - }, - security: [{ auth_token: [] }], -} as const; +// 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: "blob", value: ${JSON.stringify(JSON.stringify(resendSpec))} }, + spec: { kind: "url", url: ${JSON.stringify(EMULATOR_SPEC_URL)} }, slug: ${JSON.stringify(slug)}, - baseUrl: ${JSON.stringify(EMULATOR_BASE)}, }); return added.ok ? { ok: true, slug: added.data.slug, toolCount: added.data.toolCount } : { ok: false, error: added.error }; `; diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index ac6cc8cf8..3632a16b9 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -318,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/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 029167ed7..c3578da05 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -559,6 +559,65 @@ describe("OpenAPI Plugin", () => { ), ); + 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 1292b66d1..e80e81d9f 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -42,7 +42,7 @@ import { extract } from "./extract"; import { compileToolDefinitions, type ToolDefinition } from "./definitions"; import { annotationsForOperation, invokeWithLayer } from "./invoke"; import { previewSpec, previewSpecText, type SpecPreview } from "./preview"; -import { deriveAuthenticationTemplateFromPreview } from "./derive-auth"; +import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth"; import { openApiPresets } from "./presets"; import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store"; import type { Authentication } from "./types"; @@ -714,18 +714,31 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer); const compiled = yield* compileSpec(resolved.specText); - // No explicit template and nothing converter-derived → fall back to - // the spec's own declared auth (same derivation the add page runs on - // its preview). Without this, headless callers (MCP, API) silently - // produce auth-less integrations whose Add-connection modal is a - // dead end — see e2e/scenarios/connect-handoff.test.ts. + // 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 = - config.authenticationTemplate || resolved.authenticationTemplate - ? undefined - : deriveAuthenticationTemplateFromPreview( - yield* previewSpecText(resolved.specText), - config.baseUrl ?? resolved.baseUrl, - ); + needsDerivedAuth && preview + ? deriveAuthenticationTemplateFromPreview(preview, effectiveBaseUrl) + : undefined; const slug = IntegrationSlug.make(config.slug); @@ -749,9 +762,7 @@ 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 @@ -911,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",