Skip to content

Commit 5c715b5

Browse files
authored
Derive OpenAPI auth methods from the spec when addSpec gets no explicit template (#957)
* 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. * Default addSpec baseUrl from the spec's servers; explicit [] means no auth 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.
1 parent 13580d1 commit 5c715b5

6 files changed

Lines changed: 536 additions & 150 deletions

File tree

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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
16+
import { randomBytes } from "node:crypto";
17+
18+
import { expect } from "@effect/vitest";
19+
import { Effect } from "effect";
20+
import { composePluginApi } from "@executor-js/api/server";
21+
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
22+
23+
import { scenario } from "../src/scenario";
24+
import { Api, Browser, Mcp, Target } from "../src/services";
25+
import type { Identity, Target as TargetShape } from "../src/target";
26+
import type { BrowserSurface } from "../src/surfaces/browser";
27+
import type { McpSession } from "../src/surfaces/mcp";
28+
29+
const api = composePluginApi([openApiHttpPlugin()] as const);
30+
31+
const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
32+
33+
const EMULATOR_BASE = "https://resend.emulators.dev";
34+
35+
// The emulator serves its own OpenAPI document (bearer auth, same shape as
36+
// real Resend — and as the Sentry spec that failed in prod). Adding it by URL
37+
// with no authenticationTemplate exercises exactly the agentic path: the
38+
// add-account modal must render a paste-a-token flow derived from the spec's
39+
// bare `http`/`bearer` security scheme.
40+
const EMULATOR_SPEC_URL = `${EMULATOR_BASE}/openapi.json`;
41+
42+
const addSpecCode = (slug: string) => `
43+
const added = await tools.executor.openapi.addSpec({
44+
spec: { kind: "url", url: ${JSON.stringify(EMULATOR_SPEC_URL)} },
45+
slug: ${JSON.stringify(slug)},
46+
});
47+
return added.ok ? { ok: true, slug: added.data.slug, toolCount: added.data.toolCount } : { ok: false, error: added.error };
48+
`;
49+
50+
const createHandoffCode = (slug: string) => `
51+
const handoff = await tools.executor.coreTools.connections.createHandoff({
52+
integration: ${JSON.stringify(slug)},
53+
owner: "org",
54+
label: "Resend (emulated)",
55+
});
56+
return handoff.ok ? { ok: true, url: handoff.data.url } : { ok: false, error: handoff.error };
57+
`;
58+
59+
// Selfhost scenarios share one workspace identity — leaked connections fail
60+
// other scenarios' zero-state assertions, so remove everything this one made.
61+
const removeConnectionsCode = (slug: string) => `
62+
const list = await tools.executor.coreTools.connections.list({});
63+
const mine = (list.ok ? list.data.connections : []).filter((c) => c.integration === ${JSON.stringify(slug)});
64+
for (const c of mine) {
65+
await tools.executor.coreTools.connections.remove({ owner: c.owner, integration: c.integration, name: c.name });
66+
}
67+
return { removed: mine.length };
68+
`;
69+
70+
const sendEmailCode = (slug: string, subject: string) => `
71+
const found = await tools.search({ namespace: ${JSON.stringify(slug)}, query: "send email", limit: 5 });
72+
const path = found.items[0]?.path;
73+
if (!path) return { ok: false, error: "no send tool found", items: found.items };
74+
let t = tools;
75+
for (const seg of path.split(".")) t = t[seg];
76+
const sent = await t({
77+
body: {
78+
from: "onboarding@example.com",
79+
to: "e2e@example.com",
80+
subject: ${JSON.stringify(subject)},
81+
html: "<p>connect-handoff e2e</p>",
82+
},
83+
});
84+
return { ok: sent.ok, path, result: sent.ok ? sent.data : sent.error };
85+
`;
86+
87+
/** Run `execute`, auto-approving a paused execution (policy elicitation) once,
88+
* and parse the sandbox's JSON return value. */
89+
const executeJson = (session: McpSession, code: string) =>
90+
Effect.gen(function* () {
91+
let result = yield* session.call("execute", { code });
92+
if (result.text.includes("executionId:")) {
93+
result = yield* session.approvePaused(result.text);
94+
}
95+
expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true);
96+
return JSON.parse(result.text) as Record<string, unknown>;
97+
});
98+
99+
const mintEmulatorApiKey = Effect.promise(async () => {
100+
const response = await fetch(`${EMULATOR_BASE}/_emulate/credentials`, {
101+
method: "POST",
102+
headers: { "content-type": "application/json" },
103+
body: JSON.stringify({ type: "api-key" }),
104+
});
105+
const body = (await response.json()) as { credential?: { token?: string } };
106+
const token = body.credential?.token;
107+
if (!token) throw new Error(`emulator credential mint failed: ${JSON.stringify(body)}`);
108+
return token;
109+
});
110+
111+
const fetchLedgerText = Effect.promise(async () => {
112+
const response = await fetch(`${EMULATOR_BASE}/_emulate/ledger`);
113+
return response.text();
114+
});
115+
116+
scenario(
117+
"Connect · the agentic handoff URL opens this deployment's add-account flow and the pasted key works",
118+
{ timeout: 240_000 },
119+
Effect.gen(function* () {
120+
const target = yield* Target;
121+
const mcp = yield* Mcp;
122+
const browser = yield* Browser;
123+
const { client: makeApiClient } = yield* Api;
124+
125+
const integration = unique("resendhf");
126+
const emailSubject = unique("connect-handoff");
127+
const apiKey = yield* mintEmulatorApiKey;
128+
129+
const identity = yield* target.newIdentity();
130+
const session = mcp.session(identity);
131+
const client = yield* makeApiClient(api, identity);
132+
133+
yield* runScenario({
134+
target,
135+
browser,
136+
session,
137+
identity,
138+
integration,
139+
emailSubject,
140+
apiKey,
141+
}).pipe(
142+
// Best-effort cleanup even on failure: drop the created connection(s)
143+
// over MCP, then the integration over the API.
144+
Effect.ensuring(
145+
Effect.gen(function* () {
146+
yield* session.call("execute", { code: removeConnectionsCode(integration) });
147+
yield* client.openapi.removeSpec({ params: { slug: integration } });
148+
}).pipe(Effect.ignore),
149+
),
150+
);
151+
}),
152+
);
153+
154+
const runScenario = (input: {
155+
readonly target: TargetShape;
156+
readonly browser: BrowserSurface;
157+
readonly session: McpSession;
158+
readonly identity: Identity;
159+
readonly integration: string;
160+
readonly emailSubject: string;
161+
readonly apiKey: string;
162+
}) =>
163+
Effect.gen(function* () {
164+
const { target, browser, session, identity, integration, emailSubject, apiKey } = input;
165+
166+
// 1. Agent registers the emulated provider over MCP.
167+
const added = yield* executeJson(session, addSpecCode(integration));
168+
expect(added.ok, `addSpec succeeded: ${JSON.stringify(added)}`).toBe(true);
169+
170+
// 2. Agent asks for the browser handoff URL.
171+
const handoff = yield* executeJson(session, createHandoffCode(integration));
172+
expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true);
173+
const handoffUrl = String(handoff.url);
174+
175+
// 3. The URL must target THIS deployment. (Production returned a URL the
176+
// user called "wrong/bad" — pin the contract here.)
177+
const parsed = new URL(handoffUrl);
178+
expect(parsed.origin, `handoff URL (${handoffUrl}) targets this deployment`).toBe(
179+
new URL(target.baseUrl).origin,
180+
);
181+
expect(parsed.pathname).toBe(`/integrations/${integration}`);
182+
expect(parsed.searchParams.get("addAccount")).toBe("1");
183+
184+
// 4. The user opens the handoff URL and pastes the credential.
185+
yield* browser.session(identity, async ({ page, step }) => {
186+
await step("Open the handoff URL from the agent", async () => {
187+
await page.goto(handoffUrl, { waitUntil: "networkidle" });
188+
});
189+
190+
await step("The Add connection modal is open", async () => {
191+
await page.getByRole("heading", { name: /Add connection/ }).waitFor({ timeout: 15_000 });
192+
});
193+
194+
await step("Paste the emulator API key", async () => {
195+
const credential = page.getByPlaceholder(/paste the value \/ token/i);
196+
await credential.waitFor({ timeout: 15_000 });
197+
await credential.fill(apiKey);
198+
});
199+
200+
await step("Submit Add connection", async () => {
201+
await page.getByRole("button", { name: "Add connection", exact: true }).click();
202+
await page
203+
.getByRole("heading", { name: /Add connection/ })
204+
.waitFor({ state: "hidden", timeout: 20_000 });
205+
});
206+
});
207+
208+
// 5. The connection is live: send an email through the new tools and see
209+
// it arrive at the emulator with the pasted token.
210+
const sent = yield* executeJson(session, sendEmailCode(integration, emailSubject));
211+
expect(sent.ok, `email sent through the pasted connection: ${JSON.stringify(sent)}`).toBe(true);
212+
213+
const ledger = yield* fetchLedgerText;
214+
expect(
215+
ledger.includes(emailSubject),
216+
"the emulator's request ledger recorded the call made through Executor",
217+
).toBe(true);
218+
});

0 commit comments

Comments
 (0)