History

Derive OpenAPI auth methods from the spec when addSpec gets no template

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.

Rhys Sullivan committed 5 months ago

Commit 06131021 parent 13580d16 files · +489 −145Browse files

Unified · Split

6 files
  1. e2e/scenarios/connect-handoff.test.ts +252 −0
  2. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +4 −138
  3. packages/plugins/openapi/src/sdk/derive-auth.ts +150 −0
  4. packages/plugins/openapi/src/sdk/plugin.test.ts +53 −0
  5. packages/plugins/openapi/src/sdk/plugin.ts +20 −3
  6. packages/plugins/openapi/src/sdk/preview.ts +10 −4
e2e/scenarios/connect-handoff.test.ts+252−0
@@ -0,0 +1,252 @@
1// The agentic connect handoff: an agent adds an API over MCP, asks for a
2// handoff URL (`coreTools.connections.createHandoff`), and the user opens that
3// URL in a browser to paste the credential. This scenario walks the WHOLE
4// path — the exact flow that failed in production with a "wrong / bad" URL —
5// against a real emulated provider (resend.emulators.dev) so the failure
6// point is captured with trace + screenshots instead of guessed at:
7//
8// 1. MCP `execute` → `openapi.addSpec` registers the emulated Resend API
9// 2. MCP `execute` → `connections.createHandoff` returns the browser URL
10// 3. The URL's origin must be THIS deployment (not a hardcoded host)
11// 4. Playwright opens it: the Add connection modal must be open with a
12// credential field, the emulator-minted API key is pasted and submitted
13// 5. The saved connection is proven live: `execute` sends an email through
14// the new tools and the emulator's request ledger shows the call
15// arriving with the pasted bearer token
16import { randomBytes } from "node:crypto";
17
18import { expect } from "@effect/vitest";
19import { Effect } from "effect";
20import { composePluginApi } from "@executor-js/api/server";
21import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
22
23import { scenario } from "../src/scenario";
24import { Api, Browser, Mcp, Target } from "../src/services";
25import type { Identity, Target as TargetShape } from "../src/target";
26import type { BrowserSurface } from "../src/surfaces/browser";
27import type { McpSession } from "../src/surfaces/mcp";
28
29const api = composePluginApi([openApiHttpPlugin()] as const);
30
31const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
32
33const EMULATOR_BASE = "https://resend.emulators.dev";
34
35/** Minimal Resend-shaped OpenAPI subset pointed at the emulator. Bearer auth
36 * mirrors the real provider (and the Sentry spec that failed in prod): the
37 * add-account modal must render a paste-a-token flow from a bare
38 * `http`/`bearer` security scheme, not just from an explicit apiKey
39 * authenticationTemplate. */
40const resendSpec = {
41 openapi: "3.0.3",
42 info: { title: "Resend (emulated)", version: "1.0.0" },
43 paths: {
44 "/emails": {
45 post: {
46 operationId: "sendEmail",
47 tags: ["emails"],
48 requestBody: {
49 required: true,
50 content: {
51 "application/json": {
52 schema: {
53 type: "object",
54 properties: {
55 from: { type: "string" },
56 to: { type: "string" },
57 subject: { type: "string" },
58 html: { type: "string" },
59 },
60 required: ["from", "to", "subject"],
61 },
62 },
63 },
64 },
65 responses: { "200": { description: "sent" } },
66 },
67 },
68 },
69 components: {
70 securitySchemes: { auth_token: { type: "http", scheme: "bearer" } },
71 },
72 security: [{ auth_token: [] }],
73} as const;
74
75const addSpecCode = (slug: string) => `
76const added = await tools.executor.openapi.addSpec({
77 spec: { kind: "blob", value: ${JSON.stringify(JSON.stringify(resendSpec))} },
78 slug: ${JSON.stringify(slug)},
79 baseUrl: ${JSON.stringify(EMULATOR_BASE)},
80});
81return added.ok ? { ok: true, slug: added.data.slug, toolCount: added.data.toolCount } : { ok: false, error: added.error };
82`;
83
84const createHandoffCode = (slug: string) => `
85const handoff = await tools.executor.coreTools.connections.createHandoff({
86 integration: ${JSON.stringify(slug)},
87 owner: "org",
88 label: "Resend (emulated)",
89});
90return handoff.ok ? { ok: true, url: handoff.data.url } : { ok: false, error: handoff.error };
91`;
92
93// Selfhost scenarios share one workspace identity — leaked connections fail
94// other scenarios' zero-state assertions, so remove everything this one made.
95const removeConnectionsCode = (slug: string) => `
96const list = await tools.executor.coreTools.connections.list({});
97const mine = (list.ok ? list.data.connections : []).filter((c) => c.integration === ${JSON.stringify(slug)});
98for (const c of mine) {
99 await tools.executor.coreTools.connections.remove({ owner: c.owner, integration: c.integration, name: c.name });
100}
101return { removed: mine.length };
102`;
103
104const sendEmailCode = (slug: string, subject: string) => `
105const found = await tools.search({ namespace: ${JSON.stringify(slug)}, query: "send email", limit: 5 });
106const path = found.items[0]?.path;
107if (!path) return { ok: false, error: "no send tool found", items: found.items };
108let t = tools;
109for (const seg of path.split(".")) t = t[seg];
110const sent = await t({
111 body: {
112 from: "onboarding@example.com",
113 to: "e2e@example.com",
114 subject: ${JSON.stringify(subject)},
115 html: "<p>connect-handoff e2e</p>",
116 },
117});
118return { ok: sent.ok, path, result: sent.ok ? sent.data : sent.error };
119`;
120
121/** Run `execute`, auto-approving a paused execution (policy elicitation) once,
122 * and parse the sandbox's JSON return value. */
123const executeJson = (session: McpSession, code: string) =>
124 Effect.gen(function* () {
125 let result = yield* session.call("execute", { code });
126 if (result.text.includes("executionId:")) {
127 result = yield* session.approvePaused(result.text);
128 }
129 expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true);
130 return JSON.parse(result.text) as Record<string, unknown>;
131 });
132
133const mintEmulatorApiKey = Effect.promise(async () => {
134 const response = await fetch(`${EMULATOR_BASE}/_emulate/credentials`, {
135 method: "POST",
136 headers: { "content-type": "application/json" },
137 body: JSON.stringify({ type: "api-key" }),
138 });
139 const body = (await response.json()) as { credential?: { token?: string } };
140 const token = body.credential?.token;
141 if (!token) throw new Error(`emulator credential mint failed: ${JSON.stringify(body)}`);
142 return token;
143});
144
145const fetchLedgerText = Effect.promise(async () => {
146 const response = await fetch(`${EMULATOR_BASE}/_emulate/ledger`);
147 return response.text();
148});
149
150scenario(
151 "Connect · the agentic handoff URL opens this deployment's add-account flow and the pasted key works",
152 { timeout: 240_000 },
153 Effect.gen(function* () {
154 const target = yield* Target;
155 const mcp = yield* Mcp;
156 const browser = yield* Browser;
157 const { client: makeApiClient } = yield* Api;
158
159 const integration = unique("resendhf");
160 const emailSubject = unique("connect-handoff");
161 const apiKey = yield* mintEmulatorApiKey;
162
163 const identity = yield* target.newIdentity();
164 const session = mcp.session(identity);
165 const client = yield* makeApiClient(api, identity);
166
167 yield* runScenario({
168 target,
169 browser,
170 session,
171 identity,
172 integration,
173 emailSubject,
174 apiKey,
175 }).pipe(
176 // Best-effort cleanup even on failure: drop the created connection(s)
177 // over MCP, then the integration over the API.
178 Effect.ensuring(
179 Effect.gen(function* () {
180 yield* session.call("execute", { code: removeConnectionsCode(integration) });
181 yield* client.openapi.removeSpec({ params: { slug: integration } });
182 }).pipe(Effect.ignore),
183 ),
184 );
185 }),
186);
187
188const runScenario = (input: {
189 readonly target: TargetShape;
190 readonly browser: BrowserSurface;
191 readonly session: McpSession;
192 readonly identity: Identity;
193 readonly integration: string;
194 readonly emailSubject: string;
195 readonly apiKey: string;
196}) =>
197 Effect.gen(function* () {
198 const { target, browser, session, identity, integration, emailSubject, apiKey } = input;
199
200 // 1. Agent registers the emulated provider over MCP.
201 const added = yield* executeJson(session, addSpecCode(integration));
202 expect(added.ok, `addSpec succeeded: ${JSON.stringify(added)}`).toBe(true);
203
204 // 2. Agent asks for the browser handoff URL.
205 const handoff = yield* executeJson(session, createHandoffCode(integration));
206 expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true);
207 const handoffUrl = String(handoff.url);
208
209 // 3. The URL must target THIS deployment. (Production returned a URL the
210 // user called "wrong/bad" — pin the contract here.)
211 const parsed = new URL(handoffUrl);
212 expect(parsed.origin, `handoff URL (${handoffUrl}) targets this deployment`).toBe(
213 new URL(target.baseUrl).origin,
214 );
215 expect(parsed.pathname).toBe(`/integrations/${integration}`);
216 expect(parsed.searchParams.get("addAccount")).toBe("1");
217
218 // 4. The user opens the handoff URL and pastes the credential.
219 yield* browser.session(identity, async ({ page, step }) => {
220 await step("Open the handoff URL from the agent", async () => {
221 await page.goto(handoffUrl, { waitUntil: "networkidle" });
222 });
223
224 await step("The Add connection modal is open", async () => {
225 await page.getByRole("heading", { name: /Add connection/ }).waitFor({ timeout: 15_000 });
226 });
227
228 await step("Paste the emulator API key", async () => {
229 const credential = page.getByPlaceholder(/paste the value \/ token/i);
230 await credential.waitFor({ timeout: 15_000 });
231 await credential.fill(apiKey);
232 });
233
234 await step("Submit Add connection", async () => {
235 await page.getByRole("button", { name: "Add connection", exact: true }).click();
236 await page
237 .getByRole("heading", { name: /Add connection/ })
238 .waitFor({ state: "hidden", timeout: 20_000 });
239 });
240 });
241
242 // 5. The connection is live: send an email through the new tools and see
243 // it arrive at the emulator with the pasted token.
244 const sent = yield* executeJson(session, sendEmailCode(integration, emailSubject));
245 expect(sent.ok, `email sent through the pasted connection: ${JSON.stringify(sent)}`).toBe(true);
246
247 const ledger = yield* fetchLedgerText;
248 expect(
249 ledger.includes(emailSubject),
250 "the emulator's request ledger recorded the call made through Executor",
251 ).toBe(true);
252 });
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+4−138
@@ -4,11 +4,7 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines
4import * as Exit from "effect/Exit";4import * as Exit from "effect/Exit";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6 6
7import {7import { IntegrationSlug } from "@executor-js/sdk/shared";
8 AuthTemplateSlug,
9 IntegrationSlug,
10 type OAuthAuthentication,
11} from "@executor-js/sdk/shared";
12import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";8import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
13import {9import {
14 slugifyNamespace,10 slugifyNamespace,
@@ -48,9 +44,10 @@ import { · 33 unmodified lines
48 googleOpenApiPresets,44 googleOpenApiPresets,
49 type GoogleOpenApiPreset,45 type GoogleOpenApiPreset,
50} from "../sdk/google-presets";46} from "../sdk/google-presets";
51import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";47import type { SpecPreview } from "../sdk/preview";
52import { type APIKeyAuthentication, type Authentication, type ServerInfo } from "../sdk/types";48import { type Authentication, type ServerInfo } from "../sdk/types";
53import { expandServerUrlOptions } from "../sdk/openapi-utils";49import { expandServerUrlOptions } from "../sdk/openapi-utils";
50import { detectedAuthenticationTemplates, firstBaseUrlForPreview } from "../sdk/derive-auth";
54 51
55const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";52const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";
56const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";53const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";
@@ -73,46 +70,6 @@ const googleBundleUrls = ( · 16 unmodified lines
73 return [...new Set([...fromPresets, ...customUrls])];70 return [...new Set([...fromPresets, ...customUrls])];
74};71};
75 72
76// ---------------------------------------------------------------------------
77// OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve
78// them against the chosen base URL so the stored auth template is absolute.
79// ---------------------------------------------------------------------------
80
81export function resolveOAuthUrl(url: string, baseUrl: string): string {
82 if (!url) return url;
83 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs
84 try {
85 new URL(url);
86 return url;
87 } catch {
88 if (!baseUrl) return url;
89 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs
90 try {
91 return new URL(url, baseUrl).toString();
92 } catch {
93 return url;
94 }
95 }
96}
97
98const standardOidcIdentityScopes = ["openid", "email", "profile"] as const;
99
100const identityScopesForPreset = (
101 identityScopes: OAuth2Preset["identityScopes"],
102): readonly string[] => {
103 if (identityScopes === false) return [];
104 return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes;
105};
106
107const resolvedOAuthScopes = (
108 apiScopes: Iterable<string>,
109 identityScopes: OAuth2Preset["identityScopes"],
110): string[] => {
111 const merged = new Set(apiScopes);
112 for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope);
113 return [...merged];
114};
115
116const isGoogleDiscoveryUrl = (url: string): boolean => {73const isGoogleDiscoveryUrl = (url: string): boolean => {
117 const trimmed = url.trim();74 const trimmed = url.trim();
118 if (!URL.canParse(trimmed)) return false;75 if (!URL.canParse(trimmed)) return false;
@@ -146,100 +103,9 @@ const specInputForAdd = (input: string) => { · 27 unmodified lines
146 : { kind: "blob" as const, value };103 : { kind: "blob" as const, value };
147};104};
148 105
149// ---------------------------------------------------------------------------
150// Auth-template builders — turn a preview preset into the integration's stored
151// `Authentication` template (v2). The header preset becomes an `apiKey` template
152// whose secret header value renders the resolved credential via `variable(token)`;
153// the oauth2 preset becomes an `oauth` template carrying the provider endpoints.
154//
155// Post-redesign the add flow no longer asks the user to pick ONE method: every
156// spec-detected method is registered so the integration's detail hub can list
157// them and Add-account can choose among them (P6: add without auth, connect
158// later).
159// ---------------------------------------------------------------------------
160
161const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefined => {
162 const label = preset.label.toLowerCase();
163 if (headerName.toLowerCase() === "authorization") {
164 if (label.includes("bearer")) return "Bearer ";
165 if (label.includes("basic")) return "Basic ";
166 }
167 return undefined;
168};
169
170const apiKeyTemplateFromHeaderPreset = (
171 preset: HeaderPreset,
172 slug: AuthTemplateSlug,
173): APIKeyAuthentication => ({
174 slug,
175 kind: "apikey",
176 // Every secret header shares the one credential input (the canonical
177 // `token`, stored as an absent placement variable).
178 placements: preset.secretHeaders.map((headerName) => {
179 const prefix = headerPrefix(preset, headerName);
180 return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) };
181 }),
182});
183
184const oauthTemplateFromPreset = (
185 preset: OAuth2Preset,
186 baseUrl: string,
187 slug: AuthTemplateSlug,
188 scopes: readonly string[],
189): OAuthAuthentication => ({
190 slug,
191 kind: "oauth2",
192 authorizationUrl: resolveOAuthUrl(
193 Option.getOrElse(preset.authorizationUrl, () => ""),
194 baseUrl,
195 ),
196 tokenUrl: resolveOAuthUrl(preset.tokenUrl, baseUrl),
197 scopes: [...scopes],
198});
199
200const expandServerOptions = (server: ServerInfo) =>106const expandServerOptions = (server: ServerInfo) =>
201 expandServerUrlOptions(server).map((value) => ({ value, label: value }));107 expandServerUrlOptions(server).map((value) => ({ value, label: value }));
202 108
203const firstBaseUrlForPreview = (preview: SpecPreview): string => {
204 const firstServer = preview.servers[0];
205 return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : "";
206};
207
208// ---------------------------------------------------------------------------
209// All spec-detected auth methods → the union of stored `Authentication`
210// templates. Header presets become apiKey templates; each oauth2 preset becomes
211// an oauth template (with its declared API scopes plus, for auth-code flows,
212// the standard identity scopes). Slugs stay deterministic per method so the
213// stored template is stable across previews of the same spec. Adding an
214// integration whose slug already exists is blocked (see the existing-slug
215// guard below); to add more auth, update the existing integration instead.
216// ---------------------------------------------------------------------------
217
218const detectedAuthenticationTemplates = (
219 headerPresets: readonly HeaderPreset[],
220 oauth2Presets: readonly OAuth2Preset[],
221 baseUrl: string,
222): readonly Authentication[] => {
223 const templates: Authentication[] = [];
224 headerPresets.forEach((preset, index) => {
225 templates.push(
226 apiKeyTemplateFromHeaderPreset(preset, AuthTemplateSlug.make(`apikey-${index}`)),
227 );
228 });
229 for (const preset of oauth2Presets) {
230 const scopes = resolvedOAuthScopes(Object.keys(preset.scopes), preset.identityScopes);
231 templates.push(
232 oauthTemplateFromPreset(
233 preset,
234 baseUrl,
235 AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`),
236 scopes,
237 ),
238 );
239 }
240 return templates;
241};
242
243// ---------------------------------------------------------------------------109// ---------------------------------------------------------------------------
244// Component — single progressive form. Post-redesign: preview → addSpec110// Component — single progressive form. Post-redesign: preview → addSpec
245// (register the integration catalog entry with ALL detected auth methods) →111// (register the integration catalog entry with ALL detected auth methods) →
packages/plugins/openapi/src/sdk/derive-auth.ts+150−0
@@ -0,0 +1,150 @@
1// Spec-detected auth → stored `Authentication` templates, shared by every add
2// path. The React add flow derives templates from the preview before calling
3// `addSpec`; `addSpec` itself falls back to the same derivation when the
4// caller omits `authenticationTemplate` (the agentic/API path has no client
5// to do it). One implementation so the web UI and headless callers cannot
6// drift: an integration added over MCP gets the same auth methods the add
7// page would have produced.
8import * as Option from "effect/Option";
9
10import { AuthTemplateSlug, type OAuthAuthentication } from "@executor-js/sdk/shared";
11
12import type { HeaderPreset, OAuth2Preset, SpecPreview } from "./preview";
13import type { APIKeyAuthentication, Authentication } from "./types";
14import { expandServerUrlOptions } from "./openapi-utils";
15
16// ---------------------------------------------------------------------------
17// OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve
18// them against the chosen base URL so the stored auth template is absolute.
19// ---------------------------------------------------------------------------
20
21export function resolveOAuthUrl(url: string, baseUrl: string): string {
22 if (!url) return url;
23 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs
24 try {
25 new URL(url);
26 return url;
27 } catch {
28 if (!baseUrl) return url;
29 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs
30 try {
31 return new URL(url, baseUrl).toString();
32 } catch {
33 return url;
34 }
35 }
36}
37
38const standardOidcIdentityScopes = ["openid", "email", "profile"] as const;
39
40const identityScopesForPreset = (
41 identityScopes: OAuth2Preset["identityScopes"],
42): readonly string[] => {
43 if (identityScopes === false) return [];
44 return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes;
45};
46
47export const resolvedOAuthScopes = (
48 apiScopes: Iterable<string>,
49 identityScopes: OAuth2Preset["identityScopes"],
50): string[] => {
51 const merged = new Set(apiScopes);
52 for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope);
53 return [...merged];
54};
55
56// ---------------------------------------------------------------------------
57// Auth-template builders — turn a preview preset into the integration's stored
58// `Authentication` template (v2). The header preset becomes an `apiKey` template
59// whose secret header value renders the resolved credential via `variable(token)`;
60// the oauth2 preset becomes an `oauth` template carrying the provider endpoints.
61// ---------------------------------------------------------------------------
62
63const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefined => {
64 const label = preset.label.toLowerCase();
65 if (headerName.toLowerCase() === "authorization") {
66 if (label.includes("bearer")) return "Bearer ";
67 if (label.includes("basic")) return "Basic ";
68 }
69 return undefined;
70};
71
72const apiKeyTemplateFromHeaderPreset = (
73 preset: HeaderPreset,
74 slug: AuthTemplateSlug,
75): APIKeyAuthentication => ({
76 slug,
77 kind: "apikey",
78 // Every secret header shares the one credential input (the canonical
79 // `token`, stored as an absent placement variable).
80 placements: preset.secretHeaders.map((headerName) => {
81 const prefix = headerPrefix(preset, headerName);
82 return { carrier: "header" as const, name: headerName, ...(prefix ? { prefix } : {}) };
83 }),
84});
85
86const oauthTemplateFromPreset = (
87 preset: OAuth2Preset,
88 baseUrl: string,
89 slug: AuthTemplateSlug,
90 scopes: readonly string[],
91): OAuthAuthentication => ({
92 slug,
93 kind: "oauth2",
94 authorizationUrl: resolveOAuthUrl(
95 Option.getOrElse(preset.authorizationUrl, () => ""),
96 baseUrl,
97 ),
98 tokenUrl: resolveOAuthUrl(preset.tokenUrl, baseUrl),
99 scopes: [...scopes],
100});
101
102// ---------------------------------------------------------------------------
103// All spec-detected auth methods → the union of stored `Authentication`
104// templates. Header presets become apiKey templates; each oauth2 preset becomes
105// an oauth template (with its declared API scopes plus, for auth-code flows,
106// the standard identity scopes). Slugs stay deterministic per method so the
107// stored template is stable across previews of the same spec.
108// ---------------------------------------------------------------------------
109
110export const detectedAuthenticationTemplates = (
111 headerPresets: readonly HeaderPreset[],
112 oauth2Presets: readonly OAuth2Preset[],
113 baseUrl: string,
114): readonly Authentication[] => {
115 const templates: Authentication[] = [];
116 headerPresets.forEach((preset, index) => {
117 templates.push(
118 apiKeyTemplateFromHeaderPreset(preset, AuthTemplateSlug.make(`apikey-${index}`)),
119 );
120 });
121 for (const preset of oauth2Presets) {
122 const scopes = resolvedOAuthScopes(Object.keys(preset.scopes), preset.identityScopes);
123 templates.push(
124 oauthTemplateFromPreset(
125 preset,
126 baseUrl,
127 AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`),
128 scopes,
129 ),
130 );
131 }
132 return templates;
133};
134
135export const firstBaseUrlForPreview = (preview: SpecPreview): string => {
136 const firstServer = preview.servers[0];
137 return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : "";
138};
139
140/** The fallback `addSpec` uses when no explicit template was passed: every
141 * spec-detected method, resolved against the integration's base URL. */
142export const deriveAuthenticationTemplateFromPreview = (
143 preview: SpecPreview,
144 baseUrl: string | undefined,
145): readonly Authentication[] =>
146 detectedAuthenticationTemplates(
147 preview.headerPresets,
148 preview.oauth2Presets,
149 baseUrl ?? firstBaseUrlForPreview(preview),
150 );
packages/plugins/openapi/src/sdk/plugin.test.ts+53−0
@@ -506,6 +506,59 @@ describe("OpenAPI Plugin", () => { · 505 unmodified lines
506 ),506 ),
507 );507 );
508 508
509 it.effect("addSpec derives auth methods from the spec's security schemes by default", () =>
510 Effect.scoped(
511 Effect.gen(function* () {
512 const server = yield* servePluginTestApi();
513 const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
514
515 // The spec declares bearer auth; the caller passes NO template — the
516 // agentic add path (MCP/API) does exactly this. Without server-side
517 // derivation the integration is auth-less and its Add-connection
518 // modal is a dead end (e2e/scenarios/connect-handoff.test.ts).
519 // oxlint-disable-next-line executor/no-json-parse -- boundary: test fixture surgery on the test server's own spec JSON
520 const spec = JSON.parse(server.specJson) as Record<string, unknown>;
521 const specWithBearer = JSON.stringify({
522 ...spec,
523 components: {
524 ...(spec.components as Record<string, unknown> | undefined),
525 securitySchemes: { auth_token: { type: "http", scheme: "bearer" } },
526 },
527 security: [{ auth_token: [] }],
528 });
529
530 yield* executor.openapi.addSpec({
531 spec: { kind: "blob", value: specWithBearer },
532 slug: "derived_auth_api",
533 baseUrl: server.baseUrl,
534 });
535
536 // The derived template is persisted on the integration…
537 const config = yield* executor.openapi.getConfig("derived_auth_api");
538 const derived = config?.authenticationTemplate ?? [];
539 expect(derived.map((a) => ({ slug: String(a.slug), kind: a.kind }))).toEqual([
540 { slug: "apikey-0", kind: "apikey" },
541 ]);
542
543 // …and it renders a pasted credential as a bearer Authorization header.
544 yield* executor.connections.create({
545 owner: "org",
546 name: ConnectionName.make("main"),
547 integration: IntegrationSlug.make("derived_auth_api"),
548 template: AuthTemplateSlug.make("apikey-0"),
549 value: "pasted-token-xyz",
550 });
551 const result = unwrapInvocation(
552 yield* executor.execute(
553 ToolAddress.make("tools.derived_auth_api.org.main.items.echoHeaders"),
554 {},
555 ),
556 ).data as { authorization?: string };
557 expect(result.authorization).toBe("Bearer pasted-token-xyz");
558 }),
559 ),
560 );
561
509 it.effect("removeSpec cleans up the integration and its tools", () =>562 it.effect("removeSpec cleans up the integration and its tools", () =>
510 Effect.scoped(563 Effect.scoped(
511 Effect.gen(function* () {564 Effect.gen(function* () {
packages/plugins/openapi/src/sdk/plugin.ts+20−3
@@ -41,7 +41,8 @@ import { · 40 unmodified lines
41import { extract } from "./extract";41import { extract } from "./extract";
42import { compileToolDefinitions, type ToolDefinition } from "./definitions";42import { compileToolDefinitions, type ToolDefinition } from "./definitions";
43import { annotationsForOperation, invokeWithLayer } from "./invoke";43import { annotationsForOperation, invokeWithLayer } from "./invoke";
44import { previewSpec, type SpecPreview } from "./preview";44import { previewSpec, previewSpecText, type SpecPreview } from "./preview";
45import { deriveAuthenticationTemplateFromPreview } from "./derive-auth";
45import { openApiPresets } from "./presets";46import { openApiPresets } from "./presets";
46import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store";47import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store";
47import type { Authentication } from "./types";48import type { Authentication } from "./types";
@@ -713,6 +714,19 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 665 unmodified lines
713 const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer);714 const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer);
714 const compiled = yield* compileSpec(resolved.specText);715 const compiled = yield* compileSpec(resolved.specText);
715 716
717 // No explicit template and nothing converter-derived → fall back to
718 // the spec's own declared auth (same derivation the add page runs on
719 // its preview). Without this, headless callers (MCP, API) silently
720 // produce auth-less integrations whose Add-connection modal is a
721 // dead end — see e2e/scenarios/connect-handoff.test.ts.
722 const derivedAuthenticationTemplate =
723 config.authenticationTemplate || resolved.authenticationTemplate
724 ? undefined
725 : deriveAuthenticationTemplateFromPreview(
726 yield* previewSpecText(resolved.specText),
727 config.baseUrl ?? resolved.baseUrl,
728 );
729
716 const slug = IntegrationSlug.make(config.slug);730 const slug = IntegrationSlug.make(config.slug);
717 731
718 // Block re-adding an existing slug. The core `integrations.register`732 // Block re-adding an existing slug. The core `integrations.register`
@@ -742,14 +756,17 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 23 unmodified lines
742 ...(config.queryParams ? { queryParams: config.queryParams } : {}),756 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
743 // Prefer the caller's explicit template; otherwise adopt the one the757 // Prefer the caller's explicit template; otherwise adopt the one the
744 // Google Discovery converter derived from the spec (the bundle add758 // Google Discovery converter derived from the spec (the bundle add
745 // path relies on this — it has no preview to detect auth from).759 // path relies on this — it has no preview to detect auth from);
760 // otherwise derive from the spec's declared security schemes.
746 ...(config.authenticationTemplate761 ...(config.authenticationTemplate
747 ? {762 ? {
748 authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate),763 authenticationTemplate: normalizeOpenApiAuthInputs(config.authenticationTemplate),
749 }764 }
750 : resolved.authenticationTemplate765 : resolved.authenticationTemplate
751 ? { authenticationTemplate: resolved.authenticationTemplate }766 ? { authenticationTemplate: resolved.authenticationTemplate }
752 : {}),767 : derivedAuthenticationTemplate && derivedAuthenticationTemplate.length > 0
768 ? { authenticationTemplate: derivedAuthenticationTemplate }
769 : {}),
753 };770 };
754 771
755 // The spec blob is written OUTSIDE the transaction: it's772 // The spec blob is written OUTSIDE the transaction: it's
packages/plugins/openapi/src/sdk/preview.ts+10−4
@@ -374,10 +374,9 @@ const collectTags = (result: ExtractionResult): string[] => { · 373 unmodified lines
374// Public API374// Public API
375// ---------------------------------------------------------------------------375// ---------------------------------------------------------------------------
376 376
377/** Preview an OpenAPI spec — extract metadata without registering anything.377/** Preview already-resolved spec text — extract metadata without registering
378 * Accepts either a URL or raw JSON/YAML text. */378 * anything and without any HTTP dependency. */
379export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: string) {379export const previewSpecText = Effect.fn("OpenApi.previewSpecText")(function* (specText: string) {
380 const specText = yield* resolveSpecText(input);
381 const doc: ParsedDocument = yield* parse(specText);380 const doc: ParsedDocument = yield* parse(specText);
382 const result = yield* extract(doc);381 const result = yield* extract(doc);
383 382
@@ -417,3 +416,10 @@ export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: st · 33 unmodified lines
417 oauth2Presets: buildOAuth2Presets(securitySchemes),416 oauth2Presets: buildOAuth2Presets(securitySchemes),
418 });417 });
419});418});
419
420/** Preview an OpenAPI spec — extract metadata without registering anything.
421 * Accepts either a URL or raw JSON/YAML text. */
422export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (input: string) {
423 const specText = yield* resolveSpecText(input);
424 return yield* previewSpecText(specText);
425});