Skip to content

Commit 0613102

Browse files
committed
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.
1 parent 13580d1 commit 0613102

6 files changed

Lines changed: 489 additions & 145 deletions

File tree

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
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+
/** 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. */
40+
const 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+
75+
const addSpecCode = (slug: string) => `
76+
const 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+
});
81+
return added.ok ? { ok: true, slug: added.data.slug, toolCount: added.data.toolCount } : { ok: false, error: added.error };
82+
`;
83+
84+
const createHandoffCode = (slug: string) => `
85+
const handoff = await tools.executor.coreTools.connections.createHandoff({
86+
integration: ${JSON.stringify(slug)},
87+
owner: "org",
88+
label: "Resend (emulated)",
89+
});
90+
return 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.
95+
const removeConnectionsCode = (slug: string) => `
96+
const list = await tools.executor.coreTools.connections.list({});
97+
const mine = (list.ok ? list.data.connections : []).filter((c) => c.integration === ${JSON.stringify(slug)});
98+
for (const c of mine) {
99+
await tools.executor.coreTools.connections.remove({ owner: c.owner, integration: c.integration, name: c.name });
100+
}
101+
return { removed: mine.length };
102+
`;
103+
104+
const sendEmailCode = (slug: string, subject: string) => `
105+
const found = await tools.search({ namespace: ${JSON.stringify(slug)}, query: "send email", limit: 5 });
106+
const path = found.items[0]?.path;
107+
if (!path) return { ok: false, error: "no send tool found", items: found.items };
108+
let t = tools;
109+
for (const seg of path.split(".")) t = t[seg];
110+
const 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+
});
118+
return { 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. */
123+
const 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+
133+
const 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+
145+
const fetchLedgerText = Effect.promise(async () => {
146+
const response = await fetch(`${EMULATOR_BASE}/_emulate/ledger`);
147+
return response.text();
148+
});
149+
150+
scenario(
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+
188+
const 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+
});

0 commit comments

Comments
 (0)