|
| 1 | +// The agentic no-auth wire-up: an agent registers a public REST API over MCP |
| 2 | +// and then creates its connection PROGRAMMATICALLY through the gateway core |
| 3 | +// tool — `coreTools.connections.create` with `template: "none"` and no |
| 4 | +// credential origin. This is the path that used to be impossible: the core |
| 5 | +// tool's arg schema demanded "exactly one provider credential origin", so an |
| 6 | +// agent wiring up a public, no-auth integration (public MCP server, public |
| 7 | +// REST API) was forced to bounce the user into the web UI via createHandoff, |
| 8 | +// even though the engine fully supports a zero-credential connection. |
| 9 | +// |
| 10 | +// This scenario walks the WHOLE path against a real public no-auth API (the |
| 11 | +// npm registry downloads endpoint, https://api.npmjs.org) so the proof is an |
| 12 | +// actual 200 over the wire, not a stub: |
| 13 | +// |
| 14 | +// 1. MCP `execute` → `openapi.addSpec` registers a tiny no-auth spec |
| 15 | +// (no securitySchemes ⇒ the integration is no-auth) |
| 16 | +// 2. MCP `execute` → `coreTools.connections.create` with template "none" |
| 17 | +// and NEITHER `from` NOR `inputs` — the call that used to fail validation |
| 18 | +// 3. The operation is now a callable tool: invoke it and read back a 200 |
| 19 | +// with the real download count |
| 20 | +// 4. Guard the relaxed-but-still-strict contract: a no-auth create that |
| 21 | +// DOES carry an origin (here an empty `inputs: {}`) is still rejected |
| 22 | +import { randomBytes } from "node:crypto"; |
| 23 | + |
| 24 | +import { expect } from "@effect/vitest"; |
| 25 | +import { Effect } from "effect"; |
| 26 | +import { composePluginApi } from "@executor-js/api/server"; |
| 27 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 28 | + |
| 29 | +import { scenario } from "../src/scenario"; |
| 30 | +import { Api, Mcp, Target } from "../src/services"; |
| 31 | +import type { McpSession } from "../src/surfaces/mcp"; |
| 32 | + |
| 33 | +const api = composePluginApi([openApiHttpPlugin()] as const); |
| 34 | + |
| 35 | +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; |
| 36 | + |
| 37 | +// A real public, no-auth REST API. No `components.securitySchemes` and no |
| 38 | +// top-level `security`, so addSpec derives no auth method and the integration |
| 39 | +// is no-auth — exactly the shape a connection on `template: "none"` targets. |
| 40 | +const NPM_DOWNLOADS_SPEC = JSON.stringify({ |
| 41 | + openapi: "3.0.3", |
| 42 | + info: { title: "npm Registry Downloads", version: "1.0.0" }, |
| 43 | + servers: [{ url: "https://api.npmjs.org" }], |
| 44 | + paths: { |
| 45 | + "/downloads/point/{period}/{package}": { |
| 46 | + get: { |
| 47 | + operationId: "getPackageDownloads", |
| 48 | + summary: "Total downloads for a package over a fixed period", |
| 49 | + parameters: [ |
| 50 | + { name: "period", in: "path", required: true, schema: { type: "string" } }, |
| 51 | + { name: "package", in: "path", required: true, schema: { type: "string" } }, |
| 52 | + ], |
| 53 | + responses: { |
| 54 | + "200": { |
| 55 | + description: "Download counts for the package", |
| 56 | + content: { |
| 57 | + "application/json": { |
| 58 | + schema: { |
| 59 | + type: "object", |
| 60 | + properties: { |
| 61 | + downloads: { type: "number" }, |
| 62 | + start: { type: "string" }, |
| 63 | + end: { type: "string" }, |
| 64 | + package: { type: "string" }, |
| 65 | + }, |
| 66 | + }, |
| 67 | + }, |
| 68 | + }, |
| 69 | + }, |
| 70 | + }, |
| 71 | + }, |
| 72 | + }, |
| 73 | + }, |
| 74 | +}); |
| 75 | + |
| 76 | +const addSpecCode = (slug: string) => ` |
| 77 | +const added = await tools.executor.openapi.addSpec({ |
| 78 | + spec: { kind: "blob", value: ${JSON.stringify(NPM_DOWNLOADS_SPEC)} }, |
| 79 | + slug: ${JSON.stringify(slug)}, |
| 80 | + baseUrl: "https://api.npmjs.org", |
| 81 | +}); |
| 82 | +return added.ok ? { ok: true, slug: added.data.slug, toolCount: added.data.toolCount } : { ok: false, error: added.error }; |
| 83 | +`; |
| 84 | + |
| 85 | +// THE call under test: a no-auth connection with no credential origin at all. |
| 86 | +const createNoAuthConnectionCode = (slug: string) => ` |
| 87 | +const created = await tools.executor.coreTools.connections.create({ |
| 88 | + owner: "org", |
| 89 | + name: "public", |
| 90 | + integration: ${JSON.stringify(slug)}, |
| 91 | + template: "none", |
| 92 | +}); |
| 93 | +return created.ok ? { ok: true, connection: created.data } : { ok: false, error: created.error }; |
| 94 | +`; |
| 95 | + |
| 96 | +// The relaxed filter must still reject an origin on a no-auth create — an |
| 97 | +// empty `inputs: {}` is a (degenerate) origin and a credential the connection |
| 98 | +// can't hold, so it stays a validation failure. |
| 99 | +const createNoAuthWithEmptyInputsCode = (slug: string) => ` |
| 100 | +const created = await tools.executor.coreTools.connections.create({ |
| 101 | + owner: "org", |
| 102 | + name: "public-bad", |
| 103 | + integration: ${JSON.stringify(slug)}, |
| 104 | + template: "none", |
| 105 | + inputs: {}, |
| 106 | +}); |
| 107 | +return created.ok ? { ok: true, connection: created.data } : { ok: false, error: created.error }; |
| 108 | +`; |
| 109 | + |
| 110 | +const invokeDownloadsCode = (slug: string) => ` |
| 111 | +const found = await tools.search({ namespace: ${JSON.stringify(slug)}, query: "downloads", limit: 5 }); |
| 112 | +const path = found.items[0]?.path; |
| 113 | +if (!path) return { ok: false, error: "no downloads tool found", items: found.items }; |
| 114 | +let t = tools; |
| 115 | +for (const seg of path.split(".")) t = t[seg]; |
| 116 | +const result = await t({ period: "last-week", package: "react" }); |
| 117 | +return { ok: result.ok, path, data: result.ok ? result.data : result.error }; |
| 118 | +`; |
| 119 | + |
| 120 | +const removeConnectionsCode = (slug: string) => ` |
| 121 | +const list = await tools.executor.coreTools.connections.list({}); |
| 122 | +const mine = (list.ok ? list.data.connections : []).filter((c) => c.integration === ${JSON.stringify(slug)}); |
| 123 | +for (const c of mine) { |
| 124 | + await tools.executor.coreTools.connections.remove({ owner: c.owner, integration: c.integration, name: c.name }); |
| 125 | +} |
| 126 | +return { removed: mine.length }; |
| 127 | +`; |
| 128 | + |
| 129 | +/** Run `execute`, auto-approving any policy-paused execution, and parse the |
| 130 | + * sandbox's JSON return value. */ |
| 131 | +const executeJson = (session: McpSession, code: string) => |
| 132 | + Effect.gen(function* () { |
| 133 | + let result = yield* session.call("execute", { code }); |
| 134 | + let guard = 0; |
| 135 | + while (result.text.includes("executionId:") && guard < 10) { |
| 136 | + result = yield* session.approvePaused(result.text); |
| 137 | + guard += 1; |
| 138 | + } |
| 139 | + expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true); |
| 140 | + return JSON.parse(result.text) as Record<string, unknown>; |
| 141 | + }); |
| 142 | + |
| 143 | +scenario( |
| 144 | + "Connections · an agent creates a no-auth connection over the core tool and the public API answers 200", |
| 145 | + { timeout: 180_000 }, |
| 146 | + Effect.gen(function* () { |
| 147 | + const target = yield* Target; |
| 148 | + const mcp = yield* Mcp; |
| 149 | + const { client: makeApiClient } = yield* Api; |
| 150 | + |
| 151 | + const integration = unique("npmdl"); |
| 152 | + const identity = yield* target.newIdentity(); |
| 153 | + const session = mcp.session(identity); |
| 154 | + const client = yield* makeApiClient(api, identity); |
| 155 | + |
| 156 | + yield* Effect.gen(function* () { |
| 157 | + // 1. Register the public no-auth API over MCP. |
| 158 | + const added = yield* executeJson(session, addSpecCode(integration)); |
| 159 | + expect(added.ok, `addSpec succeeded: ${JSON.stringify(added)}`).toBe(true); |
| 160 | + expect(added.toolCount, "the spec's operation was extracted as a tool").toBe(1); |
| 161 | + |
| 162 | + // 2. THE FIX: create the connection with template "none" and NO origin. |
| 163 | + // Pre-fix this failed arg validation with |
| 164 | + // "Expected exactly one provider credential origin". |
| 165 | + const created = yield* executeJson(session, createNoAuthConnectionCode(integration)); |
| 166 | + expect( |
| 167 | + created.ok, |
| 168 | + `no-auth connection created via the core tool: ${JSON.stringify(created)}`, |
| 169 | + ).toBe(true); |
| 170 | + expect( |
| 171 | + (created.connection as { template?: string } | undefined)?.template, |
| 172 | + "the connection is saved on the no-auth template", |
| 173 | + ).toBe("none"); |
| 174 | + |
| 175 | + // 3. The operation is a live tool: invoke it and read back a real 200. |
| 176 | + const invoked = yield* executeJson(session, invokeDownloadsCode(integration)); |
| 177 | + expect( |
| 178 | + invoked.ok, |
| 179 | + `the no-auth operation answered over the wire: ${JSON.stringify(invoked)}`, |
| 180 | + ).toBe(true); |
| 181 | + const downloads = (invoked.data as { downloads?: number } | undefined)?.downloads; |
| 182 | + expect(typeof downloads, "the public API returned a download count").toBe("number"); |
| 183 | + expect(downloads as number, "react has a non-zero weekly download count").toBeGreaterThan(0); |
| 184 | + |
| 185 | + // 4. The relaxation is narrow: a no-auth create that carries an origin |
| 186 | + // (empty `inputs: {}`) is still rejected. |
| 187 | + const rejected = yield* executeJson(session, createNoAuthWithEmptyInputsCode(integration)); |
| 188 | + expect( |
| 189 | + rejected.ok, |
| 190 | + `a no-auth create with an empty inputs origin is rejected: ${JSON.stringify(rejected)}`, |
| 191 | + ).toBe(false); |
| 192 | + }).pipe( |
| 193 | + // Selfhost shares one workspace identity — leaked connections fail other |
| 194 | + // scenarios' zero-state assertions, so drop everything this run made. |
| 195 | + Effect.ensuring( |
| 196 | + Effect.gen(function* () { |
| 197 | + yield* session.call("execute", { code: removeConnectionsCode(integration) }); |
| 198 | + yield* client.openapi.removeSpec({ params: { slug: integration } }); |
| 199 | + }).pipe(Effect.ignore), |
| 200 | + ), |
| 201 | + ); |
| 202 | + }), |
| 203 | +); |
0 commit comments